Status: proposed — not yet implemented. Audience: the engineer/agent implementing WebSocket streaming for the CLI.
The CLI can start runs and read their state over the public /v1 REST API, but
it cannot stream a run's output. agent run get --watch exists today and gives a
status-level live view by polling GET /v1/agents/runs/{id} until the run
reaches a terminal status (completed/error/cancelled/stopped). It shows
status transitions and the final summary — not the step-by-step output.
Crucially, the backend already streams steps live — just not over /v1. The
dashboard consumes a WebSocket stream; this work is about re-exposing that same
stream under /v1 for bearer-authenticated CLI clients. The bulk of the
machinery (step model, persistence, event bus) already exists and must be reused,
not reinvented.
- Endpoint:
@router.websocket("/agents/runs/{run_id}/stream")—agents_router.py:1200, loop_stream_run_loopat:1147. - Frames: an initial
{"type":"snapshot","run":...,"steps":[...]}, then{"type":"steps_append","steps":[...]},{"type":"run","run":...}, and{"type":"heartbeat"}. Socket closes when the run status is terminal.STREAM_RECONCILE_SECONDS = 10forces a DB re-read even with no event. - Step model:
AgentStep(src/models/agents/agent_step.py) with a monotonicstep_index: intanddata: CCStep.CCStep(src/models/agents/claude/cc_step.py) is the typed Claude Code stream-json message — a discriminated union of assistant (text/tool_use/thinking blocks + usage), user (tool results incl. stdout/stderr), system-init, and result (cost/duration). Stored in theagent_stepstable, indexed on(agent_session_id, step_index). - Source + signal: Claude Code runs with
--output-format stream-json; the sandbox runner parses each stdout line into aCCStep, assignsstep_index(run_in_sandbox.py:221-257), inserts anAgentSteprow, and firespg_notify('agent_events', {run_id, kind})— a pointer, never the data (realtime/agent_events.py). Eachpublic_apiprocess runs anAgentEventBus(realtime/agent_event_bus.py) doingLISTEN agent_eventson a dedicated session-mode connection, fanning notifies to per-runasyncio.Queues. The WS handler wakes on a notify and re-reads run+steps from the DB (the DB is the source of truth; NOTIFY is only a wake-up). - Cursor:
step_indexis monotonic per session, but the protocol exposes no client cursor — each (re)connect gets a fullsnapshot, then deltas via a localsent_stepscounter. Dropped notifies self-heal via reconcile. - Auth: browsers can't set WS handshake headers, so an authenticated REST
call (
GET /agents/runs/{id}/stream-ticket,:1090) mints a 60s HMAC ticket (realtime/stream_tickets.py, signed withFRONTEND_API_KEY) passed as?ticket=. The handler verifies signature+expiry, then re-checks run ownership against the DB (close4401/4403on failure).
agent run get <id> --watch streams a run's steps live, in real time, and falls
back to REST status-polling when streaming is unavailable. The same flag covers
both modes — no new top-level command.
Re-export the existing stream; do not build a parallel one. Reuse
agent_steps, CCStep, and the AgentEventBus exactly as the frontend stream
does. The /v1 endpoint is a thin re-auth + re-encode of _stream_run_loop.
- Endpoint:
GET /v1/agents/runs/{run_id}/stream, upgraded to WebSocket. - Auth — bearer, not ticket. The CLI holds a
/v1bearer token, so resolve it with the sameV1Authpath as the REST API (Authorization header on the handshake, which non-browser clients can set), authorizing the run's customer. The 60s?ticket=dance is a browser workaround the CLI doesn't need; don't require it. - Frame protocol — reuse the existing shape:
snapshot(run + steps),steps_append(newAgentSteps),run(run-row change),heartbeat, and a terminal close. Each step serializes as the existingAgentStep/CCStepJSON so CLI and dashboard share one schema. - Add a real resume cursor. Accept
?since=<step_index>(and/or a first client message). On connect, send only steps withstep_index > since(skipping the full snapshot) — a CLI reconnect must resume incrementally, not re-download every step. Absentsince, behave like today (full snapshot). Consider also adding?since=to the RESTGET /agents/runs/{id}/steps(agents_router.py:1073), which currently takes no cursor. - Heartbeat / reconcile: keep the existing heartbeat + 10s DB reconcile so dropped connections are detectable and dropped notifies self-heal.
- Termination: send the final
run/terminal frame, then close normally. For an already-terminal run, replay steps (honoringsince) then close.
agent run get <id> --watchopens the/v1stream and renders frames:snapshot/steps_append→ render eachCCStep(assistant text + tool calls, tool stdout/stderr, thinking if--verbose);run→ status transitions; terminal close → final summary. Exit 0 on a successful terminal status, non-zero on error/failed terminal status.- Reconnect with backoff, resuming from the highest
step_indexseen via?since=, so a dropped socket loses/duplicates nothing. - Fallback: if the WS can't connect (server without the endpoint, or a
1003/unsupported close), fall back to the existing REST status-pollwatchRun()with a one-line notice.--watchmust keep working against a backend that lacks streaming. --jsonwith--watch: emit one JSON object per frame (NDJSON).- Reuse/replace the
src/lib/ws.ts+src/ui/RunView.tsxscaffolding. Fixws.tserror handling to surface a readable message, not[object ErrorEvent](the original bug came from connecting to a then-nonexistent endpoint). - Define a TS mirror of
CCStep(or a pragmatic subset) insrc/lib/types.ts, matching the backend union.
Mirror the backend's existing codes where possible:
| Code | Meaning |
|---|---|
| 1000 | normal — run reached a terminal state |
| 4401 | auth failed (bad/expired credential) |
| 4403 | authenticated but not authorized for this run |
| 1003 | streaming unsupported (client falls back to polling) |
| 1011 | server error |
- Streaming a live run shows assistant output + tool stdout/stderr in near real
time, sharing the
AgentStep/CCStepschema with the dashboard. - Killing the socket mid-run and reconnecting with
?since=<last step_index>resumes with no lost or duplicated steps and without a full re-snapshot. --watchagainst a backend without the/v1endpoint transparently falls back to REST status-polling and still completes.--json --watchemits valid NDJSON, one frame per line.- Unit tests for the client frame handler, the
sinceresume cursor, and the fallback trigger (mirror the fake-timer style intest/auth.test.ts/test/run.test.ts). - No
[object ErrorEvent]; connection errors print a real message.
- Bidirectional control (stop/input).
run stopis tracked separately and also has no/v1endpoint yet. - Re-architecting the transport (NOTIFY + DB-as-source-of-truth stays).
- Multiplexing multiple runs over one socket.
src/public_api/routers/agents/agents_router.py— WS:1200/_stream_run_loop :1147, REST steps:1073, stream-ticket:1090.src/public_api/realtime/agent_events.py,agent_event_bus.py,postgres_listen_bus.py,stream_tickets.py.src/agents/engine/claude/run_in_sandbox.py:221-257,shared.py:45-94.src/models/agents/agent_step.py,src/models/agents/claude/cc_step.py,src/models/database/agents/agent_steps_row.py.