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
76 changes: 73 additions & 3 deletions apps/website/content/docs/a2ui/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,35 @@
],
"examples": []
},
{
"name": "A2uiFunctionContext",
"kind": "interface",
"description": "Execution context handed to every function implementation.",
"properties": [
{
"name": "locale",
"type": "string",
"description": "BCP 47 locale for Intl-based formatting; host default when undefined.",
"optional": true
}
],
"methods": [
{
"name": "resolveArg",
"signature": "resolveArg(value: unknown): unknown",
"description": "Resolve a (possibly dynamic) argument value — bare literal, `{ path }`\nbinding, or nested `{ call }` — against the current data model/scope.",
"params": [
{
"name": "value",
"type": "unknown",
"description": "",
"optional": false
}
]
}
],
"examples": []
},
{
"name": "A2uiIcon",
"kind": "interface",
Expand Down Expand Up @@ -1465,6 +1494,20 @@
"signature": "A2uiCatalogComponent | A2uiComponentBase & Record<string, unknown>",
"examples": []
},
{
"name": "A2uiFunctionImpl",
"kind": "type",
"description": "",
"signature": "(args: Record<string, unknown>, ctx: A2uiFunctionContext) => unknown",
"examples": []
},
{
"name": "A2uiFunctionRegistry",
"kind": "type",
"description": "",
"signature": "ReadonlyMap<string, A2uiFunctionImpl>",
"examples": []
},
{
"name": "A2uiMessage",
"kind": "type",
Expand Down Expand Up @@ -1528,6 +1571,27 @@
"signature": "\"v0.9\"",
"examples": []
},
{
"name": "createA2uiFunctionRegistry",
"kind": "function",
"description": "Creates an A2UI client-side function registry containing the standard\nbasic-catalog functions (`formatString`, `formatNumber`, `formatCurrency`,\n`formatDate`, `pluralize`, `and`, `or`, `not`), optionally extended or\noverridden with custom implementations.",
"signature": "createA2uiFunctionRegistry(overrides: Record<string, A2uiFunctionImpl>): A2uiFunctionRegistry",
"params": [
{
"name": "overrides",
"type": "Record<string, A2uiFunctionImpl>",
"description": "",
"optional": true
}
],
"returns": {
"type": "A2uiFunctionRegistry",
"description": ""
},
"examples": [
"```ts\nconst registry = createA2uiFunctionRegistry();\nresolveDynamic({ call: 'formatCurrency', args: { value: 42, currency: 'USD' } }, {}, undefined, registry);\n```"
]
},
{
"name": "createA2uiMessageParser",
"kind": "function",
Expand Down Expand Up @@ -1637,8 +1701,8 @@
{
"name": "resolveDynamic",
"kind": "function",
"description": "Resolves an A2UI v0.9 dynamic value against a client data model.\n\nBare literals (strings, numbers, booleans) pass through unchanged, `{ path }`\nreferences read from the model by JSON-pointer path, arrays resolve\nelement-wise, and client-side function calls (`{ call }`) resolve to\n`undefined` until function execution ships. Unrecognized plain objects pass\nthrough unchanged.",
"signature": "resolveDynamic(value: unknown, model: Record<string, unknown>, scope: A2uiScope): unknown",
"description": "Resolves an A2UI v0.9 dynamic value against a client data model.\n\nBare literals (strings, numbers, booleans) pass through unchanged, `{ path }`\nreferences read from the model by JSON-pointer path, arrays resolve\nelement-wise, and client-side function calls (`{ call }`) execute through the\nprovided function registry — argument values resolve recursively, so args may\nthemselves be bindings or nested calls. Without a registry (or for unknown\nfunction names) calls resolve to `undefined`. Unrecognized plain objects pass\nthrough unchanged.",
"signature": "resolveDynamic(value: unknown, model: Record<string, unknown>, scope: A2uiScope, registry: A2uiFunctionRegistry): unknown",
"params": [
{
"name": "value",
Expand All @@ -1657,14 +1721,20 @@
"type": "A2uiScope",
"description": "",
"optional": true
},
{
"name": "registry",
"type": "A2uiFunctionRegistry",
"description": "",
"optional": true
}
],
"returns": {
"type": "unknown",
"description": ""
},
"examples": [
"```ts\nconst model = { customer: { name: 'Ada' } };\nresolveDynamic({ path: '/customer/name' }, model); // 'Ada'\nresolveDynamic('Checkout', model); // 'Checkout'\n```"
"```ts\nconst model = { customer: { name: 'Ada' } };\nresolveDynamic({ path: '/customer/name' }, model); // 'Ada'\nresolveDynamic('Checkout', model); // 'Checkout'\nresolveDynamic(\n { call: 'formatString', args: { value: 'Hi ${/customer/name}' } },\n model, undefined, createA2uiFunctionRegistry(),\n); // 'Hi Ada'\n```"
]
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ The parser and resolver are deliberately conservative:
- unknown envelope keys are ignored (forward compatibility with future protocol versions);
- missing data-model paths resolve to `undefined`;
- unrecognized dynamic-value shapes pass through unchanged;
- `{ call: ... }` function-call values resolve to `undefined` until client-side function execution ships.
- `{ call: ... }` function-call values execute through the standard function registry when one is passed to `resolveDynamic`; without a registry (or for unknown names) they resolve to `undefined`.

This makes the protocol layer suitable for streaming, but it is not a full schema validator. If you accept untrusted agent output, validate the payload at your boundary before wiring it to privileged handlers.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ 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 resolves to `undefined` until client-side function execution ships.
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

Expand Down
2 changes: 1 addition & 1 deletion apps/website/content/docs/a2ui/guides/data-model.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Nesting is just JSON: `value: { name: 'Ada', address: { city: 'London' } }` writ

1. `null` / `undefined` pass through as-is.
2. Arrays are mapped recursively — each element resolved in turn.
3. A `{ call }` function-call value resolves to `undefined` (client-side function execution ships in an upcoming release). Checked before path refs so a call's `args` never masquerade as a binding.
3. A `{ call }` function-call value executes through the function registry passed to `resolveDynamic` (standard set: `formatString`, `formatNumber`, `formatCurrency`, `formatDate`, `pluralize`, `and`, `or`, `not`); args resolve recursively, so they may be bindings or nested calls. Without a registry, or for unknown names, the value resolves to `undefined`. Checked before path refs so a call's `args` never masquerade as a binding.
4. A `{ path }` reference reads from the model.
5. Anything else — a bare string, number, boolean, or plain object — passes through unchanged. Bare values *are* the v0.9 literal form; there are no wrapper objects.

Expand Down
2 changes: 1 addition & 1 deletion apps/website/content/docs/a2ui/guides/message-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ A reference is `{"path":"/origin"}` — a JSON pointer into the surface's data m
{"text":{"path":"/headline"}}
```

A function call is `{"call":"formatDate","args":{...}}` — a typed invocation of a client-side catalog function (`formatString`, `formatCurrency`, `required`, ...). Function calls are part of the wire format today; `resolveDynamic` resolves them to `undefined` until function execution ships in an upcoming release.
A function call is `{"call":"formatDate","args":{...}}` — a typed invocation of a client-side catalog function (`formatString`, `formatCurrency`, `required`, ...). Function calls execute client-side: pass `createA2uiFunctionRegistry()` as the fourth argument to `resolveDynamic` and the standard formatting/logic functions run with recursively-resolved args. Unknown names resolve to `undefined` (with a one-time console warning).

## What are the four envelopes?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ resolveDynamic(2, model); // 2
|-------------|--------|
| bare literal (string, number, boolean) | returned as-is |
| `{ path }` | the value at that model path |
| `{ call }` | `undefined` — client-side function execution ships in an upcoming release |
| `{ call }` | executes via the `A2uiFunctionRegistry` passed as the fourth argument (`createA2uiFunctionRegistry()` provides the standard set); `undefined` without a registry or for unknown names |
| arrays | recursively resolved array values |
| `null` or `undefined` | returned as-is |
| unrecognized plain objects | returned as-is |
Expand Down
2 changes: 1 addition & 1 deletion apps/website/content/docs/a2ui/reference/schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ type DynamicBoolean = boolean | A2uiPathRef | A2uiFunctionCall;
type DynamicStringList = string[] | A2uiPathRef | A2uiFunctionCall;
```

Absolute paths start with `/` and are resolved from the model root. Relative paths are resolved from an optional `A2uiScope` (used inside children templates). Function calls are typed on the wire today; execution ships in an upcoming release, so `resolveDynamic` returns `undefined` for them.
Absolute paths start with `/` and are resolved from the model root. Relative paths are resolved from an optional `A2uiScope` (used inside children templates). Function calls execute through an `A2uiFunctionRegistry` (see `createA2uiFunctionRegistry`); `resolveDynamic` returns `undefined` for them only when no registry is supplied or the name is unknown.

## Children

Expand Down
2 changes: 1 addition & 1 deletion apps/website/content/docs/chat/a2ui/catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ Renders an HTML5 `<audio>` element with native controls.

## Client-Side Functions

The v0.9 basic catalog defines typed client-side functions that can appear in `{ "call": ... }` dynamic values and `checks` rules: validation (`required`, `regex`, `length`, `numeric`, `email`), formatting (`formatString`, `formatNumber`, `formatCurrency`, `formatDate`, `pluralize`), logic (`and`, `or`, `not`), and `openUrl`. Function values are part of the wire format today, but **execution ships in an upcoming release** — `resolveDynamic` currently returns `undefined` for them. The exception is `openUrl` used as a local action, which the surface component's built-in `a2ui:localAction` fallback already handles.
The v0.9 basic catalog defines typed client-side functions that can appear in `{ "call": ... }` dynamic values and `checks` rules: validation (`required`, `regex`, `length`, `numeric`, `email`), formatting (`formatString`, `formatNumber`, `formatCurrency`, `formatDate`, `pluralize`), logic (`and`, `or`, `not`), and `openUrl`. The formatting and logic functions (`formatString` with `${...}` interpolation, `formatNumber`, `formatCurrency`, `formatDate`, `pluralize`, `and`, `or`, `not`) **execute client-side** via `createA2uiFunctionRegistry()`, which `surfaceToSpec` applies to every dynamic value. `openUrl` runs as a local action through the surface component's built-in `a2ui:localAction` fallback (new tab, `noopener`). The validation functions (`required`, `regex`, `length`, `numeric`, `email`) are typed on the wire but not yet enforced — validation `checks` ship in an upcoming release.

## Component Summary

Expand Down
2 changes: 1 addition & 1 deletion apps/website/content/docs/chat/a2ui/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ The surface-to-spec conversion turns this into a render `click` binding that cal

`label` is a Threadplane extension, derived from the Button's child Text; transcripts use it to label the user bubble. If the surface has `sendDataModel: true`, the emitted message also includes `metadata.a2uiClientDataModel` with the current surface data model snapshot.

The other action form, `{ "functionCall": { "call": ..., "args": ... } }`, executes a client-side function locally instead of round-tripping to the agent; function execution ships in an upcoming release.
The other action form, `{ "functionCall": { "call": ..., "args": ... } }`, executes a client-side function locally instead of round-tripping to the agent — wired to the surface component's `a2ui:localAction` handler, with `openUrl` (new tab, `noopener`) built in.

Catalog components receive resolved props as Angular inputs from the render engine. Bind `(action)` when you want agent-bound events, and bind `(events)` when you want the lower-level render stream.

Expand Down
4 changes: 2 additions & 2 deletions apps/website/content/docs/chat/a2ui/surface-component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ Before the spec is emitted, each component prop is evaluated against the surface

- A bare literal value — passed through as-is
- A path reference `{ path: '/some/pointer' }` — kept as a live json-render state binding against `dataModel` (so user input writes back through the render state store)
- A function call `{ call: ... }` — omitted until client-side function execution ships
- A function call `{ call: ... }` — executed through the standard function registry (formatString/formatters/logic); unknown functions omit the prop

**3. Map actions to `on` bindings**

The internal conversion maps each component's A2UI `event` action into a render-spec `on` binding on the corresponding element. Actions map to the `a2ui:event` handler, which builds an `A2uiActionMessage` (with the event's `context` resolved against the data model) for the `(action)` output. `functionCall` actions execute client-side and are not wired until function execution ships.
The internal conversion maps each component's A2UI `event` action into a render-spec `on` binding on the corresponding element. Actions map to the `a2ui:event` handler, which builds an `A2uiActionMessage` (with the event's `context` resolved against the data model) for the `(action)` output. `functionCall` actions map to the built-in `a2ui:localAction` handler — consumer handlers take priority, and `openUrl` opens in a new tab with `noopener` as the built-in fallback.

**4. Expand template children**

Expand Down
46 changes: 46 additions & 0 deletions docs/superpowers/plans/2026-08-17-a2ui-v09-phase2-functions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# A2UI v0.9 Phase 2 — Client-Side Functions Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Implement A2UI v0.9 client-side function execution — `{call, args}` dynamic values resolve through a function registry (formatString/formatNumber/formatCurrency/formatDate/pluralize/and/or/not), and `action.functionCall` buttons execute locally (`openUrl`).

**Architecture:** A pure-TS function registry in `libs/a2ui` (`functions.ts`); `resolveDynamic` gains an optional registry parameter and invokes functions with recursively-resolved args; `surface-to-spec` passes a default registry and wires `functionCall` actions to the existing `a2ui:localAction` handler in `<a2ui-surface>`. Additive, non-breaking. Base: main after PR #817. Authoritative arg shapes: `scratchpad/basic-catalog.json` `functions` map (already verified).

**Tech Stack:** Pure TS + `Intl` (NumberFormat/PluralRules); vitest; no new deps.

---

### Task 1: `libs/a2ui/src/lib/functions.ts` — registry + standard functions

**Files:** Create `libs/a2ui/src/lib/functions.ts`, `functions.spec.ts`. Modify `libs/a2ui/src/index.ts`.

- [ ] Failing spec covering, per official schemas: `formatNumber` (decimals, grouping), `formatCurrency` (currency code, decimals), `formatDate` (TR35 subset: yy yyyy M MM MMM MMMM d dd E EEEE h hh H HH m mm s ss a; ISO-string and epoch input), `pluralize` (Intl.PluralRules categories, `other` fallback), `and`/`or` (values array, min 2)/`not`, `formatString` interpolation: `${/abs/path}`, `${relative}` (scope), nested calls with named args `${formatDate(value:${/d}, format:'yyyy-MM-dd')}`, quoted string args, `\${` escape, unknown function → `undefined` result for the whole value + one-time console.warn.
- [ ] Implement `A2uiFunctionContext { resolveArg(v: unknown): unknown; locale?: string }`, `A2uiFunctionImpl`, `A2uiFunctionRegistry = ReadonlyMap<string, A2uiFunctionImpl>`, `createA2uiFunctionRegistry(overrides?: Record<string, A2uiFunctionImpl>)`. formatString gets a small recursive expression parser (path | 'quoted' | number | ident(args)); keep it linear (no backtracking regexes — CodeQL).
- [ ] Export from `index.ts`. Green + commit.

### Task 2: `resolveDynamic` registry integration

**Files:** Modify `libs/a2ui/src/lib/resolve.ts`, `resolve.spec.ts`.

- [ ] Failing spec: `resolveDynamic({call:'formatCurrency',args:{value:{path:'/price'},currency:'USD'}}, {price: 42}, undefined, registry)` → formatted string; `{call}` without registry (or unknown name) → `undefined`; args containing `{path}`/nested `{call}` resolve against the model/scope.
- [ ] Implement optional 4th param `registry?: A2uiFunctionRegistry`; on `isFunctionCall`, look up impl and invoke with `ctx.resolveArg = (v) => resolveDynamic(v, model, scope, registry)`; missing impl → `undefined` (+ one-time warn per name). Green + commit.

### Task 3: renderer wiring

**Files:** Modify `libs/chat/src/lib/a2ui/surface-to-spec.ts`, `surface-to-spec.spec.ts`, `libs/chat/src/lib/a2ui/surface.component.ts` (openUrl noopener), specs.

- [ ] Failing spec: a Text `text: {call:'formatString', args:{value:'Total: ${/total}'}}` resolves in the spec props; `action: {functionCall:{call:'openUrl',args:{url}}}` produces `on.click = { action: 'a2ui:localAction', params: { call, args } }`; unknown-function props resolve to `undefined` (prop omitted-equivalent).
- [ ] Implement: module-level `DEFAULT_A2UI_FUNCTIONS = createA2uiFunctionRegistry()`; pass to every `resolveDynamic` call; delete the Phase-1 `isFunctionCall → skip` branches; `resolveAction` handles `functionCall`. In `surface.component.ts`, the existing `a2ui:localAction` openUrl builtin gains `'noopener'` window features. Green + commit.

### Task 4: prompts + docs

**Files:** Modify `examples/chat/python/src/schemas/a2ui_v09.py` + byte-identical ag-ui twin; `apps/website/content/docs/a2ui/reference/parser-resolver-guards.mdx`, `apps/website/content/docs/chat/a2ui/catalog.mdx` (functions section), `libs/a2ui/README.md`; `npm run generate-api-docs`.

- [ ] Schema prompt: functions section advertising the 8 value functions + `functionCall` actions with `openUrl`; examples use named-arg interpolation exactly per spec. Verify twins byte-identical; pytest suites still green.
- [ ] Docs updated from "typed, execution ships later" to shipped semantics. Commit.

### Task 5: verification + PR

- [ ] `npx nx run-many -t lint test build -p a2ui chat`; both example pytest suites; `npx nx affected -t lint test build`.
- [ ] Live Chrome smoke: serve examples/chat with real key, prompt for a surface using a formatted value (e.g. "show a card with today's date formatted"), verify function output renders.
- [ ] PR `feat(a2ui): client-side functions (Phase 2)`; merge on green.
Loading
Loading