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 — without reimplementing the TypeScript SDK in another language or running Pi inside your host process. A subprocess gives the host a separate failure and lifecycle boundary. It does not sandbox the agent: Pi and its tools can still reach whatever files, credentials, and services the child process is permitted to access.
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 a one-shot event stream, and chapter 34, where you called in-process methods. RPC is the long-lived subprocess option: your controller can send more commands without restarting Pi and can terminate the child when it is no longer needed. Other modes also have caller-controlled lifetimes; what distinguishes RPC is bidirectional control over a running child process.
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 and extension UI responses go to stdin; protocol responses, events, and extension UI requests go to stdout. Diagnostic output belongs on stderr and must not be parsed as protocol data. Keep draining stdout, because stopping the read can block the child on pipe backpressure.
sequenceDiagram
participant C as Controller
participant P as Pi RPC process
C->>P: prompt command with id, one line
P-->>C: response: accepted or rejected
P-->>C: "session events (may interleave with responses)"
P-->>C: "agent_end (one low-level run)"
P-->>C: "agent_settled (no automatic work pending)"
Note over C,P: Match responses by id events are session-scoped
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 for the controller. If the extension supplied a timeout, the agent side resolves the dialog with its default when that timeout expires; without one, it may wait indefinitely. The extension supplies the request timeout — the controller cannot add one to a request that has already been emitted. A production controller should answer or explicitly cancel every supported dialog, set its own operation deadline, and handle a stalled child. An extension that requires approval should treat timeout or cancellation as refusal, not authorization.
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.”
There are three correlation cases, not three guaranteed globally distinct ID namespaces: command responses carry the command ID; most session events are not tied to a command, although direct-bash output can carry its ID; and UI responses echo a UI-request ID without generating a command response. Keep separate bookkeeping for outstanding commands, session lifecycle, and unanswered dialogs. IDs alone do not attribute an untagged agent_settled event to one particular command.
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". "started" means Pi began a run, "queued" means the input was accepted for later delivery, and "handled" means an input handler consumed it without starting a run for that prompt. Do not wait for a new agent_settled just because a "handled" command succeeded. For queued work, observe queue and run events: agent_settled describes the session reaching an idle state, not a per-command completion acknowledgment.
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 completes its tool calls, before the next model call) and "followUp" (delivered after the agent finishes its currently pending work; the RPC process stays alive). Extension commands such as /mycommand are an exception: they may execute immediately during streaming and can initiate their own interactions through pi.sendMessage(). Acceptance of any of these commands still does not prove the requested model work succeeded.
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
# Requires a configured model and credentials. Stderr is inherited for diagnostics.
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()
accepted = False
settled = False
last_stop_reason = None
try:
# A binary pipe's readline() splits on LF; it does not split on U+2028/U+2029.
for line in process.stdout:
record = json.loads(line)
kind = record.get("type")
if kind == "response" and record.get("id") == "prompt-1":
if not record.get("success"):
raise RuntimeError(f"Prompt rejected: {record.get('error')}")
if record.get("data", {}).get("disposition") != "started":
raise RuntimeError(f"Unexpected prompt disposition: {record}")
accepted = True
elif kind == "message_update":
update = record["assistantMessageEvent"]
if update["type"] == "text_delta":
print(update["delta"], end="", flush=True)
elif kind == "message_end" and record.get("message", {}).get("role") == "assistant":
last_stop_reason = record["message"].get("stopReason")
elif kind == "extension_ui_request" and record.get("method") in {
"select", "confirm", "input", "editor"
}:
# No interactive UI in this sample: refuse rather than approve.
reply = {"type": "extension_ui_response", "id": record["id"], "cancelled": True}
process.stdin.write(json.dumps(reply).encode("utf-8") + b"\n")
process.stdin.flush()
elif kind == "agent_settled":
settled = True
if accepted and settled:
break
if not accepted or not settled:
raise RuntimeError("RPC ended before prompt acceptance and settlement")
if last_stop_reason in ("error", "aborted"):
raise RuntimeError(f"Agent ended with stop reason: {last_stop_reason}")
print()
finally:
process.stdin.close()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.terminate()
process.wait(timeout=5)
Three things to notice. The sample checks the response to its own prompt and waits for agent_settled; a successful RPC command response alone is not a successful model run. It cancels any interactive dialogs it cannot display rather than silently approving them, and it closes stdin to request orderly shutdown. The child has a bounded shutdown wait, but this is still a teaching example, not a hardened controller: production clients also need a deadline while reading stdout, startup and unexpected-exit handling, and a policy for errors, retries, and multiple outstanding prompts. rpc.md documents that closing stdin requests runtime disposal before exit.
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. Direct RPC bash executes a shell command through the host-controlled command surface; do not confuse it with a tool call chosen by the model or assume a model-tool allowlist limits what the controller can request. The subprocess still has its operating-system permissions. Its output is recorded and can be added to model context on the next prompt, not immediately: rpc-commands.md says the internal BashExecutionMessage is converted to a UserMessage when that prompt is sent. Multiple direct bash commands can run before a prompt; outputs are included unless excludeFromContext was set. Treat command input and output as sensitive, and restrict who can write to the RPC pipe.
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 seen as since to fetch later entries in that session file. The file is append-only with stable entry IDs, so the ID works as a cursor across client restarts only while the client continues tracking the same session. Store the session identity alongside the cursor; switching sessions invalidates that assumption. The response also carries leafId, which identifies the active leaf even when newly appended entries belong to other branches. It need not equal the ID of the last entry returned.
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 suspend the calling extension until a matching extension_ui_response arrives, or until the agent-side timeout expires if the extension supplied one. A client must not assume every request will have a timeout.
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()returnsundefined.onTerminalInput()returns a no-op unsubscribe.setWorkingMessage(),setWorkingVisible(),setWorkingIndicator(),setHiddenThinkingLabel(),setFooter(),setHeader(),addAutocompleteProvider(),setEditorComponent(),setToolsExpanded()are no-ops.getEditorText()returns"",getEditorComponent()returnsundefined,getToolsExpanded()returnsfalse.pasteToEditor()delegates tosetEditorText()without terminal paste handling.getAllThemes()returns[]andgetTheme()returnsundefined.setTheme()returns{ success: false, error: "Theme switching not supported in RPC mode" }.
Read the first two entries together with hasUI: in RPC mode, ctx.mode is "rpc" and ctx.hasUI is true because Pi exposes the extension UI protocol — not because it verified that the controller implements dialogs. A controller that ignores extension_ui_request can leave an extension waiting. An extension guarded only on hasUI can also take a custom() path that returns undefined, because terminal-only components are not supported. Use ctx.mode === "tui" for terminal-specific UI, provide an RPC-compatible alternative where appropriate, and fail closed when an approval dialog is unavailable.
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 was run
examples/ch35-rpc/ drives the shipped binary in --mode rpc with a hand-written JSONL client that splits on LF only, as the chapter requires:
- responses repeat the command
id, and two outstanding commands were matched by id and not by order; - a successful
promptresponse means accepted, not finished:data.dispositionwas"started", and in this test it arrived before the run’s first event. Treat that as observed ordering, not a guarantee that events from all commands will always follow their responses; - session events carry no command id;
agent_endis followed byagent_settled, which is what a client waits for;- a
promptwhile streaming with nostreamingBehaviorgets an error response, and with"steer"is queued; - malformed JSON produces a response with
command: "parse"and no id, and an unknown command is an error response while the process keeps serving; - closing stdin is an orderly shutdown and the process exits 0;
- RPC mode rejects an
@fileprompt argument; get_commandslists an extension command and not the built-in terminal commands;- the extension UI subprotocol: an extension’s
confirm()produced anextension_ui_requestand blocked, the extension had not continued until the client answered,extension_ui_responsewith the request id resolved it, and no normal command response was produced for it. Answering withcancelled: truedeliveredfalse; extension_erroris an RPC record: a handler that threw was reported and the run still completed;get_entriesuses an entry id as a cursor: withsinceit returned only later entries,leafIdmatched the last entry, and an unknown id was an error.
Not run: clear_queue followed by abort, the extension UI timeout default, and the less common dialog methods.
What this chapter does not settle
A protocol that lets you send commands to an agent is also a protocol that lets a controller execute privileged operations through it. RPC gives process separation, not a sandbox or an authentication mechanism for the stdin pipe. Pi runs with the permissions, credentials, and workspace of the launched child; loaded extensions and direct RPC commands can exercise those permissions independently of what tools the model was offered. security.md is explicit that project trust “does not limit what tool calls can access or affect.” Keep the pipe private to the intended controller and apply OS-level isolation when the child must not reach the host’s files or services.
So the question left over 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.
That question has to wait one more part, because there is a layer this book has only described and not yet used. Everything in Parts I to IV ran inside the coding agent, and Part IV showed five ways of driving it. Part V goes below it, to the model access and agent core that the coding agent is built on, and builds something with them.
Next: Model Access Without the Coding Agent — a single model call that returns a typed value, with no loop, no session and no terminal.