Skip to main content
Every turn is a stream of typed events, decoded once so nothing above has to parse prose.

Quick Start

1

Decode a frame

decodeEvent never throws. Every rejection is a value with a reason, so a client can count them.
2

Stop at the terminal event

After a terminal event nothing else follows.

What Happens To A Rejection

A rejection is not silent — it lands on the transcript as a dropped row.
A refused frame is not discarded. The mobile app surfaces it as a dropped event, so a shorter-than-expected reply must never be treated as a clean answer. See Dropped Events.
parseFrame adds two reasons the decoder alone could not report — unparseable_json (an HTML error page or truncated body) and not_an_object (a JSON array, string, or null) — so five distinct wire failures are no longer collapsed to one missing_msg_id.

The 11 Events

Every event carries msgId. Fields listed are the ones beyond that.

What The Decoder Refuses

decodeEvent takes unknown, so the validation below is the wire contract — not a suggestion a buggy engine adapter can skip.
The “always allow” button exists in the UI and is bound to the string "always". Any decoder that quietly maps an unknown choice to a default authorises something the user did not pick — and the falsy direction to guess is "allow", which is the dangerous one.

Approval choices

The decoder exports decodeApprovalChoice, and the set is exhaustive — every unknown string returns null, never a silent default.

Event type comes from the SSE event: line, never the data payload

The event’s type is read from the SSE event: line, not from any type field inside the frame’s data object. The engine builds the decoded event as decodeEvent({ ...parsed.value, type: frame.event }) — the event: name overwrites any type the payload carried. A delta frame whose data object happens to include type: "error" is still a delta:
This is the shape assumption the whole 11-event contract stands on. Before #4579 the two were transposed, so a delta with type: "error" in its payload decoded as an ill-formed error, was dropped silently, and text vanished from the answer.
This class of mis-decode never reached onIgnored: the frame was mis-typed, not refused, so the DropSink channel could not have caught it. See Dropped Events. The regression "the SSE event name decides the type, not a field inside the payload" pins that a delta frame whose data object carries type: "error" is decoded as a deltaassert.equal(delta.text, "kept").

How A Turn Streams


Fields That Trap Readers

callId vs approvalId are never interchangeable. callId says which row to attach the prompt to; approvalId is what gets sent back. In the two-approval case the prompts arrive in the opposite order to their tool rows, so zipping by index crosses rm with curl.
end.userIndex === null means “not on disk”. The write failed, so Fork and Delete must be withheld. Index 0 is a valid, persisted message — a falsy check (if (!userIndex)) is a trap.
Terminal events are mutually exclusive and final. After end, cancelled, or error, nothing else may follow. A cancelled turn is never persisted, so no end and no usage follow it.
chat_id on the wire is per-conversation. Each request carries the id of the conversation it belongs to, minted at New chat via controller.setChat(mintChatId()). In a shipped app it is never the literal "unassigned" — that placeholder only appears when setChat is never called, which keys every conversation to one server-side thread against an engine that stores history by chat_id. See New chat semantics.
Unknown ErrorKind degrades to "internal". decode.ts maps any kind it does not recognise to "internal" rather than dropping the event, so a newer engine’s category still reaches the user.
Large tool_result payloads no longer block the main thread. A big file arriving as one frame over many small chunks is read in linear time — see Frame buffering below.

Error Kinds

ErrorKind selects the recovery the UI offers. Before this, a 403 — what an engine behind a proxy or a scoped key actually returns — was classified transport, so the UI offered Retry forever instead of sending the user to credentials.
A CRLF, LF, or bare-CR stream is handled identically. createSseReader tracks a pending \r across chunk boundaries, so a \r\n split between two chunks is still one terminator, and a lone-CR stream still completes its last frame. Mixed-terminator streams (a CRLF followed by a bare LF, legal per the SSE spec) are also handled correctly — the LF that follows a swallowed CRLF is still recognised as its own line break, so an event boundary written two ways in one stream is not lost. Before this, a chunk ending in \r followed by a chunk beginning \n synthesised a spurious frame boundary and half the answer could vanish; before #4589 the reverse could also happen — a CRLF-then-LF pair inside one stream joined two frames into one, so an event boundary was lost. Pinned by the differential-probe test in sse.test.ts — cutting event: delta\r\n\ndata: x\n\n at [13,14] yields two frames when correct and one when the flag is inverted.
The \n\n boundary that straddles two chunks is handled explicitly. The reader searches for the frame terminator inside each arriving chunk alone rather than over the concatenated buffer. When the buffered text ends with \n and the next chunk begins with \n, that \n\n exists only across the join and is invisible to the per-chunk search — the reader detects that case up front and completes the frame. Pinned by the differential-probe test in sse.test.ts — 932 (stream, chunking) pairs, every split index for LF, CRLF, bare CR, mixed terminators, comments, folded data, id/retry, no-colon lines, blank runs, and unterminated tails, plus each with an empty chunk wedged in.
Frame buffering is linear in frame length, not quadratic. createSseReader keeps arriving chunks as an unjoined array and searches for the \n\n boundary inside each chunk alone; the pieces are joined only when a frame completes, over that frame’s own length. A single large frame arriving over many small chunks — a tool_result carrying a big file over a cellular link is exactly that shape — is O(n) in its length. Measured for one 1 MB frame over 512 B chunks: 2.4 / 10.7 / 40.7 / 163.1 ms at 128 / 256 / 512 / 1024 kB before the fix (4.01× per doubling) → 0.3 / 0.5 / 0.8 / 1.4 ms after (1.73× per doubling). At 1 / 2 / 4 MB over 1 kB chunks: 100 / 288 / 1185 ms → 1.3 / 2.0 / 2.8 ms. Many small frames — the common case — are unchanged.
The SSE reader strips exactly one leading space from a field value, per the SSE spec — never more. A payload that legitimately begins with whitespace (indented JSON, or text whose first token is deliberately padded) is preserved verbatim after the single-space strip: " {" on the wire becomes " {" in frame.data, not "{". The rule is if (value.startsWith(" ")) value = value.slice(1) — never value.trimStart(), which would strip all leading whitespace and corrupt those payloads silently, with neither the reader nor the decoder reporting the corruption. Pinned by "the SSE reader strips exactly one leading space, per the SSE spec".
The SSE reader recognises four fields: event, data, id, and retry. id and retry are read and discarded today (the desktop engine sends neither), but they still count as “recognised”: a retry-only frame is a real frame the reader deliberately ignored, not a null that vanishes from the stream. The paired rule: a block carrying no recognised field at all (blank lines, comments only, or only unknown fields) returns null — otherwise every SSE heartbeat would inject a junk { event: "", data: "" } frame. This is a reader contract, upstream of the decoder. Pinned by "a retry-only frame is read and discarded, not dropped" and "a block with no recognised field is not a frame".
An id:-only frame is read-and-discarded, the same way retry: is. In protocol/src/sse.ts, id is the field that would become the resume cursor if the engine ever sent one; it is not consumed today, but the reader must not drop the frame around it. Same rule as retry, different field — the recognised-but-unused set is {id, retry}, not {retry} alone.
A line without a colon is a legal SSE field. Per the SSE spec, a bare data (no colon, no value) is an empty data field, not garbage — the reader must not drop the frame that contains it. This is subtle because most implementations parse field:value only; the spec requires the field-only form to work, so protocol/src/sse.ts treats a colonless line as field with an empty value rather than discarding it. The paired positive case remains the ordinary field: value line, which still parses as before.

Protocol version reporting

checkProtocol in protocol/src/version.ts compares the engine’s stated version against what this client requires, and shapes a diagnostic a user can paste into a bug report. Two rules keep that diagnostic honest — they are the same absent-vs-present discipline the decoder uses for tool_result.seconds. engine: 0 would read as “the engine answered 0” instead of “the engine did not answer”, so a version that was never stated is reported as null, not zero — a present number and an absent one are different facts. Pinned by "an engine that cannot state its version reports ABSENT, not zero".
A refusal states expected: PROTOCOL_VERSION — what we require, not what we got. Reporting expected: engine produces the self-contradictory "too_old: engine=1 expected=1" string a user then pastes into a bug report; a message where expected equals actual explains nothing. The refusal’s whole job is to name the mismatch. Pinned by "a refusal states the version WE expect, not the one we got".
A refusal carries one of four reasons: The paired positive case: an engine stating exactly PROTOCOL_VERSION passes, so the check does not degenerate to “refuse everything”.

Best Practices

ok is the only signal of tool success. Inferring success from a non-empty output is the exact defect the field prevents.
Bind a decision to a row by approvalId, not by position — position holds only while exactly one approval is outstanding.
For end.userIndex and tool_result.seconds, null means “unknown / not persisted”, which is different from 0 and from absent.For tool_result.seconds the decoder distinguishes three states: the field is absent on the wire (seconds key missing) → decoded as null (unknown); the field is present and null → decoded as null; the field is present and 0 → decoded as 0 (a measured zero — a call the engine timed that returned instantly). The rule is seconds === undefined ? null : seconds — decoding absent as 0 would present a measurement as fact. formatElapsedLocalised (see format-intl.ts) keeps them apart: nullstrings.unknownValue, 0"0.0s". The same absent/null/zero distinction holds for end.userIndex and usage.ttftSeconds. Pinned by "a tool_result with no seconds decodes as unknown, not as zero" and its pair "a tool_result WITH seconds keeps the number it was given".

Approvals & Cancellation

The human-in-the-loop flow on mobile.

Capabilities & Gaps

Which events each engine emits today.

Dropped Events

Where a refused frame goes instead of the floor.

Errors & Recovery

How each ErrorKind maps to a recovery affordance.