piagent_
← ~/guides

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 or provider/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/file arguments 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:

  1. Split records on \n only.
  2. Accept optional \r\n on input by stripping a trailing \r.
  3. 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:

  1. A saved decision in ~/.pi/agent/trust.json for the project folder or a parent folder.
  2. The defaultProjectTrust setting in ~/.pi/agent/settings.json.
  3. Per-run override via --approve / -a or --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-scripts matches the project’s own CI pattern; lifecycle scripts are off by default and the AGENTS.md rule says to leave them that way unless the user asks.
  • PI_OFFLINE=1 is worth adding if your runner is air-gapped.
  • For deterministic runs, set --model to 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