A2UI Overview
A2UI is the structured UI path for agent-built surfaces in chat.
The important boundary is simple: the agent streams declarative messages, the client owns rendering, and Angular handlers stay inside your application. The model does not ship code. It emits a constrained surface description that @threadplane/chat, @threadplane/a2ui, and @threadplane/render turn into Angular UI. Threadplane implements the A2UI v0.9.1 stable release — every envelope carries "version": "v0.9".
When an assistant message starts with ---a2ui_JSON---, the chat streaming pipeline treats the rest of the content as newline-delimited A2UI JSON.
Runtime Flow
assistant text starts with ---a2ui_JSON---
-> content classifier switches to A2UI mode
-> createA2uiMessageParser() parses JSONL messages
-> createA2uiSurfaceStore() applies those messages by surface id
-> ChatComponent passes surface + state into A2uiSurfaceComponent
-> A2uiSurfaceComponent renders progressive state through your catalogThis is why A2UI sits between chat and render. Chat owns message streaming. A2UI owns the protocol shapes. A2uiSurfaceComponent turns the accumulated surface state into a render spec and delegates to @threadplane/render, so handlers, render events, and json-render state bindings use the same path for both the preferred state input and the legacy surface input.
Message Envelopes
The parser recognizes four envelope keys:
| Envelope | Purpose |
|---|---|
createSurface | Creates a surface and declares its component catalog, theme, and sendDataModel behavior. Must come first. |
updateComponents | Adds or replaces components on a surface, merged incrementally by id. One component must have id: "root". |
updateDataModel | Sets (or deletes) data at a JSON-pointer path in the surface data model. |
deleteSurface | Removes a surface. |
Unknown envelope keys are ignored (future v1.0 messages included). Malformed JSONL lines are skipped. Incomplete JSON waits in the parser buffer until a newline arrives.
That behavior is deliberate. Agent streams are partial. The parser should not crash the UI because one line is unfinished mid-token.
Minimal Protocol Stream
A surface needs a createSurface, its data, and a component tree containing root. On the wire, each envelope is one newline-delimited JSON object after the sentinel:
---a2ui_JSON---
{"version":"v0.9","createSurface":{...}}
{"version":"v0.9","updateDataModel":{...}}
{"version":"v0.9","updateComponents":{...}}The same updateComponents envelope, expanded for readability, looks like this:
{
"version": "v0.9",
"updateComponents": {
"surfaceId": "contact",
"components": [
{
"id": "root",
"component": "Column",
"children": ["title", "name", "submit"]
},
{
"id": "title",
"component": "Text",
"text": "Contact us",
"variant": "h2"
},
{
"id": "name",
"component": "TextField",
"label": "Name",
"value": { "path": "/name" }
},
{
"id": "submit_label",
"component": "Text",
"text": "Send"
},
{
"id": "submit",
"component": "Button",
"child": "submit_label",
"variant": "primary",
"action": {
"event": {
"name": "formSubmit",
"context": { "name": { "path": "/name" } }
}
}
}
]
}
}The create and data envelopes are smaller:
{
"version": "v0.9",
"createSurface": {
"surfaceId": "contact",
"catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"
}
}{
"version": "v0.9",
"updateDataModel": {
"surfaceId": "contact",
"value": { "name": "" }
}
}Components are flat objects discriminated by the component string:
{
"id": "title",
"component": "Text",
"text": "Contact us",
"variant": "h2"
}There is no type-keyed wrapper and no literal wrapper objects — "Contact us" is just a bare string, and { "path": "/name" } is a data-model binding. Rendering starts as soon as createSurface has arrived and a component with id root is defined; the rest of the tree fills in progressively as more updateComponents envelopes merge by id.
This stream demonstrates the protocol boundary. The chat path accumulates the surface state as JSONL arrives, then A2uiSurfaceComponent converts the current surface into a render spec: it maps flat components onto catalog views, wires children, creates render state bindings, and turns actions into handlers.
Data Model
A2UI component props can point at the surface data model.
{
"id": "name",
"component": "TextField",
"label": "Name",
"value": { "path": "/name" }
}The surface-to-spec conversion turns path references into json-render state bindings. Catalog input components can use emitBinding() to write back through the render event pipeline.
import { emitBinding } from '@threadplane/chat';
onInput(event: Event): void {
const value = (event.target as HTMLInputElement).value;
emitBinding(this.emit(), this._bindings(), 'text', value);
}The write-back protocol is client-side state. If the surface's createSurface set sendDataModel: true, outgoing action messages also include the current live surface data model (user edits included) under metadata.a2uiClientDataModel.
Actions
Buttons carry an A2uiAction. The agent-bound form wraps an event with a name and a plain-object context:
{
"action": {
"event": {
"name": "formSubmit",
"context": { "name": { "path": "/name" } }
}
}
}The surface-to-spec conversion turns this into a render click binding that calls the built-in a2ui:event handler. A2uiSurfaceComponent then emits an A2uiActionMessage with the context values resolved against the current data model:
{
"version": "v0.9",
"action": {
"name": "formSubmit",
"surfaceId": "contact",
"sourceComponentId": "submit",
"timestamp": "2026-04-10T14:30:00.000Z",
"context": { "name": "Alice" },
"label": "Send"
}
}label is a Threadplane extension, derived from the Button's child Text; transcripts use it to label the user bubble. If the surface has sendDataModel: true, the emitted message also includes metadata.a2uiClientDataModel with the live surface data model (user edits included).
The other action form, { "functionCall": { "call": ..., "args": ... } }, executes a client-side function locally instead of round-tripping to the agent — wired to the surface component's a2ui:localAction handler, with openUrl (new tab, noopener) built in.
Catalog components receive resolved props as Angular inputs from the render engine. Bind (action) when you want agent-bound events, and bind (events) when you want the lower-level render stream.
Theming
createSurface.theme carries agent-supplied presentation hints. primaryColor flows to <a2ui-surface> as the --a2ui-primary CSS custom property, which catalog components consume for accents (buttons, sliders, focus rings). iconUrl and agentDisplayName identify the agent that owns the surface: when either is set, <a2ui-surface> renders a small identity header (a 16px round icon and the display name in muted label text) above the surface. Themeless surfaces render no header.
Local Handlers
A2uiSurfaceComponent also registers an a2ui:localAction handler. Consumer handlers take priority, and the built-in fallback currently supports openUrl.
Use local handlers for client-owned behavior. Use A2UI event actions for agent-bound events.
<a2ui-surface
[surface]="surface()"
[catalog]="catalog"
[handlers]="handlers"
(action)="sendToAgent($event)"
(events)="logRenderEvent($event)"
/>handlers = {
openDetails: async (args: Record<string, unknown>) => {
await this.router.navigate(['/orders', args['orderId']]);
},
};A2UI vs json-render
Both paths render structured UI, but they optimize for different jobs.
| Dimension | A2UI | json-render |
|---|---|---|
| Wire shape | JSONL message stream | Single JSON spec |
| State | Surface data model | Spec state |
| Best fit | Incremental agent-owned surfaces; protocol-level A2UI streams | One-shot rendered content |
| Detection | ---a2ui_JSON--- prefix | JSON object content |
| Rendering | Surface state converted to a render spec | json-render spec directly |
Use A2UI when the agent needs to keep updating a surface and you are working at the protocol boundary. Use json-render when the agent needs a stable, directly rendered structured result. For production interaction that depends on component handlers, state bindings, and child projection, verify the exact A2UI rendering path you are using.
Setup
Pass the built-in A2UI catalog to chat:
import { Component } from '@angular/core';
import { ChatComponent, a2uiBasicCatalog } from '@threadplane/chat';
import { injectAgent, provideAgent } from '@threadplane/langgraph';
@Component({
standalone: true,
imports: [ChatComponent],
providers: [provideAgent({ apiUrl: '/api/langgraph', assistantId: 'support' })],
template: `<chat [agent]="chat" [views]="catalog" />`,
})
export class SupportChatComponent {
protected readonly chat = injectAgent();
protected readonly catalog = a2uiBasicCatalog();
}For custom component sets, build a catalog with the same view registry tools used by @threadplane/render.
Gotchas
The A2UI parser is not a full schema validator. It recognizes envelope keys and leaves deeper validation to typed code, tests, and your runtime boundary.
Schema-valid messages are not enough to make UI executable. Your catalog must contain components for the emitted types, and your handlers must exist for the actions you expect users to take.
Do not use pre-v0.9 envelope names such as surfaceUpdate, dataModelUpdate, or beginRendering — the current parser recognizes only createSurface, updateComponents, updateDataModel, and deleteSurface, and silently ignores anything else. Likewise, the pre-v0.9 type-keyed component wrappers and literalString-style value wrappers are gone: components are flat, and literals are bare values.
Do not assume the progressive chat renderer and the render-spec compatibility path have identical capabilities. The compatibility path projects a surface through the surface-to-spec conversion. The progressive path tracks per-component readiness from A2uiComponentView state.
What's Next
Render a surface outside the full ChatComponent composition.
Understand how messages update surfaces and data models.
See the built-in catalog components and their props.
Read the protocol package docs for parser, schema, and pointer helpers.