title: RPC mode
description: letscode --mode rpc: newline-delimited JSON over stdio, for IDE and host-app integration. The Event models are the wire.
RPC mode¶
Since v0.4, letscode --mode rpc speaks newline-delimited JSON over stdio. A host process spawns letscode as a subprocess, drives it over stdin, and consumes the Event stream as one JSON object per stdout line. There is no frontend; the host renders whatever it wants from the event stream.
The design brief was: let embedders integrate letscode without committing to a web UI. The design decision was: reuse the existing Event models verbatim as the wire; no translation layer, no parallel schema to drift.
Full wire spec: notes/17-rpc-protocol.md. This page is the summary.
Framing¶
- Transport: subprocess stdin / stdout, one JSON object per
\n-terminated line, UTF-8. - stdout flushes after every line.
- Blank stdin lines are ignored. A non-object or unparseable line yields an
errorcontrol message and the stream continues; no desync on one bad line. - stdout is protocol-only. Diagnostics go to stderr. A single stray stdout write corrupts the stream.
Handshake¶
At startup, before reading any command, the child emits exactly one:
The host MAY send {"cmd":"hello","protocol":"letscode-rpc/1"} first. On major mismatch the child replies with a fatal error and exits non-zero. Additive changes (new type, new optional field) do not bump the major; clients MUST ignore unknown type values and unknown fields.
Commands (inbound)¶
id is echoed back on this command's ack / done / error. Lifecycle replies carry "id": null when the command had no id.
cmd |
extra | maps to | when valid |
|---|---|---|---|
hello |
protocol |
handshake check | first line, optional |
prompt |
text |
await Agent.prompt(text) |
only when idle |
steer |
text |
Agent.steer(text) (queues) |
any time, esp. mid-run |
follow_up |
text |
Agent.follow_up(text) |
any time |
abort |
(none) | Agent.abort() |
any time (no-op if idle) |
reset |
(none) | clear state.messages + queues |
only when idle |
bye |
(none) | graceful shutdown | any time |
Unknown commands yield an error (recoverable:true) and the stream continues. prompt while a run is active is likewise rejected; one run at a time is the v1 contract.
Outbound stream¶
Two categories, dispatched by type:
Control messages (type ∈ ready|ack|done|error|bye):
ready: once, at startup.ack{id}: command accepted, before its effects. Forpromptthis precedesagent_start.done{id}: command's work is complete. Forprompt, after the terminalagent_end. Forsteer/follow_up/abort/reset, immediately after the (fast) action; those don't produce their own event streams (their effect surfaces in a later run).error{id, error, recoverable[, fatal]}: bad command, busy, or (withfatal:true) a handshake mismatch before non-zero exit. Control errors always carryid(possibly null); an AgentErrorEventdoes not, and that's how a client distinguishes.bye: final line before exit 0.
Domain events (type ∈ the Event union): emitted verbatim as event.model_dump(mode="json"). agent_start, turn_start, message_start / _update / _end, tool_execution_start / _update / _end, turn_end, retry, error, agent_end.
The two type vocabularies are disjoint by construction. The client dispatches on type with one table, no re-wrapping every event in an envelope.
Correlation¶
The Agent does not tag events with a request id, and Agent.prompt rejects re-entry. Correlation is by lifecycle:
ack{id=r1}
agent_start
turn_start
message_start / message_update... / message_end
tool_execution_start / _end
turn_end
agent_end
done{id=r1}
All events between ack{r1} and done{r1} belong to that command. The core provides no per-event request ids, and RPC does not invent them.
Shutdown¶
bye command, or stdin EOF, means stop accepting commands and wind down, not cancel the answer. If idle, emit bye and exit 0. If a run is active, the child lets it drain: the loop emits its terminal agent_end + the active prompt's done, then bye, then exit 0. This makes the batch pattern (printf '{"cmd":"prompt",...}\n' | letscode --mode rpc) return the completed run instead of a truncated one. Explicit abort is the only deliberate cancel.
A bounded safety valve prevents a hung run from blocking exit forever. After the grace period, the child calls abort() internally and exits regardless. Fatal I/O / handshake mismatch produces error{fatal:true} and non-zero exit.
Example: one prompt, one tool call¶
< {"type":"ready","protocol":"letscode-rpc/1","letscode":"0.7.0"}
> {"cmd":"prompt","id":"r1","text":"read app.py and summarise"}
< {"type":"ack","id":"r1"}
< {"type":"agent_start","messages":[...]}
< {"type":"turn_start","turn":1}
< {"type":"message_start","message":{...assistant...}}
< {"type":"message_update","message":{...},"stream_event":{"type":"text_delta","delta":"Reading"}}
< {"type":"message_update","message":{...},"stream_event":{"type":"tool_call_start","tool_call_id":"c1","name":"read"}}
< {"type":"message_end","message":{...}}
< {"type":"tool_execution_start","tool_call_id":"c1","tool_name":"read","arguments":{"path":"app.py"}}
< {"type":"tool_execution_end","tool_call_id":"c1","result":{...tool_result...}}
< {"type":"turn_end","turn":1,"message":{...},"tool_results":[...]}
< {"type":"turn_start","turn":2}
< {"type":"message_end","message":{...final assistant...}}
< {"type":"agent_end","messages":[...],"error":null}
< {"type":"done","id":"r1"}
What's not in v1¶
Recorded so their absence is intentional:
- Concurrent runs: one prompt at a time. Correlation stays simple.
- Request-scoped event tagging: the
Agentdoesn't produce per-request event ids; RPC exposes what the core has. - A
continue/ retry command: resendprompt. Add if a real consumer needs it. - Binary framing / length prefixes: line-oriented JSON is enough.
- Auth or multiplexing: the stdio channel is the trust boundary.
Where the code lives¶
- Entry point:
src/letscode/cli/rpc.py - Event models on the wire:
src/letscode/agent/events.py. See Events for the union. - Subprocess integration test:
tests/b_integration/test_rpc_subprocess.py. Drivesletscode --mode rpcfor real, asserts the ack → agent_start … agent_end → done sequence for a multi-turn conversation with one tool call. - Full spec:
notes/17-rpc-protocol.md