A2UI: agents' generated UI
architect-a2ui: an agent shows the person a form, a list, cards or an approval as A2UI v1.0, drawn with architect-ui from a catalog that is the security boundary, kept in Loro, its buttons running Action List actions that ask before every write.
What it is and when to use it#
An agent that wants the person to fill in a form, pick from a list or approve something sends an A2UI v1.0 surface instead of text. architect draws it with its own components (architect-ui, the app’s theme), in any pane. It is the A2UI payload of the generative-UI view kind (#226), mounted through the view-kind contract (Embedded views).
We don’t maintain protocol code. The messages, JSON-Pointer data
binding, client functions and validation come from
a2ui-base (MIT), pinned exactly
(a2ui-base = "=0.3.1" in features/a2ui/Cargo.toml). Its umbrella crate
a2ui always pulls in a ratatui backend, so we depend on a2ui-base
alone. architect owns only three things:
- The catalog (
features/a2ui/src/catalog.rsCATALOG), which is the security boundary. Only the components it lists are drawn, and only the props it lists are read. A component outside it shows as a labelled placeholder and nothing of it is drawn. Its JSON Schema (features/a2ui/catalog.schema.json) and the system prompt a model is given (prompt, json-render’scatalog.prompt()idea) are generated from it. - The state. Each surface lives in a Loro document
(
features/a2ui/src/store.rsSurfaces), so it persists, syncs and can be co-edited. AnupdateDataModelupsert is a write into a tree of Loro maps, and so is a field the person types into. - The command path. A button’s
{"event": {"name": "TASKS_ADD"}}runs the Action List actionTASKS_ADD(features/a2ui/src/events.rspress). It runs as the surface’s author, through the list’s authorizer, asking the person before any write.
Use it for forms, lists, cards, dashboards and approvals that an agent composes at run time. Use a Frame surface for a control surface, and sandboxed HTML (the WebView kind, #230) for a visualisation the catalog can’t express.
A minimal example#
The app puts the surfaces and the A2UI_* actions on its list, and the
view in its workspace (the sandbox: apps/ui/src/lib.rs LabApp,
apps/ui/src/vault.rs):
let surfaces = architect_a2ui::Surfaces::new(crdt::CrdtDoc::ephemeral(), architect_a2ui::store::ROOT);
architect_a2ui::actions::register(&list, &surfaces)?;
architect_a2ui::ui::use_a2ui(surfaces, (*list).clone()); // in the root component
let views = Views::new().view(architect_a2ui::ui::surfaces_view());
list.require_confirmation(CONFIRM_TTL); // and a ConfirmSheet: writes ask
An agent then renders, over MCP or from the command line:
{ "messages": [ { "createSurface": {
"surfaceId": "signup", "catalogId": "https://architect.fasttrackstudio.dev/a2ui/catalog/v1",
"dataModel": { "name": "" },
"components": [
{ "id": "root", "component": "Column", "children": ["name", "save"] },
{ "id": "name", "component": "TextField", "label": "Name", "value": { "path": "/name" } },
{ "id": "save", "component": "Button", "child": "label",
"action": { "event": { "name": "PEOPLE_ADD", "context": { "name": { "path": "/name" } } } } },
{ "id": "label", "component": "Text", "text": "Save" } ] } } ] }
architect-desktop actions run A2UI_RENDER --json - < signup.json
architect-desktop actions run A2UI_PATCH --json '{"messages":[{"updateDataModel":{"surfaceId":"signup","path":"/name","value":"Ada"}}]}'
architect-desktop actions run A2UI_SURFACE --json '{"surfaceId":"signup"}'
Pressing Save asks the person first (the confirmation sheet), because
PEOPLE_ADD changes something and an agent wrote the button.
The actions#
| id | effect | does |
|---|---|---|
A2UI_RENDER | mutating | applies A2UI messages. A surface it creates replaces any surface with the same id. It answers the surfaces it touched and any component outside the catalog. |
A2UI_PATCH | mutating | applies updateComponents, updateDataModel and deleteSurface to surfaces that already exist |
A2UI_SURFACE | read-only | returns one surface’s components and data model (what the person entered), or every surface’s id |
A2UI_PRESS | mutating | presses a component the way the person would. A write waits for the person’s yes; run it again with the ticket once they approve. |
A2UI_CATALOG | read-only | returns the catalog: its id, its components, its JSON Schema and the prompt |
A batch of messages is applied whole or not at all, as one undoable step
when the store has a history (Surfaces::with_history). Over MCP, an
answer that is an object is also the tool result’s structuredContent
(features/mcp/src/lib.rs text_result).
The catalog#
Text, Row, Column, List, Card, Divider, Button, CheckBox,
TextField, Slider, ChoicePicker, Tabs and Image are A2UI v1.0’s
basic names, so an agent written for another A2UI renderer works. Each is
drawn by architect-ui: Heading/Text, HStack/VStack-shaped flex,
Card, Divider, Button, Checkbox, Input/Textarea, Slider,
buttons for choices, and tabs as buttons over the active child.
Modal, Video, AudioPlayer, Icon and DateTimeInput are not in it
yet. They show as placeholders.
What an agent can’t do from a surface:
- Run a local function from a button.
{"functionCall": …}actions are refused. Bindings may still calla2ui-base’s pure client functions. - Reach anything but the Action List. No network and no other
command path.
callFunctionandactionResponsemessages are refused. - Name another catalog. A
createSurfacewith anothercatalogIdis refused with the one this app draws. - Write without asking. Its events run as
ActionCaller::confirming_writes(crates/architect/src/action_list.rsActionCaller). Such a caller gets a confirmation ticket for every mutating or destructive action, and where the list asks no one its writes are refused rather than run. Read-only actions run at once. A surface the app rendered itself runs as the app.
Code tour#
features/a2ui/src/catalog.rs:CATALOG(ComponentDef,PropDef,Prop),schema,prompt,refusal.features/a2ui/src/store.rs:Surfaces(applychecks the whole batch, then writes it in one step;getreads aSurfaceback;set_data;subscribe),parse(an array, one object or JSONL; a model’s smart quotes and trailing commas are fixed bya2ui-base’s payload fixer).features/a2ui/src/events.rs:resolve(the event’s action id and its context resolved througha2ui-base’sDataContext),press,caller,answered.features/a2ui/src/actions.rs:register.features/a2ui/src/ui.rs(featureui):use_a2ui,A2uiSurface,draws(catalog name → architect-ui),surfaces_view.
Tests to read#
features/a2ui/tests/render.rs: a 13-component surface rendered withA2UI_RENDERand drawn headless on Blitz. The test checks that theScriptplaceholder shows, that a live patch of the data model and a component redraws, that a mutating press waits for the person’s yes and then runs with the resolved context, and that the data model survives a reload from the document’s bytes.features/a2ui/tests/catalog_schema.rs: the committed schema is the catalog’s (A2UI_UPDATE_SCHEMA=1rewrites it), and everything in it is drawn and nothing else is.features/a2ui/src/store.rstests: the Loro round trip, all-or-nothing batches, the catalog refusal.apps/desktop/tests/actions.rs:A2UI_RENDER,A2UI_PATCHandA2UI_SURFACEgive the same answers over MCP and the CLI.
Status#
Done (architect#228):
- the catalog and its generated schema and prompt;
- surfaces in Loro;
- events as actions, confirmed;
- the
A2UI_*actions over MCP and the CLI; - the renderer on Blitz, and the sandbox’s “Agent UI” view.
- the default build and
uicompile towasm32-unknown-unknown(CI’s wasm job checks both, #253). In the browser aCrdtDocis notSend, soSurfacesholds it in asend_wrapper::SendWrapperthere (features/a2ui/src/store.rs); the browser has one thread, so the Action List’sSendhandlers can still hold the surfaces.
Not yet:
- Web host. The renderer builds for the browser, but no web app
draws it yet: a web lab target rendering a surface from
A2UI_RENDER, its interactive components (Slider,CheckBox,TextField,Tabs) and the theme’s sheets next to an app’s own Tailwind sheet are checked in a browser by #253’s second part. It needsdxand awasm-bindgenCLI in the dev shell. - The sandbox’s persistence. The sandbox keeps its surfaces in an in-memory
document. An app keeps them in one of its own documents (a
DocFile, a synced doc). - Opening the pane.
A2UI_RENDERdoes not open the “Agent UI” pane by itself yet. The workspace’s operations are actions now (#234), so render can open it; that is #254. - Agent turns and provenance. Each render as an attributed agent turn waits on #227.
If upstream stalls. a2ui-base has a single maintainer. We track its
releases every two weeks and move the exact pin deliberately. If it stalls
(no release or answer to an issue for a quarter, or a v1.0 change we need
that it doesn’t take), fork it as FastTrackStudios/a2ui with an fts
branch and pin by tag, as with figcraft. Keep the delta small and offer it
upstream. Until then: no fork, no vendored copy.
Linked from
- FeaturesOne page per major architect feature: what it is, the least code to use it, a tour of its code, the tests to read, and its status.
- Embedded views: view kindsOne contract every view mounts through — a Dioxus component, a GPU scene, a web view, a Frame surface, an agent's generated UI — a registry of the kinds an app provides, where each can be shown and mounted, and what each may do.