A2UI · Getting Started

Quick Start

Parse an A2UI stream, build its data model, and resolve a dynamic value — end to end, in a few minutes.

@threadplane/a2ui is the protocol layer. It parses the JSONL message stream, gives you typed envelopes, and resolves dynamic values against a data model. It does not render anything. Rendering is @threadplane/chat's <a2ui-surface>. This library is what sits underneath it.

Goals

By the end of this page you'll be able to:

  • Install @threadplane/a2ui.
  • Parse a newline-delimited A2UI stream into typed messages.
  • Build a plain data-model object with setByPointer.
  • Resolve a dynamic value with resolveDynamic.

Install

npm install @threadplane/a2ui

The package has no peer dependencies.

Parse a stream

Let's start with a real stream. An agent emits A2UI as newline-delimited JSON — one envelope per line, each stamped with "version": "v0.9". Here's a booking form, in emission order: the surface is created first, then its data, then the component tree (whose first component is root).

---a2ui_JSON---
{"version":"v0.9","createSurface":{"surfaceId":"booking","catalogId":"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"}}
{"version":"v0.9","updateDataModel":{"surfaceId":"booking","value":{"origin":["LAX"],"dest":["JFK"],"passengers":1}}}
{"version":"v0.9","updateComponents":{"surfaceId":"booking","components":[{"id":"root","component":"Column","children":["title","origin","submit"]},{"id":"title","component":"Text","text":"Book a flight","variant":"h2"},{"id":"origin","component":"ChoicePicker","label":"Origin","options":[{"label":"LAX","value":"LAX"},{"label":"JFK","value":"JFK"}],"value":{"path":"/origin"},"variant":"mutuallyExclusive"},{"id":"submit_label","component":"Text","text":"Search flights"},{"id":"submit","component":"Button","child":"submit_label","variant":"primary","action":{"event":{"name":"bookingSubmit","context":{"origin":{"path":"/origin"},"dest":{"path":"/dest"}}}}}]}}

Feed each chunk to a parser. push returns the A2uiMessage[] it could complete from everything buffered so far.

import { createA2uiMessageParser } from '@threadplane/a2ui';
 
const parser = createA2uiMessageParser();
 
const messages = parser.push(
  '{"version":"v0.9","createSurface":{"surfaceId":"s1","catalogId":"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"}}\n',
);
// messages -> 1 message: { version: 'v0.9', createSurface: { surfaceId: 's1', catalogId: '...' } }

The parser is line-oriented. A line is only parsed once a newline arrives, so partial JSON buffers until it's complete:

parser.push('{"version":"v0.9","deleteSurface":');  // -> []  (incomplete, buffered)
parser.push('{"surfaceId":"s1"}}\n');               // -> 1 message

That buffering is deliberate. Agent output streams in fragments, and a half-finished line shouldn't throw mid-render. A missing version field defaults to v0.9, and unknown envelope keys — such as future v1.0 messages — are skipped rather than treated as errors.

Build the data model

An updateDataModel envelope carries an optional path (a JSON pointer; omitted or "/" targets the whole model) and an optional value — plain JSON, no typed wrappers. If value is present, it replaces (or creates) the data at path. If value is omitted, the key at path is deleted.

Applying an update is just the pointer helpers:

import { setByPointer, deleteByPointer } from '@threadplane/a2ui';
import type { A2uiUpdateDataModel } from '@threadplane/a2ui';
 
function applyUpdateDataModel(
  model: Record<string, unknown>,
  update: A2uiUpdateDataModel,
): Record<string, unknown> {
  const path = update.path && update.path !== '/' ? update.path : undefined;
  if (!('value' in update) || update.value === undefined) {
    // Omitted value = delete at path (whole-model reset when path is root).
    return path ? deleteByPointer(model, path) : {};
  }
  if (!path) return update.value as Record<string, unknown>;
  return setByPointer(model, path, update.value);
}

Run it against the booking stream's updateDataModel and you get the model back:

let model: Record<string, unknown> = {};
model = applyUpdateDataModel(model, {
  surfaceId: 'booking',
  value: { origin: ['LAX'], dest: ['JFK'], passengers: 1 },
});
// model -> { origin: ['LAX'], dest: ['JFK'], passengers: 1 }

setByPointer builds the object immutably — each call returns a new object, the input is untouched. The data model guide covers path scoping, deletes, and the pointer helpers in depth.

Resolve a value

A component's props can be bare literals or path references. resolveDynamic collapses both against the model.

import { resolveDynamic } from '@threadplane/a2ui';
 
resolveDynamic({ path: '/passengers' }, model);  // 1
resolveDynamic('Search flights', model);         // "Search flights"
resolveDynamic({ path: '/missing' }, model);     // undefined

A bare literal (string, number, boolean) passes through unchanged. A { path } reads from the model by JSON pointer. A missing path resolves to undefined rather than throwing — same conservative posture as the parser. A { call } function-call value executes through a function registry (createA2uiFunctionRegistry()) when one is supplied; without one it resolves to undefined.

Conclusion

That's the full loop: stream in, model built, value resolved. From here, the three guides go deeper: