The wire protocol
Docs/Automate/The wire protocol

The wire protocol

Edit on GitHub
docs-site/automate/wire.md

A client talks to a kernel (orrery serve or a node) in two directions:

  • Frames out: the kernel pushes AG-UI 1.0 events. Every frame carries a seq, in AG-UI's open metadata field: "metadata": {"orrery": {"seq": 7}}.
  • Requests in: the client sends intents, never state. The kernel validates each one.

A client renders what it receives; it never writes the kernel's state directly.

Transports

One HTTP listener serves every route:

RouteWhat
POST /runAG-UI's own: a RunAgentInput in, text/event-stream out — one run, as a stock AG-UI client expects (below). An off-the-shelf AG-UI client works unmodified.
GET /eventsThe same stream without starting a run: watch a session someone else drives. ?session=<id|new>.
POST /controlOne request in, its reply as JSON out.
GET /wsA WebSocket: the pipe's protocol, one JSON frame per message.

A pipe (pipe:<name>) speaks the same protocol as /ws, one JSON object per line.

POST /run is one run

The stream starts with the run's RUN_STARTED, then a STATE_SNAPSHOT ({"ui": …, "surfaces": {}}), then that run's events, and closes after its RUN_FINISHED or RUN_ERROR. What the session says around the run — the sender's own message, the screen's echo — stays on GET /events.

While a run is in flight, POST /run answers 409: input sent then would join that run as a steer and open no run of its own. Steer with run.send on POST /control, or wait for the run to end.

curl -N -X POST http://127.0.0.1:<port>/run -H "content-type: application/json"   -d '{"threadId":"<session>","runId":"r1","messages":[{"id":"u1","role":"user","content":"hello"}],"tools":[],"context":[],"state":{},"forwardedProps":{}}'

Auth. A request from loopback needs no token. Any other needs Authorization: Bearer <token> (or ?token=, or token in the Hello over a WebSocket), from orrery node token.

Requests

Each request has a t and an id:

tDoes
session.attachAttach to a session, replaying from since.
turn.submitSubmit a prompt.
run.sendSend input to a run: target is steer (next step), queue (after the current task) or inject (context, no wake).
turn.cancelCancel a turn.
intentAnswer a surface: a form submitted, a choice picked.
ui.intentAct on a UI node: a list item chosen, a button pressed.
consent.answerAnswer a consent prompt.
commandRun a command by name.
state.watch · state.unwatchWatch one extension's public state key.
queryAsk about state.
sessions.list · sessions.new · sessions.endA node's sessions.
curl -s -X POST http://127.0.0.1:<port>/control -H "content-type: application/json" \
  -d '{"t":"run.send","id":"r1","session":"<id>","input":{"text":"use vitest"},"target":"steer"}'

On a pipe or WebSocket, every request gets a reply:

{"t":"reply","reqId":"r1","ok":true,"value":{}}
{"t":"reply","reqId":"r2","ok":false,"error":"what to do"}

A frame always has metadata.orrery.seq and never t; a reply has t and never a seq. A reader that does not know replies can skip them. Over HTTP, the body is the reply's value, and a non-2xx status is ok: false.

Resuming

seq is the whole resumption story. A client that sees a gap, or reconnects, attaches again with since set to the last seq it has, and the kernel replays from there. A coalesced frame stands for several: metadata.orrery.merged_from is the first seq it covers. A STATE_SNAPSHOT sent outside the session's order has seq 0.

How a run ends

Once: RUN_FINISHED, or — when the turn failed — RUN_ERROR alone, never followed by a RUN_FINISHED.

  • RUN_FINISHED.result is Orrery's usage (micro_usd is the cost); usage is the same counts as AG-UI's TokenUsage.
  • A cancelled run finishes with "outcome": {"type": "cancelled"}.
  • A failed run's usage is in RUN_ERROR's metadata.orrery.result.
  • A tool result's full outcome (its surface, a denial's rule) is in TOOL_CALL_RESULT's metadata.orrery.outcome.

Shared state

STATE_SNAPSHOT and STATE_DELTA carry the UI document under /ui and each turn's surfaces under /surfaces/<id>. Every STATE_DELTA op is RFC 6902 (add, replace, remove), so a stock JSON Patch library applies it. Text streamed onto a surface (a command's output) arrives as add …/kind/chunks (the first piece, as an array) and add …/kind/chunks/- (each next one): the surface's text is kind.value followed by its chunks.

Components (decision 0092) use the same state:

  • Each structure is at /ui/components/<name>. It is sent once.
  • A placed component node (/ui/nodes/placed:<owner>:<id>) names its structure and its data pointer.
  • Each instance's data is at /data/<owner>/<id>, a sibling of /ui. The snapshot carries it under data, and each change is a STATE_DELTA op under /data.
  • The first add brings any parents that are missing, so a stock client applies it to state that has no /data yet. On orrery run --json the data ops go out with no screen.
  • A committed action is ui.intent {node: "placed:<owner>:<id>", action, value}. It goes to the extension that showed the component, as ui/event; open on a link the component shows is the host's.

Limits

  • /data is in the snapshot only while some component has data. Hiding the last one removes /data itself.
  • An intent on a component whose extension is not running is refused, and the refusal names the fix.
  • An intent on a component the agent showed becomes a hidden note the model sees on its next turn ([ext:ui] <id>: <action> <value>). It is never the person's message and starts no turn (The agent's route).
  • An intent on a declared extension's component (runtime = "none") runs its [provides.actions.<name>] (Show things: components).
  • orrery.placed for a component carries component, data and the text form. A client that does not bind draws that text.

Orrery's own events

Beyond AG-UI's standard events, Orrery sends CUSTOM events named orrery.*, such as orrery.user.message (every prompt or steer, before its run), orrery.state.changed (a watched key changed), orrery.consent.resolved (a prompt was answered elsewhere) and orrery.notice ({ext, level, text}: an extension's ui.notify when there is no screen to show it on, as on orrery run --json).

Declaring what a client draws

A client that connects over HTTP has no manifest. It declares itself: GET /events?client=<name>&draws=<id>,<id>,.... A client missing a required element is refused, naming it. One that declares nothing can watch but cannot answer consent. See Clients.

The schema

The full types are generated from the Rust definitions into harness/protocol (protocol.schema.json, protocol.d.ts). They are not hand-edited.