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. Here's a booking form, in emission order: data first, then the component tree, then the signal to render.

---a2ui_JSON---
{"dataModelUpdate":{"surfaceId":"booking","contents":[{"key":"origin","valueString":"LAX"},{"key":"dest","valueString":"JFK"},{"key":"passengers","valueNumber":1}]}}
{"surfaceUpdate":{"surfaceId":"booking","components":[{"id":"root","component":{"Column":{"children":{"explicitList":["title","origin","submit"]}}}},{"id":"title","component":{"Text":{"text":"Book a flight","usageHint":"h2"}}},{"id":"origin","component":{"MultipleChoice":{"label":"Origin","options":[{"label":"LAX","value":"LAX"},{"label":"JFK","value":"JFK"}],"selections":{"path":"/origin"},"maxAllowedSelections":1}}},{"id":"submit_label","component":{"Text":{"text":"Search flights"}}},{"id":"submit","component":{"Button":{"child":"submit_label","primary":true,"action":{"name":"bookingSubmit","context":[{"key":"origin","value":{"path":"/origin"}},{"key":"dest","value":{"path":"/dest"}}]}}}}]}}
{"beginRendering":{"surfaceId":"booking","root":"root"}}

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(
  '{"beginRendering":{"surfaceId":"s1","root":"root"}}\n',
);
// messages -> 1 message: { beginRendering: { surfaceId: 's1', root: 'root' } }

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

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

That buffering is deliberate. Agent output streams in fragments, and a half-finished line shouldn't throw mid-render.

#Build the data model

A dataModelUpdate carries contents โ€” an array of typed entries, each with a key and one of valueString / valueNumber / valueBoolean / valueMap. The parser hands you those entries verbatim. Turning them into a plain object the resolver can read is your code.

The library gives you the pointer helpers but doesn't ship a contents -> object reducer โ€” assembling the model from contents (reading valueString vs valueNumber, recursing into valueMap) is the caller's job. Here's a small one that walks the entries and writes the reduced object at the envelope's optional path (defaulting to the root):

import { setByPointer } from '@threadplane/a2ui';
import type { A2uiDataModelEntry, A2uiDataModelUpdate } from '@threadplane/a2ui';
 
// Branch on the entry's value field; recurse into valueMap for nesting.
function entriesToObject(entries: A2uiDataModelEntry[]): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const e of entries) {
    if (e.valueString !== undefined) out[e.key] = e.valueString;
    else if (e.valueNumber !== undefined) out[e.key] = e.valueNumber;
    else if (e.valueBoolean !== undefined) out[e.key] = e.valueBoolean;
    else if (e.valueMap !== undefined) out[e.key] = entriesToObject(e.valueMap);
  }
  return out;
}
 
// Apply a dataModelUpdate to a model, honoring its optional `path`.
function applyDataModelUpdate(
  model: Record<string, unknown>,
  update: A2uiDataModelUpdate,
): Record<string, unknown> {
  const obj = entriesToObject(update.contents);
  return setByPointer(model, update.path ?? '/', obj);
}

Run it against the booking stream's dataModelUpdate and you get the model back, derived from the entries you just parsed โ€” not re-typed by hand:

let model: Record<string, unknown> = {};
model = applyDataModelUpdate(model, {
  surfaceId: 'booking',
  contents: [
    { key: 'origin', valueString: 'LAX' },
    { key: 'dest', valueString: 'JFK' },
    { key: 'passengers', valueNumber: 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 the reducer, path scoping, and the pointer helpers in depth.

#Resolve a value

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

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

A literal wrapper unwraps to its value. A { path } reads from the model by JSON-Pointer. A missing path resolves to undefined rather than throwing โ€” same conservative posture as the parser.

#Conclusion

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