Skip to content

A2UI

A2UI is the declarative agent-to-UI protocol: an agent sends JSON describing a component tree plus a data model, and the renderer maps the abstract component names onto its own widgets — React, Angular, Lit, Flutter, Swift, Jetpack Compose.

prefab speaks it as a second output target. The same server-side component tree that produces a $prefab payload also produces A2UI messages, so one authoring API reaches both:

$prefabA2UI
Rendered byprefab's own rendererthe host's A2UI renderer
DeliveryMCP Apps ui:// HTML resource, or any web pagea2ui:// resource or an embedded resource in a tool result
Surfacesandboxed iframenative widgets, no iframe
Catalog115+ componentsthe 18-component Basic catalog

Emitting

ts
const app = new PrefabApp({
  view: Column({ children: [H1('Users'), autoTable(rows)] }),
})

const { messages, diagnostics } = app.toA2UI()

messages is a list of A2UI protocol messages. By default everything is inlined into a single createSurface, which is what a stored payload wants. Pass { stream: true } to split it into createSurface + updateComponents + updateDataModel so a streaming transport can paint early.

diagnostics is the part worth reading. prefab has 115+ components and the Basic catalog has 18, so some of the tree changes shape on the way across, and every change is reported:

ts
for (const d of app.toA2UI().diagnostics) {
  console.warn(`${d.kind}: ${d.subject} — ${d.detail}`)
}
KindMeaning
degradedRendered as something simpler. An Alert became a Card, a Metric became a Column of Text.
unsupportedDropped. Charts, diagrams and file uploads have no Basic-catalog reading.
expressionA {{ }} template was richer than a JSON Pointer, so the binding could not be made.
actionAn action had no direct equivalent and was reported to the agent as an event instead.

Serving it over MCP

Return a surface from a tool with display_a2ui. The payload travels as an embedded resource under the application/a2ui+json MIME type, which is how a host knows to route it to its A2UI renderer:

ts
server.registerTool('list-users', schema, async () =>
  display_a2ui(autoTable(await db.users())))

For a surface that does not depend on the conversation, register it as a resource instead — the host reads it once and caches it:

ts
registerA2uiResource(server, () => Column({ children: [
  H1('Settings'),
  Input({ name: 'apiKey', label: 'API key' }),
] }), { uri: 'a2ui://myserver/settings' })

The builder runs on every read, so a surface closing over live data refreshes without re-registering. Caching is off by default for that reason; pass cache: { ttlMs, cacheScope } when the surface really is static.

Both live alongside the MCP Apps path in MCP Apps — a server can offer ui:// and a2ui:// from the same tool and let the host pick.

In the browser

The emitter also ships as a standalone IIFE bundle, apart from renderer.min.js. Almost no page that renders $prefab also emits A2UI, so folding the two together would tax every consumer for a feature they do not use:

html
<script src="https://cdn.jsdelivr.net/npm/@maxhealth.tech/prefab/dist/a2ui.min.js"></script>
<script>
  const { messages, diagnostics } = PrefabA2UI.emit(wireJson)
  const payload = PrefabA2UI.envelope(messages)   // { messages: [...] }
</script>

emit takes parsed $prefab JSON rather than a component tree, so nothing in the bundle needs the authoring API. That is what keeps it around a sixth the size of the renderer.

The playground runs this bundle: switch the preview pane to A2UI to see any payload translated live, with the diagnostics listed underneath.

How the tree crosses over

Two structural differences drive everything:

Flat, not nested. A2UI components live in an adjacency list. Every component carries an id and parents reference children by id. prefab's nested children are flattened, ids are allocated deterministically in traversal order, and the entry component is named root as the protocol requires. An id you set yourself is honoured.

Bound, not interpolated. A2UI reads dynamic values through JSON Pointer bindings. {{ user.name }} becomes { "path": "/user/name" }, and prefab's state becomes the surface data model.

Text that mixes literals with values goes through the formatString catalog function, so Score: {{ score }} becomes { call: 'formatString', args: { value: 'Score: ${/score}' } } rather than being lost. What has no equivalent is arithmetic, pipes and conditionals — {{ count + 1 }}, {{ price | currency:'USD' }} — and those raise an expression diagnostic. A string is interpolated only if every value in it binds; one unbindable expression makes the whole string unbindable, because interpolating half of it would change what the text says without saying so.

Pipes

A2UI has no expression language, but its catalog has the formatting functions that prefab's common pipes correspond to, so seven of the twenty-two map:

prefabA2UI
currencyformatCurrency(value, currency)
number, roundformatNumber(value, decimals)
date, time, datetimeformatDate(value, format)
pluralizepluralize(value, one, other)

{{ price | currency:'EUR' }} becomes { call: 'formatCurrency', args: { value: { path: '/price' }, currency: 'EUR' } }.

The date pipes are the one inexact mapping. prefab renders through the reader's locale; A2UI's formatDate requires an explicit Unicode TR35 pattern, so one is chosen and a degraded diagnostic says which. Losing the value entirely would be the worse trade.

The other fifteen — truncate, join, selectattr, percent, compact and friends — transform data rather than format it, and stay reported. So does a chained pipe: no single catalog function is two pipes, and translating half of one would change the value without saying so.

Validation

Every A2UI input is Checkable, which is the same job prefab's required and inputType do, so a form keeps its validation on the way across:

json
{
  "component": "TextField",
  "label": "Email",
  "checks": [
    { "condition": { "call": "required", "args": { "value": { "path": "/email" } } } },
    { "condition": { "call": "email",    "args": { "value": { "path": "/email" } } } }
  ]
}

No message is emitted. The rule already says which check failed, and the renderer is better placed to word and localize that than prefab is.

numeric is deliberately not emitted for a number field. The catalog requires it to carry a min or a max — it is a range check rather than a type check — and prefab's number inputs carry no range. variant: 'number' on the field already says the value is numeric.

Control flow

prefabA2UI
ForEachthe child template — one instance per item, $item resolving to a path relative to the current item and $index to the @index function
Define / Use / Slotresolved at emit time by inlining the definition; a Use's overrides are seeded into the data model and brought into scope by name
If / Elif / Else / Conditionno equivalent

Conditionals are the one real capability gap between the two protocols. A2UI has no declarative if: the renderer draws what the adjacency list says, and the agent sends a fresh updateComponents when the shape should change. prefab runs a reactive client that re-shapes itself without a round trip.

A one-item list template would look like a conditional and behave like one only by accident, so the emitter reports the loss instead of faking it. A UI leaning on If does not cross over intact, and no amount of emitter work changes that.

Tables

DataTable and autoTable map onto A2UI's child template rather than being flattened row by row: one Row template, one Text per column bound to the column key, and a Column whose children is { path, componentId }. The renderer instantiates one copy per item in the data-model list, so the emitted surface stays as small and as reactive as the prefab original.

A literal row array is seeded into the data model so the template has something to iterate; a {{ rows }} expression binds straight to where the rows already live.

Mapping table

prefabA2UI BasicNote
Column, RowColumn, Rowalign and justify carried across
Div, Container, Grid, Form, Page, …Columncontainers with no A2UI meaning flatten
Card, CardContentCardseveral children get a Column wrapper
H1H6, HeadingTextMarkdown # prefix
Text, P, Lead, Large, MarkdownText
Muted, Small, Label, BadgeTextvariant: caption
Code, KbdTextbacktick-wrapped
BlockQuoteText> prefix
Input, TextareaTextFieldinputType picks the variant; required becomes a check
Checkbox, SwitchCheckBox
Select, RadioGroup, ComboboxChoicePickeroptions read from the children
SliderSliderstep converted to division count
DatePicker, TimePickerDateTimeInput
ButtonButtonlabel becomes a child Text
LinkButtonborderless, running the openUrl function
Image, Video, Audio, IconImage, Video, AudioPlayer, Icon
Tabs / TabTabs
DialogModal
SeparatorDivider
AlertCardvariant styling dropped
MetricColumn of Texttrend and delta dropped
Table, DataTableColumn of Rowssee above
CardTitle, CardDescription, TooltipText
ForEachtemplated Columnsee Control flow
Define, Use, Slotinlinedsee Control flow
If, Elif, Else, Conditionunsupported
charts, Mermaid, Svg, DropZone, Progressunsupported

A component the table does not name still emits: one with children flattens to a Column, one with text renders as Text, and each raises a degraded diagnostic. Nothing is dropped silently.

Actions

prefab actionA2UI
CallTool (either toolCall or callTool on the wire){ event: { name: tool, context: arguments } }
SendMessage{ event: { name: 'sendMessage', context: { message } } }
OpenLink{ functionCall: { call: 'openUrl', args: { url } } }
SetState, ToggleState, everything elsean agent event named after the action

Argument values go through the same binding conversion as component props, so CallTool('search', { arguments: { q: '{{ query }}' } }) sends the bound value rather than the raw template.

A2UI carries one action per control. Where prefab binds several, the first is used and the rest raise an action diagnostic.

Conformance

Emitted payloads are validated against the official A2UI v1.0 JSON Schemas, vendored under test/fixtures/a2ui/v1_0/, plus the two structural rules the schemas cannot express: every referenced child id must exist, and every component must be reachable from root. test/a2ui.test.ts runs one view per mapper family through that gate.

Refresh the vendored schemas when A2UI publishes a revision:

bash
bun scripts/sync-a2ui-schemas.ts

Released under the MIT License.