Skip to content

WebSocket protocol

The chat back-and-forth runs over a single WebSocket at /ws, registered behind the same auth hook as REST. The web client opens one shared socket (packages/web/src/lib/ws.ts), auto-reconnects, and keeps it alive with a JSON ping/pong every 25s (plus protocol-level WS pings server-side).

This protocol is deliberately not part of the OpenAPI spec — that document is generated from Fastify route schemas and can only describe the HTTP surface. This page is the hand-maintained contract for the WebSocket, and the only place it is written down.

Every frame is a JSON object { type, payload } (ping/pong are just { type }, no payload). type is the message kind.

Server→client chat events carry a common Routing block in payload:

FieldTypeNotes
projectSlugstringProject slug, or "scratch" for one-off chats.
targetstringLegacy alias for projectSlug (server emits both).
sessionIdstring | nullNull until a brand-new chat’s id first streams back.
jobIdstring | nullThe cancellable job id, when known.
seqnumber?Per-turn monotonic sequence for reconnect/gap-replay. Absent on frames not routed through the hub (chat:error, chat:resync, chat:active, chat:queued_flushed, pong).

Client→server payloads accept either projectSlug or the legacy target alias. Invalid JSON / unknown kinds get a chat:error reply.

KindWhen it firesPayload (beyond projectSlug/target)
chat:subscribeOn (re)connect, to attach a socket to a session’s live stream and replay any missed gap.sessionId: string, wantReplay?: boolean, lastSeq?: number
chat:sendUser (or a server-side queue drain) sends a message / starts or resumes a turn.sessionId?: string | null (null ⇒ new chat), message: string, preloadContext?: boolean, model?: string
chat:commandUser runs a slash command (e.g. /compact) in the current chat.sessionId?: string | null, command: string (full text incl. leading slash)
chat:cancelUser clicks Stop; cancels the running turn’s job.jobId: string
chat:set_queuePersist/clear the single-slot composer queue server-side (survives browser close).sessionId?: string | null, text?: string | null (null/empty ⇒ clear), ts?: number | null
chat:continueThe Continue button on a killed-task notice — re-drives a hung keeper with a recovery-attributed nudge (sender: { kind: "recovery" }). Refused server-side when the resolved recovery.surfaceKilledTask is off, so a client can’t re-drive an instance whose operator turned Layer 2 off.sessionId: string (required — recovery needs a chat), projectSlug?: string, target?: string
pingClient keepalive every 25s.(none)
KindWhen it firesPayload (beyond Routing)
chat:activeA session’s live-turn status changed (start/stop); broadcast to all clients, and sent as a snapshot to a newly-connected or subscribing socket.sessionId: string, jobId: string | null, running: boolean (this frame carries its own projectSlug/target/sessionId, no seq)
chat:responseA streamed assistant text delta. Also surfaces a /compact boundary as a synthetic note.chunk: string
chat:tool_startA tool_use begins (before it runs) — renders a pending “running…” row.toolName: string, inputSummary?: string, toolUseId?: string, parentToolUseId: string | null
chat:tool_callA tool completes (paired tool_use→tool_result); reconciles the pending row.toolName: string, inputSummary?: string, output: string, isError: boolean, durationMs?: number, toolUseId?: string
chat:message_boundaryAn assistant message bubble ended.(Routing only)
chat:completeThe turn finished (success or failure); carries final usage/model.success: boolean, error?: string, model?: string, usage?: ChatCompleteUsage
chat:errorA turn threw before/without a resolved session (sent to the origin socket only); also the reply to invalid JSON / unknown frames.projectSlug: string, target: string, error: string (no sessionId/jobId/seq)
chat:resyncReconnect fallback: the live turn’s frame buffer aged out past the requested gap, so the client must re-hydrate from the transcript.projectSlug: string, target: string, sessionId: string
chat:queued_flushedThe server auto-drained the persisted queued message after a turn (or when idle).projectSlug: string, target: string, sessionId: string, text?: string (present ⇒ render as a user bubble; absent ⇒ just clear a stale copy)
chat:killed_taskA background task the keeper was waiting on was killed. Broadcast live, the moment the recovery engine detects it — otherwise the notification sits in the SDK input queue until some later turn flushes it, and the “keeper is idle / Continue” affordance only appears after a manual refresh. Rendered as the amber killed-task notice. Gated on recovery.surfaceKilledTask, which is on by default.projectSlug: string, target: string, sessionId: string, summary: string (the killed <task-notification>’s <summary>, or a generic fallback), timestamp: string (ISO, used client-side to dedup replays)
chat:noticeA turn dead-ended without a normal reply — a usage/subscription limit, the max-turns cap, or an error (network, API 5xx-overloaded, auth, crash). Emitted inline during the turn and session-routed like the other turn frames, so the chat says why it stopped instead of looking dead.notice: TurnNotice (carries the reset time for a usage limit, and retryable for the Retry/Continue affordance)
pongReply to a client ping.(none)

ChatCompleteUsage (on chat:complete): inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, contextTokens (= input + cacheRead + cacheCreation), contextLimit (= the model’s context limit). Stale-by-one-turn by design.

Notes: There is no chat:tool_end (completion is chat:tool_call), no chat:queued (drain is chat:queued_flushed), and no dedicated snapshot frame — chat:active doubles as the on-connect snapshot, and reconnect/replay flows through chat:subscribe → (replay | chat:resync). A /compact compaction is folded into a chat:response chunk + chat:message_boundary, not its own kind.