Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions apps/website/content/docs/a2ui/getting-started/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,50 @@ That buffering is deliberate. Agent output streams in fragments, and a half-fini

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.

For the booking stream that's three entries: `origin`, `dest`, `passengers`. `setByPointer` builds the object immutably — each call returns a new object, the input is untouched.
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):

```ts
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:

```ts
let model: Record<string, unknown> = {};
model = setByPointer(model, '/origin', 'LAX');
model = setByPointer(model, '/dest', 'JFK');
model = setByPointer(model, '/passengers', 1);
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 }
```

To be clear: assembling the model from `contents` (reading `valueString` vs `valueNumber`, honoring the entry's optional `path`, recursing into `valueMap`) is the caller's job. This library gives you the pointer helpers; it doesn't ship a `contents` -> object reducer. The [data model guide](/docs/a2ui/guides/data-model) covers the helpers in depth.
`setByPointer` builds the object immutably — each call returns a new object, the input is untouched. The [data model guide](/docs/a2ui/guides/data-model) covers the reducer, `path` scoping, and the pointer helpers in depth.

## Resolve a value

Expand Down
48 changes: 48 additions & 0 deletions apps/website/content/docs/a2ui/guides/data-model.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,54 @@ If the parent of the target doesn't exist, `deleteByPointer` returns the origina
These helpers use JSON-Pointer-style syntax but do **not** implement RFC 6901's `~0` / `~1` unescaping. A path is split on `/` and the segments are used as literal keys. So keys that themselves contain `/` or `~` aren't addressable — there's no escape sequence to reach them.
</Callout>

## From contents to model

The pointer helpers are the primitives. The most common real task is one level up: turning a `dataModelUpdate`'s `contents` array into the plain object the resolver reads. The library doesn't ship that reducer — you write it. Each `A2uiDataModelEntry` has a `key` and exactly one of `valueString`, `valueNumber`, `valueBoolean`, or `valueMap` (a nested array of entries). Branch on which is set, and recurse on `valueMap`:

```ts
import type { A2uiDataModelEntry } from '@threadplane/a2ui';

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;
}

entriesToObject([
{ key: 'name', valueString: 'Ada' },
{ key: 'address', valueMap: [{ key: 'city', valueString: 'London' }] },
]);
// { name: 'Ada', address: { city: 'London' } }
```

A `dataModelUpdate` may also carry an optional top-level `path`. It scopes where the reduced object lands in the model — without it the entries write at the root; with `path: '/customer'` they nest under `customer`. Honor it by reducing first, then writing the result with `setByPointer`:

```ts
import { setByPointer } from '@threadplane/a2ui';
import type { A2uiDataModelUpdate } from '@threadplane/a2ui';

function applyDataModelUpdate(
model: Record<string, unknown>,
update: A2uiDataModelUpdate,
): Record<string, unknown> {
const obj = entriesToObject(update.contents);
return setByPointer(model, update.path ?? '/', obj);
}

applyDataModelUpdate(
{},
{ surfaceId: 's1', path: '/customer', contents: [{ key: 'name', valueString: 'Ada' }] },
);
// { customer: { name: 'Ada' } }
```

When `path` is absent, `setByPointer(model, '/', obj)` replaces the root with the reduced object. Pass a pointer like `/customer` to merge under a key instead.

## Resolving dynamic values

`resolveDynamic` collapses a component's prop to a concrete value against the model. The order is fixed:
Expand Down
10 changes: 9 additions & 1 deletion apps/website/content/docs/a2ui/guides/message-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ Sets data for a surface. `contents` is an array of typed entries — each has a
{"dataModelUpdate":{"surfaceId":"booking","contents":[{"key":"origin","valueString":"LAX"},{"key":"dest","valueString":"JFK"},{"key":"passengers","valueNumber":1}]}}
```

`valueMap` nests. An entry whose value is a `valueMap` holds its own array of entries, which reduce to a nested object. And the envelope's optional top-level `path` scopes where the whole batch lands:

```json
{"dataModelUpdate":{"surfaceId":"booking","path":"/customer","contents":[{"key":"name","valueString":"Ada"},{"key":"address","valueMap":[{"key":"city","valueString":"London"}]}]}}
```

That reduces to `{ customer: { name: 'Ada', address: { city: 'London' } } }` — the `valueMap` becomes the nested `address` object, and `path: '/customer'` nests the batch under `customer`. The [data model guide](/docs/a2ui/guides/data-model#from-contents-to-model) shows the reducer that walks these entries.

### `beginRendering`

Names the `root` component id for the surface — the entry point the renderer mounts. It may also carry `styles` (`font`, `primaryColor`).
Expand Down Expand Up @@ -111,7 +119,7 @@ Two details are worth pinning down, because the inbound and outbound shapes diff
- **Context flips from list to map.** The inbound Button's `action.context` is a *list* of `{ key, value }` entries, where each `value` is still a dynamic value (often a `{ path }`). The outbound message's `action.context` is a *map* keyed by those keys, with each value already a wrapped literal — the path references resolved against the current model and re-wrapped (here `{ path: '/origin' }` became `{ literalString: 'LAX' }`).
- **`label` is derived.** It comes from the source component's authored text — for a Button-with-Text-child, the child Text's literal string ("Search flights"). It's optional; the transcript renderer uses it to label the user bubble, and backends may ignore it.

The client's current data model is only attached as `metadata.a2uiClientDataModel` when the surface opts in. It's omitted otherwise.
The client's current data model is only attached as `metadata.a2uiClientDataModel` when the surface opts in. It's omitted otherwise. When present, it's an `A2uiClientDataModel` — `{ version: 'v1', surfaces: Record<surfaceId, Record<string, unknown>> }`, the per-surface model keyed by `surfaceId`. See [the schema reference](/docs/a2ui/reference/schema#outbound-action-messages) for the full outbound shape.

## Relationship to Google's A2UI

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ resolveDynamic({ literalNumber: 2 }, model); // 2
| `null` or `undefined` | returned as-is |
| unrecognized shapes | returned as-is |

Resolution order is fixed: literal wrappers are checked and unwrapped **first**, then a `{ path }` reference, then plain passthrough. So a value carrying both a literal key and a `path` key resolves as the literal — the `path` is never reached.

Absolute paths start with `/`.

Relative paths resolve against `scope.basePath` when a scope is supplied. Without a scope, a relative path is treated as root-relative by prefixing `/`.
Expand Down
20 changes: 20 additions & 0 deletions apps/website/content/docs/a2ui/reference/schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,26 @@ The exported component definitions are:
| `MultipleChoice` | `selections`, `options`, `maxAllowedSelections`, `label` |
| `Slider` | `value`, `minValue`, `maxValue`, `step`, `label` |

Several of these fields are constrained to a fixed enum. Emit one of the listed values — an unknown value isn't validated at the protocol layer, but a renderer may ignore it or fall back to a default:

| Field | On | Allowed values |
|-------|----|----------------|
| `usageHint` | `Text` | `'h1'` \| `'h2'` \| `'h3'` \| `'h4'` \| `'h5'` \| `'caption'` \| `'body'` |
| `textFieldType` | `TextField` | `'date'` \| `'longText'` \| `'number'` \| `'shortText'` \| `'obscured'` |
| `alignment` | `Row`, `Column` | `'start'` \| `'center'` \| `'end'` \| `'stretch'` |
| `distribution` | `Row` | `'start'` \| `'center'` \| `'end'` \| `'space-between'` \| `'space-around'` |
| `direction` | `List` | `'vertical'` \| `'horizontal'` |
| `direction` | `Divider` | `'horizontal'` \| `'vertical'` |

`Tabs` is the one container that doesn't use `A2uiChildren`. Its `tabItems` is an array of `A2uiTabItem`, each pairing a `title` (a `DynamicString`) with a single `child` id:

```ts
interface A2uiTabItem {
title: DynamicString;
child: string;
}
```

The schema exposes `validationRegexp` on `TextField`, but validation execution is not implemented in this package. Treat schema fields as protocol data until a renderer wires behavior.

## Message envelopes
Expand Down
Loading