JSON event stream mode
JSON mode runs one prompt and writes its Session header and model-loop events as JSON Lines.
pig --mode json --model <provider/model> "Your prompt"Use this mode when one process invocation handles one prompt. Use RPC mode when a client must send several commands to a long-running PiG process. The record types and fields are those of Pi 1.5.0 (Pi's JSON event stream reference).
Framing
Each output record is one JSON object followed by LF (\n). PiG writes diagnostics to standard error. JSON mode uses the same field ordering and string escaping as the RPC event encoder.
The first output line is the Session header. PiG writes it with --no-session too:
{"type":"session","version":3,"id":"session-uuid","timestamp":"2026-01-15T14:00:00Z","cwd":"/work/project"}Model-loop events follow as they occur. PiG converts and serializes each event during the Session notification, before message persistence. The output reader writes those owned bytes in order; it does not observe a retained partial again later.
Events
JSON mode converts Session events with the same code as RPC mode. The model loop emits:
agent_start;agent_endwithmessagesandwillRetry;agent_settledwithaborted, which istruewhen the run ended because it was aborted;turn_start;turn_endwith the final assistant message and tool results;message_start;message_update;message_end;tool_execution_start;tool_execution_update;tool_execution_endwithdurationMs, how long the tool'sexecute()took in milliseconds, measured with a monotonic clock and excluding hooks; it is absent when the tool did not run.
The Session can also emit these events during a JSON-mode run:
queue_updatewithsteeringandfollowUpwhen a queued message changes;entry_appendedwithentrywhen an extension appends a custom session entry;session_info_changedwithnamewhen the session display name changes; an absentnamemeans it was cleared;thinking_level_changedwithlevel;compaction_startandcompaction_endaround automatic compaction;auto_retry_startandauto_retry_endaround an automatic retry of a failed provider request;summarization_retry_scheduled,summarization_retry_attempt_start, andsummarization_retry_finishedaround a retried summary.
A retried request produces more than one agent_start and agent_end pair. For example, a request that fails once and then retries emits agent_end, auto_retry_start, a second agent_start through agent_end, auto_retry_end, and then agent_settled.
JSON mode does not emit extension UI requests. Use RPC mode for extension UI.
Streaming message updates
A message_update record contains one assistantMessageEvent. It does not contain a cumulative partial message.
Text streaming uses:
{"type":"message_update","assistantMessageEvent":{"type":"text_start","contentIndex":0}}
{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}}
{"type":"message_update","assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Hello"}}Thinking streaming uses thinking_start, thinking_delta, and thinking_end with the same fields. Tool-call streaming uses toolcall_start (with id and toolName), toolcall_delta (with the serialized argument text in delta), and toolcall_end (with the complete toolCall). A stream also has start at the beginning, without the cumulative partial, and ends with done (with reason and message) or error (with reason and error).
The final message_end record is authoritative:
{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}],"stopReason":"stop"}}Do not concatenate message_end text with accumulated deltas. Use one or the other.
Tool events
A tool call emits lifecycle records:
{"type":"tool_execution_start","toolCallId":"call-1","toolName":"bash","args":{"command":"echo out; exit 3"}}
{"type":"tool_execution_update","toolCallId":"call-1","toolName":"bash","args":{"command":"echo out; exit 3"},"partialResult":{"content":[{"type":"text","text":"out\n"}],"details":{}}}
{"type":"tool_execution_end","toolCallId":"call-1","toolName":"bash","result":{"content":[{"type":"text","text":"out\n\n\nCommand exited with code 3"}],"structuredContent":{"output":"out\n","truncated":false,"exit_code":3,"wall_time_seconds":0},"isError":true},"isError":true,"durationMs":12}The top-level isError reports whether the call failed. result is the tool's finalized result object. It carries its own isError only when the tool returned a failure, as bash does for a non-zero exit. A thrown tool error, an unknown tool, invalid arguments, a blocked call, and a failing tool_result handler produce a result of content and an empty details object without isError.
Completion and errors
PiG drains serialized event output after the terminal agent_settled event before it closes the stream. It does not print the final assistant text a second time.
A conversion or serialization failure fails the run without abandoning the event drain. Already serialized records remain ordered, and the first failure remains the reported cause. Termination signals retain their signal exit status rather than reporting an internal cancellation error.
The process returns the prompt error as its exit status. A consumer must read standard error and inspect terminal message events.
Worked example
This example runs one prompt that makes the model call the bash tool, then reads the stream three ways. The provider is a scripted test server that answers with one bash call and then a short sentence, so the model's wording is illustrative. The record types, their order and the tool fields are what PiG printed. Pi 1.1.0 printed the same record types and field names for the same prompt, and no later Pi release changed the event types.
pig --mode json --no-session --model mock/mock-1 "Run: echo out; exit 3" \
2>pig-json.stderr > run.jsonl
echo "exit status: $?"exit status: 0The exit status is 0 although the command inside failed. The tool's exit 3 is data in the stream, not the exit status of pig (the exit status is the prompt error, see Completion and errors). pig-json.stderr is empty.
1. The shape of the run. This jq filter prints one line per record: the type, the role of the message, and the kind of streaming update.
jq -c '[.type, .message.role, .assistantMessageEvent.type]' run.jsonl["session",null,null]
["agent_start",null,null]
["turn_start",null,null]
["message_start","system",null]
["message_end","system",null]
["message_start","user",null]
["message_end","user",null]
["message_start","assistant",null]
["message_update",null,"toolcall_start"]
["message_update",null,"toolcall_delta"]
["message_update",null,"toolcall_end"]
["message_end","assistant",null]
["tool_execution_start",null,null]
["tool_execution_update",null,null]
["tool_execution_update",null,null]
["tool_execution_end",null,null]
["message_start","toolResult",null]
["message_end","toolResult",null]
["turn_end","assistant",null]
["turn_start",null,null]
["message_start","assistant",null]
["message_update",null,"text_start"]
["message_update",null,"text_delta"]
["message_update",null,"text_end"]
["message_end","assistant",null]
["turn_end","assistant",null]
["agent_end",null,null]
["agent_settled",null,null]Read it from the top:
- Line 1 is the Session header. Lines 2 and 3 open the run and its first turn.
- Lines 4 and 5 are a message with
"role":"system"and emptycontent. It carries the system prompt insections(preamble,tools,rules,docs,cwd) and the tool declarations intoolsAdded. It appears once, at the start of the run, so a consumer sees exactly what the model was given. Skip it if you only want the conversation. Pi emits the same record (packages/coding-agent/src/core/agent-session.ts:1753). - A turn that ends in a tool call has
tool_execution_*records and atoolResultmessage between the assistant'smessage_endand theturn_end. The model then starts a second turn, here with the final text. - Every
message_updatealso has ausagefield with the token counts so far. TheassistantMessageEventholds the new piece (rpcMessageUpdateincoding/cli/rpc_events.go).
2. The tool call. This filter prints the record for the finished call:
jq -c 'select(.type == "tool_execution_end")' run.jsonl{"type":"tool_execution_end","toolCallId":"call_0","toolName":"bash","result":{"content":[{"type":"text","text":"out\n\n\nCommand exited with code 3"}],"structuredContent":{"output":"out\n","truncated":false,"exit_code":3,"wall_time_seconds":0},"isError":true},"isError":true,"durationMs":6}"isError":true is how a consumer sees that the command failed. structuredContent.exit_code is 3. durationMs is the time bash took to run (6 ms here; yours differs).
3. The final answer. Use the last assistant message_end and do not join the deltas (see Streaming message updates):
jq -r 'select(.type == "message_end" and .message.role == "assistant")
| .message.content[] | select(.type == "text") | .text' run.jsonlThe command printed out and exited with code 3.The first assistant message has no text block, only the toolCall, so only one line prints. Its stopReason is toolUse. The last one's is stop.
An unknown model name. pig --mode json --model mock/nope "hi" prints Warning: Model "nope" not found for provider "mock". Using custom model id. on standard error. PiG then sends the request with the model ID nope, and the stream and exit status 0 follow as above. A typo in the model name therefore does not stop the run when the provider accepts any ID.
Message shapes
Read Session file format for message, content-block, usage, and Session entry shapes.
Source references
PiG implementation:
coding/cli/print_mode.goowns one-shot text and JSON mode.coding/cli/rpc_events.goconverts internal events to wire records.
Pi reference (1.2.0):