The wire protocol
Edit on GitHubA 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 openmetadatafield:"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:
| Route | What |
|---|---|
POST /run | AG-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 /events | The same stream without starting a run: watch a session someone else drives. ?session=<id|new>. |
POST /control | One request in, its reply as JSON out. |
GET /ws | A 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:
t | Does |
|---|---|
session.attach | Attach to a session, replaying from since. |
turn.submit | Submit a prompt. |
run.send | Send input to a run: target is steer (next step), queue (after the current task) or inject (context, no wake). |
turn.cancel | Cancel a turn. |
intent | Answer a surface: a form submitted, a choice picked. |
ui.intent | Act on a UI node: a list item chosen, a button pressed. |
consent.answer | Answer a consent prompt. |
command | Run a command by name. |
state.watch · state.unwatch | Watch one extension's public state key. |
query | Ask about state. |
sessions.list · sessions.new · sessions.end | A 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.resultis Orrery's usage (micro_usdis the cost);usageis the same counts as AG-UI'sTokenUsage.- A cancelled run finishes with
"outcome": {"type": "cancelled"}. - A failed run's usage is in
RUN_ERROR'smetadata.orrery.result. - A tool result's full outcome (its surface, a denial's rule) is in
TOOL_CALL_RESULT'smetadata.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
componentnode (/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 underdata, and each change is aSTATE_DELTAop under/data. - The first
addbrings any parents that are missing, so a stock client applies it to state that has no/datayet. Onorrery run --jsonthe 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, asui/event;openon a link the component shows is the host's.
Limits
/datais in the snapshot only while some component has data. Hiding the last one removes/dataitself.- 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.placedfor a component carriescomponent,dataand 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.