Drive a live Pi subprocess over JSONL
Send commands with an id, correlate responses by that id, wait for agent_settled not agent_end, and answer extension UI dialogs.
RPC keeps Pi in a separate process and talks to it as newline-delimited JSON, so you decide the lifetime of the child — across a process you wait for an event, not a promise. The price is a protocol: command and response records, a stream of session events that carry no command id, and a fourth family, extension UI records, that travels in both directions. Everything below is observed against the shipped binary, on Pi 1.0.4, by a hand-written client that splits on LF only.
Run it
Start pi --mode rpc --no-session and send one JSON object per line. The canonical
exercise is examples/ch35-rpc/rpc.test.ts: it drives the shipped pi binary with a
minimal JSONL client, on the example repository’s test runner. No model key is needed
because the provider is scripted.
A command round trip
{"id":"req-1","type":"prompt","message":"Review this repository"}
{"id":"req-1","type":"response","command":"prompt","success":true,"data":{"disposition":"started"}}The response repeats the command id, which is how two outstanding commands are matched
— by id, never by order. And a successful prompt means accepted, queued, or
handled, not finished: here disposition is "started", and it arrived before the
run’s first event. Everything interesting afterwards arrives as session events, which
carry no command id; the completion a client waits for is agent_settled, which
follows agent_end.
Protocol walkthrough
| Direction | Record | Carries |
|---|---|---|
| stdin | command | a prompt, a state inspection, a config change, an abort |
| stdout | response |
one per command: success plus data, repeating the id |
| stdout | session event | run, tool, queue, compaction and retry activity; no command id (except bash_execution_update, which repeats an originating bash id) |
| both | extension UI record | dialogs and notifications, correlated by request id |
rpc.test.ts assertion |
What the process did |
|---|---|
| Two outstanding commands | Matched by id (a→get_state, b→get_available_models), not order |
prompt success |
disposition: "started", before agent_start |
Streaming prompt |
Without streamingBehavior: an error response; with "steer": "queued" |
| Malformed JSON | A parse response with success: false and no id |
| Unknown command | An error response; the process keeps serving |
| Closing stdin | Orderly shutdown; the process exits 0 |
@file argument |
Rejected in RPC mode |
get_commands |
Listed the extension command ask, not /settings or /hotkeys |
| Extension dialog | confirm() emitted an extension_ui_request and blocked; the response with the same id resolved it; cancelled: true delivered false; no normal response was produced |
extension_error |
A throwing handler was reported and the run still completed |
get_entries |
since = an entry id returned only later entries; leafId matched the cursor; an unknown id was an error |
Mechanism and limitations
Documented: rpc.md, rpc-commands.md and rpc-extension-ui.md (1.0.4) define
the four record families, correlation by id (“correlate by ID rather than response
order”), the disposition contract, closed-stdin shutdown, and the extension UI
subprotocol with its timeout default. Observed: rpc.test.ts asserts every row
above against the shipped binary. Not run: clear_queue then abort, the UI
timeout default, and the less common dialog methods.
RPC is not a sandbox: the child has whatever permissions it was started with and loads
extensions from your working folder. An extension_ui_request made without a timeout
can wait indefinitely for a controller that never answers, and stderr must never be
parsed as protocol data. A success response covers only command handling — provider
failures and aborts after a prompt is accepted appear in the message and event stream.
Understand this example
Copy this prompt into your AI tool. No code runs here.
Apply this example
Copy this prompt into your AI tool. No code runs here.