← Pi Agents

RPC

Driving a live Pi subprocess over JSONL — four record families, correlated commands, an extension UI subprotocol, and the shutdown contract.

You want to drive Pi from an editor plugin, from a Python service, or from an IDE — but you do not want to reimplement the SDK’s event types in another language, and you do not want the agent living inside your process where a bad tool call can take your server with it.

RPC is the fourth interface in this progression and the one that keeps the isolation. This chapter is about the protocol: what flows in each direction, how you correlate, and the subprotocol that almost nobody realises exists.

What RPC is for

rpc.md gives the use cases directly — “language-independent integrations, process isolation, IDEs, and custom user interfaces” — and a comparison table that is worth reproducing here because it is the clearest statement of the SDK/RPC trade anywhere in the documentation:

Interface Process boundary Control model Best fit
SDK In process Direct TypeScript methods and events Node.js or Bun hosts that want complete API access
RPC Child process JSONL commands, responses, and events Other languages, isolated processes, IDEs, or custom clients

Contrast that with chapter 33, where you could only read. And contrast it with chapter 34, where everything was a method call. RPC is the only one of the four where you decide the lifetime of the agent and the agent never decides yours.

Four record families

rpc.md is precise about this, and the precision matters:

Direction Record Purpose
stdin Command Ask Pi to prompt, inspect state, change configuration, or manage the session
stdout response Report whether one command succeeded and return any command data
stdout Session event Stream run, message, tool, queue, compaction, and retry activity
Both Extension UI record Forward supported extension interactions between Pi and the client

The last row is the one to notice. Extension UI records travel in both directions, which makes them a subprotocol layered on top of the three ordinary ones.

Start the process:

pi --mode rpc --no-session

Normal CLI options still apply — --provider, --model, --name, --no-session, --session-dir. One restriction: “RPC mode rejects @file prompt arguments. Send prompts through the prompt command instead.”

Framing is rpc.md’s, not an assumption: strict JSONL, one complete JSON object per record, terminated by LF. Commands go to stdin, everything Pi says goes to stdout.

    sequenceDiagram
  participant C as Controller
  participant P as Pi RPC process
  C->>P: prompt command with id, one line
  P-->>C: response, disposition
  P-->>C: session events, no command id
  P-->>C: agent_end
  P-->>C: agent_settled
  C->>P: next command, or close stdin
  

The acceptance and the completion are different records, and the gap between them is the whole chapter.

The fourth family arrives out of sequence and blocks:

    sequenceDiagram
  participant X as Extension
  participant P as Pi
  participant C as Controller
  X->>P: ctx.ui.confirm()
  P-->>C: extension_ui_request with an id
  C-->>P: extension_ui_response, same id
  P-->>X: the dialog resolves
  

While that arrow is outstanding, Pi is waiting on the controller. Ignore it and the run stalls until the request’s timeout expires — at which point the agent side auto-resolves with a default, so the client does not have to track timeouts itself.

Correlation: ids, and one exception

Every command accepts an optional string id, and the matching response repeats it:

{"id":"req-1","type":"get_state"}
{"id":"req-1","type":"response","command":"get_state","success":true,"data":{"...":"..."}}

The guidance is unambiguous: “Use unique IDs whenever more than one command can be outstanding. Command handling is asynchronous, so clients should correlate by ID rather than response order.”

Session events generally have no command id, because they describe session activity rather than a request. The exception is bash_execution_update: “when the originating bash command has an ID, its output events repeat that ID.”

And a third rule for the subprotocol: “An extension_ui_response uses the ID supplied by its extension_ui_request. It does not produce a normal command response.”

Three id namespaces, three behaviours. A client that conflates them will hang or double-count.

The rule that catches everyone

{"id":"req-2","type":"prompt","message":"Review this repository"}
{"id":"req-2","type":"response","command":"prompt","success":true,"data":{"disposition":"started"}}

rpc.md: “A successful prompt response means the prompt was accepted, queued, or handled. It does not mean model work completed.”

data.disposition is "started", "queued", or "handled". And the trap: “If it is "handled", no run started for this prompt, so don’t wait for agent_settled.”

From rpc-commands.md, prompt has one more rule worth reading twice: if the agent is already streaming and you send prompt without streamingBehavior, the command returns an error. The values are "steer" (delivered after the current assistant turn finishes executing its tool calls, before the next LLM call) and "followUp" (delivered only when the agent stops). Extension commands such as /mycommand are the exception — they “execute immediately even during streaming,” because they manage their own model interaction via pi.sendMessage().

A client you can actually run

rpc.md ships a Python example, and it is short enough to be the chapter’s centrepiece:

import json
import subprocess

process = subprocess.Popen(
    ["pi", "--mode", "rpc", "--no-session"],
    stdin=subprocess.PIPE,
    stdout=subprocess.PIPE,
)

assert process.stdin is not None
assert process.stdout is not None

command = {"id": "prompt-1", "type": "prompt", "message": "Hello"}
process.stdin.write(json.dumps(command).encode("utf-8") + b"\n")
process.stdin.flush()

while line := process.stdout.readline():
    record = json.loads(line)
    if record.get("type") == "message_update":
        update = record["assistantMessageEvent"]
        if update["type"] == "text_delta":
            print(update["delta"], end="", flush=True)
    elif record.get("type") == "agent_settled":
        print()
        break

process.stdin.close()
process.wait()

Two things to notice. It waits for agent_settled, not agent_end. And closing stdin is how you shut down — rpc.md says “Close the child’s stdin to request an orderly shutdown. Pi disposes the active runtime before exiting.”

For TypeScript, rpc.md and cli-integration.md both point at RpcClient from @earendil-works/pi-coding-agent, which “starts a Pi RPC child process, correlates requests, exposes typed command methods, and delivers events to listeners.” One documented race it fixes: “RpcClient.promptAndWait() installs its event listener before sending the prompt, avoiding a race with fast completions.”

The command surface

rpc-commands.md is a long reference, so here is the shape. Grouped as the document groups them:

Prompting — prompt, steer, follow_up, abort, clear_queue, new_session. clear_queue returns the queued text so you can restore it in your editor; the documented recipe is to send clear_queue before abort, “then restore the returned text in the client editor,” because abort continues queued messages when they remain in the session. That is how you implement the terminal’s Escape behaviour.

State — get_state (model, thinkingLevel, isStreaming, isCompacting, steeringMode, followUpMode, sessionFile, sessionId, sessionName, autoCompactionEnabled, messageCount, pendingMessageCount), get_messages.

Model and thinking — set_model, cycle_model, get_available_models, set_thinking_level, cycle_thinking_level, get_available_thinking_levels. Levels are off, minimal, low, medium, high, xhigh, max, with xhigh and max exposed only when the selected model supports them.

Queue modes — set_steering_mode and set_follow_up_mode, each all or one-at-a-time, with one-at-a-time the default for both.

Compaction and retry — compact (optionally with customInstructions), set_auto_compaction, set_auto_retry, abort_retry.

Bash — bash and abort_bash. One documented subtlety that changes how you reason about it: bash output reaches the model on the next prompt, not immediately. rpc-commands.md says the internal BashExecutionMessage becomes a UserMessage when the next prompt command is sent, and that “Multiple bash commands can run before a prompt; Pi includes each output that does not set excludeFromContext.”

Session — get_session_stats, export_html, switch_session, fork, clone, get_fork_messages, get_entries, get_tree, get_last_assistant_text, set_session_name.

get_entries deserves a mention because it solves a real problem: pass the last entry id you have seen as since “to get only entries strictly after it, even across client restarts.” The session is append-only with stable ids, so an entry id is a durable cursor. The response also carries leafId, “so a client can tell in one round trip whether the active branch moved.”

Discoverable commands — get_commands lists extension commands, prompt templates, and skills, each with name, description, source, and sourceInfo. The caveat: “Built-in TUI commands (/settings, /hotkeys, etc.) are not included. They are handled only in interactive mode and would not execute if sent via prompt.”

Errors, and what success does not mean

A failed command returns one response with success: false:

{"id":"req-3","type":"response","command":"set_model","success":false,"error":"Model not found: invalid/model"}

Malformed JSON produces a parse response without a request id — command is "parse". So your correlation table needs a slot for responses that belong to nobody.

The most important sentence in the error section: “A success response only covers command handling. Provider failures and aborts after a prompt is accepted appear in the message and event stream.” The response is about the command, not about the model.

And the client obligations rpc.md lists explicitly: startup failures, unexpected exits, stderr diagnostics, cancellation, and your own deadlines. “Do not parse stderr as protocol data.”

The extension UI subprotocol

This is the part that changes what an RPC client can be. rpc-extension-ui.md documents it fully, and it splits into two categories:

Dialog methods — select, confirm, input, editor — emit an extension_ui_request on stdout and block until you send back an extension_ui_response with the matching id.

Fire-and-forget methods — notify, setStatus, setWidget, setTitle, set_editor_text — emit a request but expect nothing back. “The client can display the information or ignore it.”

So an extension that calls ctx.ui.confirm() under RPC is waiting for you, and a client that answers only prompt commands is not yet a UI.

A full exchange:

{"type":"extension_ui_request","id":"uuid-2","method":"confirm","title":"Clear session?","message":"All messages will be lost.","timeout":5000}
{"type":"extension_ui_response","id":"uuid-2","confirmed":true}

Cancellation is a single shape for every dialog: {"type":"extension_ui_response","id":"uuid-3","cancelled":true}, which delivers undefined to select/input/editor and false to confirm.

The degradation list

rpc-extension-ui.md enumerates what does not work, and it is the mirror image of the terminal chapter’s ladder:

  • custom() returns undefined.
  • onTerminalInput() returns a no-op unsubscribe.
  • setWorkingMessage(), setWorkingVisible(), setWorkingIndicator(), setHiddenThinkingLabel(), setFooter(), setHeader(), addAutocompleteProvider(), setEditorComponent(), setToolsExpanded() are no-ops.
  • getEditorText() returns "", getEditorComponent() returns undefined, getToolsExpanded() returns false.
  • pasteToEditor() delegates to setEditorText() without terminal paste handling.
  • getAllThemes() returns [] and getTheme() returns undefined.
  • setTheme() returns { success: false, error: "Theme switching not supported in RPC mode" }.

Read the first two entries together with hasUI: an RPC client that implements a dialog subprotocol is a real UI as far as extensions are concerned. ctx.mode is "rpc" and ctx.hasUI is true there. So an extension guarded only on hasUI will run under RPC — and then take a custom() path that silently does nothing.

Contrast with the chapter before

The SDK session was a function call: session.prompt(...) returned when the run finished, and your TypeScript compiler knew the shape of everything. RPC is a message exchange: the prompt command returns the moment the prompt is accepted, and everything interesting happens afterwards in events you subscribe to.

That single difference produces the discipline of this chapter. In process you wait for a promise. Across a process you wait for an event, and you must choose the right one — agent_settled, not agent_end — while correlating by id, honouring backpressure, and handling a child that can exit without warning.

You get three things back: any language, an isolated process you can kill, and a UI subprotocol that makes RPC a legitimate target for building a client. You pay in framing, correlation, and lifecycle.

What this chapter does not settle

A protocol that lets you send commands to an agent is also a protocol that lets something else send commands to an agent. RPC mode is not a sandbox: the process has whatever permissions it was started with, it loads extensions from your working folder, and security.md is blunt that project trust “does not limit what tool calls can access or affect.”

So the last question is not how to talk to the agent. It is what the agent can reach, and which of its capabilities are promises you can rely on versus judgement you have to supply yourself.

Next: Authority, Isolation, and What Makes an Agent Worth Building — the last chapter.