Wire protocol
September 3, 2026 ยท View on GitHub
What the WebSocket protocol is still being moved toward, and the decisions the code cites by number. Agreed up front rather than discovered along the way: the frontend and the backend change together, and without a stated target they renegotiate the payload shape three or four times. Changing a decision here is allowed โ but it is a decision, made once, not a drift.
The protocol that ships today is documented in the wire surface; the live definition is
internal/protocol/messages.go, mirrored ininternal/web/frontend/src/lib/protocol.ts. Neither may diverge from the other at any point.
What does not change
- The envelope stays
{"type": "...", "payload": {...}}. - Application-level auth stays:
{"type":"auth","token":"..."}first, server repliesauth.ok. No token in the query string. - Run events stay a broadcast bus, per owner: every connection of a
session owner's is attached to that owner's runs, and nobody else's hears
them. A dropped socket does not cancel a run, and
run.subscribewithfrom_seqresumes from a cursor. - Event-type constants stay single-sourced in Go and mirrored in TypeScript. A typo must remain a compile error, never a silently-undelivered event.
F1 ยท Every streaming delta carries an entry id โ ๐ง open
The single most important change. Today run.step is {run_id, delta} โ
there is no way to tell which item a delta belongs to. run.message carries an
item_id, but the deltas that preceded it do not. The frontend therefore
attributes deltas by position, and streamReducer.mergeLiveTail exists to
reconcile the guess against what actually got persisted. It goes away when
deltas are attributable.
Frozen:
// run.delta โ replaces run.step and run.reasoning
{
"run_id": "...",
"entry_id": "msg_abc", // REQUIRED. Ties the delta to its entry.
"field": "text", // "text" | "reasoning"
"delta": "partial cont"
}
Client rule, frozen: a delta appends into a provisional buffer keyed by
entry_id. The completed entry (F2) replaces that buffer wholesale โ it is
never merged into it. An entry that arrives with no preceding delta renders
immediately; deltas that arrive for an entry already completed are discarded.
This keeps bandwidth O(n) โ the alternative of resending the whole entry per token is O(nยฒ) on long answers โ while removing every case where the live view and the persisted view can disagree.
F2 ยท One run.entry event replaces six item events โ ๐ง open
Today six events carry items, each with its own payload shape:
run.message, run.reasoning_item, run.tool_call, run.tool_result,
run.handoff, run.compaction. Each addition means a new event type, a new
reducer branch, and a new persisted-vs-live shape to reconcile.
The SDK's session.Entry makes them one thing.
Frozen:
// run.entry โ one completed entry, authoritative
{
"run_id": "...",
"entry": {
"id": "msg_abc",
"seq": 41, // storage-assigned, monotonic per session
"kind": "item", // item | annotation | compaction | terminal | custom
"parent_id": "msg_aba", // tree model; empty at a root
"source": { "type": "model" }, // model | user | tool | handoff | error_handler | compaction
"display": { /* F4 */ },
"usage": { "input_tokens": 0, "output_tokens": 0 },
"payload": { /* kind-specific; for kind=item this is the Responses item verbatim */ }
}
}
Frozen invariant: the entry delivered over the wire is byte-identical in
shape to the entry returned by the REST history endpoint. One shape, one
renderer, no adapter. This is what makes mergeLiveTail deletable rather than
merely smaller.
kind is an open vocabulary โ an unknown kind renders through the custom
fallback rather than being dropped. Clients must not switch exhaustively on it.
Not replaced by run.entry (they are run lifecycle, not content):
run.started, run.agent_start, run.output, run.error, run.interrupted,
run.cancelled.
run.compaction survives as a progress signal (phase: started|finished)
while the compaction checkpoint itself arrives as a run.entry with
kind: "compaction". Progress is transient; the checkpoint is history.
F1 and F2 travel together: they replace per-delta text with per-entry snapshots, which is a protocol change whose payoff is on the client โ roughly half of the streaming reducer's transforms become a replace-by-id. The half that would NOT go away is the reconciliation between a REST history fetch and the live events that arrived while it was in flight, which is a client-side ordering problem the payload shape does not touch. Weigh that before starting.
F3 ยท run.error.code mirrors the SDK ErrorCode โ shipped
The SDK owns the error vocabulary; the bridge calls agents.CodeOf(err), and
agents-server adds transport-only codes with no SDK equivalent. Frozen: the
two sets share ONE flat namespace on the wire and must not collide, so the
set grows without a client release โ an unrecognized code falls back to
generic error rendering. The codes themselves are in
the wire surface.
F4 ยท display is a structured projection, not a string โ shipped
display is agents.ItemDisplay serialized as-is; the field list follows the
SDK. Frozen: display is a rendering HINT โ a client that ignores it
entirely must still produce a correct timeline from payload alone, which is
what lets display evolve without a lockstep frontend release. Streaming
partial tool results ship as run.tool_progress today; under F1 they become a
run.delta on the tool call's entry_id.
Shipped elsewhere
In the wire surface: F4a run.gap,
F5 uplink queues (run.inject, run.cancel), F7 cursor pagination,
F8 session trees. F6 run phase was dropped 2026-08-04 โ the stream's
own events carry more than a phase enum would.
Discipline
messages.gois the source of truth;protocol.tsmirrors it. Same commit, always.- No string literals for event types or error codes on either side.
- Unknown values degrade, never drop โ unknown
kind,rendererandcodeall have defined fallbacks above. This is what lets the server ship ahead of the client. - Changing this document is a decision, recorded here with its reason โ not an edit made in passing while implementing something else.