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
projectSlugstringWorkspace key — a project slug, or "" for the root workspace.
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 stamped by the hub’s emitchat:error, chat:resync, chat:active, chat:queued_flushed, chat:queued_state, chat:queued_returned, chat:killed_task, pong. Those last four go out via hub.broadcast, which reaches the origin socket and every subscriber without seq-stamping or buffering, precisely so an out-of-band signal still reaches a client that reconnected on a new socket.

Client→server payloads carry projectSlug. Invalid JSON / unknown kinds get a chat:error reply.

KindWhen it firesPayload (beyond projectSlug)
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, attachments?: AttachmentRef[]
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_queueWrite/clear the chat’s single queue slot server-side (survives browser close, and is shared across clients — see chat:queued_state).sessionId?: string | null, text?: string | null (null/empty ⇒ clear), qid?: string | null, attachments?: AttachmentRef[], ts?: number | null (legacy)
chat:continueThe Continue button on a killed-task notice — re-drives a hung chat 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
pingClient keepalive every 25s.(none)

AttachmentRef (on chat:send and chat:set_queue): { id: string, filename: string, kind?: string }. Each references an attachment already uploaded via POST …/chats/:id/upload — the frame carries the reference, never the bytes. Project chats only.

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, startedAt?: number (epoch-ms the turn began, from the hub — the only thing that knows, since a job record is written when a turn ends and the transcript’s timestamps are the model’s; present on the stop frame too, where it describes the turn that just ended. This frame carries its own projectSlug/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, subagentType?: string, description?: string, hasSubagent?: boolean
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, subagentType?: string, description?: string, hasSubagent?: boolean
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, 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, sessionId: string
chat:injectedA resumed turn received its prompt. Emitted once per turn, and only on a resume, so a client can render the injected message attributed to whoever (or whatever) sent it.sender: MessageSender, content: string, timestamp: string (ISO)
chat:queued_flushedThe server auto-drained the queued message after a turn (or when idle).projectSlug: string, sessionId: string, text?: string (present ⇒ render as a user bubble; absent ⇒ just clear a stale copy), attachments?: AttachmentRef[] (only alongside text)
chat:queued_stateThe chat’s queue slot was written. Broadcast to every socket attached to the session, so the queue is shared chat state that all clients render identically.projectSlug: string, sessionId: string, text: string | null (null ⇒ the slot is now empty), attachments?: AttachmentRef[], qid?: string (adopt it, so your next edit updates this slot in place rather than appending beside it), reason?: "returned"
chat:queued_returnedA user pressed Stop, so the message queued behind that turn is handed back to them. Sent only to the socket that issued chat:cancel; the other clients get a chat:queued_state with reason: "returned" instead.projectSlug: string, sessionId: string, text: string, attachments?: AttachmentRef[]
chat:killed_taskA background task the chat 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 “Claude 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, 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.

The queued message is shared chat state, not per-client state, and that takes four frames plus the inbound chat:set_queue. Which one you get says what happened to the slot:

FrameDirectionMeaning
chat:set_queueclient → serverWrite or clear the slot. Contributions merge; attachments union by id, so one client’s write can never silently drop another’s file.
chat:queued_stateserver → all attached socketsThe slot changed; here is its full current contents. This is what makes the merge visible.
chat:queued_flushedserver → allThe slot was drained and sent as a turn. Render text as the user bubble — the drained turn streams only the reply.
chat:queued_returnedserver → the stopping socket onlyStop was pressed; the message was not sent and goes back into that user’s composer.

The _returned / _flushed split is deliberate and not a flag on one frame: _flushed means “this text was sent, render it as the user’s bubble”, and a returned message is precisely one that was not sent. Overloading it would put a phantom user turn in the transcript for a message the agent never received.

Notes: There is no chat:tool_end (completion is chat:tool_call), no bare chat:queued, 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.