The A2UI Contract: A Deep Dive into Agent-to-UI Architecture
DEV Community

The A2UI Contract: A Deep Dive into Agent-to-UI Architecture

A2UI answers one design question: what happens when you let a language model design a UI on the fly. It refuses to let the model write a single line of code. It sends a description instead, and a contract on the wire is what makes that description safe to trust. This piece follows one request all the way through: what the agent decides, what shape that decision is allowed to take, and how a renderer turns it into something on screen. - Agent: decides what to show - Protocol: the shape it's sent in - Renderer: decides how it looks Why not just send code The obvious way to let an agent build a UI on the fly is to have it write one: ask the model for JSX, or a chunk of HTML and script, and mount whatever comes back. It works in a demo, but it has real costs: - Every response is untrusted code running with the page's own privileges. - No two responses look alike, because nothing enforces a design system. - Nothing about the output can be cached. It's different text every time, even when the underlying decision (show a stat card and a chart) is one the model has made a thousand times before. A2UI moves the line. The model is never asked to produce anything that runs. It's asked to produce data: a small JSON object naming a component the client already has, plus which values it should show. The client owns every line of code that executes. The model only ever picks names off a menu and points at where the numbers live. The same request, two designs. On the left there's nothing left to validate: the string is the behaviour. On the right, the model's output is a lookup key into a menu the client wrote and trusts. Whatever it names, that's all it can do. Everything else in A2UI follows from that one move: - Because the model only names things, its output can be validated against a schema before anything happens with it. - Because the same component serves every request, the description can be cached, and only the values behind it need to be fresh. - Because nothing executable ever needs to leave the server, the format the description travels in can be treated as a real contract: versioned, documented, and implementable by more than one client. Three roles, one request A2UI splits the work across three roles that never blur into each other: - Agent: your server code. It decides what to show, meaning which components, filled with which values. - Protocol: the wire format that decision travels in. It only ever answers what shape the description takes: four message types the agent may send, and one the client may send back. - Renderer: a client library plus the set of components you've built for it. It decides how a description actually looks and behaves on screen. Only the agent runs on a server you control. Everything from the wire rightward is client code. @a2ui/web_core is the protocol's own engine: it validates and applies messages the same way no matter what draws next. @a2ui/react is one renderer built on top of that engine, not the only one. Angular, Lit and Flutter renderers exist too, covered below. Three design commitments hold that split together, restated from the protocol's own overview: - Streaming messages: a UI arrives as a sequence of small JSON messages instead of one document, so the client renders incrementally instead of waiting. - Declarative components: UI is described, for example "a StatCard with this label," never programmed. The client, not the model, maps a name to real code. - Data binding: structure and state are separate messages. Values change through the data model. The component tree underneath doesn't move. How the concepts fit together Six ideas do essentially all the work, and none of them stand alone. Each one exists because of what a specific role needs from it. Before the table, here's the shape they make together, for one surface: One surface, drawn as a system. The agent only ever reaches the surface through three messages; the catalog is the one thing it can't touch at all; and the only way anything travels back out is a single action, carrying values the component read from the data model. | Concept | What it is | Who owns it | |---|---|---| | Surface | An independent UI container, addressed by surfaceId , with its own component tree and its own data model. A page can host several at once: a stat card, a chart, a summary. | Created by the agent's createSurface message. Lives entirely as renderer-side state after that. | | Component | A flat record: id , a component type name, then props. Every surface needs exactly one record with id: "root" . | Emitted by the agent inside updateComponents . Resolved into an actual tree by the renderer. | | Catalog | The allowlist of component types, with their prop schemas, addressed by catalogId . Chosen once when a surface is created and fixed for that surface's whole life. | Fixed by the renderer ahead of time. The agent may only pick from it, never extend it mid-conversation. | | Data model | One JSON tree per surface. Props point into it with JSON Pointer paths (RFC 6901), like /statValue , so a value can change without the structure around it changing. | Filled by the agent's updateDataModel message. Read by the renderer wherever a prop binds to it. | | Action | The only client to agent message: a name, plus context values pulled from the data model. Purely local interactions don't need one at all. | Raised by the renderer when the user interacts. The agent decides what, if anything, happens next. | | Transport | Deliberately left out of the spec. SSE, WebSocket, the A2A agent protocol, or a single POST response all carry the same messages. A2UI doesn't care which. | Belongs to neither side. It's just the pipe every message above travels through. | Agent, protocol, renderer: one request in order The split above only holds together because of a strict handoff. The agent decides first, the protocol fixes what that decision is allowed to look like, and only then does the renderer get to act on it. Two illustrative surfaces run through the rest of this section, a stat card (surface stat ) and a chart (surface chart ), as a stand-in for any small dashboard. 1. Agent: decide the layout, then supply the values The agent's job splits into two halves that happen at different times and for different reasons: - An LLM call picks the layout once per kind of question: which catalog components to use, on which surfaces, bound to which data paths. - Ordinary code computes the real values those paths should hold (an API call, a database read, a calculation), and it does this on every single request, never cached. Nothing here has produced any JSON yet. That's the protocol's job, next. 2. Protocol: the shape that decision is only ever allowed to take Whatever the agent just decided has to fit into one of five message shapes. There's no sixth escape hatch. A component the model invents that isn't in the catalog, or a value written somewhere the schema doesn't expect, gets rejected before it changes anything. | Message | Direction | Does | Fails when | |---|---|---|---| createSurface | agent to client | Registers a surfaceId against a catalogId (optional theme , sendDataModel ) | the catalog isn't registered, or the surface id already exists | updateComponents | agent to client | Adds or replaces component records by id ; every record is validated against the catalog schema before any of them are applied | the surface is missing, a record has no id , or props fail the catalog's schema | updateDataModel | agent to client | Sets value at path (default the whole model, at / ) | the surface is missing | deleteSurface | agent to client | Tears down a surface and its state | none | action | client to agent | A user event: name , surfaceId , sourceComponentId , context | the agent's job to validate, like any other untrusted input | Layout becomes bytes inside updateComponents specifically, and it isn't a nested JSON tree. It's a flat list of records that reference each other by id . That's deliberate: - Each record is small enough for a model to emit reliably without losing track of a deep structure. - A stream can send one component at a time. - An update becomes the simplest possible operation: replace the record at this id , leave every other record alone. This is the agent's output, exactly as it goes out on the wire: Column, Text and List are Basic Catalog types used here generically. The left half is what the agent puts on the wire. The right half, a real parent-child tree the screen can actually draw, only exists after the renderer resolves it. That's the renderer's first job, covered below. Any prop inside one of those records can be written two ways: a literal, like "label": "1-Year Return" , or a binding, like "value": {"path": "/statValue"} . Both are just JSON the protocol allows. What a binding means is a renderer concern, covered next. The reason it exists here is caching. A binding is a promise to look a value up later rather than a value itself, so the very same updateComponents record can serve every request for a stat card. Only the updateDataModel message behind it needs to change. Illustrative values. The component message and the data message are independent. One is generated (or cached) once per layout decision, the other is computed fresh every time. Ordering isn't a convention. It's enforced by throwing. The renderer rejects any message for a surface that doesn't exist yet, which means a surface's createSurface must arrive before any other message about it. Nothing constrains the order across different surfaces. That's what lets one response describe a stat card and a chart without a fixed sequence between them: Within a surface, create → components → data is the only order that's legal. Between surfaces, an agent is free to interleave. chart 's three messages arrive later and in a different internal position than stat 's, and that's allowed. 3. Renderer: turn messages into an actual tree on screen Every message above lands in a framework-

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.