From b743c959500d743150c74cb824b1769696ca1c5d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 17 Aug 2026 09:29:17 -0700 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20Phase=202=20plan=20=E2=80=94=20A2UI?= =?UTF-8?q?=20client-side=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../2026-08-17-a2ui-v09-phase2-functions.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-17-a2ui-v09-phase2-functions.md diff --git a/docs/superpowers/plans/2026-08-17-a2ui-v09-phase2-functions.md b/docs/superpowers/plans/2026-08-17-a2ui-v09-phase2-functions.md new file mode 100644 index 000000000..e3db9a495 --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-a2ui-v09-phase2-functions.md @@ -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 ``. 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`, `createA2uiFunctionRegistry(overrides?: Record)`. 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. From 6756cf798c3919fc80eaa2fd475a8746e5b48337 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 17 Aug 2026 09:33:44 -0700 Subject: [PATCH 2/3] feat(a2ui): client-side function registry + execution (Phase 2) formatString/formatNumber/formatCurrency/formatDate/pluralize/and/or/not resolve through createA2uiFunctionRegistry(); functionCall actions route to a2ui:localAction (openUrl opens with noopener). Co-Authored-By: Claude Fable 5 --- libs/a2ui/src/index.ts | 2 + libs/a2ui/src/lib/functions.spec.ts | 180 +++++++++ libs/a2ui/src/lib/functions.ts | 368 ++++++++++++++++++ libs/a2ui/src/lib/resolve.ts | 37 +- .../chat/src/lib/a2ui/surface-to-spec.spec.ts | 30 +- libs/chat/src/lib/a2ui/surface-to-spec.ts | 41 +- libs/chat/src/lib/a2ui/surface.component.ts | 2 +- 7 files changed, 639 insertions(+), 21 deletions(-) create mode 100644 libs/a2ui/src/lib/functions.spec.ts create mode 100644 libs/a2ui/src/lib/functions.ts diff --git a/libs/a2ui/src/index.ts b/libs/a2ui/src/index.ts index 5f6e96082..802d8ec56 100644 --- a/libs/a2ui/src/index.ts +++ b/libs/a2ui/src/index.ts @@ -18,4 +18,6 @@ export { createA2uiMessageParser } from './lib/parser.js'; export type { A2uiMessageParser } from './lib/parser.js'; export { resolveDynamic } from './lib/resolve.js'; export type { A2uiScope } from './lib/resolve.js'; +export { createA2uiFunctionRegistry } from './lib/functions.js'; +export type { A2uiFunctionRegistry, A2uiFunctionImpl, A2uiFunctionContext } from './lib/functions.js'; export { isPathRef, isFunctionCall } from './lib/guards.js'; diff --git a/libs/a2ui/src/lib/functions.spec.ts b/libs/a2ui/src/lib/functions.spec.ts new file mode 100644 index 000000000..e4915c74e --- /dev/null +++ b/libs/a2ui/src/lib/functions.spec.ts @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, test, vi, afterEach } from 'vitest'; +import { createA2uiFunctionRegistry } from './functions'; +import { resolveDynamic } from './resolve'; + +const registry = createA2uiFunctionRegistry(); +const model = { + price: 1234.5, + count: 3, + name: 'Ada', + date: '2026-08-17T14:30:00', + flags: { a: true, b: false }, +}; + +function run(call: string, args: Record, m: Record = model): unknown { + return resolveDynamic({ call, args }, m, undefined, registry); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('formatNumber', () => { + test('formats with decimals and grouping', () => { + expect(run('formatNumber', { value: { path: '/price' }, decimals: 2, grouping: true })) + .toBe(new Intl.NumberFormat(undefined, { + minimumFractionDigits: 2, maximumFractionDigits: 2, useGrouping: true, + }).format(1234.5)); + }); + + test('grouping off', () => { + expect(run('formatNumber', { value: 1234.5, decimals: 0, grouping: false })) + .toBe(new Intl.NumberFormat(undefined, { + minimumFractionDigits: 0, maximumFractionDigits: 0, useGrouping: false, + }).format(1234.5)); + }); + + test('non-numeric value resolves to undefined', () => { + expect(run('formatNumber', { value: 'nope' })).toBeUndefined(); + }); +}); + +describe('formatCurrency', () => { + test('formats with ISO currency code', () => { + expect(run('formatCurrency', { value: { path: '/price' }, currency: 'USD' })) + .toBe(new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(1234.5)); + }); + + test('honors decimals', () => { + expect(run('formatCurrency', { value: 10, currency: 'EUR', decimals: 0 })) + .toBe(new Intl.NumberFormat(undefined, { + style: 'currency', currency: 'EUR', + minimumFractionDigits: 0, maximumFractionDigits: 0, + }).format(10)); + }); +}); + +describe('formatDate', () => { + test('formats ISO string with TR35 pattern', () => { + const out = run('formatDate', { value: '2026-08-17T00:00:00', format: 'yyyy-MM-dd' }); + expect(out).toBe('2026-08-17'); + }); + + test('month and weekday names', () => { + const out = run('formatDate', { value: '2026-08-17T00:00:00', format: 'EEEE, MMMM d, yyyy' }); + expect(out).toBe('Monday, August 17, 2026'); + }); + + test('12h time with meridiem', () => { + const out = run('formatDate', { value: '2026-08-17T14:05:09', format: 'h:mm a' }); + expect(out).toBe('2:05 PM'); + }); + + test('two-digit year and short month', () => { + expect(run('formatDate', { value: '2026-01-05T00:00:00', format: 'MMM d, yy' })).toBe('Jan 5, 26'); + }); + + test('epoch millis input', () => { + const d = new Date(2026, 0, 2, 0, 0, 0); + expect(run('formatDate', { value: d.getTime(), format: 'yyyy' })).toBe('2026'); + }); + + test('unparseable date resolves to undefined', () => { + expect(run('formatDate', { value: 'not a date', format: 'yyyy' })).toBeUndefined(); + }); +}); + +describe('pluralize', () => { + test('one vs other', () => { + expect(run('pluralize', { value: 1, one: 'item', other: 'items' })).toBe('item'); + expect(run('pluralize', { value: { path: '/count' }, one: 'item', other: 'items' })).toBe('items'); + }); + + test('zero category argument wins when provided', () => { + expect(run('pluralize', { value: 0, zero: 'nothing', one: 'item', other: 'items' })).toBe('nothing'); + }); + + test('falls back to other', () => { + expect(run('pluralize', { value: 5, other: 'things' })).toBe('things'); + }); +}); + +describe('logic', () => { + test('and', () => { + expect(run('and', { values: [true, { path: '/flags/a' }] })).toBe(true); + expect(run('and', { values: [true, { path: '/flags/b' }] })).toBe(false); + }); + + test('or', () => { + expect(run('or', { values: [{ path: '/flags/b' }, false] })).toBe(false); + expect(run('or', { values: [{ path: '/flags/b' }, true] })).toBe(true); + }); + + test('not', () => { + expect(run('not', { value: { path: '/flags/b' } })).toBe(true); + }); +}); + +describe('formatString interpolation', () => { + test('absolute path expression', () => { + expect(run('formatString', { value: 'Hello ${/name}!' })).toBe('Hello Ada!'); + }); + + test('relative path resolves against scope', () => { + const out = resolveDynamic( + { call: 'formatString', args: { value: 'Hi ${name}' } }, + { items: [{ name: 'Bo' }] }, + { basePath: '/items/0', item: undefined }, + registry, + ); + expect(out).toBe('Hi Bo'); + }); + + test('nested function call with named args', () => { + const out = run('formatString', { + value: "Due ${formatDate(value:${/date}, format:'yyyy-MM-dd')}", + }); + expect(out).toBe('Due 2026-08-17'); + }); + + test('multiple expressions and literal text', () => { + expect(run('formatString', { value: '${/name} has ${/count} items' })).toBe('Ada has 3 items'); + }); + + test('escaped \\${ stays literal', () => { + expect(run('formatString', { value: 'literal \\${/name}' })).toBe('literal ${/name}'); + }); + + test('unresolvable path interpolates empty', () => { + expect(run('formatString', { value: 'x=${/missing}!' })).toBe('x=!'); + }); + + test('quoted string and number args in nested calls', () => { + const out = run('formatString', { + value: "${pluralize(value:${/count}, one:'thing', other:'things')}", + }); + expect(out).toBe('things'); + }); +}); + +describe('registry behavior', () => { + test('unknown function resolves to undefined and warns once per name', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + expect(run('mysteryFn', { x: 1 })).toBeUndefined(); + expect(run('mysteryFn', { x: 2 })).toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + }); + + test('overrides extend the standard set', () => { + const custom = createA2uiFunctionRegistry({ + shout: (args, ctx) => String(ctx.resolveArg(args['value'])).toUpperCase(), + }); + expect(resolveDynamic({ call: 'shout', args: { value: { path: '/name' } } }, model, undefined, custom)) + .toBe('ADA'); + }); + + test('function args recurse through nested calls', () => { + expect(run('not', { value: { call: 'and', args: { values: [true, true] } } })).toBe(false); + }); +}); diff --git a/libs/a2ui/src/lib/functions.ts b/libs/a2ui/src/lib/functions.ts new file mode 100644 index 000000000..9fb7278b6 --- /dev/null +++ b/libs/a2ui/src/lib/functions.ts @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: MIT +// A2UI v0.9 client-side functions (basic catalog `functions` map). +// Arg shapes follow the official catalog schema at +// https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json. + +/** Execution context handed to every function implementation. */ +export interface A2uiFunctionContext { + /** Resolve a (possibly dynamic) argument value — bare literal, `{ path }` + * binding, or nested `{ call }` — against the current data model/scope. */ + resolveArg(value: unknown): unknown; + /** BCP 47 locale for Intl-based formatting; host default when undefined. */ + locale?: string; +} + +export type A2uiFunctionImpl = ( + args: Record, + ctx: A2uiFunctionContext, +) => unknown; + +export type A2uiFunctionRegistry = ReadonlyMap; + +function toNumber(v: unknown): number | undefined { + if (typeof v === 'number' && Number.isFinite(v)) return v; + if (typeof v === 'string' && v.trim() !== '') { + const n = Number(v); + if (Number.isFinite(n)) return n; + } + return undefined; +} + +function toDate(v: unknown): Date | undefined { + if (v instanceof Date) return Number.isNaN(v.getTime()) ? undefined : v; + if (typeof v === 'number' && Number.isFinite(v)) return new Date(v); + if (typeof v === 'string') { + const d = new Date(v); + return Number.isNaN(d.getTime()) ? undefined : d; + } + return undefined; +} + +// --- formatDate: Unicode TR35 pattern subset --- + +const DATE_TOKENS = [ + 'yyyy', 'yy', 'MMMM', 'MMM', 'MM', 'M', 'dd', 'd', 'EEEE', 'E', + 'HH', 'H', 'hh', 'h', 'mm', 'm', 'ss', 's', 'a', +] as const; + +function dateToken(token: string, d: Date, locale?: string): string { + const pad = (n: number) => String(n).padStart(2, '0'); + switch (token) { + case 'yyyy': return String(d.getFullYear()); + case 'yy': return pad(d.getFullYear() % 100); + case 'MMMM': return new Intl.DateTimeFormat(locale ?? 'en-US', { month: 'long' }).format(d); + case 'MMM': return new Intl.DateTimeFormat(locale ?? 'en-US', { month: 'short' }).format(d); + case 'MM': return pad(d.getMonth() + 1); + case 'M': return String(d.getMonth() + 1); + case 'dd': return pad(d.getDate()); + case 'd': return String(d.getDate()); + case 'EEEE': return new Intl.DateTimeFormat(locale ?? 'en-US', { weekday: 'long' }).format(d); + case 'E': return new Intl.DateTimeFormat(locale ?? 'en-US', { weekday: 'short' }).format(d); + case 'HH': return pad(d.getHours()); + case 'H': return String(d.getHours()); + case 'hh': return pad(((d.getHours() + 11) % 12) + 1); + case 'h': return String(((d.getHours() + 11) % 12) + 1); + case 'mm': return pad(d.getMinutes()); + case 'm': return String(d.getMinutes()); + case 'ss': return pad(d.getSeconds()); + case 's': return String(d.getSeconds()); + case 'a': return d.getHours() < 12 ? 'AM' : 'PM'; + default: return token; + } +} + +function formatDatePattern(d: Date, pattern: string, locale?: string): string { + let out = ''; + let i = 0; + while (i < pattern.length) { + const token = DATE_TOKENS.find((t) => pattern.startsWith(t, i)); + if (token) { + out += dateToken(token, d, locale); + i += token.length; + } else { + out += pattern[i]; + i += 1; + } + } + return out; +} + +// --- formatString: `${expression}` interpolation --- +// Expressions: JSON-pointer paths (absolute `/a/b` or relative `a/b`), +// nested named-arg function calls `fn(name:value, ...)`, quoted strings, +// numbers, booleans. `\${` escapes a literal `${`. The scanner is a +// single-pass state machine — no backtracking regexes (CodeQL ReDoS). + +interface ExprEnv { + ctx: A2uiFunctionContext; + registry: A2uiFunctionRegistry; + resolvePath(path: string): unknown; +} + +/** Find the `}` closing the `${` that starts at `start` (index of `$`), + * honoring nested `${...}` and single-quoted strings. Returns -1 if + * unterminated. */ +function findClosingBrace(s: string, start: number): number { + let depth = 0; + let i = start; + let inQuote = false; + while (i < s.length) { + const c = s[i]; + if (inQuote) { + if (c === "'") inQuote = false; + i += 1; + continue; + } + if (c === "'") { inQuote = true; i += 1; continue; } + if (c === '$' && s[i + 1] === '{') { depth += 1; i += 2; continue; } + if (c === '}') { + depth -= 1; + if (depth === 0) return i; + } + i += 1; + } + return -1; +} + +/** Split a call's argument list on top-level commas (nesting + quote aware). */ +function splitArgs(s: string): string[] { + const parts: string[] = []; + let depth = 0; + let inQuote = false; + let cur = ''; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (inQuote) { + cur += c; + if (c === "'") inQuote = false; + continue; + } + if (c === "'") { inQuote = true; cur += c; continue; } + if (c === '(' || c === '{') depth += 1; + else if (c === ')' || c === '}') depth -= 1; + else if (c === ',' && depth === 0) { + parts.push(cur); + cur = ''; + continue; + } + cur += c; + } + if (cur.trim().length > 0) parts.push(cur); + return parts; +} + +const CALL_HEAD = /^([A-Za-z_][A-Za-z0-9_]*)\(/; + +/** Evaluate one expression (the text inside `${...}`). */ +function evalExpr(raw: string, env: ExprEnv): unknown { + const s = raw.trim(); + if (s.length === 0) return undefined; + // Quoted string + if (s.startsWith("'") && s.endsWith("'") && s.length >= 2) { + return s.slice(1, -1); + } + if (s === 'true') return true; + if (s === 'false') return false; + const asNumber = toNumber(s); + if (asNumber !== undefined && /^-?[\d.]+$/.test(s)) return asNumber; + // Nested `${...}` wrapper + if (s.startsWith('${') && s.endsWith('}')) { + return evalExpr(s.slice(2, -1), env); + } + // Function call with named args + const head = CALL_HEAD.exec(s); + if (head && s.endsWith(')')) { + const name = head[1]; + const argsText = s.slice(head[0].length, -1); + const args: Record = {}; + for (const part of splitArgs(argsText)) { + const colon = topLevelColonIndex(part); + if (colon === -1) continue; + const key = part.slice(0, colon).trim(); + const valueText = part.slice(colon + 1).trim(); + if (key) args[key] = evalExpr(valueText, env); + } + const impl = env.registry.get(name); + if (!impl) { + warnUnknownFunction(name); + return undefined; + } + // Args are already evaluated to plain values here. + return impl(args, { ...env.ctx, resolveArg: (v) => v }); + } + // JSON-pointer path (absolute or relative) + return env.resolvePath(s); +} + +/** Index of the first top-level `:` (outside quotes/parens/braces). */ +function topLevelColonIndex(s: string): number { + let depth = 0; + let inQuote = false; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (inQuote) { + if (c === "'") inQuote = false; + continue; + } + if (c === "'") inQuote = true; + else if (c === '(' || c === '{') depth += 1; + else if (c === ')' || c === '}') depth -= 1; + else if (c === ':' && depth === 0) return i; + } + return -1; +} + +function interpolate(template: string, env: ExprEnv): string { + let out = ''; + let i = 0; + while (i < template.length) { + if (template[i] === '\\' && template.startsWith('${', i + 1)) { + out += '${'; + i += 3; + continue; + } + if (template[i] === '$' && template[i + 1] === '{') { + const close = findClosingBrace(template, i); + if (close === -1) { + out += template.slice(i); + break; + } + const value = evalExpr(template.slice(i + 2, close), env); + out += value == null ? '' : String(value); + i = close + 1; + continue; + } + out += template[i]; + i += 1; + } + return out; +} + +// --- Standard function implementations --- + +const warnedFunctions = new Set(); +function warnUnknownFunction(name: string): void { + if (warnedFunctions.has(name)) return; + warnedFunctions.add(name); + console.warn(`[a2ui] unknown client-side function "${name}" — resolving to undefined`); +} + +function numberFormatOptions( + args: Record, + ctx: A2uiFunctionContext, + extra?: Intl.NumberFormatOptions, +): Intl.NumberFormatOptions { + const options: Intl.NumberFormatOptions = { ...extra }; + const decimals = toNumber(ctx.resolveArg(args['decimals'])); + if (decimals !== undefined) { + options.minimumFractionDigits = decimals; + options.maximumFractionDigits = decimals; + } + const grouping = ctx.resolveArg(args['grouping']); + if (typeof grouping === 'boolean') options.useGrouping = grouping; + return options; +} + +const STANDARD_FUNCTIONS: Record = { + formatString(args, ctx) { + const template = ctx.resolveArg(args['value']); + if (typeof template !== 'string') return undefined; + const env: ExprEnv = { + ctx, + registry: currentRegistry ?? new Map(), + resolvePath: (p) => ctx.resolveArg({ path: p }), + }; + return interpolate(template, env); + }, + formatNumber(args, ctx) { + const value = toNumber(ctx.resolveArg(args['value'])); + if (value === undefined) return undefined; + return new Intl.NumberFormat(ctx.locale, numberFormatOptions(args, ctx)).format(value); + }, + formatCurrency(args, ctx) { + const value = toNumber(ctx.resolveArg(args['value'])); + const currency = ctx.resolveArg(args['currency']); + if (value === undefined || typeof currency !== 'string' || currency.length === 0) return undefined; + try { + return new Intl.NumberFormat( + ctx.locale, + numberFormatOptions(args, ctx, { style: 'currency', currency }), + ).format(value); + } catch { + return undefined; + } + }, + formatDate(args, ctx) { + const date = toDate(ctx.resolveArg(args['value'])); + const format = ctx.resolveArg(args['format']); + if (!date || typeof format !== 'string') return undefined; + return formatDatePattern(date, format, ctx.locale); + }, + pluralize(args, ctx) { + const value = toNumber(ctx.resolveArg(args['value'])); + if (value === undefined) return undefined; + const category = new Intl.PluralRules(ctx.locale).select(value); + const explicitZero = value === 0 && args['zero'] !== undefined ? 'zero' : undefined; + const pick = explicitZero ?? category; + const chosen = args[pick] !== undefined ? args[pick] : args['other']; + const resolved = ctx.resolveArg(chosen); + return resolved === undefined ? undefined : String(resolved); + }, + and(args, ctx) { + const values = args['values']; + if (!Array.isArray(values)) return undefined; + return values.every((v) => ctx.resolveArg(v) === true); + }, + or(args, ctx) { + const values = args['values']; + if (!Array.isArray(values)) return undefined; + return values.some((v) => ctx.resolveArg(v) === true); + }, + not(args, ctx) { + return ctx.resolveArg(args['value']) !== true; + }, +}; + +// formatString needs the registry that owns it to evaluate nested calls; +// tracked per-invocation via resolveDynamic's wiring (see resolve.ts), +// with a module fallback for direct registry use. +let currentRegistry: A2uiFunctionRegistry | null = null; + +/** @internal Used by resolveDynamic to make nested `${fn(...)}` calls inside + * formatString dispatch through the same registry. */ +export function withActiveRegistry(registry: A2uiFunctionRegistry, fn: () => T): T { + const prev = currentRegistry; + currentRegistry = registry; + try { + return fn(); + } finally { + currentRegistry = prev; + } +} + +/** @internal One-time warning helper shared with resolveDynamic. */ +export function warnUnknownA2uiFunction(name: string): void { + warnUnknownFunction(name); +} + +/** + * Creates an A2UI client-side function registry containing the standard + * basic-catalog functions (`formatString`, `formatNumber`, `formatCurrency`, + * `formatDate`, `pluralize`, `and`, `or`, `not`), optionally extended or + * overridden with custom implementations. + * + * @example + * ```ts + * const registry = createA2uiFunctionRegistry(); + * resolveDynamic({ call: 'formatCurrency', args: { value: 42, currency: 'USD' } }, {}, undefined, registry); + * ``` + */ +export function createA2uiFunctionRegistry( + overrides?: Record, +): A2uiFunctionRegistry { + const map = new Map(Object.entries(STANDARD_FUNCTIONS)); + if (overrides) { + for (const [name, impl] of Object.entries(overrides)) map.set(name, impl); + } + return map; +} diff --git a/libs/a2ui/src/lib/resolve.ts b/libs/a2ui/src/lib/resolve.ts index 5f8027e5f..8641ac72c 100644 --- a/libs/a2ui/src/lib/resolve.ts +++ b/libs/a2ui/src/lib/resolve.ts @@ -1,6 +1,10 @@ // SPDX-License-Identifier: MIT import { getByPointer } from './pointer.js'; import { isFunctionCall, isPathRef } from './guards.js'; +import { + withActiveRegistry, warnUnknownA2uiFunction, + type A2uiFunctionRegistry, +} from './functions.js'; export interface A2uiScope { basePath: string; @@ -23,8 +27,10 @@ function resolvePathRef( * * Bare literals (strings, numbers, booleans) pass through unchanged, `{ path }` * references read from the model by JSON-pointer path, arrays resolve - * element-wise, and client-side function calls (`{ call }`) resolve to - * `undefined` until function execution ships. Unrecognized plain objects pass + * element-wise, and client-side function calls (`{ call }`) execute through the + * provided function registry — argument values resolve recursively, so args may + * themselves be bindings or nested calls. Without a registry (or for unknown + * function names) calls resolve to `undefined`. Unrecognized plain objects pass * through unchanged. * * @example @@ -32,20 +38,37 @@ function resolvePathRef( * const model = { customer: { name: 'Ada' } }; * resolveDynamic({ path: '/customer/name' }, model); // 'Ada' * resolveDynamic('Checkout', model); // 'Checkout' + * resolveDynamic( + * { call: 'formatString', args: { value: 'Hi ${/customer/name}' } }, + * model, undefined, createA2uiFunctionRegistry(), + * ); // 'Hi Ada' * ``` */ export function resolveDynamic( value: unknown, model: Record, scope?: A2uiScope, + registry?: A2uiFunctionRegistry, ): unknown { if (value == null) return value; - if (Array.isArray(value)) return value.map(item => resolveDynamic(item, model, scope)); + if (Array.isArray(value)) return value.map(item => resolveDynamic(item, model, scope, registry)); - // Client-side function call — execution ships in a later phase. Checked - // before path refs so `{ call, args: { path: ... } }`-style args never - // masquerade as bindings. - if (isFunctionCall(value)) return undefined; + // Client-side function call. Checked before path refs so + // `{ call, args: { path: ... } }`-style args never masquerade as bindings. + if (isFunctionCall(value)) { + if (!registry) return undefined; + const impl = registry.get(value.call); + if (!impl) { + warnUnknownA2uiFunction(value.call); + return undefined; + } + const args = (value.args ?? {}) as Record; + return withActiveRegistry(registry, () => + impl(args, { + resolveArg: (v) => resolveDynamic(v, model, scope, registry), + }), + ); + } // Path reference if (isPathRef(value)) return resolvePathRef(value, model, scope); diff --git a/libs/chat/src/lib/a2ui/surface-to-spec.spec.ts b/libs/chat/src/lib/a2ui/surface-to-spec.spec.ts index f45dd9d51..bcf07fd53 100644 --- a/libs/chat/src/lib/a2ui/surface-to-spec.spec.ts +++ b/libs/chat/src/lib/a2ui/surface-to-spec.spec.ts @@ -37,9 +37,28 @@ describe('surfaceToSpec (v0.9)', () => { expect(spec.state).toEqual({ greeting: 'Hello World' }); }); - it('omits function-call props until Phase 2 ships execution', () => { + it('resolves function-call props through the standard registry', () => { + const surface = makeSurface( + [c({ id: 'root', component: 'Text', text: { call: 'formatString', args: { value: 'Total: ${/total}' } } })], + { total: 42 }, + ); + const spec = surfaceToSpec(surface)!; + expect(spec.elements['root'].props['text']).toBe('Total: 42'); + }); + + it('resolves formatCurrency props against the data model', () => { + const surface = makeSurface( + [c({ id: 'root', component: 'Text', text: { call: 'formatCurrency', args: { value: { path: '/price' }, currency: 'USD' } } })], + { price: 10 }, + ); + const spec = surfaceToSpec(surface)!; + expect(spec.elements['root'].props['text']) + .toBe(new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(10)); + }); + + it('omits props whose function call is unknown', () => { const surface = makeSurface([ - c({ id: 'root', component: 'Text', text: { call: 'formatString', args: { value: 'x' } } }), + c({ id: 'root', component: 'Text', text: { call: 'mysteryFn', args: {} } }), ]); const spec = surfaceToSpec(surface)!; expect('text' in spec.elements['root'].props).toBe(false); @@ -164,14 +183,17 @@ describe('surfaceToSpec (v0.9)', () => { expect(params['context']).toEqual({ email: 'alice@example.com' }); }); - it('functionCall actions emit no on binding (Phase 2)', () => { + it('functionCall actions wire to the local-action handler', () => { const surface = makeSurface([ c({ id: 'root', component: 'Button', child: 'lbl', action: { functionCall: { call: 'openUrl', args: { url: 'https://x' } } } }), c({ id: 'lbl', component: 'Text', text: 'Open' }), ]); const spec = surfaceToSpec(surface)!; - expect(spec.elements['root'].on).toBeUndefined(); + expect(spec.elements['root'].on!['click']).toEqual({ + action: 'a2ui:localAction', + params: { call: 'openUrl', args: { url: 'https://x' } }, + }); }); it('passes through elements without actions unchanged', () => { diff --git a/libs/chat/src/lib/a2ui/surface-to-spec.ts b/libs/chat/src/lib/a2ui/surface-to-spec.ts index 65d85c701..737b048c7 100644 --- a/libs/chat/src/lib/a2ui/surface-to-spec.ts +++ b/libs/chat/src/lib/a2ui/surface-to-spec.ts @@ -3,7 +3,13 @@ import type { Spec, UIElement } from '@json-render/core'; import type { A2uiSurface, A2uiAction, A2uiChildren, } from '@threadplane/a2ui'; -import { resolveDynamic, getByPointer, isPathRef, isFunctionCall } from '@threadplane/a2ui'; +import { + resolveDynamic, getByPointer, isPathRef, isFunctionCall, + createA2uiFunctionRegistry, +} from '@threadplane/a2ui'; + +/** Shared standard-function registry (formatString, formatters, logic). */ +const A2UI_FUNCTIONS = createA2uiFunctionRegistry(); /** Keys that are protocol structure (base fields + child/action wiring), * not renderable props. */ @@ -21,7 +27,20 @@ function resolveAction( ): RenderedAction | undefined { if (!action || typeof action !== 'object') return undefined; if (!('event' in action)) { - // functionCall actions execute client-side (Phase 2); nothing to wire yet. + // Local client-side function action — routed to the surface component's + // built-in `a2ui:localAction` handler (openUrl et al.). + if ('functionCall' in action && action.functionCall + && typeof action.functionCall.call === 'string') { + return { + click: { + action: 'a2ui:localAction', + params: { + call: action.functionCall.call, + args: action.functionCall.args ?? {}, + }, + }, + } as RenderedAction; + } return undefined; } const event = action.event; @@ -29,7 +48,7 @@ function resolveAction( const resolvedContext: Record = {}; if (event.context && typeof event.context === 'object') { for (const [key, value] of Object.entries(event.context)) { - resolvedContext[key] = resolveDynamic(value, surface.dataModel); + resolvedContext[key] = resolveDynamic(value, surface.dataModel, undefined, A2UI_FUNCTIONS); } } return { @@ -89,10 +108,10 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null { bindings[key] = path; resolvedProps[key] = { $bindState: path }; } else if (isFunctionCall(value)) { - // Client-side function values ship in Phase 2 — omit until then. - continue; + const resolved = resolveDynamic(value, surface.dataModel, undefined, A2UI_FUNCTIONS); + if (resolved !== undefined) resolvedProps[key] = resolved; } else { - resolvedProps[key] = resolveDynamic(value, surface.dataModel); + resolvedProps[key] = resolveDynamic(value, surface.dataModel, undefined, A2UI_FUNCTIONS); } } if (Object.keys(bindings).length > 0) { @@ -116,13 +135,17 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null { children = items.map(t => t.child); // Resolve tab titles and pass them as a plain string array for the Tabs component's tab bar. resolvedProps['tabTitles'] = items.map(t => - t.title !== undefined ? String(resolveDynamic(t.title, surface.dataModel)) : '', + t.title !== undefined + ? String(resolveDynamic(t.title, surface.dataModel, undefined, A2UI_FUNCTIONS)) + : '', ); } else if (type === 'ChoicePicker') { // Resolve options[*].label (DynamicString) so the component receives plain strings. const opts = (rawProps as { options?: { label?: unknown; value: string }[] }).options ?? []; resolvedProps['options'] = opts.map(o => ({ - label: o.label !== undefined ? String(resolveDynamic(o.label, surface.dataModel)) : '', + label: o.label !== undefined + ? String(resolveDynamic(o.label, surface.dataModel, undefined, A2UI_FUNCTIONS)) + : '', value: o.value, })); } else { @@ -140,7 +163,7 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null { const itemProps: Record = {}; for (const [k, v] of Object.entries(tRaw)) { if (RESERVED_PROP_KEYS.has(k)) continue; - itemProps[k] = resolveDynamic(v, surface.dataModel, scope); + itemProps[k] = resolveDynamic(v, surface.dataModel, scope, A2UI_FUNCTIONS); } elements[`${t.componentId}__${i}`] = { type: tType, props: itemProps }; } diff --git a/libs/chat/src/lib/a2ui/surface.component.ts b/libs/chat/src/lib/a2ui/surface.component.ts index 56c232709..6ed485f6f 100644 --- a/libs/chat/src/lib/a2ui/surface.component.ts +++ b/libs/chat/src/lib/a2ui/surface.component.ts @@ -134,7 +134,7 @@ export class A2uiSurfaceComponent { // Built-in fallback if (call === 'openUrl' && typeof globalThis.window !== 'undefined') { - globalThis.window.open(String(args['url'] ?? ''), '_blank'); + globalThis.window.open(String(args['url'] ?? ''), '_blank', 'noopener'); } return undefined; }, From 7e372aac1adc98c278f2c801229209ea9ef9c900 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 17 Aug 2026 09:34:51 -0700 Subject: [PATCH 3/3] =?UTF-8?q?docs(a2ui):=20client-side=20functions=20are?= =?UTF-8?q?=20live=20=E2=80=94=20prompts,=20guides,=20api-docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../content/docs/a2ui/api/api-docs.json | 76 ++++++++++++++++++- .../a2ui/getting-started/introduction.mdx | 2 +- .../docs/a2ui/getting-started/quickstart.mdx | 2 +- .../content/docs/a2ui/guides/data-model.mdx | 2 +- .../docs/a2ui/guides/message-protocol.mdx | 2 +- .../a2ui/reference/parser-resolver-guards.mdx | 2 +- .../content/docs/a2ui/reference/schema.mdx | 2 +- .../content/docs/chat/a2ui/catalog.mdx | 2 +- .../content/docs/chat/a2ui/overview.mdx | 2 +- .../docs/chat/a2ui/surface-component.mdx | 4 +- examples/ag-ui/python/src/schemas/a2ui_v09.py | 24 +++++- examples/chat/python/src/schemas/a2ui_v09.py | 24 +++++- libs/a2ui/README.md | 2 +- 13 files changed, 128 insertions(+), 18 deletions(-) diff --git a/apps/website/content/docs/a2ui/api/api-docs.json b/apps/website/content/docs/a2ui/api/api-docs.json index 7005841de..ed322a41f 100644 --- a/apps/website/content/docs/a2ui/api/api-docs.json +++ b/apps/website/content/docs/a2ui/api/api-docs.json @@ -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", @@ -1465,6 +1494,20 @@ "signature": "A2uiCatalogComponent | A2uiComponentBase & Record", "examples": [] }, + { + "name": "A2uiFunctionImpl", + "kind": "type", + "description": "", + "signature": "(args: Record, ctx: A2uiFunctionContext) => unknown", + "examples": [] + }, + { + "name": "A2uiFunctionRegistry", + "kind": "type", + "description": "", + "signature": "ReadonlyMap", + "examples": [] + }, { "name": "A2uiMessage", "kind": "type", @@ -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): A2uiFunctionRegistry", + "params": [ + { + "name": "overrides", + "type": "Record", + "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", @@ -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, 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, scope: A2uiScope, registry: A2uiFunctionRegistry): unknown", "params": [ { "name": "value", @@ -1657,6 +1721,12 @@ "type": "A2uiScope", "description": "", "optional": true + }, + { + "name": "registry", + "type": "A2uiFunctionRegistry", + "description": "", + "optional": true } ], "returns": { @@ -1664,7 +1734,7 @@ "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```" ] }, { diff --git a/apps/website/content/docs/a2ui/getting-started/introduction.mdx b/apps/website/content/docs/a2ui/getting-started/introduction.mdx index 7d0027f55..0071a07a9 100644 --- a/apps/website/content/docs/a2ui/getting-started/introduction.mdx +++ b/apps/website/content/docs/a2ui/getting-started/introduction.mdx @@ -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. diff --git a/apps/website/content/docs/a2ui/getting-started/quickstart.mdx b/apps/website/content/docs/a2ui/getting-started/quickstart.mdx index 1105ecec0..77c7b65de 100644 --- a/apps/website/content/docs/a2ui/getting-started/quickstart.mdx +++ b/apps/website/content/docs/a2ui/getting-started/quickstart.mdx @@ -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 diff --git a/apps/website/content/docs/a2ui/guides/data-model.mdx b/apps/website/content/docs/a2ui/guides/data-model.mdx index 13ab67b65..142237d58 100644 --- a/apps/website/content/docs/a2ui/guides/data-model.mdx +++ b/apps/website/content/docs/a2ui/guides/data-model.mdx @@ -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. diff --git a/apps/website/content/docs/a2ui/guides/message-protocol.mdx b/apps/website/content/docs/a2ui/guides/message-protocol.mdx index 0412cf26d..bea9f91b9 100644 --- a/apps/website/content/docs/a2ui/guides/message-protocol.mdx +++ b/apps/website/content/docs/a2ui/guides/message-protocol.mdx @@ -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? diff --git a/apps/website/content/docs/a2ui/reference/parser-resolver-guards.mdx b/apps/website/content/docs/a2ui/reference/parser-resolver-guards.mdx index 61fb261fb..1870304bf 100644 --- a/apps/website/content/docs/a2ui/reference/parser-resolver-guards.mdx +++ b/apps/website/content/docs/a2ui/reference/parser-resolver-guards.mdx @@ -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 | diff --git a/apps/website/content/docs/a2ui/reference/schema.mdx b/apps/website/content/docs/a2ui/reference/schema.mdx index e7a7b7ae5..a463bd7f8 100644 --- a/apps/website/content/docs/a2ui/reference/schema.mdx +++ b/apps/website/content/docs/a2ui/reference/schema.mdx @@ -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 diff --git a/apps/website/content/docs/chat/a2ui/catalog.mdx b/apps/website/content/docs/chat/a2ui/catalog.mdx index a7e37d848..36e6b75d1 100644 --- a/apps/website/content/docs/chat/a2ui/catalog.mdx +++ b/apps/website/content/docs/chat/a2ui/catalog.mdx @@ -431,7 +431,7 @@ Renders an HTML5 `