Pi RPC and JSON Mode
Last updated: Aug 18, 2026
$ pi –rpc
TL;DR. Pi ships three non-interactive entry points: -p / --print for one-shot prompt → reply, --mode json for the same shape with the full event stream on stdout as JSONL, and --mode rpc for stateful multi-turn sessions over JSON-RPC on stdio. RPC uses strict LF-delimited JSONL framing (do not use Node readline — it splits on U+2028/U+2029), correlates requests via an optional id, and exposes ~40 commands across prompting (prompt, steer, follow_up, abort), state (get_state, get_messages, set_model, compact, switch_session, fork), shell (bash, abort_bash), and UI primitives (select, confirm, input, editor, notify, setStatus). JSON mode emits the same events as RPC minus the request/response correlation, and message_update events are delta-only — use them for streaming, not for assembling messages from scratch. All three modes skip the interactive trust prompt; control project trust with defaultProjectTrust and the --approve / --no-approve one-shot overrides.
This guide is the deep-dive companion to the official RPC mode and JSON event stream mode documentation. It pulls the rules from packages/coding-agent/docs/rpc.md, json.md, usage.md, and environment-variables.md into one place, with the practical knobs a CI job or scripted host actually needs. Where upstream changes, this guide will lag behind — always cross-check against the canonical doc before relying on a detail.
The three non-interactive modes
| Mode | Shape | Use it for |
|---|---|---|
-p / --print |
One-shot prompt → reply on stdout | Quick CI tasks, git hooks, shell pipelines |
--mode json |
One-shot prompt + the complete session event stream as JSONL | Tools that want to log every event but do not need multi-turn control |
--mode rpc |
Stateful multi-turn JSON-RPC over stdio | IDEs, custom UIs, agent loops that steer mid-stream, session orchestration |
All three skip the interactive trust prompt. Without an applicable saved trust decision they fall back to defaultProjectTrust from ~/.pi/agent/settings.json — "ask" (default) and "never" ignore project resources, "always" trusts them. Pass --approve / -a or --no-approve / -na to override project trust for one run.
Print mode: the simplest entry point
pi -p "summarize the open issues labeled 'bug'"
echo "exit=$?"
The reply streams to stdout; non-zero exit codes surface on failure. The flag pairs:
--provider <name>— set the LLM provider (anthropic,openai,google, etc.)--model <pattern>— model pattern orprovider/id, optionally with:<thinking>suffix--name <name>/-n <name>— set the session display name at startup--no-session— disable session persistence (no JSONL written)--session-dir <path>— custom session storage directory--approve/--no-approve— one-shot project-trust override--thinking/--no-thinking— control reasoning--system-prompt/--append-system-prompt— replace or extend the default--no-extensions/--no-skills— disable those layers for the run--tools/--exclude-tools/--no-tools— control the tool surface@file— expand@path/to/filearguments as their content
For CI jobs the typical incantation is:
pi -p "..." --no-session --approve --provider anthropic --model claude-opus-5
JSON mode: the event stream
pi --mode json "Your prompt" > session.jsonl
Outputs every session event as a JSON line on stdout. The wire type is JsonAgentSessionEvent, which matches AgentSessionEvent except that streaming message_update events drop the cumulative partial snapshot and only carry the delta (assistantMessageEvent) plus usage. Use the deltas for live streaming; do not try to assemble a complete assistant message from message_update events — use message_end for that.
The base event types:
type AgentEvent =
// Agent lifecycle
| { type: "agent_start" }
| { type: "agent_end"; messages: AgentMessage[] }
// Turn lifecycle
| { type: "turn_start" }
| { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
// Message lifecycle
| { type: "message_start"; message: AgentMessage }
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
| { type: "message_end"; message: AgentMessage }
// Tool execution
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
| { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any }
| { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError: boolean };
Plus queue_update (full pending steering and follow-up queues whenever they change), compaction_start, and compaction_end for both manual and automatic compaction. Base message types come from packages/ai/src/types.ts (UserMessage, AssistantMessage, ToolResultMessage); extended types live in packages/coding-agent/src/core/messages.ts (BashExecutionMessage, CustomMessage, BranchSummaryMessage).
A jq recipe to assemble final assistant text from a JSON stream:
pi --mode json "explain the regex" \
| jq -c 'select(.type == "message_end") | .message.content[]? | select(.type == "text") | .text'
RPC mode: the full protocol
Framing
RPC mode uses strict JSONL with LF (\n) as the only record delimiter. Three rules clients must follow:
- Split records on
\nonly. - Accept optional
\r\non input by stripping a trailing\r. - Do not use generic line readers that treat Unicode separators as newlines.
In particular, Node’s built-in readline is not protocol-compliant because it splits on U+2028 and U+2029, both of which are valid inside JSON strings. Use a JSONL reader that respects LF only.
Request/response correlation
Every command accepts an optional id field. When you supply one, the response carries the same id so you can match it back to the request. bash_execution_update events also include the id of their originating bash command. If you omit id, commands still work but responses are not correlated — which is fine for fire-and-forget use but painful for any non-trivial client.
A minimal request and response:
{"id": "req-1", "type": "prompt", "message": "Hello, world!"}
{"id": "req-1", "type": "response", "command": "prompt", "success": true}
success: true means the prompt was accepted, queued, or handled immediately. success: false means it was rejected before acceptance. Failures after acceptance are reported through the normal event and message stream, not as a second response for the same id.
The prompt commands
prompt sends a user prompt. The response returns after the prompt is accepted, queued, or handled; events continue streaming asynchronously after acceptance.
{"id": "req-1", "type": "prompt", "message": "Hello, world!"}
With images (each uses ImageContent format):
{"type": "prompt", "message": "What's in this image?", "images": [{"type": "image", "data": "base64...", "mimeType": "image/png"}]}
If the agent is already streaming, you must specify streamingBehavior to queue the message:
{"type": "prompt", "message": "New instruction", "streamingBehavior": "steer"}
"steer"— queue while running; delivered after the current assistant turn finishes its tool calls, before the next LLM call."followUp"— wait until the agent stops; message is delivered only on agent stop.
If the agent is streaming and streamingBehavior is omitted, the command returns an error. Skill commands (/skill:name) and prompt templates (/template) are expanded before sending/queueing; extension commands (e.g. /mycommand) execute immediately even during streaming because they manage their own LLM interaction via pi.sendMessage().
steer and follow_up are the dedicated queueing primitives. steer cannot carry extension commands (use prompt for those), and Skill//template expansion still applies to steer. Use set_steering_mode and set_follow_up_mode to control how each queue is processed; use abort to drop a pending message.
The state commands
| Command | Purpose |
|---|---|
get_state |
Snapshot the full session state |
get_messages |
All messages so far |
get_session_stats |
Token usage, turn count, etc. |
get_entries / get_tree |
Session entry list and tree |
get_last_assistant_text |
Convenience for the latest assistant reply |
get_available_models / get_available_thinking_levels |
Enumerate the configured provider/model/reasoning options |
set_model / cycle_model |
Select or step through models |
set_thinking_level / cycle_thinking_level |
Select or step through reasoning level |
set_steering_mode / set_follow_up_mode |
Configure how queued messages are processed |
set_session_name |
Rename the session |
get_commands |
List registered commands |
compact |
Trigger compaction |
set_auto_compaction |
Enable/disable automatic compaction |
set_auto_retry / abort_retry |
Control retry behaviour |
new_session |
Start a fresh session |
switch_session / fork / clone |
Navigate or branch the tree (switch_session and fork honour session_before_switch / session_before_fork extension events for cancellation) |
get_fork_messages |
Inspect messages at a fork point |
export_html |
Render the session to HTML |
The bash command
bash runs a shell command inside the agent session and reports its result through a BashExecutionMessage that is injected into the next prompt, not into the current one:
{"type": "bash", "command": "ls -la"}
abort_bash kills an in-flight bash invocation. The bash tool exposes session variables (PI_SESSION_ID, PI_SESSION_FILE, PI_PROVIDER, PI_MODEL, PI_REASONING_LEVEL) so the LLM-callable shell can discover which model is currently running — a much better signal than parsing the system prompt.
The UI primitives
RPC also exposes a small set of UI primitives that surface in the TUI when RPC is being driven from an interactive parent, and otherwise are no-ops or echoed back: select, confirm, input, editor, notify, setStatus, setWidget, setTitle. These are useful when embedding pi inside a custom frontend — pass them through and your user gets the same in-session UI affordances the TUI offers.
Project trust in non-interactive modes
When pi starts in -p, --mode json, or --mode rpc, it does not prompt for project trust. The resolution order is:
- A saved decision in
~/.pi/agent/trust.jsonfor the project folder or a parent folder. - The
defaultProjectTrustsetting in~/.pi/agent/settings.json. - Per-run override via
--approve/-aor--no-approve/-na.
"ask" (the default) and "never" ignore project resources entirely (no .pi/settings.json, no project extensions, no project skills); "always" loads them. For most CI jobs you want --approve so the project’s own context files and pinned extensions are honored; for sandboxes you want --no-approve to keep the run on a clean, trusted base.
Environment variables
Pi sets several environment variables that scripted hosts and child processes should know about:
PI_OFFLINE=1— disable startup network access (no update checks, no telemetry pings).PI_SKIP_VERSION_CHECK=1— skip the version check on startup.PI_TELEMETRY=0— suppress telemetry.PI_CODING_AGENT_DIR— override the agent home directory (default~/.pi/agent/).PI_CODING_AGENT_SESSION_DIR— override the session storage directory.AI_AGENT=pi— generic marker so any tooling can detect pi as the launching agent.PI_CODING_AGENT=true— pi-specific marker child processes use to detect they’re inside pi.
These markers are inherited by child processes automatically; they are not session-specific and are not set when pi is embedded through the SDK (which is a different process tree).
The bash tool sets additional per-command variables when each LLM-callable command starts — switching models or reasoning level between turns therefore affects the next bash invocation without restarting pi:
| Variable | Value |
|---|---|
PI_SESSION_ID |
Current session ID |
PI_SESSION_FILE |
Absolute path to the current session JSONL; unset for ephemeral sessions |
PI_PROVIDER |
Currently selected provider |
PI_MODEL |
Currently selected model ID |
PI_REASONING_LEVEL |
One of off, minimal, low, medium, high, xhigh, max |
When asked “which model is running?”, inspect these instead of inferring from the system prompt:
printf '%s/%s\n' "$PI_PROVIDER" "$PI_MODEL"
printf 'reasoning=%s session=%s\n' "$PI_REASONING_LEVEL" "$PI_SESSION_ID"
CI integration patterns
A minimal GitHub Actions step that runs pi in print mode and surfaces the exit code:
- uses: actions/setup-node@v7
with:
node-version: 22
- run: npm ci --ignore-scripts
- name: Run pi
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
set -e
pi -p "label this issue" \
--no-session \
--approve \
--provider anthropic \
--model claude-opus-5
Notes:
npm ci --ignore-scriptsmatches the project’s own CI pattern; lifecycle scripts are off by default and theAGENTS.mdrule says to leave them that way unless the user asks.PI_OFFLINE=1is worth adding if your runner is air-gapped.- For deterministic runs, set
--modelto an exact ID rather than relying on the provider default. - For trust in CI, prefer
--no-approve(run on a clean base) unless you specifically want project-local settings honored.
The pi project itself uses the same pattern in its .github/workflows/ — actions/setup-node@v7 + Node 22 + npm ci --ignore-scripts, and the publish-npm and announce-pi-dev-release jobs wire in the npm Trusted Publishing OIDC flow with id-token: write rather than a long-lived NPM_TOKEN secret.
Writing your own RPC client
For Node hosts, prefer the in-process AgentSession from @earendil-works/pi-coding-agent over spawning a subprocess; for a subprocess-based TypeScript client, see packages/coding-agent/src/modes/rpc/rpc-client.ts. The Python sketch below shows the load-bearing rules any language implementation must follow: LF-only framing, JSON-parse per line, optional id correlation, delta-only streaming events.
import json
import subprocess
import sys
import threading
proc = subprocess.Popen(
["pi", "--mode", "rpc"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True, # text mode; we control line splitting ourselves
bufsize=1,
)
def reader():
for line in proc.stdout: # split on '\n' only — what RPC requires
line = line.rstrip("\n")
if not line:
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
# Handle response vs event here
print(event, flush=True)
threading.Thread(target=reader, daemon=True).start()
req = {"id": "req-1", "type": "prompt", "message": "Hello"}
proc.stdin.write(json.dumps(req) + "\n")
proc.stdin.flush()
For any language implementation, the four rules are: LF-only framing, no U+2028/U+2029 splitting, JSON parse/serialize per line, and id correlation if you need to match responses to requests. Streaming message_update events carry only the delta, so to assemble a final message wait for the matching message_end.
Further reading
- RPC mode — canonical upstream doc, this guide’s primary source
- JSON event stream mode — the event-type schema in full
- Usage — every CLI flag and what it pairs with
- Environment variables —
PI_*knobs and the bash tool’s session variables - Settings —
defaultProjectTrustlocation - Security — what non-interactive trust skip means in practice
packages/coding-agent/src/modes/rpc/rpc-client.ts— the reference TypeScript RPC client- Custom tools and SDK integration — the in-process SDK path, which is the right choice for Node hosts