Chat · A2UI

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.

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 catalog

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:

EnvelopePurpose
surfaceUpdateAdds or replaces components on a surface.
dataModelUpdateUpdates the surface data model.
beginRenderingMarks the surface root and optional style hints.
deleteSurfaceRemoves a surface.

Unknown envelopes are ignored. 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 usually needs three messages: components, data, and a root. On the wire, each envelope is one newline-delimited JSON object after the sentinel:

---a2ui_JSON---
{"surfaceUpdate":{...}}
{"dataModelUpdate":{...}}
{"beginRendering":{...}}

The same surfaceUpdate envelope, expanded for readability, looks like this:

{
  "surfaceUpdate": {
    "surfaceId": "contact",
    "components": [
      {
        "id": "root",
        "component": {
          "Column": {
            "children": { "explicitList": ["title", "name", "submit"] },
            "gap": 12
          }
        }
      },
      {
        "id": "title",
        "component": {
          "Text": {
            "text": { "literalString": "Contact us" },
            "usageHint": "h2"
          }
        }
      },
      {
        "id": "name",
        "component": {
          "TextField": {
            "label": { "literalString": "Name" },
            "text": { "path": "/name" }
          }
        }
      },
      {
        "id": "submit_label",
        "component": {
          "Text": {
            "text": { "literalString": "Send" }
          }
        }
      },
      {
        "id": "submit",
        "component": {
          "Button": {
            "child": "submit_label",
            "primary": true,
            "action": {
              "name": "formSubmit",
              "context": [
                { "key": "name", "value": { "path": "/name" } }
              ]
            }
          }
        }
      }
    ]
  }
}

The data and root envelopes are smaller:

{
  "dataModelUpdate": {
    "surfaceId": "contact",
    "contents": [{ "key": "name", "valueString": "" }]
  }
}
{
  "beginRendering": {
    "surfaceId": "contact",
    "root": "root"
  }
}

Components use keyed union definitions:

{
  "id": "title",
  "component": {
    "Text": {
      "text": { "literalString": "Contact us" },
      "usageHint": "h2"
    }
  }
}

This is different from a flat component: "Text" shape. The current source expects the keyed union form.

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 unwraps keyed component definitions, maps 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": { "literalString": "Name" },
      "text": { "path": "/name" }
    }
  }
}

surfaceToSpec() converts 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. The current action-message builder does not re-read component state at click time. It serializes the action params it receives. If a caller provides an internal A2uiSurface with sendDataModel: true, the builder also includes the current surface data model snapshot under metadata.a2uiClientDataModel; the JSONL wire envelopes do not expose a sendDataModel field.

#Actions

Buttons carry an A2uiAction:

{
  "name": "formSubmit",
  "context": [
    { "key": "name", "value": { "path": "/name" } }
  ]
}

surfaceToSpec() turns this into a render click binding that calls the built-in a2ui:event handler. A2uiSurfaceComponent then emits an A2uiActionMessage.

{
  "version": "v1",
  "action": {
    "name": "formSubmit",
    "surfaceId": "contact",
    "sourceComponentId": "submit",
    "timestamp": "2026-04-10T14:30:00.000Z",
    "context": {
      "name": { "literalString": "Alice" }
    }
  }
}

If the internal surface object has sendDataModel: true, the emitted message also includes metadata.a2uiClientDataModel with the current surface data model snapshot. Streamed protocol surfaces created by the current surface store do not set that flag.

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.

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

DimensionA2UIjson-render
Wire shapeJSONL message streamSingle JSON spec
StateSurface data modelSpec state
Best fitIncremental agent-owned surfaces; protocol-level A2UI streamsOne-shot rendered content
Detection---a2ui_JSON--- prefixJSON object content
RenderingSurface state converted to a render specjson-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 old envelope names such as createSurface, updateComponents, or updateDataModel with the current parser. They are ignored because the source recognizes surfaceUpdate, dataModelUpdate, beginRendering, and deleteSurface.

Do not assume the progressive chat renderer and the render-spec compatibility path have identical capabilities. The compatibility path projects a surface through surfaceToSpec(). The progressive path mounts catalog components from A2uiComponentView state and pushes Angular inputs directly.

#What's Next