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 @ngaf/chat, @ngaf/a2ui, and @ngaf/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. The normal chat path uses progressive surface state and a2uiSlot so components can appear as their required data arrives.

There is also a compatibility path: when A2uiSurfaceComponent receives a surface without a state, it calls surfaceToSpec() and renders through @ngaf/render. That fallback is where render-spec handlers, render events, and json-render state bindings apply.

#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, not a guarantee that every built-in catalog component is fully wired in the progressive chat renderer. The current chat path renders surface state first and pushes resolved A2uiComponentView.props directly into Angular components. The richer projection work - unwrapping component definitions, mapping children, creating render state bindings, and turning actions into handlers - lives in the render-spec compatibility path.

#Data Model

A2UI component props can point at the surface data model.

{
  "id": "name",
  "component": {
    "TextField": {
      "label": { "literalString": "Name" },
      "text": { "path": "/name" }
    }
  }
}

In the render-spec compatibility path, 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 '@ngaf/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" } }
  ]
}

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

{
  "version": "v0.9",
  "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.

In the progressive chat path, A2uiSurfaceComponent renders the surface state first. Catalog components receive resolved props as Angular inputs. The current a2uiSlot implementation does not wire render-spec handlers, child spec context, or render events into those mounted components. Treat agent-bound action messages as a render-spec compatibility behavior unless your catalog explicitly handles its own event wiring.

#Local Handlers

In the render-spec compatibility path, 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
RenderingProgressive surface state in chat; render-spec fallback when no state is suppliedjson-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 '@ngaf/chat';
import { agent } from '@ngaf/langgraph';
 
@Component({
  standalone: true,
  imports: [ChatComponent],
  template: `<chat [agent]="chat" [views]="catalog" />`,
})
export class SupportChatComponent {
  protected readonly chat = agent({
    apiUrl: '/api/langgraph',
    assistantId: 'support',
  });
 
  protected readonly catalog = a2uiBasicCatalog();
}

For custom component sets, build a catalog with the same view registry tools used by @ngaf/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