Page actions

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.

graph.py — the component schema
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 v

The 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.

graph.py — the surface spec
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."""
    pass

The 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".

graph.py — the envelope wrapping
# 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.

Note: The sentinel is what switches the client into A2UI mode

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.

graph.py — the build_form node
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.

Warning: An LLM-authored surface needs a fallback

_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.

graph.py — the route node
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.

graph.py — graph wiring
_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())
Warning: This graph compiles its own checkpointer

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.

server.py
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.

app.config.ts
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.

a2ui.component.ts
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.

Note: Where the action context values come from

<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

Assistant text starts with ---a2ui_JSON---@THREADPLANE/CHATContent classifierswitches to A2UI mode@THREADPLANE/A2UIcreateA2uiMessageParser()parses JSONL messages@THREADPLANE/CHATcreateA2uiSurfaceStore()applies those messages by surface id@THREADPLANE/CHAT<a2ui-surface>renders progressive state through your catalog

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:

AreaExports
Wire typesA2uiMessage, A2uiComponent, component prop interfaces, envelope types, action message types
Protocol constantsA2UI_WIRE_VERSION ('v0.9'), A2UI_MIME_TYPE ('application/a2ui+json'), A2UI_BASIC_CATALOG_ID
Stream parsingcreateA2uiMessageParser()
Data accessgetByPointer(), setByPointer(), deleteByPointer()
Dynamic valuesresolveDynamic(), 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
deleteSurface

When 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 to resolveDynamic; without a registry (or for unknown names) they resolve to undefined.

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/a2ui

The package has no peer dependencies.

What's Next

Looking for something specific?