@llui/a2ui

Renders Google's A2UI (Agent-to-UI) protocol on the LLui signal runtime. A2UI is a transport-agnostic JSON protocol: a local or remote agent streams declarative UI messages that reference components from a catalog the client already trusts — the agent never ships code. @llui/a2ui is a renderer for that stream, built on LLui's chunked-mask reconciler, and it reuses @llui/components headless primitives for the interactive parts.

pnpm add @llui/a2ui

@llui/dom is a peer dependency; @llui/components comes along for the ride.

Usage

import { mountA2ui } from '@llui/a2ui'
import '@llui/a2ui/styles/theme.css'

const ui = mountA2ui(document.getElementById('app')!, {
  // User interactions surface here — forward them over any transport.
  onAction: (event) => socket.send(JSON.stringify(event)),
})

// Feed server→client A2UI envelopes (from A2A, WebSocket, AG-UI, MCP, …).
ui.apply([
  {
    version: 'v0.9',
    createSurface: {
      surfaceId: 'card',
      catalogId: 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json',
      theme: { primaryColor: '#2563eb' },
    },
  },
  {
    version: 'v0.9',
    updateComponents: {
      surfaceId: 'card',
      components: [
        { id: 'root', component: 'Column', children: ['title', 'agree'] },
        { id: 'title', component: 'Text', variant: 'h2', text: { path: '/title' } },
        { id: 'agree', component: 'CheckBox', label: 'I agree', value: { path: '/agree' } },
      ],
    },
  },
  {
    version: 'v0.9',
    updateDataModel: { surfaceId: 'card', path: '/', value: { title: 'Welcome', agree: false } },
  },
])

The stream can arrive incrementally — components may be referenced before they are defined, and data may arrive after the components that bind to it. The renderer fills subtrees in as they resolve.

How it maps onto LLui

LLui is The Elm Architecture: state is immutable and only changes through a reducer. A2UI fits it cleanly:

A2UI concept LLui
The four server→client messages Msg variants applied by a pure reducer (a2uiUpdate)
Data model + JSON-Pointer paths Surface dataModel + reactive { path } bindings
updateDataModel (streaming) Reconciler re-commits only bound values — no tree rebuild
Flat component adjacency list Registry-dispatch tree walk (renderSurfaces)
Template children {componentId,path} each over the collection, relative paths scoped per row
Two-way inputs onInputsetData reducer message
Component actions Resolved and delivered to your onAction

Structure reacts to the component map; data reacts to the data model. Because a data update never changes the component map's identity, a high-frequency updateDataModel stream only re-commits changed values and never rebuilds the tree. Pass scheduler: 'raf' to coalesce a streaming burst of updates to one paint per frame.

Basic catalog

All 18 A2UI Basic components are implemented. Display and layout render as semantic HTML; the richer interactive controls reuse @llui/components:

Component Backed by
CheckBox @llui/components/checkbox
Tabs @llui/components/tabs (roving focus, ARIA)
Modal @llui/components/dialog (focus-trap + scroll-lock)
Slider @llui/components/slider (pointer-drag + keyboard)
ChoicePicker @llui/components/combobox (typeahead filter + chips)
DateTimeInput @llui/components/date-picker (inline calendar); native input for time/datetime
TextField native accessible input
Text, Image, Icon, Video, AudioPlayer, Row, Column, List, Card, Divider, Button semantic HTML

Interactive components with client-local view state (a tab's active index, a modal's open flag — state A2UI does not put in the data model) keep that state in a per-surface uiState store, driven by the component's own @llui/components reducer, so keyboard navigation and focus behaviour are preserved.

Custom catalogs

Bring your own design system or component set with defineCatalog, optionally extending the Basic catalog:

import { defineCatalog, basicCatalog, mountA2ui } from '@llui/a2ui'
import { el } from '@llui/dom'

const myCatalog = defineCatalog({
  id: 'https://example.com/catalogs/my/catalog.json',
  extends: basicCatalog,
  components: {
    Gauge: ({ node, ctx, scope }) => [el('my-gauge', { value: ctx /* … */ })],
  },
})

mountA2ui(container, { catalogs: { [myCatalog.id!]: myCatalog } })

The client's createSurface.catalogId selects the catalog; unknown ids fall back to the Basic catalog.

Transport is yours

@llui/a2ui is transport-agnostic, exactly as A2UI intends. It consumes envelopes via handle.apply(...) and emits actions via onAction — wire those to A2A, WebSockets, AG-UI, MCP, SSE, or plain HTTP however you like.

A WebSocket adapter ships built-in via the shared A2uiTransport seam:

import { connectA2ui, webSocketTransport } from '@llui/a2ui'

const handle = connectA2ui(container, webSocketTransport(new WebSocket(url)))
// inbound envelope frames render; user actions are sent as `{ action }` frames.

Other transports (A2A, AG-UI, MCP) implement the same A2uiTransport interface (onEnvelope / sendAction).

Status

Implements the full A2UI v0.9 server→client message set (createSurface, updateComponents, updateDataModel, deleteSurface), plus:

  • literal, { path }, and nested { call } bindings;
  • client-defined functionsformatString, formatNumber/Currency/Date, pluralize, required/regex/length/numeric/email, and/or/not;
  • validation checks (error messages on inputs, disabled buttons);
  • templates over arrays and objects, with spec-correct item-scoped paths;
  • two-way binding and actions;
  • sendDataModel client→server sync, best-effort version negotiation, and handle.capabilities().

Conformance-tested against the real google/A2UI v0.9 sample payloads. Custom components (e.g. inline-catalog OrgChart) render once the consumer registers a catalog via defineCatalog.

API

Functions

a2uiUpdate()

The component update: returns the next state and any effects to emit.

function a2uiUpdate(state: A2uiState, msg: A2uiMsg): [A2uiState, A2uiEffect[]]

applyEnvelope()

Apply a single server→client envelope to the state (pure).

function applyEnvelope(state: A2uiState, envelope: ServerToClientEnvelope): A2uiState

applyPointer()

Return a new data model with value written at pointer (upsert semantics). Missing intermediate containers are created; undefined removes the key. Untouched siblings keep their identity (structural sharing).

function applyPointer(root: JsonValue, pointer: string, value: JsonValue | undefined): JsonValue

bindBoolean()

Reactive boolean binding (CheckBox value).

function bindBoolean(ctx: RenderContext, scope: RenderScope, dyn: DynamicBoolean | undefined): Reactive<boolean>

bindNumber()

Reactive number binding (Slider value/min/max).

function bindNumber(ctx: RenderContext, scope: RenderScope, dyn: DynamicNumber | undefined): Reactive<number>

bindString()

Reactive string binding for text/attrs.

function bindString(ctx: RenderContext, scope: RenderScope, dyn: DynamicString | undefined): Reactive<string>

bindStringList()

Reactive string-list binding (ChoicePicker value).

function bindStringList(ctx: RenderContext, scope: RenderScope, dyn: DynamicStringList | undefined): Reactive<readonly string[]>

connectA2ui()

Mount an A2UI renderer wired to a transport: inbound envelopes flow into the renderer, user actions flow out to the transport (plus any onAction you pass). Disposing the returned handle also unsubscribes from the transport.

function connectA2ui(container: Element, transport: A2uiTransport, options: A2uiOptions = {}): A2uiHandle

defineCatalog()

Build a catalog, optionally layering over a base.

function defineCatalog(spec: CatalogSpec): Catalog

displayString()

Coerce any JSON value to a display string.

function displayString(value: JsonValue | undefined): string

initialA2uiState()

function initialA2uiState(): A2uiState

isChildTemplate()

function isChildTemplate(value: ChildList | undefined): value is ChildTemplate

isFunctionCall()

function isFunctionCall(value: unknown): value is FunctionCall

isPathBinding()

function isPathBinding(value: unknown): value is PathBinding

mountA2ui()

Mount an A2UI renderer into container.

function mountA2ui(container: Element, options: A2uiOptions = {}): A2uiHandle

pointerTokens()

Split a pointer into its path tokens. '' and '/' both mean the root.

function pointerTokens(pointer: string): string[]

renderSurfaces()

Render every live surface, in creation order.

function renderSurfaces(state: Signal<A2uiState>, send: (msg: A2uiMsg) => void, resolveCatalog: CatalogResolver, fallbackCatalog: Catalog): Renderable

resolveDynamic()

One-shot, non-reactive resolution — for action context + input write-back reads.

function resolveDynamic(ctx: RenderContext, scope: RenderScope, dyn: unknown): JsonValue | undefined

resolvePointer()

Resolve a pointer against a data model, or undefined if any segment is missing.

function resolvePointer(root: JsonValue, pointer: string): JsonValue | undefined

webSocketTransport()

A WebSocket A2UI transport. Inbound frames are parsed as an envelope or an array of envelopes; outbound actions are sent as { action } JSON frames. Unparseable or non-envelope frames are reported via options.onError (default: console.warn) rather than dropped silently.

function webSocketTransport(socket: WebSocketLike, options: WebSocketTransportOptions = {}): A2uiTransport

Types

A2uiEffect

export type A2uiEffect = A2uiActionEffect

A2uiMsg

Messages driving the renderer.

export type A2uiMsg =
  /** Feed one server→client protocol envelope. */
  | { readonly type: 'apply'; readonly envelope: ServerToClientEnvelope }
  /** Two-way input write-back at an absolute data-model path. */
  | {
      readonly type: 'setData'
      readonly surfaceId: string
      readonly path: string
      readonly value: JsonValue
    }
  /** Client-local UI-state write for a stateful component (Tabs, Modal, …). */
  | {
      readonly type: 'setUi'
      readonly surfaceId: string
      readonly componentId: ComponentId
      readonly value: JsonValue
    }
  /** A user-triggered action with its context resolved at the interaction site. */
  | {
      readonly type: 'action'
      readonly surfaceId: string
      readonly sourceComponentId: ComponentId
      readonly name: string
      readonly context: JsonObject
    }

CatalogFunction

A client-defined function (formatting, validation, local actions). Pure: given a call and its evaluation environment, return a value. Reactivity is handled once at the binding site, so functions need not deal with signals.

export type CatalogFunction = (call: FunctionCall, env: EvalEnv) => JsonValue

CatalogResolver

A resolver from an A2UI catalogId to a concrete catalog.

export type CatalogResolver = (catalogId: string) => Catalog | undefined

ChildList

export type ChildList = readonly ComponentId[] | ChildTemplate

ComponentBuilder

Builds the live DOM for one A2UI component type.

export type ComponentBuilder = (args: BuildArgs) => Renderable

ComponentId

export type ComponentId = string

DynamicBoolean

export type DynamicBoolean = boolean | PathBinding | FunctionCall

DynamicNumber

export type DynamicNumber = number | PathBinding | FunctionCall

DynamicString

A value that may be a literal, a data-model path, or a function result.

export type DynamicString = string | PathBinding | FunctionCall

DynamicStringList

export type DynamicStringList = readonly string[] | PathBinding | FunctionCall

JsonObject

export type JsonObject = { readonly [key: string]: JsonValue }

JsonValue

A JSON-serializable value — the shape of an A2UI surface data model.

export type JsonValue =
  | null
  | boolean
  | number
  | string
  | readonly JsonValue[]
  | { readonly [key: string]: JsonValue }

Interfaces

A2uiActionEffect

An action the user triggered, with its context already resolved to values.

export interface A2uiActionEffect {
  readonly type: 'a2ui/action'
  readonly surfaceId: string
  readonly sourceComponentId: ComponentId
  readonly name: string
  readonly context: JsonObject
}

A2uiActionEvent

A user-triggered action, resolved and ready to send to your agent/server.

export interface A2uiActionEvent {
  readonly surfaceId: string
  readonly sourceComponentId: string
  readonly name: string
  readonly context: JsonObject
  readonly timestamp: string
  /**
   * The surface's full data model, present only when the surface was created
   * with `sendDataModel: true` (A2UI's client→server data sync — the data model
   * rides along with the client-bound action for collaborative editing).
   */
  readonly dataModel?: JsonValue
}

A2uiHandle

export interface A2uiHandle {
  /** Apply one server→client envelope, or a batch, as a single reconcile. */
  apply(envelopes: ServerToClientEnvelope | readonly ServerToClientEnvelope[]): void
  /** Snapshot the current renderer state. */
  getState(): A2uiState
  /** The client capabilities to send to the server (catalogs the client supports). */
  capabilities(): ClientCapabilities
  /** Observe state after every update cycle. Returns an unsubscribe. */
  subscribe(listener: (state: A2uiState) => void): () => void
  /** Tear down the mounted renderer. */
  dispose(): void
}

A2uiOptions

export interface A2uiOptions {
  /** Called when the user triggers a component action. */
  readonly onAction?: (event: A2uiActionEvent) => void
  /** Custom catalogs keyed by their A2UI `catalogId`. */
  readonly catalogs?: Readonly<Record<string, Catalog>>
  /** DOM commit scheduler — `'raf'` coalesces streaming updates to one paint/frame. */
  readonly scheduler?: 'sync' | 'raf'
}

A2uiState

Root renderer state: all live surfaces plus their creation order.

export interface A2uiState {
  readonly surfaces: Readonly<Record<string, Surface>>
  readonly order: readonly string[]
}

A2uiTransport

A bidirectional A2UI channel: deliver server→client envelopes, accept actions.

export interface A2uiTransport {
  /** Subscribe to inbound server→client envelopes. Returns an unsubscribe. */
  onEnvelope(handler: (envelope: ServerToClientEnvelope) => void): () => void
  /** Send a client→server action to the channel. */
  sendAction(event: A2uiActionEvent): void
}

Action

A component action: dispatch an event to the server and/or run a local function.

export interface Action {
  readonly event?: ActionEvent
  readonly functionCall?: FunctionCall
}

ActionEvent

A server-bound event dispatched when the user triggers an action.

export interface ActionEvent {
  readonly name: string
  readonly context?: Readonly<Record<string, unknown>>
}

BuildArgs

export interface BuildArgs {
  readonly node: ComponentNode
  readonly ctx: RenderContext
  readonly scope: RenderScope
}

Catalog

export interface Catalog {
  readonly id?: string
  readonly components: Readonly<Record<string, ComponentBuilder>>
  readonly functions: Readonly<Record<string, CatalogFunction>>
}

CatalogSpec

export interface CatalogSpec {
  readonly id?: string
  readonly components: Readonly<Record<string, ComponentBuilder>>
  readonly functions?: Readonly<Record<string, CatalogFunction>>
  /** A base catalog to inherit builders/functions from (this spec wins on conflict). */
  readonly extends?: Catalog
}

ChildTemplate

Container children: a static id list, or a template repeated over a collection.

export interface ChildTemplate {
  readonly componentId: ComponentId
  readonly path: string
}

ClientCapabilities

Client capabilities to advertise to the server (A2UI client_capabilities).

export interface ClientCapabilities {
  /** Catalog ids the client can render (the Basic catalog plus any custom ones). */
  readonly supportedCatalogIds: readonly string[]
}

ComponentNode

A single component in the flat adjacency-list tree. Component-type-specific properties are open (custom catalogs), so this is an open record keyed by the two guaranteed fields.

export interface ComponentNode {
  readonly id: ComponentId
  readonly component: string
  readonly [prop: string]: unknown
}

CreateSurface

export interface CreateSurface {
  readonly surfaceId: string
  readonly catalogId: string
  readonly theme?: Theme
  readonly sendDataModel?: boolean
}

DeleteSurface

export interface DeleteSurface {
  readonly surfaceId: string
}

FunctionCall

A catalog-registered function invocation (validation, formatting, actions).

export interface FunctionCall {
  readonly call: string
  readonly args?: Readonly<Record<string, unknown>>
  readonly returnType?: string
}

PathBinding

A reference into the surface data model via a JSON-Pointer-ish path.

export interface PathBinding {
  readonly path: string
}

RenderContext

Everything a builder needs to render one component and recurse into children.

export interface RenderContext {
  readonly surfaceId: string
  readonly theme: Signal<Theme>
  /** The surface data model root (absolute `/…` bindings resolve against this). */
  readonly rootData: Signal<JsonValue>
  readonly send: (msg: A2uiMsg) => void
  readonly catalog: Catalog
  /** Write a stateful component's local UI state (Tabs active tab, Modal open). */
  setUi(componentId: ComponentId, value: JsonValue): void
  /** Look up a component definition by id in the current structural snapshot. */
  getComponent(id: ComponentId): ComponentNode | undefined
  /** Render a component (and its subtree) by id within a scope. */
  renderById(id: ComponentId, scope: RenderScope): Renderable
  /** Render a static id list or a repeated template within a scope. */
  renderChildren(children: ChildList | undefined, scope: RenderScope): Renderable
}

RenderScope

The reactive data context a component renders against. At the surface root this wraps the whole data model; inside a template it wraps the current item.

export interface RenderScope {
  /** Reactive data for this scope (root data model, or a template item). */
  readonly data: Signal<JsonValue>
  /**
   * The local data-model root for THIS scope: the surface data model at the top
   * level, or the current item inside a template. Per the A2UI spec, template
   * paths are item-scoped, so both relative (`name`) and leading-slash (`/name`)
   * bindings resolve against this. Correctly scoped for the current depth.
   */
  readonly root: Signal<JsonValue>
  /**
   * Client-local UI state for stateful components, correctly scoped for THIS
   * depth (threaded through template rows like {@link root}). Read via this;
   * write via {@link RenderContext.setUi}.
   */
  readonly uiState: Signal<JsonObject>
  /**
   * Resolve a component-relative pointer to an ABSOLUTE data-model pointer,
   * used for two-way write-back. Absolute pointers pass through unchanged.
   */
  absPath(pointer: string): string
  /**
   * Stable prefix identifying THIS scope instance (`''` at the surface root,
   * `/rows/0` in a template row). Namespaces client-local UI state so a stateful
   * component repeated across template rows gets independent state per row.
   */
  readonly keyPrefix: string
}

ServerToClientEnvelope

One server→client message. Exactly one of the four payload fields is set.

export interface ServerToClientEnvelope {
  readonly version?: string
  readonly createSurface?: CreateSurface
  readonly updateComponents?: UpdateComponents
  readonly updateDataModel?: UpdateDataModel
  readonly deleteSurface?: DeleteSurface
}

Surface

A single live A2UI surface.

export interface Surface {
  readonly surfaceId: string
  readonly catalogId: string
  readonly theme: Theme
  /** Flat adjacency-list component map, keyed by component id. */
  readonly components: Readonly<Record<ComponentId, ComponentNode>>
  /** Id of the tree root (`"root"` per spec), or null until it arrives. */
  readonly rootId: ComponentId | null
  /** The surface data model that bindings resolve against. */
  readonly dataModel: JsonValue
  /**
   * Client-local UI state the A2UI protocol does not model in the data model —
   * e.g. a Tabs component's active tab or a Modal's open flag. Keyed by
   * component id; each value is that component's own (JSON-serializable) state.
   */
  readonly uiState: JsonObject
  /** Whether the client echoes the full data model back to the server. */
  readonly sendDataModel: boolean
}

Theme

export interface Theme {
  readonly primaryColor?: string
  readonly iconUrl?: string
  readonly agentDisplayName?: string
  readonly font?: string
  readonly [key: string]: JsonValue | undefined
}

UpdateComponents

export interface UpdateComponents {
  readonly surfaceId: string
  readonly components: readonly ComponentNode[]
}

UpdateDataModel

export interface UpdateDataModel {
  readonly surfaceId: string
  /** JSON-Pointer location; defaults to `/` (whole model) when omitted. */
  readonly path?: string
  /** New value; omission removes the key at `path`. */
  readonly value?: JsonValue
}

WebSocketLike

The minimal WebSocket surface webSocketTransport needs (mockable in tests).

export interface WebSocketLike {
  send(data: string): void
  addEventListener(type: 'message', listener: (event: { data: unknown }) => void): void
  removeEventListener(type: 'message', listener: (event: { data: unknown }) => void): void
}

WebSocketTransportOptions

Options for webSocketTransport.

export interface WebSocketTransportOptions {
  /**
   * Called when an inbound frame can't be used — unparseable JSON, or a value
   * that isn't an envelope object. Defaults to `console.warn`; pass your own to
   * route these somewhere else (or a no-op to intentionally ignore them).
   * Frames are never dropped *silently*.
   */
  onError?: (error: Error, rawData: unknown) => void
}

Constants

A2UI_VERSION

A2UI protocol v0.9 — type definitions for the server→client message stream and the value/binding primitives shared across catalogs. Reference: https://a2ui.org/specification/v0.9-a2ui/

const A2UI_VERSION

BASIC_CATALOG_ID

Canonical id of the A2UI v0.9 Basic catalog.

const BASIC_CATALOG_ID

basicCatalog

const basicCatalog: Catalog

SUPPORTED_VERSIONS

A2UI protocol versions this renderer accepts (others render best-effort).

const SUPPORTED_VERSIONS: readonly string[]