Skip to content

Latest commit

 

History

History
964 lines (773 loc) · 65.2 KB

File metadata and controls

964 lines (773 loc) · 65.2 KB

Web UI (odek serve)

odek ships with a single-page web UI built entirely from Go's embed and zero external dependencies (no npm, no React, no build step). It's served from the same binary that runs on your terminal.

odek serve
# → odek serve ⚡  http://127.0.0.1:8080/?token=<per-instance token>
#   WebSocket: ws://127.0.0.1:8080/ws
#   WS token:  <per-instance token>
#   Type @ to reference files, drop or attach files inline.

Open the printed token URL in your browser — the token authenticates the WebSocket handshake and every /api/* call (cookie + X-Odek-Ws-Token header + odek.<token> subprotocol). A plain http://localhost:8080 loads the UI but cannot connect until you use the token URL. The UI reconnects automatically (exponential backoff, 1s → 30s cap) if the server restarts.

Architecture

┌─────────────┐  WebSocket /ws (interactive)  ┌──────────────┐
│   Browser    │ ◄──────────────────────────► │   odek serve  │
│  index.html  │  REST /api/* (management)    │  (Go binary)  │
└─────────────┘                               └──────┬───────┘
                                                     │
                                           ┌─────────┴─────────┐
                                           │    Agent Loop      │
                                           │  (ReAct engine)    │
                                           └───────────────────┘

Two surfaces, one binary:

  • /ws — the interactive chat transport (prompt → stream → done, approvals, heartbeat). One agent per connection.
  • /api/* — the REST management surface used by the UI and by external clients (bodek, curl, dashboards): sessions, headless runs with remote approvals, memory/skills/tools, events, usage, connections, health, config. See "REST endpoints" below.

The WebSocket transport is provided by golang.org/x/net/websocket (RFC 6455 handshake and frame I/O); internal/ws/ holds the shared frame constants, and serve.go owns the per-connection lifecycle.

Building an external client (TUI, bots, tools)

Everything the bundled WebUI does is available to any client over the same two surfaces. This section is the integration contract; the full reference follows below.

Authentication model

Two token layers:

  1. Instance token (one per odek serve process) — printed to stderr as the token URL (http://127.0.0.1:8080/?token=<64-hex>) at startup. Present it as the X-Odek-Ws-Token header on every REST call, and/or as the odek.<token> WebSocket subprotocol, and/or rely on the odek_ws_token cookie a browser obtained from the token URL. Without it every /api/* call is 403 and the WS handshake is rejected.
  2. Session token (per session) — required by session-scoped mutations and detail reads (X-Session-Token header). A fresh token is issued when the session is created: the WS session event carries auth_token, and REST runs echo it in detail responses. To adopt a session you didn't create (or after losing the token), GET /api/sessions/{id} with the instance token header — the server bootstraps and returns the session token in the X-Session-Token response header.

Minimal interactive flow (WebSocket)

  1. Read the token URL from serve's stderr; extract the token.
  2. GET /?token=<t> (optional — validates the token, sets the cookie).
  3. Open ws://…/ws with subprotocol odek.<t> → receive server_info.
  4. Send {"type":"prompt","content":"…","session_id":null} → receive session (capture session_id + auth_token), then thinking_delta / token_delta fragments (or one bulk token when streaming is off or the provider falls back), tool_call/tool_result pairs, and finally done. done is emitted only after the session is persisted — refreshing session state on done is race-free by contract.
  5. Answer approval_request with {"type":"approval_response","id":…, "action":"approve"|"deny"|"trust"} (default timeout 60s). Answer clarify_request with {"type":"clarify_response","id":…,"answer":"…"} (default timeout 300s).
  6. Keep alive with {"type":"ping"} (answered inline with pong, even mid-run). The server also pushes keepalive every 20s so idle proxies do not drop a socket waiting on a thinking model. Cancel with {"type":"cancel","session_id":…,"auth_token":…}.
  7. Continue the session by including session_id+auth_token on the next prompt, or {"type":"session_switch", …} to adopt one without prompting.

Minimal headless flow (REST only — no WebSocket)

  1. POST /api/prompt {"content":"…","approval_timeout_seconds":300}202 {run_id, session_id, status:"running"}.
  2. Poll GET /api/runs/{id} until status is completed / failed / cancelled; read result, input_tokens/output_tokens, and the bounded events tail.
  3. While status is waiting_approval: GET /api/runs/{id}/approvals, answer with POST /api/runs/{id}/approvals/{aid} {"action":"approve"}.
  4. Cancel with DELETE /api/runs/{id} or POST /api/runs/{id}/cancel.

Cross-cutting contracts

  • Caching: every /api/* response carries Cache-Control: no-store; static assets carry strong ETags with no-cache, must-revalidate.
  • Errors: 403 missing/invalid instance token; 401 missing/invalid session token; 404 unknown id; 400 malformed input; 405 wrong method; 429 session-detail rate limit (60/min per IP).
  • Limits: WS frame ≤ 8 MiB; ≤ 20 concurrent WS connections; 30 WS upgrades/min per IP; prompt ≤ 1 MiB; attachments ≤ 5 MiB each / 10 MiB total; model ids ≤ 128 chars matching [A-Za-z0-9_.:/@-]+; REST request bodies ≤ 2 MiB on the runs surface (POST /api/prompt), ≤ 1 MiB on the management endpoints (/api/* in serve_api.go).
  • Token field naming: WS event fields are camelCase (windowTokens, inputTokens, outputTokens); REST JSON is snake_case (input_tokens, session_id). Don't mix them up.
  • Window vs spend: windowTokens is the PARENT conversation window (last parent LLM call's provider-normalized prompt size — input + cache read + cache creation). It drives the ctx gauge. inputTokens (on done) is the run-cumulative billing total across ALL LLM calls including charged sub-agent spend — never render it as a gauge. Sub-agent spend lives on subagent_state (tokens_used, cost_usd).

Features

Command center

The bundled client is a zero-framework command center — same EMBER language as bodek, but built for a pointer, a persistent inspector, and a command palette.

  • Model + thinking pickers — top-bar selects for the active model and reasoning depth (disabled / low / medium / high). Thinking is persisted as odek_thinking and sent on every prompt.
  • Command palette (⌘K / Ctrl+K) — fuzzy jump to commands, sessions, models, and inspector workspaces
  • Slash commands — typing / in the composer opens the same completions as the palette (commands, sessions, models). Enter/Tab runs the selected item; /new /clear /retry /cancel /stop /help and the other palette verbs also dispatch on Enter. Typed shutdown death-gate stays a modal
  • Prompt queueEnter while a turn is running holds the next prompt (reorder / delete in the strip above the composer); the queue drains automatically on done
  • Three themesember-dark · ember-light · high-contrast (health popover or palette)
  • Desktop notifications — opt-in; titles/bodies are truncated and never include raw tool arguments
  • Inspector (⌘.) — four workspaces: Sessions, Now (plan / jobs / agents), Memory (facts / skills / tools), Ops (runs / events / config). Kick is two-step; shutdown is a typed confirm.

Chat interface

  • Plain text input — type your prompt, press Enter to send (or queue), Shift+Enter for a newline
  • Slash / palette — typing / in the composer autocompletes the same items as ⌘K (commands, sessions, models). Palette verbs also dispatch from the composer on Enter. Printable keys always type in the composer. Bodek-style JIT tips (💡 tip: …) dwell 8s the first time a queue, tool step, or swarm appears.
  • Long replies — the latest assistant answer is never folded. Older overflows get a sticky Show more ↓ / Show less ↑ fold under the content (not a floating pill). History reload keeps the last reply open.
  • Multi-turn sessions — each prompt continues the same conversation (the inspector Sessions tab lists history)
  • Turn receipts — Bodek-style coding receipt on the ⬡ odek head (touched N · +A −D · tests), not a tool count
  • Wake turnsturn_started.initiated=system renders as ⬡ odek · wake on the assistant head, never as a user message
  • Busy spinner — Bodek braille spinner in the top bar, composer rail, and transcript while a turn runs (reasoning · 4s). The spinner keeps moving across thinking, tools, and approval waits — removing the transcript placeholder does not freeze it.
  • Live plan & jobs — Bodek header chips (plan 1/4, ● 2 jobs / ✗ job) stay visible when idle; click opens the inspector Now tab. While a turn runs the status rail appends ▸ plan 2/5 · <active step> · ⛔N. A plan tool_call patches the snapshot on that frame; REST confirms after tool_result.
  • Markdown — hand-written tokenizer (zero deps, no CDN): headings, lists, task lists, quotes, GFM tables, fenced code with copy, emphasis, strikethrough, allowlisted links/autolinks. Images are caption links, never <img> (CSP + no remote fetch). Streaming-safe: an open fence still renders; an open ** stays literal.
  • Live streaming (on by default; --no-stream / stream: false / ODEK_STREAM=false) — answer and reasoning fragments arrive as they are generated (token_delta / thinking_delta) and render through the same rAF-batched pipeline; streaming state is in the health popover. Providers that reject SSE fall back silently to the bulk path.
  • Reasoning, partial replies, and tools — one sequential log per turn. Reasoning is collapsed behind a ▶ thinking toggle (hidden by default; click to expand). Visible assistant text (token_delta / token, including DeepSeek/GLM mid-turn “Let me look…” replies) is a timeline row sealed when a tool starts so the next tokens open a new row instead of concatenating the turn. Tool heads sit in that same stream in arrival order. Tool args and results stay collapsed until the head is opened; long results truncate behind “show all”. History replays the same interleaved log.
  • Sub-agent swarmdelegate_tasks uses the same spine as a tool step (▶ ▸ ⑂ delegate_tasks · 1/2 agents) plus an always-on chip strip (⟳ SA1 <goal|tool>). Click a chip (or the head) for the log and summary; the inspector Now tab still lists every agent.
  • Inline approvals — dangerous operations block the run and show a decision card (risk class, plain-language explanation, verbatim command). Friction mode (after 3 same-class approvals in 60s) requires typing the literal word approve; trust session is hidden for destructive/blocked/unknown classes. Keyboard: A approve, D deny, T trust
  • Clarify — when the agent needs a decision, a question card waits for a typed answer (5 minute wait). Bound to that WebSocket session; headless REST runs do not register the tool.
  • Cancel — the ✕ button cancels the running prompt over the WebSocket (cancel message), with the REST endpoint as fallback
  • Model switching — the picker lists GET /api/models (provider ListModels catalog, configured model marked current, with context sizes) plus an "Other…" free-text entry; switches apply from the next prompt
  • History navigation/ arrows cycle through your previous prompts (stored in localStorage)
  • Keyboard shortcuts? toggles the cheat sheet (Enter, @ completion, ⌘K palette, ⌘. inspector, Alt+R retry, A/D/T approvals). Browser ⌘F / ⌘R are left alone.
  • File attachments — drag-and-drop files onto the chat area, or use the paperclip button. Attached files appear as chips with filename, size, and a remove button. 5 MB per file, 10 MB total per prompt; content crosses the trust boundary wrapped in the untrusted-content envelope

Server status & heartbeat

The top-bar status group (connected / reconnecting) doubles as a health popover — click it for version, uptime, model, sandbox/streaming state, live connection count, WebSocket round-trip latency, session tokens/cost, theme, notifications, and lifetime usage. An application-level heartbeat (ping/pong every 20s) measures RTT and detects dead links early; the server also pushes keepalive every 20s so idle proxies do not drop a silent thinking turn.

A dropped socket is not lamp-only. The top-bar word turns amber (reconnecting), a sticky #conn-banner sits above the transcript (connection lost · retrying in Ns) until the socket is open again, and one system line is written per outage (⚠ Connection lost — reconnecting…). Restore writes Connection restored (or notes that an in-flight turn ended). Sending while down toasts instead of failing silently. Retries do not spam the log.

Inspector (⌘.)

The right-side drawer exposes the REST management surface in four workspaces:

  • Sessions — the session index (search, pin, rename, export, delete, “more”). The top-bar list button and the empty-state inspector tip open this tab. Switching or starting a session closes the drawer.
  • Now — the active session plan and background jobs, plus GET /api/subagents with per-task stop. Bodek cadences: a plan tool_call patches the snapshot immediately, tool_result confirms via REST (250ms debounce), the strip polls every 1s while a turn runs and the Now tab polls every 3s. Jobs: bg_job kicks GET /api/jobs now, 10s watcher otherwise, 3s while Now is open. The Now tab badges when a plan or job is live. Header chips (plan 1/4, ● 2 jobs / ✗ job) stay visible when idle; click opens Now. While busy the status rail appends ▸ plan 2/5 · <active step> · ⛔N.
  • Memory — user/env facts with add/remove, caps, pending-review episode promote, skills (promote / force-promote), and the built-in tool registry
  • Ops — headless REST runs (POST /api/prompt: status, cancel, remote approve/deny/trust), the recent odek.event/v1 feed, sanitized config, MCP listing, two-step connection kick, and the typed shutdown death-gate

@ resource completion

Type @ followed by a filename to see an autocomplete dropdown. odek resolves matching files and sessions:

Token economics

Each response shows per-message token stats appended to the assistant bubble:

  • Latency: wall-clock time for the agent loop
  • Input tokens: cumulative prompt tokens across all iterations
  • Output tokens: cumulative completion tokens
  • Cache (when non-zero): combined cache read / write / prefix hits

The status strip shows a live context-window gauge once a run reports data, plus a session-cost chip when prices are configured:

  • Context gauge — a hairline bar and tabular % from per-iteration usage events (and a streamed-token estimate between them), against the model's window size from /api/models or usage.maxContextTokens. Amber above 60%, red above 85%; a context_trimmed signal flashes the gauge. Without a known window size it shows raw tokens. Hover for exact numbers and the trimming note.
  • Session tokens⇥ in ↦ out, cumulative session totals. usage.inputTokens / usage.outputTokens overlay this-run spend on the pre-turn baseline so the numbers move mid-turn; done replaces them with the persisted session totals (health popover).
  • Session cost — Bodek header chip $0.201 (#cost-chip), estimated from those live session totals and the resolved prices (/api/limits: model_prices per-model override, flat pair fallback — the client-side twin of limits.ResolvePrices). Hidden entirely when no prices are configured. Click opens the health popover for the token breakdown.

Each assistant message's stats footer also gains a per-turn cost () when prices are configured, and the inline loading indicator shows live elapsed time and iteration count (thinking · 7s · iter 2) while the run is in flight. /api/usage aggregates server-lifetime totals with cost.

Inline loading indicator

While a turn is running, Bodek's braille spinner (⠋⠙⠹…, 12 fps) appears in the top bar (#busy-spin) and the composer status rail (#intent-rail); operator sends also get a compact .loading-indicator under the last message until the first thinking/tool/answer lands. The spinner keeps moving while tools, approvals, or long LLM calls are in flight — dropping the transcript placeholder does not freeze it. The label stays stable (reasoning → tool progress → composing) with elapsed time and the live plan strip — it does not cycle verbs. prefers-reduced-motion freezes the braille glyph at ; tool-head CSS spinners still rotate (they are wait-state status). Wake and remote turns arm the top-bar and rail. The chrome clears on done / cancel / error.

Smart autoscroll

The chat only auto-scrolls when you're near the bottom (within 60px). If you scroll up to read previous content while the agent responds, the page does not steal your scroll position. When you send a new message, it force-scrolls to the latest response.

This uses requestAnimationFrame batching to avoid layout thrashing during high-frequency token updates.

Prefix Source Example
@ + path Current directory files @src/main.go → inlines src/main.go
@sess: + id Saved sessions @sess:20260519-abc123 → inlines session transcript

The dropdown fetches from GET /api/resources?q=<query>&limit=8. Results include files (recursive directory walk, skips .git, node_modules, etc.) and sessions.

Security: file paths are resolved relative to the working directory. Symlinks are blocked. Content is truncated at 50KB.

Session management

  • Auto-save: every prompt creates a new session if none is active, or appends to the current one; per-turn persistence means an interrupted run resumes from the last completed step
  • Sessions tab: a session index (50 per page, whisper “more”), amber rule on the active row, title + relative time + model as type. Pin / rename / export / delete appear on hover
  • Search: the find field queries server-side (GET /api/sessions?q=…, case-insensitive over task/model/id, debounced 250ms)
  • Pin: 📌 floats a session to the top of the list (persisted on the session, POST /api/sessions/{id} {pinned})
  • Rename: ✎ inline-edits the session label
  • Export: ⇩ downloads the transcript — markdown by default, JSON with Shift held (GET /api/sessions/{id}/export)
  • Delete: ✕ with a confirmation dialog
  • Switching: clicking a session renders the full transcript (tool calls, reasoning, sub-agents) and sends a session_switch message so the connection's agent adopts the session (memory-buffer restore) before you type
  • Session data: stored in ~/.odek/sessions/ as JSON files, same format used by odek session

REST endpoints

Background jobs

  • GET /api/jobs — the authenticated session's background jobs (id, command head, status, runtime, exit code).
  • GET /api/jobs/{id}/output?since=N&limit=N — output window with next_cursor for continuation.
  • POST /api/jobs/{id}/stop — kill a running job.

All three require the same per-instance CSRF token, loopback Host check, and session authentication as the other /api routes; the POST additionally requires the local-origin check. Session tokens are validated strictly on every call — the token must be presented (legacy token-less sessions are minted but never bootstrap-passed on jobs routes). Foreign job ids answer {"status":"unknown"} (same shape as stale ids — no existence oracle across sessions).

All /api/* endpoints require the per-instance CSRF token (odek_ws_token cookie or X-Odek-Ws-Token header) and a loopback Host header; state-changing methods additionally require a local Origin. Missing/invalid credentials return 403.

Every response carries Cache-Control: no-store. The surface covers six groups, detailed below: sessions (search/list, detail, rename/pin, export, delete, cancel), budgets (/api/limits), agent state (models, resources, tools, memory, skills), headless runs (/api/prompt + /api/runs/*), observability (health, usage, events, connections), and administration (config view, MCP listing, memory consolidate, skills promote, shutdown). Session-scoped reads and mutations additionally require the per-session auth token (X-Session-Token header or the session_token cookie — the header wins when both are present), which the server issues when the session is created (see "Building an external client" above for the bootstrap flow). The agent itself cannot reach any of these — its browser/http tools refuse loopback via the SSRF guard and it never holds the instance token.

GET /api/resources?q=&limit=

@-reference search over workspace files and saved sessions — the completion backend. Skills are not included. limit defaults to 10, capped at 100.

[
  { "id": "@src/main.go",  "type": "file",    "label": "src/main.go",  "detail": "Go source, 142 lines" },
  { "id": "@sess:2026…",   "type": "session", "label": "fix login bug", "detail": "3 turns" }
]

Inline a result by embedding its id verbatim in the prompt content; the server resolves and wraps it as untrusted content.

GET /api/models

The provider's ListModels catalog plus the configured model (always present, current: true). Context windows come from the provider, then the last-resort table, else 0. Capped at 256 entries. /api/profiles is retired.

[
  { "id": "glm-5.3-flash", "max_context": 1000000, "description": "GLM 5.3 Flash — 976K ctx", "current": true },
  { "id": "glm-5.3", "max_context": 1000000, "description": "GLM 5.3 — 976K ctx" }
]

max_context feeds the metrics gauge. The picker also has an "Other…" free-text entry for ids not in the catalog. If ListModels fails, the response is the configured model only.

GET/POST/DELETE /api/sessions/{id}

The core session CRUD (session-token gated):

  • GET — the full session record (messages, buffer, auth_token, pinned, input_tokens/output_tokens). The effective session token is echoed in the X-Session-Token response header; presenting only the instance token bootstraps and returns it for sessions you didn't create. Rate-limited to 60 lookups/min per IP (429 beyond that).
  • POST {"name"?: string, "pinned"?: bool} — rename and/or pin; at least one field required (400 otherwise).
  • DELETE — removes the session and its index entry; 204.

POST /api/cancel?session_id=

Cancels the prompt currently executing on a session (the REST twin of the WS cancel message). Requires the session's auth token. Returns 200 with the outcome — idle:true means no live prompt was registered for that session, so nothing was cancelled:

{ "session_id": "20260519-abc123", "idle": false }

Cancellation

All cancel paths — the WebUI ✕ button (WS cancel message), POST /api/cancel, and POST /api/runs/{id}/cancel — interrupt a pending approval wait instead of leaving the run blocked until the approval timeout (60s by default; headless runs may raise it, capped at 10 minutes). On the cancelled event the UI dismisses pending approval cards, so an approval cannot be answered after a cancel.

Sub-agents get the same lifecycle parity:

  • Turn-level cancel kills sub-agents. Each child runs under the parent prompt's context; a turn cancel (or socket disconnect) SIGKILLs every running sub-agent process.
  • Per-sub-agent stop. The WS subagent_cancel message (session-token scoped like cancel) cancels one task by task_id without touching the turn or its siblings. Children killed before reporting still emit a terminal subagent_state (finished/cancelled) so chips and the /api/subagents registry converge.
  • Status framing. A user/turn cancel reports cancelled; only the per-task deadline reports timeout — the two are never conflated.

GET /api/limits

Returns the execution-budget configuration resolved at server start, so clients can render session costs without duplicating the price-resolution rule.

{
  "model": "deepseek-v4-flash",   // the server's configured model
  "limits": {                     // resolved budget.Limits as-is (see docs/CONFIG.md)
    "max_runtime_seconds": 300,
    "max_tool_calls": 50,
    "max_cost_usd": 0.50,
    "input_cost_per_million_usd": 2.0,
    "output_cost_per_million_usd": 8.0,
    "model_prices": {
      "deepseek-v4-flash": {
        "input_cost_per_million_usd": 0.14,
        "output_cost_per_million_usd": 0.28
      }
    }
  },
  "effective_prices": {           // limits.ResolvePrices(model) — model_prices entry wins, flat pair otherwise
    "input_cost_per_million_usd": 0.14,
    "output_cost_per_million_usd": 0.28
  }
}

Cost rendering for the current session model uses effective_prices directly:

cost_usd = input_tokens / 1e6 * input_cost_per_million_usd
         + output_tokens / 1e6 * output_cost_per_million_usd

When no prices are configured, effective_prices is 0/0 — treat that as "costs unavailable". To price a different model, look it up in limits.model_prices and fall back to the flat pair per field.

GET /api/health

Server metadata for monitoring and the WebUI status popover. Never carries secrets.

{
  "status": "ok",
  "version": "1.35.0",         // ldflags build version ("" for dev builds)
  "started_at": "2026-08-21T08:19:29Z",
  "uptime_seconds": 1903,
  "model": "glm-5.3",           // configured model
  "sandbox": true,              // sandbox mode
  "stream": true,               // live delta streaming enabled
  "ws_connections": 2           // live WebSocket connections
}

GET /api/sessions?q=&limit=&offset=

Server-side search and pagination over the session list. With no query parameters the endpoint returns the legacy bare JSON array (existing consumers pin that shape). With any of q / limit / offset present it returns an envelope:

{
  "sessions": [ /* session records, auth tokens stripped */ ],
  "offset": 0,
  "limit": 50,                  // capped at 200
  "count": 50,
  "query": "deploy"             // lowercased echo of q
}

q is a case-insensitive substring match over task, model, and session id.

GET /api/sessions/{id}/export?format=md|json

Downloads a transcript. Session-token auth applies exactly like the detail read (X-Session-Token header, session_token cookie, or the instance-token bootstrap). format=md (default) renders a standalone markdown document — metadata header, ## user / ## assistant sections, tool calls and results as fenced blocks, reasoning behind <details>, untrusted-content envelopes unwrapped; format=json returns the raw session record. Responses carry Content-Disposition: attachment.

GET /api/sessions/{id}/plan

Read-only structured plan view (the WebUI plan tab's data source; see PLANNING.md for the full surface map). Session-token auth applies exactly like the detail read, and the same 60/min-per-IP session-lookup rate limit applies (429 beyond that). The server parses the newest parseable [Current plan: system message with the same strict resume parser the engine uses (loop.ExtractPlan) — no plan state is stored anywhere beyond the transcript itself.

{
  "session_id": "2026…",
  "version": 3,
  "steps": [
    { "id": "s1", "title": "Scaffold command skeleton", "status": "done" },
    { "id": "s2", "title": "Wire flag parsing", "status": "in_progress",
      "note": "waiting on schema decision" }
  ],
  "found": true
}

found:false (still HTTP 200) means the transcript carries no parseable plan message — version/steps are then zero/empty (a collapsed all-done plan parses to a version with steps: []). Unknown session ids return 404; note is omitted when empty. Strictly GET-only: non-GET requests to …/plan do not fall through to the base-session mutators.

GET /api/memory · POST/DELETE /api/memory/facts · POST /api/memory/episodes/promote

Operator-gated memory management (the REST face of odek memory):

  • GET /api/memory → facts grouped by target (user / env, entries split on the § separator), configured caps, episode totals, and the pending-review queue (tainted episodes stored but excluded from recall).
  • POST /api/memory/facts {target:"user"|"env", content} — adds through the same MemoryManager path the agent's memory tool uses, including the unsafe-content filter (curl … | sh-style facts are rejected).
  • DELETE /api/memory/facts {target, old_text} — removes the matching entry.
  • POST /api/memory/episodes/promote {session_id} — promotes a tainted episode to recallable, the same human gate as the CLI. The agent cannot reach these endpoints (its browser/http tools refuse loopback via the SSRF guard, and it never holds the instance token), so the gate stays human.

GET /api/skills

Skill listing with provenance: name, description, auto_load, usage_count, source (directory), needs_review, untrusted. Bodies are omitted (size and injection hygiene — load them via the agent's skill_load). Skills pinned needs_review are excluded from trigger matching and from skill_load (agent-side body reads) until odek skill promote.

GET /api/tools

The built-in tool registry with the resolved enabled/disabled state after tools.enabled / tools.disabled filtering, plus the configured MCP server count (per-connection tool lists vary with MCP).

POST /api/prompt — headless runs

Runs the full agent without a WebSocket. The body is the prompt message:

{
  "content": "summarize the repo",   // required, same 1 MiB cap as WS prompts
  "session_id": "2026…",             // optional — omit to create a session
  "auth_token": "",                 // session token when continuing
  "model": "glm-5.3",                // optional per-run model override
  "thinking": "medium",              // optional per-run depth: disabled|low|medium|high (omit = inherit)
  "approval_timeout_seconds": 300,   // approval wait (default 60s, cap 600s)
  "attachments": [{ "name": "f.txt", "content": "" }]
}

Returns 202 {run_id, session_id, status:"running"}. The run executes the exact handlePrompt path (refs, audit, per-turn persistence); events land in the run record and the /api/events ring. Approvals block the run and are answerable over REST:

Endpoint Method Purpose
/api/runs GET Recent runs (status, timing, tokens; newest first)
/api/runs/{id} GET Detail incl. event tail (200 events) + result
/api/runs/{id} DELETE Cancel
/api/runs/{id}/cancel POST Cancel (reports idle:true when already finished)
/api/runs/{id}/approvals GET Pending approval requests (risk, command)
/api/runs/{id}/approvals/{aid} POST {action: "approve" | "deny" | "trust"}

Answers flow through the same wsApprover path as the WebUI — trust caching matches, and friction flags are exposed the same way. Typed confirm on this REST bridge is opt-in: default approve/trust stay single-field; set dangerous.rest_approval_friction to require a confirm field that repeats the action. Tainted/dangerous classes still never offer trust. The registry keeps the newest ~100 runs (≥20 completed) and evicts oldest completed first.

Failed runs keep their session linkage: the user prompt is persisted to the session before the first LLM call, and a failed turn closes with an explicit [Turn aborted: …] assistant note instead of ending on a dangling tool call — run.session_id is therefore always populated and the transcript never loses the prompt. Persistent provider rate limits (HTTP 429 past the client's retry budget) surface as a typed summary in the run record and the durable serve log: ~/.odek/serve.log by default (--log-file to override, mode 0600, rotated by the storage janitor alongside telegram.log/schedule.log). The log records run/turn lifecycle — IDs, statuses, latencies, failure classifications — never prompt or completion content.

GET /api/events?limit=&run_id=&session_id=

Recent odek.event/v1 runtime events (ring of 500, oldest-first, filtered). Event payloads carry SHA-256 arg hashes and redacted fields only — never raw tool arguments. Every WS prompt and REST run feeds the same ring.

GET /api/usage

Server-lifetime aggregates: prompts_started/completed/failed, tokens_in/out, estimated_cost_usd via limits.ResolvePrices, prices_configured (false ⇒ render costs as "unavailable"), and plan rollup plans_created / plans_updated / plans_blocked (counts only). Sessions also carry cumulative input_tokens/output_tokens (shown in list/detail).

GET /api/subagents?key=

Sub-agent lifecycle registry snapshot (ring of 256, oldest evicted): one entry per delegated task — task_id, run_key (connection id for WS runs, run id for headless runs), redacted + truncated goal, phase (started/active/finished), status, pid, timestamps, iterations, step, last_tool, duration_seconds, tokens_used. Filter by ?key=<run_key>; unfiltered returns all recent entries.

GET /api/connections · DELETE /api/connections/{id}

Live WebSocket connections (id, remote addr, connected-at, session, model, busy, prompt count). DELETE kicks a connection by closing its socket — the handler's defers tear down the agent and sandbox cleanly.

GET /api/config

Sanitized resolved-config view: provider id (not the providers map), model, sandbox knobs, stream/compaction/ caching flags, parent announce_budget, thinking as "" / disabled / low / medium / high (not a boolean), iteration/parallelism limits, memory/skills/tool-filter summaries, maintenance retention, dangerous default action, guard scan toggles, sub-agent budgets (subagent), background-command settings (background), and execution budgets with effective token prices (limits). Secrets (api_key, base_url, env values, search backends) are never included. The agent-facing config_view tool renders this same view (section-filterable); parity is pinned by tests.

GET /api/mcp

Configured MCP servers with command/args and extension limits, plus project markers for servers sourced from ./odek.json. Env values are withheld (they may carry credentials).

POST /api/skills/promote

{name, force?} — the REST face of odek skill promote: clears NeedsReview so a skill can auto-load. Tainted skills still require force.

POST /api/memory/consolidate

{target: "user"|"env"} — merges similar facts through the LLM (same MemoryManager.Consolidate the agent uses).

POST /api/shutdown

Triggers the graceful drain (stop accepting → close WebSockets → wait for sandbox cleanup). For remote-restart management flows.

Session pinning

POST /api/sessions/{id} now accepts {name?, pinned?} (either or both); listings return pinned sessions first, and both list and detail carry pinned, input_tokens, output_tokens, and model.

Flags

Flag Default Description
--addr <addr> 127.0.0.1:8080 Listen address (loopback by default; e.g. --addr localhost:9090). Binding a non-loopback address prints a loud warning
--open false Open browser automatically after starting
--tool <name> Enable a specific tool for served runs (repeatable; highest-priority whitelist layer)
--no-tool <name> Disable a specific tool for served runs (repeatable; merged with lower-priority disabled lists)
--trusted-proxies <ips/cidrs> Client IPs trusted for X-Forwarded-For / X-Real-Ip resolution, used by rate limiting. Only set this for proxies you control — a spoofed header from an untrusted client defeats per-IP rate limits
--log-file <path> ~/.odek/serve.log Durable run/turn log (mode 0600, symlink-resistant). Lifecycle lines only — never prompt or completion content. Rotated by the storage janitor
--stream on Stream LLM responses live to the WebUI (token_delta / thinking_delta events)
--no-stream Disable live streaming (bulk token events only)
--prompt-caching on Enable prompt-caching markers
--no-prompt-caching Disable prompt caching
--compaction / --no-compaction on Rolling compaction (dropped-turn digest). Copied onto serve agents from resolved config.
--announce-budget / --no-announce-budget on Parent 50/75/90% budget-awareness hints
--planning / --no-planning on Register the built-in plan tool
--help, -h Show usage

Plus the shared sandbox flags (--sandbox, --no-sandbox, --sandbox-image, …) — see odek serve --help.

WebSocket Protocol

The UI communicates entirely over a single WebSocket at /ws. Messages are newline-delimited JSON. The server streams events for a prompt until done, and the client may send control frames on the same socket (cancel, subagent_cancel, ping, approval_response, session_switch — see Client → Server).

Client → Server

// Prompt — send a task to the agent. Caps: content ≤ 1 MiB; model ≤ 128
// chars of [A-Za-z0-9_.:/@-]; attachments ≤ 5 MiB each, 10 MiB total.
{
  "type": "prompt",
  "content": "What files are in src/?",
  "session_id": "20260519-abc123",  // optional — omit for new session
  "auth_token": "",                // when continuing a session
  "model": "glm-5.3",               // optional per-run model override
  "thinking": "medium",             // optional per-run depth: disabled|low|medium|high (omit = inherit)
  "attachments": [{ "name": "f.txt", "content": "" }]
}

// Approval response — answer a security prompt
{
  "type": "approval_response",
  "id": "apr-a1b2c3d4",
  "action": "approve"  // "approve" | "deny" | "trust"
}

// Heartbeat — answered inline by the socket reader, so it works while a
// prompt is running. Server replies with a pong carrying a server snapshot.
{ "type": "ping" }

// Cancel the running prompt over the socket (same session-scoped auth as
// POST /api/cancel — the target session's auth_token is required).
{
  "type": "cancel",
  "session_id": "20260519-abc123",
  "auth_token": ""
}

// Stop ONE running sub-agent (the card stop button). Handled inline by
// the socket reader, so it works while delegate_tasks occupies the
// prompt processor. Same session-scoped auth as cancel. The server
// replies with a subagent_cancelled ack; the card's terminal state
// arrives as a subagent_state finished/cancelled transition.
{
  "type": "subagent_cancel",
  "session_id": "20260519-abc123",
  "auth_token": "",
  "task_id": "task-uuid"
}

// Switch the connection to an existing session without sending a prompt:
// restores the memory buffer into the connection's agent and emits the
// standard `session` event.
{
  "type": "session_switch",
  "session_id": "20260519-abc123",
  "auth_token": ""
}

Server → Client

Event Type When Fields
server_info Pushed once on connect version, model, sandbox, stream, uptime_seconds, ws_connections
pong Reply to a client ping t (unix ms), plus the server_info snapshot fields
keepalive Server-initiated idle traffic every 20s (independent of client pings) so proxies/NATs do not close a socket waiting on a slow LLM t (unix ms)
session At start of response, and after session_switch session_id, auth_token, model, sandbox
turn_started Emitted for every turn (operator and system-initiated wake alike) immediately after the matching session frame and before the first streamed frame — clients open/upsert the streaming card by turn_id, so a missed session frame can no longer strand a turn turn_id (t_<hex>), session_id, initiated ("operator" or "system" — computed server-side via the wake provenance gate; client input cannot influence it), model (mirrors the session frame's model)
token_delta Live streamed answer fragment (streaming on) content (markdown fragment)
thinking_delta Live streamed reasoning fragment (streaming on) content
cancelled After a cancel message is honored session_id, idle (true when nothing was running)
subagent_cancelled Ack for a subagent_cancel message session_id, task_id, accepted (false is a benign race — the task already finished)
token Final answer text (bulk; suppressed when streamed via token_delta) content (markdown)
thinking Reasoning content (bulk; suppressed when thinking_delta streamed it) content
tool_call Agent invokes a tool name, data (raw tool-arguments JSON)
tool_result Tool returns output name, data (full, untruncated output)
subagent_log Sub-agent progress within delegate_tasks task_idx, task_id, name, event, data (redacted, capped 8 KiB)
subagent_state Per-task sub-agent lifecycle transition (started/active/finished); child emits subagent_started/subagent_progress/subagent_finished records over the same protocol. A sub-agent killed without reporting (user stop, turn cancel, timeout, flood-kill, crash) gets its terminal finished transition emitted by the parent instead, so cards never stay running task_idx, task_id, run_key, phase, status, step, iterations, tool, duration_seconds, tokens_used
done Agent finishes — emitted only after the session is persisted, so refreshing session state on done is race-free latency (seconds), windowTokens (final parent conversation window), maxContextTokens (resolved model limit; omitted when unknown), inputTokens (run-cumulative input across all calls, incl. sub-agent spend — billing), outputTokens, cacheCreationTokens, cacheReadTokens, cachedTokens, sessionContextTokens, sessionOutputTokens, plus optional last-call speed fields (see Generation speed) and llmDurationMs (sum of main think-step LLM calls this run)
usage After each LLM iteration of a running turn windowTokens, maxContextTokens (omitted when the model limit is unknown), inputTokens (run-cumulative billing input), outputTokens (run-cumulative) (camelCase — windowTokens is the parent-only window size that drives the metrics gauge; child rounds and side-call summaries never move it), plus optional this-call speed fields (see Generation speed)
error Agent or server error message
approval_request Agent needs user approval for dangerous operation; blocks the run up to timeout_seconds (60s default) id, risk (class name), command (or resource), description, is_operation, allow_trust, friction, friction_approvals, timeout_seconds (the effective server-enforced wait in seconds — render the card's countdown from it)
approval_ack Server confirms an approval response id, action
approval_expired Server declares the approval dead after timeout_seconds elapsed with no response — autoclose the matching card; late approval_response frames for the id are dropped id
clarify_request Agent asked a principal-channel question; blocks the run up to timeout_seconds (300s) id, question, timeout_seconds
clarify_ack Server confirms a clarify answer id
clarify_expired Server declares the question dead after timeout_seconds with no answer id
skill_event Skill lifecycle event (loaded/autoloaded/used/deletedskill_save/skill_patch were removed with the self-learning feature) event, skill_name, skills, heuristic
memory_event Memory lifecycle event event, target, session_id, content, count, new_count, untrusted
agent_signal Agent self-observability signal event, detail, tool, count
bg_job Background-job transition (start / exit). Upsert by job_id. Terminal frames add exit_code, duration_ms, output_bytes, command_head job_id, session_id, status
bg_wake Server is starting a system-initiated wake turn after an idle job completes session_id

Every frame of an active turn — token, thinking, tool_call, tool_result, done, error — also carries turn_id, matching the turn's turn_started.turn_id, so a client that attached mid-turn (after a reconnect) can attribute stray frames and reconcile card state without heuristic idle detection. Lifecycle frames (session, server_info, pong, keepalive, usage, cancelled, subagent_*, approval_*, clarify_*, skill_event, memory_event, agent_signal) and the live *_delta fragments never carry it. The session frame's legacy system_initiated: true stamp (wake turns only) remains for old clients; turn_started.initiated supersedes it. Versioning: all new fields are additive and absent when not applicable — old clients ignore the unknown frame and unknown fields, and new clients against an old server simply never see turn_started (and fall back to the session stamp or lazy card open).

Example event sequence:

{"type":"session","session_id":"20260519-x1y2z3","model":"deepseek-v4-flash"}
{"type":"turn_started","turn_id":"t_9f86d081884c7d65","session_id":"20260519-x1y2z3","initiated":"operator","model":"deepseek-v4-flash"}
{"type":"token","content":"Let me look at the source directory.","turn_id":"t_9f86d081884c7d65"}
{"type":"tool_call","name":"shell","data":"{\"command\":\"ls -la src/\"}","turn_id":"t_9f86d081884c7d65"}
{"type":"tool_result","name":"shell","data":"<untrusted_content_a1b2c3d4 source=\"shell\">\ntotal 24\ndrwxr-xr-x ...\n</untrusted_content_a1b2c3d4>","turn_id":"t_9f86d081884c7d65"}
{"type":"token","content":"The `src/` directory contains 3 files:","turn_id":"t_9f86d081884c7d65"}
{"type":"done","latency":4.2,"turn_id":"t_9f86d081884c7d65"}

Each token / token_delta burst is its own assistant row in the turn log. A tool_call seals the current row so the next tokens open a new one — DeepSeek/GLM-style “Let me look…” then a tool then more text is a timeline, not one concatenated bubble.

With streaming enabled (--stream / stream: true / ODEK_STREAM=true) the answer arrives as token_delta / thinking_delta fragments as the provider generates them, and the bulk token / final-answer thinking re-sends are suppressed. Providers that reject SSE transparently fall back to the buffered path — no deltas fire and the bulk events return. See docs/STREAMING.md.

Generation speed

usage and done frames carry optional this-call generation-speed fields. All fields are additive and omitted when unknown (buffered calls have no TTFT; rates stay off when the provider sent no output tokens or the call was shorter than 50ms).

The bundled Web UI renders them in three places:

  • Topbar chip (#speed-chip) — live during the run from usage, last think-step rate after done. Prefers generationTokensPerSecond when present.
  • Health popover — the same rate under speed.
  • Per-message stats — on the assistant bubble after done.

Do not divide cumulative outputTokens by call duration. That total grows every iteration; the per-call counts are the call* fields. The bundled client never does this.

usage is also sent when the provider omitted prompt size but the think step was timed (so live tok/s still arrives). In that case windowTokens is absent, not 0. Clients must treat a missing window as "hold the last gauge" — the bundled UI already does. A missing rate is held the same way (not blanked mid-run); a new turn_started clears the chip.

Field Frame Meaning
callDurationMs usage, done Wall time of the last main think-step LLM call
ttftMs usage, done Call start → first streamed reasoning/content delta. Absent on the buffered path
generationMs usage, done First delta → call end. Absent on the buffered path
callInputTokens usage, done Prompt tokens for that call only
callOutputTokens usage, done Completion tokens for that call only
tokensPerSecond usage, done callOutputTokens / (callDurationMs/1000) — end-to-end (prefill + TTFT + decode)
generationTokensPerSecond usage, done callOutputTokens / (generationMs/1000) — closer to decode speed; prefer this in UIs when present
llmDurationMs done only Sum of main think-step LLM durations this run (tools and side calls excluded)

tokensPerSecond on thinking models will look slow because most of the wait is before the first visible token; generationTokensPerSecond is the "how fast is it typing" number. Side calls (compaction, titles, progress summaries) never update these fields. GET /api/usage stays process-lifetime counts and does not grow a tok/s average.

The same numbers are on odek.event/v1 iteration_completed / run_completed (snake_case; see EXTENSIONS.md) and on Agent.LastCallMetrics() / IterationInfo for Go embedders.

Content sanitization contract

The server sends all message content raw and unsanitized. HTML-escaping is the client's responsibility: any frontend (the bundled WebUI or a third-party client) MUST escape/sanitize every string field before inserting it into a DOM, terminal UI, or other rendering surface. Untrusted fields include token.content, thinking.content, tool_call.data, tool_result.data, subagent_log.data, error.message, all approval_request strings, skill_event.*, memory_event.*, and agent_signal.*.

Tool results (and user messages containing attachments or @-resource references) may embed the nonce'd untrusted-content envelope:

<untrusted_content_<nonce> source="shell">
...raw body...
</untrusted_content_<nonce>>

The envelope is model-facing trust metadata (prompt-injection defense), not user content. Clients SHOULD unwrap it for display: render the body (escaped) and, optionally, present the source attribute as a badge. The closing tag repeats the opening nonce — treat envelopes whose nonces don't match as plain text. The bundled WebUI implements this in cmd/odek/ui/js/untrusted.js.

Implementation details

Server stack (cmd/odek/)

Component File Purpose
HTTP server + static serve.go (handleStatic) Serves the embedded UI with strict CSP; injects the per-instance token via the ?token= URL
WebSocket upgrade golang.org/x/net/websocket, wired in serve.go RFC 6455 handshake + framing; internal/ws/ws.go holds the shared frame constants
WebSocket handler serve.go (handleWS) Per-connection agent lifecycle, connection registry, ping/pong, server keepalive, cancel / session_switch
Prompt handler serve.go (handlePrompt) Transport-agnostic (event-sink) prompt path: @ refs, attachments, audit, per-turn persistence, streaming-suppression logic — shared by the socket and headless REST runs
Approvals wsapprover.go WS approver with friction, class-trust, and a configurable approval timeout
Management REST serve_api.go health, sessions (search/pagination/pin/export), memory (+consolidate), skills (+promote), tools, config view, MCP listing, shutdown
Runs + observability serve_runs.go headless run engine (POST /api/prompt), remote approval bridge, events ring, usage stats, connection registry
Resource API serve.go (handleResourceSearch) @ completion search endpoint

WebSocket implementation

  • Transport is golang.org/x/net/websocket: the RFC 6455 upgrade, frame parsing, and close/ping/pong handling come from the library; internal/ws/ws.go defines the shared frame-type constants
  • Messages are JSON over text frames; fragmentation details are handled by the library
  • Frame writes are serialized per connection (golang.org/x/net/websocket is not safe for concurrent sends) and bounded by a 30-second write deadline
  • A clean client close surfaces as io.EOF; broken connections surface as net.Error
  • Per-connection agent lifecycle (registry, ping/pong, server keepalive, cancel / session_switch) lives in serve.go (handleWS)

Frontend (cmd/odek/ui/)

  • Vanilla JS + CSS SPA split into native ES modules under js/ — no build step, no bundler, no CDN. Module map: main (init/theme/keyboard) · commands (⌘K palette + 5 composer slash verbs) · tools (typed result chips) · ws (protocol v2 + turn_started/bg_job) · api (typed REST client) · sessions · panels (inspector workspaces: sessions / now / results / memory / ops / manage) · plan · health (heartbeat + notifications) · render/markdown/untrusted · approvals · input (send, queue, @, attachments) · state/dom/utils/net/escape
  • Escaping: all server-controlled strings are inserted escaped (escapeHtml/escapeAttr/textContent); markdownToHtml HTML-escapes all input by default and allowlists link schemes — see "Content sanitization contract" above. No inline scripts or handlers anywhere (CSP script-src 'self'); generated content uses event delegation
  • Untrusted envelope: js/untrusted.js unwraps the model-facing <untrusted_content_*> envelope before display (body shown; source discarded)
  • Design: self-contained EMBER dark, light and high-contrast themes. Locally bundled Geist Sans serves reading/interface text and Geist Mono serves code. Comfortable density uses 16px reading text; compact uses 13px. The 56px desktop header becomes two rows on mobile, with a collapsible session rail from 1100px, hidden by default, and a resizable inspector. CSS variables define colors, spacing and typography; reduced motion is respected. No CDN or font network request is required.
  • Streaming: fragments (token_delta/thinking_delta) and bulk token events share one rAF-batched render pipeline
  • DOM budget: the message list is capped at 80 elements (MAX_MESSAGES); older messages are pruned
  • Resilience: auto-reconnect with exponential backoff (1s doubling to a 30s cap, reset after a stable connection) plus the 20s application heartbeat and the server's 20s keepalive. A drop is visible: amber top-bar word, sticky #conn-banner with retry countdown, one transcript line per outage, and a composer toast if you send while down.
  • Tests: node --test cmd/odek/ui/js/ (markdown + untrusted-envelope goldens, and api.js request-shape E2E against a mocked fetch) plus Go-side WebUI E2E (cmd/odek/webui_e2e_test.go): asset/header/CSP contract, token injection, JS↔HTML id and JS↔CSS class contracts, and full client journeys (streamed WS run, headless run with the remote-approval bridge, kick, pin/export) through the production mux (newServeMux — the same constructor serveCmd uses, so tests cannot drift from the real mounting)

Tips

  • Security sandbox: odek serve --addr 127.0.0.1:8080 restricts to localhost. Use a reverse proxy (Caddy, nginx) for remote access. Serve mode enables the Docker sandbox by default — opt out with --no-sandbox.
  • Config inheritance: odek serve reads the same config chain (~/.odek/config.json./odek.json → env vars) as odek run. Set your model, API key, and sandbox settings there.
  • Live streaming: on by default — thinking-default models render reasoning and answer as they generate. Disable with --no-stream if a gateway mishandles SSE.
  • Headless clients: scripts and TUIs don't need the WebSocket — POST /api/prompt + poll GET /api/runs/{id}, and answer approvals through /api/runs/{id}/approvals/{aid}. The bundled WebUI's runs tab uses exactly this surface.
  • Session discovery: reference any saved session via @sess:ID in your prompt to give the agent full context from previous conversations.

Reference workspace additions

The desktop client provides a collapsible session rail above 1100px, hidden by default, and a resizable inspector (320–700px). Comfortable and compact density are available in server status. Draft text is retained per session in tab-scoped session storage. Tool results share bounded, searchable renderers between live and historical views; Results collects outputs and links back to their conversation position.

Tool frames add call_id and outcome (completed or failed). Completion means the tool returned without a Go error, not that its output proves task success. Historical messages retain tool_outcome; older records display unknown status. The legacy public tool callback remains supported alongside ToolDetailHandler.

New operator-authenticated endpoints:

Endpoint Behavior
GET /api/capabilities Additive feature flags and result renderer families. Missing flags mean unavailable.
GET /api/skills?name=NAME Full discovered skill body and provenance for human review.
GET /api/tools Adds descriptions and JSON schemas to built-in registry entries.
GET /api/schedules Shared scheduler definitions, runtime states and next-fire previews.
POST /api/schedules Create a schedule using the CLI's validation and file locks.
POST /api/schedules/{id} Replace a schedule definition, preserving identity and creation time.
DELETE /api/schedules/{id} Delete a schedule.
GET /api/maintenance Operator-resolved retention policy.
POST /api/maintenance Apply that policy with {"confirm":"cleanup"}; returns a report.
POST /api/uploads?name=NAME&session_id=ID Raw passive-media upload, 5 MiB limit, detected MIME. Existing sessions require their token; omitting ID creates an upload session and returns its token.
GET /api/artifacts?session_id=ID Session-scoped cached artifact metadata; requires session token.
GET /api/artifacts/{id}?session_id=ID Authenticated download of immutable cached bytes; requires session token.

Schedules are executed by a running odek schedule daemon or Telegram scheduler; the management API does not start another scheduler. New schedules in the UI are paused by default. Maintenance never accepts filesystem roots or replacement policies from the client.

Uploads return upload_id, session_id and auth_token. Send the opaque upload_id in a prompt attachment. The server validates session ownership and supplies the local uploaded file reference inside an untrusted attachment boundary; image/audio interpretation uses the configured vision/transcription tools. Files are stored under the serving workspace’s .odek-artifacts/uploads/, with a suffix derived from detected MIME, so workspace-confined tools can read them. Uploads are removed with their sessions and swept at startup and every minute. The workspace retains at most 128 uploads / 256 MiB for seven days, evicting the oldest first. Attachments in active turns are pinned against retention until the turn ends; new uploads are rejected if pinned files leave insufficient capacity. Session deletion still removes its uploads immediately. Upload handles are process-local; pending uploads must be reattached after a restart. Existing files count toward retention without being trusted as new user uploads. Binary uploads are not silently decoded as text or sent as native model image parts.

The artifact WebSocket event carries session-bound metadata for a validated MCP artifact. Preview capture revalidates roots and digest and opens through os.Root. Preview reads share a 40 MiB per-turn budget, including failed captures. Each copy is capped at 10 MiB; the process cache is capped at 40 MiB/128 entries and is cleared on restart. Missing/evicted artifacts return 404. Original extension files are unaffected. Media rendering uses authenticated Blob URLs; HTML/SVG and unknown content remain download-only, and PDF previews are sandboxed.

runtime_event relays iteration and budget-exhaustion events. Iteration data adds an authoritative budget snapshot with configured maxima and remaining amounts; zero maxima mean unconfigured. The Now inspector displays remaining allowances.

For repeatable visual review without provider credentials, run node scripts/webui/fixture-server.mjs and open http://127.0.0.1:4173. This fixture uses the real UI assets and synthetic sessions, never a real agent.

Consolidation supports POST /api/memory/consolidate with target and mode: "preview" to propose {before, after}. mode: "apply" accepts the reviewed preview object and rejects concurrent changes. Preview runs the existing scanner and consolidation logic against an isolated temporary store. Legacy callers can omit mode to retain immediate consolidation.

Text drafts survive reloads within the browser tab. Attachment drafts survive session switches in memory; reattach them after a reload. Queues pause on interruption and session changes and require an explicit resume in their owning session. Uploads that finish after a session switch cannot attach to the new conversation. Plan result links show temporal association with an active step; they do not claim that a tool result validates the step.

The result registry supports code, aligned split/unified diffs, terminal output, file-search references, source links, HTTP status cards and expandable JSON. Unknown tools keep the searchable raw-text fallback. Views start with 12 inline or 200 detail lines and expand in bounded pages. The Results collection retains up to 300 entries/16 MiB of text per visible session. Historical outcomes from older servers remain unknown instead of being inferred from optimistic output.

REST run approvals expose requires_confirmation when the operator's REST friction setting requires a typed response. The UI asks for that response before submitting. Full run details can be refreshed without closing the inspected view.

Visual regression review

Use the fixture with a current browser at 1440×1000, 1024×768 and 390×844:

  1. Open a fixture session; inspect code, unified/split diff and terminal output. Search output, switch Raw, and navigate back to the conversation.
  2. Open the inspector and resize it with pointer and arrow keys. Tab through inspector controls and use arrow keys to change tabs. Check both densities.
  3. Switch between EMBER dark, light and high contrast. Verify readable contrast, no page-level horizontal overflow, and visible keyboard focus.
  4. At mobile width, open Sessions, Results and Manage. Open the schedule editor; verify labeled inputs, scrolling and controls remain within the viewport.
  5. Type a draft, switch sessions and return. Submit a fixture prompt to exercise correlated tool frames and the live result collection.

The fixture serves synthetic responses, so it validates presentation and client interaction only. Go tests exercise production routing, auth, artifacts, uploads, schedules and WebSocket journeys; the JS suite covers renderer and lifecycle regressions. Actual provider execution remains a separate integration check.

Tool-specific views

Tool headers use semantic argument summaries (create · 3 steps, 2 commands, 2 files, 2 edits) and switch to returned result summaries when available. Unknown object arguments are serialized instead of becoming [object Object]. Live, historical and Results inspector views use the same presentation models.

Tools Structured presentation
plan Version/progress summary and step states: pending, in progress, done, blocked. Requested changes remain distinct from returned state.
parallel_shell Expandable commands with individual exit codes, duration, stdout, stderr and errors; partial failure counts in the collapsed header.
batch_read Separate file contents, total lines and per-file errors.
batch_patch Separate edit outcomes and expandable diffs/errors.
read_file, write_file, patch, diff Content extracted from the actual tool DTO, write outcomes, and unified/split diff rendering.
http_batch Individual request statuses and errors.
search_files, multi_grep File matches and per-pattern result groups with counts/skipped paths.
count_lines, word_count, checksum, sort, head_tail Per-file detail groups.
Other batch_* tools Generic per-item inspection when the tool returns a results array, without inventing success status.

Nested result bodies are built only when opened. Inline collections initially show three items; additional items are paged. Search includes child output. Raw, copy and save retain the complete received payload. Malformed or truncated JSON falls back to text rather than presenting requested work as completed. A successful tool callback is distinct from the individual outcomes within a batch, which are displayed explicitly. Go test failure lines remain terminal output and are not mistaken for diff headers.