Read the JSON event stream record by record
Strict LF framing, a session header first, delta-only message_updates, and the reconstruction rule that ends at message_end — all asserted by tests.
pi --mode json is not a single JSON result: it is a stream of records, each
one JSON object terminated by LF, with stdout carrying nothing else. The
records below are illustrative drawings of that shape — the calling
conventions themselves were observed on 1.0.4 by running the shipped
binary under a scripted model and inspecting the stream.
Framing rules
- Split only on LF; strip an optional preceding carriage return so CRLF input parses.
- U+2028 inside a string is legal JSON, not a record boundary. A
readline-style reader splits a record in half; the test demonstrates this on real output. - The first record is the session header, version 3, and it is not part of the tree.
- Read stdout continuously — a reader that stops consuming records can stall Pi when the pipe buffer fills. Stdout carries JSONL only; diagnostics go to stderr.
Illustrative records
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/project"}
{"type":"agent_start"}
{"type":"message_update","usage":{},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"the quick "}}
{"type":"tool_execution_start","toolCallId":"c1","toolName":"bash","args":{"command":"echo hi"}}
{"type":"tool_execution_end","toolCallId":"c1","toolName":"bash","result":{"content":[{"type":"text","text":"hi"}]},"isError":false}
{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"hi"}],"stopReason":"stop"}}These are illustrative: ids, timestamps and text are placeholders; the fields follow the asserted contracts, not a captured transcript.
Lifecycle and reconstruction
Order of a basic run:
agent_start -> turn_start -> ... -> turn_end -> agent_end -> agent_settledagent_end closes one low-level run — retry, overflow recovery, steering or
queued work can still continue. agent_settled is the last record and means
Pi will not continue automatically: wait for it, not for agent_end.
message_update is delta-only on the wire: no cumulative message field and
no partial snapshot, so the stream stays linear. Reconstruct in three levels
of increasing authority — buffer the text_delta / thinking_delta fields by
contentIndex for live display, replace them with the completed content in
text_end / thinking_end / toolcall_end, then replace the whole message
with message_end.message. A failed response is in-stream as a message_end
with stopReason: "error", and the run still settles.
Mechanism and limitations
Documented: json.md pins strict JSONL framing, the session header, the
event vocabulary and the delta-only wire form; rpc.md repeats the framing
rule for the bidirectional case. Observed: json-stream.test.ts asserts
each rule above against the shipped binary in --mode json on 1.0.4 with a
scripted model — a version-3 header with no parentId, LF-only framing with
U+2028 emitted raw inside a string, message_update with no
message / partial, contentIndex throughout, tool events carrying call
id, name, args and result, and agent_settled last even after a failed
response.
extension_error and bash_execution_update are RPC-only and never appear in
JSON mode: a one-shot JSON run cannot tell you an extension threw. usage is
cumulative but lazy and can stay zero until completion — do not bill from it
mid-stream. The model is scripted, so this shows the process’s framing, not a
live model’s output shape.
Understand this example
Copy this prompt into your AI tool. No code runs here.
Apply this example
Copy this prompt into your AI tool. No code runs here.