Skip to content

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 error control 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:

{"type":"ready","protocol":"letscode-rpc/1","letscode":"0.7.0"}

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)

{"cmd": <name>, "id": <opaque token, optional>, ...}

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 (typeready|ack|done|error|bye):

  • ready: once, at startup.
  • ack{id}: command accepted, before its effects. For prompt this precedes agent_start.
  • done{id}: command's work is complete. For prompt, after the terminal agent_end. For steer / 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 (with fatal:true) a handshake mismatch before non-zero exit. Control errors always carry id (possibly null); an Agent ErrorEvent does 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 Agent doesn't produce per-request event ids; RPC exposes what the core has.
  • A continue / retry command: resend prompt. 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