Introduction
@threadplane/a2ui is the protocol layer for A2UI messages. It gives the rest of the framework a shared TypeScript vocabulary for agent-built surfaces, streamed JSONL messages, dynamic values, and outbound action payloads.
It does not render Angular components. It does not register handler functions. It does not decide how an agent should respond to a button click. Those jobs sit in @threadplane/chat and @threadplane/render.
The running example is a flight booking flow. 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
Four files carry the feature: a LangGraph graph that authors the surfaces, a FastAPI server that exposes it over AG-UI, 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 without changing the shape.
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):
pass
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".
# 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. The protocol requires only that createSurface comes first and that a component with id root is defined before anything paints.
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 and its checkpointer
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(checkpointer=MemorySaver())ag-ui-langgraph reads thread state through graph.aget_state, so a graph served this way must compile a checkpointer, and the example uses MemorySaver for development. That is the opposite of a graph served by langgraph dev or LangGraph Platform, where the platform supplies persistence and compiling your own saver is an error.
Serving the graph over AG-UI
The server is the standard ag-ui-langgraph mount: wrap the compiled graph in a LangGraphAgent and attach it to a FastAPI application at a path.
from fastapi import FastAPI
from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint
from .graph import graph
agent = LangGraphAgent(name="a2ui", graph=graph)
app = FastAPI(title="cockpit-ag-ui-a2ui")
add_langgraph_fastapi_endpoint(app, agent, path="/agent")
@app.get("/ok")
def ok() -> dict:
return {"ok": True}Nothing in this file is specific to A2UI; the surfaces travel as assistant message content over the same event stream as any other reply.
Registering the AG-UI agent
provideAgent() from @threadplane/ag-ui needs the URL of that endpoint. The example passes a factory because it resolves the URL at runtime from the host that serves the demo; an application of your own passes url directly.
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/ag-ui';
export const appConfig: ApplicationConfig = {
providers: [
provideAgent(() => {
const connection = injectCockpitRuntimeConnection();
if (connection.adapter !== 'ag-ui') {
throw new Error('incompatible runtime');
}
return {
url: connection.url,
};
}),
],
};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.
import { Component } from '@angular/core';
import { ChatComponent, ChatWelcomeSuggestionComponent, a2uiBasicCatalog } from '@threadplane/chat';
import { ExampleChatLayoutComponent } from '@threadplane/example-layouts';
import { injectAgent } from '@threadplane/ag-ui';
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.
<a2ui-surface> keeps a live store of the current data-model values, so the {"path": "/origin"} bindings inside the action.event.context on the submit button resolve to what the user typed, not to the values the agent seeded. buildA2uiActionMessage() stamps the surface id, the source component id, and a timestamp onto the result.
How it fits
What the package owns
The demo shows the protocol from the agent side. The package is the same protocol expressed as TypeScript, and its public entry point exports five groups of tools:
| Area | Exports |
|---|---|
| Wire types | A2uiMessage, A2uiComponent, component prop interfaces, envelope types, action message types |
| Protocol constants | A2UI_WIRE_VERSION ('v0.9'), A2UI_MIME_TYPE ('application/a2ui+json'), A2UI_BASIC_CATALOG_ID |
| Stream parsing | createA2uiMessageParser() |
| Data access | getByPointer(), setByPointer(), deleteByPointer() |
| Dynamic values | resolveDynamic(), A2uiScope, isPathRef / isFunctionCall guards |
Use this package when you are building an adapter, validating an agent stream, testing A2UI payloads, or integrating a custom renderer with the same protocol surface that @threadplane/chat uses. Rendering the surfaces inside <chat>, as the demo does, needs none of it directly.
Message flow
The parser expects newline-delimited JSON. Each line is checked for one known envelope key:
createSurface
updateComponents
updateDataModel
deleteSurfaceWhen a line parses and has one of those envelope keys, it is returned as an A2uiMessage. Every envelope carries "version": "v0.9"; a missing version defaults to v0.9. Unknown envelope keys — including future v1.0 messages — are ignored. Malformed lines are skipped. Incomplete JSON waits in the internal buffer until a newline arrives.
import { createA2uiMessageParser } from '@threadplane/a2ui';
const parser = createA2uiMessageParser();
const messages = parser.push(
'{"version":"v0.9","createSurface":{"surfaceId":"checkout","catalogId":"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"}}\n',
);That posture is intentional. Agent streams are partial by nature. The low-level parser favors safe continuation over throwing during render.
Relationship to chat and render
@threadplane/chat detects A2UI content in assistant output, feeds the JSONL stream into createA2uiMessageParser(), applies messages to its surface store, and renders those surfaces through the A2UI render components.
@threadplane/render owns Angular component resolution, event dispatch, state updates, and handler execution. The A2UI package only describes protocol shapes and helper behavior.
That separation matters when debugging:
- If JSONL chunks are not becoming messages, inspect
@threadplane/a2ui. - If messages are not becoming surfaces, inspect the chat A2UI surface store.
- If components render incorrectly or handlers do not run, inspect the render/chat integration.
Safe fallback posture
The parser and resolver are deliberately conservative:
- malformed JSONL lines are skipped;
- unknown envelope keys are ignored (forward compatibility with future protocol versions);
- missing data-model paths resolve to
undefined; - unrecognized dynamic-value shapes pass through unchanged;
{ call: ... }function-call values execute through the standard function registry when one is passed toresolveDynamic; without a registry (or for unknown names) they resolve toundefined.
This makes the protocol layer suitable for streaming, but it is not a full schema validator. If you accept untrusted agent output, validate the payload at your boundary before wiring it to privileged handlers.
Install
npm install @threadplane/a2uiThe package has no peer dependencies.
What's Next
Surfaces, flat components, dynamic values, the four envelopes, and how actions travel back.
Pointer helpers, applying updateDataModel envelopes, and resolving dynamic values in scope.
Consume a streaming response, narrow values, build test payloads, and write a custom renderer.
Every protocol type, component prop interface, and envelope shape in one reference.