Protocol
The op stream
What the core hands a renderer as an answer streams: one JSON operation per line, for drawing answers on a platform of your own.
The SDKs never parse the format: they apply the core's operations. output: "ops" on /v1/ui and /v1/render gives you the same operations, for drawing answers yourself, without the core.
The core turns chunks of model text into a stream of operations, one JSON
object per line (served as SSE data: lines by the API). Every renderer, on
every platform, only applies ops; none of them parses the format.
Nodes
The document is a tree. A node's id is its position: the indexes from the
root joined with dots ("3", "3.0", "3.0.2"). Ids never shift, because
nodes only ever arrive after the ones before them.
| Node type | What it is | Holds |
|---|---|---|
A component name (Card, Chart, Camera, a customer's own) |
a component, aliases already resolved | children, spans, or data, by its content kind |
p |
paragraph | spans |
h |
heading; attrs.level 1 to 6 |
spans |
ul, ol |
list | li children |
li |
list item; attrs.task is "open" or "done" for task items |
spans |
table |
table | row children |
row |
table row; attrs.head is true for the header row |
cell children |
cell |
table cell; attrs.align is "right" when its column is numeric |
spans |
code |
fenced code; attrs.lang when given |
spans (one text span) |
quote |
block quote | spans |
hr |
thematic break | nothing |
Ops
| Op | Fields | Meaning |
|---|---|---|
open |
id, type, attrs (left out when empty) |
A node starts; its parent is its id up to the last dot. A component's attributes are final when it opens (the tag was complete). Attribute values are strings, or true for a bare attribute. |
spans |
id, spans |
The whole inline content of an open leaf so far (a string when it's plain text). Replaces the previous spans of that node. Sent at most once per chunk of input, and before the node closes. |
node |
id, type, attrs, spans, data (each left out when empty) |
A leaf that opened and finished within one chunk: open, spans and close in one op. |
close |
id, optional data |
The node is final and never changes again. Components with table, list or image content carry their parsed content in data. |
diag |
line, cls, detail, severity |
A problem (error) or a coercion the core made (note), tied to the line, for repair. |
end |
complete, truncated |
The stream is over. Anything still open was closed first, with a diagnostic. |
data by content kind:
table(Chart, Map):{"columns": [...], "rows": [[...], ...]}, cells as the model wrote them (renderers parse numbers for charts).list(FollowUps):{"items": [spans, ...]}.images(Gallery):{"images": [{"src": "...", "alt": "...", "credit": [spans]}]}(creditonly when the image has one).- A Question (interactive pack):
{"items": [spans, ...], "answer": index or null}; the core matchesanswerto a choice by its letter, number or words. - A Checklist:
{"checks": [{"done": true or false, "c": [spans]}]}([x]marks done). - A What-if Result:
{"formula": [tokens], "value": number or null}: the formula in postfix tokens (numbers, the Sliders' names,+ - * / ^ %,neg,min,max,round,floor,ceil,abs,sqrt) and its value at the Sliders' starting values. A Slider's name is itsname, or its label as one word ("Unit price ($)" isunit_price). Renderers run the tokens on a stack as the Sliders move; nothing else runs.
Spans
Inline content is an array of spans:
| Span | Fields |
|---|---|
{"t": "text", "v": "..."} |
plain text |
{"t": "strong" | "em" | "s" | "u" | "small" | "sub" | "sup" | "mark" | "kbd", "c": [spans]} |
formatting |
{"t": "code", "v": "..."} |
inline code |
{"t": "link", "href": "...", "c": [spans]} |
link |
{"t": "img", "src": "...", "alt": "...", "credit": [spans]} |
inline image; credit is its title, parsed, when it has one |
{"t": "br"} |
line break |
{"t": "el", "type": "Badge", "attrs": {...}, "c": [spans]} |
a component inside a sentence or a table cell |
The core strips emojis (keeping ©, ® and ™), reads the HTML models slip in as formatting, and keeps a dash between words from starting a line, so every renderer shows the same text.
Example
{"op":"open","id":"0","type":"h","attrs":{"level":"1"}}
{"op":"spans","id":"0","spans":[{"t":"text","v":"Revenue overview"}]}
{"op":"close","id":"0"}
{"op":"open","id":"1","type":"Card","attrs":{"title":"Weekly revenue"}}
{"op":"open","id":"1.0","type":"Chart","attrs":{"type":"line","x":"Week","y":"USD"}}
{"op":"close","id":"1.0","data":{"columns":["Week","Revenue"],"rows":[["W1","24100"],["W2","30800"]]}}
{"op":"close","id":"1"}
{"op":"end","complete":true,"truncated":false}Actions
Components never run code. When the user acts, the renderer sends an action, and the API turns it into the model's next turn.
| User does | Action | Next model turn |
|---|---|---|
Taps a Button with ask, or one with no href outside a Form |
{"type": "ask", "text": ask or the label, "node": id} |
a user message with that text |
| Taps a FollowUps item | {"type": "ask", "text": the item, "node": id} |
a user message with that text |
Taps a Button with href |
nothing is sent; the link opens | none |
| Submits a Form (any Button inside it) | {"type": "submit", "node": form id, "form": its name, "button": the label, "fields": {...}} |
a user message listing each field's label and value, with photos and files attached |
| Switches a Tab, opens Details | nothing; local state | none |
| Answers the last question of a Quiz | {"type": "quiz", "node": id, "score": n, "total": n, "answers": [{"question", "choice", "right": true, false or null}]} |
a user message with the score and each answer |
| Votes in a Poll | {"type": "vote", "node": id, "name": its name, "choice": the option} |
a user message with the vote |
| Ticks a Checklist item, marks a Flashcard known | {"type": "state", "node": id, "name": the Checklist's name or "known", "value": what's ticked or known} |
none: it's component state (below) |
| Moves a What-if Slider | nothing; the Results are worked out on the device | none |
fields is keyed by each field's name (or its label as a slug when it has
none):
| Field | Value |
|---|---|
| Input, TextArea, Select, Radio, Date, Scan | a string |
| Slider | a number |
| Switch | true or false |
| Checkboxes | an array of the chosen options |
| Camera, File | an array of the platform's file objects (File on the web) |
| Location | {"lat", "lng", "accuracy"} in degrees and meters |
Photos and files come to the app in the action as the platform's own file
objects (a File on the web), for the app to upload where it likes; with
AG-UI they go to the agent as content parts. The renderer checks required
before sending. Device values exist only after the user acted: a tap took
the photo, a tap shared the location.
Component state
What the reader has done to the interactive components and forms is also
kept as plain JSON per node, the same shape on every platform, so it can be
saved, restored and shared with an agent. With AG-UI it is the shared
state's morphscreen key, by message id and node id, sent with every run;
an agent that sets it sets the components, and that sends no action.
| Component | State |
|---|---|
| Quiz | {"answers": [index or null per question]} |
| Flashcards | {"known": [card index]} |
| Poll | {"vote": option or null} |
| Checklist | {"done": [item index]} |
| WhatIf | {"values": {slider name: number}} |
| Form | {"fields": {name: value}}, typed as above; uploads and locations stay on the device |
The words of an action's user message come from the core
(morphscreen_core::action::message), so every SDK and the API send the
same turn. docs/agui.md has the rest.