A2UI Overview
A2UI is the structured UI path for agent-built surfaces in chat.
The 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".
The running example is a flight booking flow served by LangGraph. The agent authors each screen as an A2UI surface, and this page walks the files that produce it.
What the demo does
The Run tab shows the prebuilt <chat> composition with the A2UI catalog registered on it. Choose the welcome suggestion "Book LAX → JFK" and the agent replies with a booking form rather than with prose: origin and destination pickers already set to those two airports, a departure date, a passenger count, and a fare class.
Fill the form and press "Search flights". The button does not post a chat message; it sends a structured A2UI action back to the agent, which searches the flight fixtures and answers with a second surface listing the matching flights. Selecting one produces a third surface, the booking confirmation, whose "Modify search" button returns to the form with the earlier values already filled in. The model authors both the results list and the confirmation surface at request time, so their exact layout and wording vary from run to run; only the form's shape is prescribed.
The second suggestion, "Book SFO → SEA", runs the same pattern on a different route, which is worth trying because nothing about the form is hardcoded on the client.
How it is built
Three files carry the feature: a LangGraph graph that authors the surfaces, an application config that registers the agent, and a component that hands the A2UI catalog to <chat>. Open the Code tab to read them in full.
The component shape the model must satisfy
A2UI v0.9 components are flat. Each entry carries an id, a component name from the catalog, and its props at the same level of the same object. The example models that as a Pydantic class and nests it inside the structured-output schema, so the model authors the component list under a validator instead of free-typing JSON.
class A2uiComponent(BaseModel):
"""Single A2UI v0.9 updateComponents entry.
Components are FLAT — the catalog component name is the `component`
string field and the per-component props sit at the top level of the
same object:
{id: "name_field", component: "TextField",
label: "Name", value: {path: "/name"}}
Dynamic values are bare literals or {"path": "/json/pointer"} bindings.
Children are plain id arrays (or {path, componentId} templates).
Actions are {"event": {"name": "...", "context": {...}}} where context
is a plain object.
Key per-component notes the LLM must respect:
Card({child: "<id>"}) — single child only
Button({child: "<text-id>", action: {...}}) — child is a Text id (label)
Column/Row/List({children: ["id1", "id2"]})
TextField({label, value: {path:"/p"}, variant: "shortText"|"number"|...})
ChoicePicker({label, options:[{label,value}], value:{path}, variant:"mutuallyExclusive"})
DateTimeInput({label, value:{path:"/p"}, enableDate: true})
Text({text: "literal or {path:'/p'}", variant?: "h1"|"h2"|"body"|...})
Divider({})
"""
model_config = {"extra": "allow"}
id: str
component: str = Field(
description=(
"Catalog component name. Must be one of: "
+ ", ".join(sorted(ALLOWED_COMPONENTS))
),
)
@field_validator("component")
@classmethod
def _known_component(cls, v: str) -> str:
if v not in ALLOWED_COMPONENTS:
raise ValueError(
f"component '{v}' not in catalog. Allowed: {sorted(ALLOWED_COMPONENTS)}"
)
return vThe validator is the safety gate: an unknown component name renders nothing visible, so the schema rejects it and the model is re-prompted with the error.
The three parts of a surface
Every surface in this demo is the same triple: an id, an initial data model, and a flat list of components. Three subclasses give each node its own schema name and description. BookingFormSpec also validates the intended picker identities and path bindings, and requires each picker to contain its complete canonical option domain. Those domains come from graph constants outside this snippet. Invalid picker data enters the existing structured-output retry and fallback flow.
class _SurfaceSpec(BaseModel):
"""Common shape — both booking and results surfaces produce the same
triple (surface_id, data_model, components)."""
surface_id: str = Field(description="Surface id. Use 'booking' for the form, 'results' for flights.")
data_model: dict[str, Any] = Field(description="Initial form/state values, e.g. prefills.")
components: list[A2uiComponent]
class BookingFormSpec(_SurfaceSpec):
@model_validator(mode="after")
def _booking_picker_domains(self) -> "BookingFormSpec":
for component_id, path, domain in (
("origin", "/origin", AIRPORT_CODES),
("dest", "/dest", AIRPORT_CODES),
("fare", "/fare_class", FARE_CLASSES),
):
# Count both the intended id and any picker claiming its binding.
# Inspect extras before comparing domains: malformed values may be
# dictionaries/lists, and must raise ValidationError, not TypeError.
matches = []
for component in self.components:
binding = getattr(component, "value", None)
if component.id == component_id or (
component.component == "ChoicePicker"
and isinstance(binding, dict)
and binding.get("path") == path
):
matches.append(component)
if len(matches) != 1:
raise ValueError(
f"Booking picker '{component_id}' must occur exactly once "
f"as ChoicePicker bound to {path}"
)
component = matches[0]
binding = getattr(component, "value", None)
if (
component.id != component_id
or component.component != "ChoicePicker"
or not isinstance(binding, dict)
or binding.get("path") != path
):
raise ValueError(
f"Booking picker '{component_id}' must be a ChoicePicker "
f"with value={{'path': '{path}'}}"
)
options = getattr(component, "options", None)
if not isinstance(options, list) or any(
not isinstance(option, dict)
or not isinstance(option.get("label"), str)
or not isinstance(option.get("value"), str)
for option in options
):
raise ValueError(
f"Booking picker '{component_id}' options must be a list "
"of objects with string labels and values"
)
values = [option["value"] for option in options]
if len(values) != len(domain) or set(values) != set(domain):
raise ValueError(
f"Booking picker '{component_id}' must contain each value "
f"exactly once from {domain}; got {values}"
)
return self
class FlightResultsSpec(_SurfaceSpec):
pass
class ConfirmationSpec(_SurfaceSpec):
"""Booking confirmation surface — selected flight + prior party context."""
passThe data_model is what path bindings such as {"path": "/origin"} resolve against.
Wrapping a surface in v0.9 envelopes
The model authors the components; the code writes the wire format. _wrap_envelopes emits the sentinel prefix and then one JSON envelope per line, each stamped "version": "v0.9". The protocol requires only that createSurface comes first and that a component with id root is defined before anything paints.
# A2UI v0.9 wire format (a2ui.org server_to_client.json): every envelope
# carries "version": "v0.9". Order matters: createSurface first (surfaceId +
# catalogId), then updateComponents (flat components; exactly one has id
# "root", which is the root of the tree), then updateDataModel (path + value)
# as needed. There is no beginRendering envelope in v0.9.
CATALOG_ID = "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"
def _wrap_envelopes(spec: _SurfaceSpec) -> str:
"""Wrap a validated SurfaceSpec into A2UI v0.9 JSONL."""
lines = [
json.dumps({"version": "v0.9", "createSurface": {
"surfaceId": spec.surface_id,
"catalogId": CATALOG_ID,
}}),
json.dumps({"version": "v0.9", "updateComponents": {
"surfaceId": spec.surface_id,
"components": [c.model_dump(exclude_none=True) for c in spec.components],
}}),
]
if spec.data_model:
lines.append(json.dumps({"version": "v0.9", "updateDataModel": {
"surfaceId": spec.surface_id,
"path": "/",
"value": spec.data_model,
}}))
return A2UI_PREFIX + "\n" + "\n".join(lines) + "\n"The demo emits createSurface, then updateComponents, then updateDataModel.
A2UI_PREFIX is ---a2ui_JSON---. The content classifier in @threadplane/chat looks for exactly that string at the start of assistant content and routes the rest of the message into the A2UI pipeline instead of the markdown renderer.
The node that authors the form
build_form runs on the first turn and again on a "Modify search" turn. It recovers any prior submission from the message history, or, on a true first turn, seeds the origin and destination from a phrase such as "I want to fly LAX to JFK", substitutes those values into the system prompt as the form defaults, and asks the model for a BookingFormSpec.
async def build_form(state: MessagesState) -> dict:
"""First-turn AND Modify-search node: LLM authors the booking form.
On a Modify-search turn (last action.name == 'modifySearch'), walks
message history to recover the user's prior bookingSubmit context and
pre-fills the form's data_model with those values. On a true first turn
(no prior submit in history), uses blank defaults.
"""
prior = _extract_prior_submit_context(state["messages"])
defaults = _form_defaults_from_prior(prior)
# If there's no prior bookingSubmit (true first turn), try to seed
# origin/dest from the most recent human prompt — e.g. the welcome chip
# "I want to fly LAX to JFK" should land on a form where Origin=LAX and
# Destination=JFK are already selected. Without this seed, the LLM is
# told to use the blank `data_model` verbatim and the user lands on a
# form whose default values find no flights (Origin=Destination=LAX is
# a common failure we've seen). Prior-context path skips this seed
# because it already carries the user's last-known values.
if not prior:
seed = _seed_airports_from_messages(state["messages"])
defaults.update(seed)
system_prompt = _BUILD_FORM_SYSTEM_TMPL.replace(
"__DATA_MODEL_DEFAULTS__", json.dumps(defaults)
)
base_messages = [SystemMessage(content=system_prompt)] + state["messages"]
try:
spec = await _emit_with_retry(BookingFormSpec, base_messages)
except RuntimeError as err:
_logger.error("Falling back to sentinel booking form: %s", err)
spec = _build_sentinel_booking_form(defaults)
return {"messages": [AIMessage(content=_wrap_envelopes(spec))]}The node returns an ordinary AIMessage whose content is the wrapped JSONL, which is why no custom event type is needed to carry a surface.
_emit_with_retry re-prompts the model with the validation error up to three attempts in total, and build_form falls back to a hand-written sentinel form when they all fail. A surface is the whole response here, so a validation failure with no fallback is a blank turn.
Routing an action message back into the graph
When the user presses a button, the surface sends an A2UI action message back to the agent as the next user message, and its content is JSON rather than prose. The entry node reads that last message and dispatches on the action name.
def route(state: MessagesState) -> Command[Literal["build_form", "search_flights", "confirm_booking"]]:
"""Inspect the last message — submit event → search_flights, flight-select
event → confirm_booking, else build_form."""
last_content = getattr(state["messages"][-1], "content", "") if state["messages"] else ""
if _is_submit_event(last_content):
return Command(goto="search_flights")
if _is_flight_select_event(last_content):
return Command(goto="confirm_booking")
return Command(goto="build_form")_is_submit_event and _is_flight_select_event each parse the content and compare action.name against bookingSubmit and flightSelect, so anything that is not one of those two is treated as a fresh request for the form.
The graph wiring
The wiring is a fan-out from route into the three surface-authoring nodes, each of which ends through background title generation.
_builder = StateGraph(MessagesState)
_builder.add_node("route", route)
_builder.add_node("build_form", build_form)
_builder.add_node("search_flights", search_flights)
_builder.add_node("confirm_booking", confirm_booking)
_builder.add_node("generate_title", generate_title)
_builder.set_entry_point("route")
_builder.add_edge("build_form", "generate_title")
_builder.add_edge("search_flights", "generate_title")
_builder.add_edge("confirm_booking", "generate_title")
_builder.add_edge("generate_title", END)
graph = _builder.compile()The graph is served by the LangGraph API server, which supplies persistence, so _builder.compile() takes no saver. A graph you serve yourself — behind AG-UI, for instance — has to compile one, because the adapter reads thread state through the graph.
Registering the LangGraph agent
provideAgent() from @threadplane/langgraph needs the API URL and the assistant id. The example passes a factory because it resolves both at runtime from the host that serves the demo; an application of your own passes apiUrl and assistantId directly.
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/langgraph';
export const appConfig: ApplicationConfig = {
providers: [
provideAgent(() => {
const connection = injectCockpitRuntimeConnection();
if (connection.adapter !== 'langgraph') {
throw new Error('incompatible runtime');
}
return {
apiUrl: connection.apiUrl,
assistantId: connection.assistantId,
clientOptions: connection.clientOptions,
};
}),
],
};Giving the chat composition a catalog
The client side is one input. a2uiBasicCatalog() from @threadplane/chat returns a view registry covering all eighteen components of the A2UI basic catalog, and passing it as [views] is what lets <chat> mount a surface — the composition renders <a2ui-surface> only when a catalog is bound.
import { Component } from '@angular/core';
import { ChatComponent, ChatWelcomeSuggestionComponent, a2uiBasicCatalog } from '@threadplane/chat';
import { ExampleChatLayoutComponent } from '@threadplane/example-layouts';
import { injectAgent } from '@threadplane/langgraph';
const WELCOME_SUGGESTIONS = [
{
label: 'Book LAX → JFK',
value: 'I want to fly LAX to JFK',
description: 'Agent emits an A2UI form spec; watch the booking form render in-chat.',
},
{
label: 'Book SFO → SEA',
value: 'I want to fly SFO to SEA',
description: 'Same A2UI pattern on a different route — try both to compare.',
},
] as const;
@Component({
selector: 'app-a2ui',
standalone: true,
imports: [ChatComponent, ChatWelcomeSuggestionComponent, ExampleChatLayoutComponent],
template: `
<example-chat-layout>
<chat main [agent]="agent" [views]="catalog" class="flex-1 min-w-0">
<div chatWelcomeSuggestions>
@for (s of suggestions; track s.value) {
<chat-welcome-suggestion
[label]="s.label"
[value]="s.value"
[description]="s.description"
(selected)="send($event)"
/>
}
</div>
</chat>
</example-chat-layout>
`,
})
export class A2uiComponent {
protected readonly agent = injectAgent();
protected readonly catalog = a2uiBasicCatalog();
protected readonly suggestions = WELCOME_SUGGESTIONS;
protected send(text: string): void {
void this.agent.submit({ message: text });
}
}No handler wiring appears here: <chat> builds the action message from the surface and submits it to the agent for you.
Runtime flow
This 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"; the surface store will not commit a surface without one. The surface-to-spec conversion itself falls back to the first component, which matters only when you drive <a2ui-surface> directly. |
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. A line with no version field is treated as v0.9.
That behavior is deliberate. Agent streams are partial. The parser should not crash the UI because one line is unfinished mid-token.
The wire shape
On the wire, each envelope from _wrap_envelopes is one newline-delimited JSON object after the sentinel:
---a2ui_JSON---
{"version":"v0.9","createSurface":{...}}
{"version":"v0.9","updateComponents":{...}}
{"version":"v0.9","updateDataModel":{...}}Components are flat objects discriminated by the component string. This is the demo's submit button, with its action context abridged to two of its six keys:
{
"id": "submit",
"component": "Button",
"child": "submit_label",
"variant": "primary",
"action": {
"event": {
"name": "bookingSubmit",
"context": {
"formId": "booking",
"origin": { "path": "/origin" },
"dest": { "path": "/dest" }
}
}
}
}There is no type-keyed wrapper and no literal wrapper objects — "Search flights" is just a bare string, and { "path": "/origin" } is a data-model binding. Rendering starts as soon as createSurface has arrived and the root component is defined; the rest of the tree fills in progressively as more updateComponents envelopes merge by id.
Data model
A2UI component props can point at the surface data model, and the surface-to-spec conversion turns those path references into json-render state bindings. It also collects them into a _bindings map that it passes to the component, so an input knows where to write back; an agent never authors _bindings itself.
Catalog input components use emitBinding() for that write-back. It is exported from @threadplane/chat, and it takes the render host, the component's _bindings map, the prop name, and the new value:
import { emitBinding } from '@threadplane/chat';
onInput(event: Event): void {
const value = (event.target as HTMLInputElement).value;
emitBinding(this.host, this._bindings(), 'value', 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. The demo does not set it: every value the agent needs travels in the action context instead.
Actions and the round trip
Buttons carry an A2uiAction. The agent-bound form wraps an event with a name and a plain-object context, exactly as the submit button above does. The surface-to-spec conversion turns that into a render click binding that calls the built-in a2ui:event handler. A2uiSurfaceComponent then evaluates the surface's check rules, resolves the context values against the live data model, and emits an A2uiActionMessage:
{
"version": "v0.9",
"action": {
"name": "bookingSubmit",
"surfaceId": "booking",
"sourceComponentId": "submit",
"timestamp": "2026-04-10T14:30:00.000Z",
"context": { "formId": "booking", "origin": "LAX" },
"label": "Search flights"
}
}label is a Threadplane extension, derived from the Button's child Text. <chat> serializes that message and submits it through the agent like any other user message, then renders the resulting human bubble from that label rather than from the serialized content: the transcript shows "Search flights" instead of the raw JSON. When no label was stamped, the composition humanizes the action name (bookingSubmit becomes "Booking submit"). That is the whole round trip, and it is why the demo's Angular component wires no handlers.
If a check rule fails, no action is emitted. The surface component writes each failure message into the live store under /_a2uiChecks/<componentId> and emits a validationError instead.
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. Consumer handlers passed to [handlers] take priority over the built-in fallback.
Use local handlers for client-owned behavior. Use A2UI event actions for agent-bound events.
This is the standalone <a2ui-surface>, documented in full at the surface component page; state() here is the surface state your host holds, not a value the demo above provides.
<a2ui-surface
[state]="state()"
[catalog]="catalog"
[handlers]="handlers"
(action)="sendToAgent($event)"
(events)="logRenderEvent($event)"
/>handlers = {
openDetails: async (args: Record<string, unknown>) => {
await this.router.navigate(['/orders', args['orderId']]);
},
};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. The demo sends no theme, so its surfaces render with the host application's colors and no header.
A2UI versus 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.
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. That is why the demo puts a Pydantic validator on the authoring side.
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.
Progressive rendering is per component, not per surface. The surface-to-spec conversion leaves each data-model prop as a $bindState binding, and the render element defers mounting a component while any bound prop is still undefined, showing the catalog entry's fallback, or the library default when the entry declares none, in its place. The surface store also tracks a monotonic ready flag per A2uiComponentView for hosts that drive their own progressive renderer. A component whose binding never arrives keeps showing its fallback.
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.