Example

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_settled

agent_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.

Full source and test .

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.