diff --git a/.claude/launch.json b/.claude/launch.json index e6dfd6054..e8808171c 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,13 +4,19 @@ { "name": "ag-ui-itinerary", "runtimeExecutable": "/bin/bash", - "runtimeArgs": ["-c", "export PATH=/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH && export GOOGLE_MAPS_API_KEY=$(grep -E '^GOOGLE_MAPS_API_KEY=' /Users/blove/repos/angular-agent-framework/.env | head -1 | cut -d= -f2- | tr -d '\"' | tr -d \"'\") && export GOOGLE_MAPS_MAP_ID=86d464ea7d5306034fe2a254 && npx nx serve examples-ag-ui-angular --port 4222"], + "runtimeArgs": [ + "-c", + "export PATH=/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH && export GOOGLE_MAPS_API_KEY=$(grep -E '^GOOGLE_MAPS_API_KEY=' /Users/blove/repos/angular-agent-framework/.env | head -1 | cut -d= -f2- | tr -d '\"' | tr -d \"'\") && export GOOGLE_MAPS_MAP_ID=86d464ea7d5306034fe2a254 && npx nx serve examples-ag-ui-angular --port 4222" + ], "port": 4222 }, { "name": "ag-ui-backend", "runtimeExecutable": "/bin/bash", - "runtimeArgs": ["-c", "cd examples/ag-ui/python && export OPENAI_API_KEY=$(grep -E '^OPENAI_API_KEY=' /Users/blove/repos/angular-agent-framework/.env | head -1 | cut -d= -f2- | tr -d '\"' | tr -d \"'\") && unset AG_UI_INTERNAL_TOKEN && uv run uvicorn src.server:app --port 8000"], + "runtimeArgs": [ + "-c", + "cd examples/ag-ui/python && export OPENAI_API_KEY=$(grep -E '^OPENAI_API_KEY=' /Users/blove/repos/angular-agent-framework/.env | head -1 | cut -d= -f2- | tr -d '\"' | tr -d \"'\") && unset AG_UI_INTERNAL_TOKEN && uv run uvicorn src.server:app --port 8000" + ], "port": 8000 }, { @@ -77,6 +83,24 @@ "export PATH=/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH && npx nx serve cockpit-chat-generative-ui-angular --port 4500" ], "port": 4500 + }, + { + "name": "chat-backend", + "runtimeExecutable": "/bin/bash", + "runtimeArgs": [ + "-c", + "cd examples/chat/python && export PATH=/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH && export OPENAI_API_KEY=$(grep -E '^OPENAI_API_KEY=' /Users/blove/repos/angular-agent-framework/.env | head -1 | cut -d= -f2- | tr -d '\"' | tr -d \"'\") && uv run langgraph dev --port 2024 --no-browser" + ], + "port": 2024 + }, + { + "name": "chat-frontend", + "runtimeExecutable": "/bin/bash", + "runtimeArgs": [ + "-c", + "export PATH=/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH && npx nx serve examples-chat-angular --port 4200" + ], + "port": 4200 } ] } \ No newline at end of file diff --git a/apps/website/content/blog/2026-08-09-agentic-ui-in-angular-production-patterns.mdx b/apps/website/content/blog/2026-08-09-agentic-ui-in-angular-production-patterns.mdx index 64433da55..10dc84de2 100644 --- a/apps/website/content/blog/2026-08-09-agentic-ui-in-angular-production-patterns.mdx +++ b/apps/website/content/blog/2026-08-09-agentic-ui-in-angular-production-patterns.mdx @@ -156,7 +156,7 @@ That boundary keeps accessibility, localization, analytics, validation, and them It also limits what the agent can render. An unregistered type can't instantiate an Angular component. -Threadplane supports this with a `ViewRegistry` for json-render and A2UI v1 surfaces. +Threadplane supports this with a `ViewRegistry` for json-render and A2UI surfaces. You can add, override, or remove components as the product evolves; the [generative UI guide](/docs/chat/guides/generative-ui) and [custom catalog patterns](/docs/chat/guides/custom-catalogs) show how. The tradeoff is intentional constraint. diff --git a/apps/website/content/docs/a2ui/api/api-docs.json b/apps/website/content/docs/a2ui/api/api-docs.json index 7f76b6fd1..7005841de 100644 --- a/apps/website/content/docs/a2ui/api/api-docs.json +++ b/apps/website/content/docs/a2ui/api/api-docs.json @@ -1,17 +1,23 @@ [ { - "name": "A2uiAction", + "name": "A2uiActionMessage", "kind": "interface", "description": "", "properties": [ { - "name": "context", - "type": "A2uiActionContextEntry[]", + "name": "action", + "type": "object", + "description": "", + "optional": false + }, + { + "name": "metadata", + "type": "object", "description": "", "optional": true }, { - "name": "name", + "name": "version", "type": "string", "description": "", "optional": false @@ -20,137 +26,345 @@ "examples": [] }, { - "name": "A2uiActionContextEntry", + "name": "A2uiAudioPlayer", "kind": "interface", "description": "", "properties": [ { - "name": "key", + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", + "optional": true + }, + { + "name": "catalogId", "type": "string", + "description": "Overrides the surface's default catalog for this component.", + "optional": true + }, + { + "name": "component", + "type": "\"AudioPlayer\"", "description": "", "optional": false }, { - "name": "value", - "type": "DynamicString | DynamicNumber | DynamicBoolean", + "name": "description", + "type": "DynamicString", + "description": "", + "optional": true + }, + { + "name": "id", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "url", + "type": "DynamicString", "description": "", "optional": false + }, + { + "name": "weight", + "type": "number", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", + "optional": true } ], "examples": [] }, { - "name": "A2uiActionMessage", + "name": "A2uiButton", "kind": "interface", "description": "", "properties": [ + { + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", + "optional": true + }, { "name": "action", - "type": "object", + "type": "A2uiAction", "description": "", "optional": false }, { - "name": "metadata", - "type": "object", - "description": "", + "name": "catalogId", + "type": "string", + "description": "Overrides the surface's default catalog for this component.", "optional": true }, { - "name": "version", - "type": "\"v1\"", + "name": "child", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "component", + "type": "\"Button\"", + "description": "", + "optional": false + }, + { + "name": "id", + "type": "string", "description": "", "optional": false + }, + { + "name": "variant", + "type": "\"default\" | \"primary\" | \"borderless\"", + "description": "", + "optional": true + }, + { + "name": "weight", + "type": "number", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", + "optional": true } ], "examples": [] }, { - "name": "A2uiAudioPlayer", + "name": "A2uiCard", "kind": "interface", "description": "", "properties": [ { - "name": "autoPlay", - "type": "boolean", - "description": "", + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", "optional": true }, { - "name": "controls", - "type": "boolean", - "description": "", + "name": "catalogId", + "type": "string", + "description": "Overrides the surface's default catalog for this component.", "optional": true }, { - "name": "url", - "type": "DynamicString", + "name": "child", + "type": "string", "description": "", "optional": false + }, + { + "name": "component", + "type": "\"Card\"", + "description": "", + "optional": false + }, + { + "name": "id", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "weight", + "type": "number", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", + "optional": true } ], "examples": [] }, { - "name": "A2uiBeginRendering", + "name": "A2uiCheck", "kind": "interface", "description": "", "properties": [ { - "name": "root", + "name": "args", + "type": "Record", + "description": "", + "optional": true + }, + { + "name": "call", "type": "string", "description": "", "optional": false }, { - "name": "styles", - "type": "object", + "name": "message", + "type": "string", "description": "", "optional": true + } + ], + "examples": [] + }, + { + "name": "A2uiCheckable", + "kind": "interface", + "description": "Mixin for input components that support client-side validation checks.", + "properties": [ + { + "name": "checks", + "type": "A2uiCheck[]", + "description": "", + "optional": true + } + ], + "examples": [] + }, + { + "name": "A2uiCheckBox", + "kind": "interface", + "description": "Mixin for input components that support client-side validation checks.", + "properties": [ + { + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", + "optional": true }, { - "name": "surfaceId", + "name": "catalogId", "type": "string", + "description": "Overrides the surface's default catalog for this component.", + "optional": true + }, + { + "name": "checks", + "type": "A2uiCheck[]", + "description": "", + "optional": true + }, + { + "name": "component", + "type": "\"CheckBox\"", "description": "", "optional": false + }, + { + "name": "id", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "label", + "type": "DynamicString", + "description": "", + "optional": false + }, + { + "name": "value", + "type": "DynamicBoolean", + "description": "", + "optional": false + }, + { + "name": "weight", + "type": "number", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", + "optional": true } ], "examples": [] }, { - "name": "A2uiButton", + "name": "A2uiChoicePicker", "kind": "interface", - "description": "", + "description": "Mixin for input components that support client-side validation checks.", "properties": [ { - "name": "action", - "type": "A2uiAction", + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", + "optional": true + }, + { + "name": "catalogId", + "type": "string", + "description": "Overrides the surface's default catalog for this component.", + "optional": true + }, + { + "name": "checks", + "type": "A2uiCheck[]", + "description": "", + "optional": true + }, + { + "name": "component", + "type": "\"ChoicePicker\"", "description": "", "optional": false }, { - "name": "child", + "name": "displayStyle", + "type": "\"checkbox\" | \"chips\"", + "description": "", + "optional": true + }, + { + "name": "filterable", + "type": "boolean", + "description": "", + "optional": true + }, + { + "name": "id", "type": "string", "description": "", "optional": false }, { - "name": "primary", - "type": "boolean", + "name": "label", + "type": "DynamicString", "description": "", "optional": true + }, + { + "name": "options", + "type": "object[]", + "description": "", + "optional": false + }, + { + "name": "value", + "type": "DynamicStringList", + "description": "", + "optional": false + }, + { + "name": "variant", + "type": "\"mutuallyExclusive\" | \"multipleSelection\"", + "description": "", + "optional": true + }, + { + "name": "weight", + "type": "number", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", + "optional": true } ], "examples": [] }, { - "name": "A2uiCard", + "name": "A2uiClientCapabilities", "kind": "interface", "description": "", "properties": [ { - "name": "child", - "type": "string", + "name": "inlineCatalogs", + "type": "unknown[]", + "description": "", + "optional": true + }, + { + "name": "supportedCatalogIds", + "type": "string[]", "description": "", "optional": false } @@ -158,71 +372,139 @@ "examples": [] }, { - "name": "A2uiCheckBox", + "name": "A2uiClientDataModel", "kind": "interface", "description": "", "properties": [ { - "name": "action", - "type": "A2uiAction", + "name": "surfaces", + "type": "Record>", "description": "", + "optional": false + } + ], + "examples": [] + }, + { + "name": "A2uiColumn", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", "optional": true }, { - "name": "checked", - "type": "DynamicBoolean", + "name": "align", + "type": "A2uiAlign", + "description": "", + "optional": true + }, + { + "name": "catalogId", + "type": "string", + "description": "Overrides the surface's default catalog for this component.", + "optional": true + }, + { + "name": "children", + "type": "A2uiChildren", "description": "", "optional": false }, { - "name": "label", - "type": "DynamicString", + "name": "component", + "type": "\"Column\"", "description": "", "optional": false + }, + { + "name": "id", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "justify", + "type": "A2uiJustify", + "description": "", + "optional": true + }, + { + "name": "weight", + "type": "number", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", + "optional": true } ], "examples": [] }, { - "name": "A2uiClientDataModel", + "name": "A2uiComponentBase", "kind": "interface", "description": "", "properties": [ { - "name": "surfaces", - "type": "Record>", + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", + "optional": true + }, + { + "name": "catalogId", + "type": "string", + "description": "Overrides the surface's default catalog for this component.", + "optional": true + }, + { + "name": "component", + "type": "string", "description": "", "optional": false }, { - "name": "version", - "type": "\"v1\"", + "name": "id", + "type": "string", "description": "", "optional": false + }, + { + "name": "weight", + "type": "number", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", + "optional": true } ], "examples": [] }, { - "name": "A2uiColumn", + "name": "A2uiCreateSurface", "kind": "interface", "description": "", "properties": [ { - "name": "alignment", - "type": "\"start\" | \"center\" | \"end\" | \"stretch\"", + "name": "catalogId", + "type": "string", "description": "", + "optional": false + }, + { + "name": "sendDataModel", + "type": "boolean", + "description": "When true, the client attaches the surface's full data model to every outbound message.", "optional": true }, { - "name": "children", - "type": "A2uiChildren", + "name": "surfaceId", + "type": "string", "description": "", "optional": false }, { - "name": "gap", - "type": "number", + "name": "theme", + "type": "A2uiTheme", "description": "", "optional": true } @@ -230,88 +512,156 @@ "examples": [] }, { - "name": "A2uiComponent", + "name": "A2uiDateTimeInput", "kind": "interface", - "description": "", + "description": "Mixin for input components that support client-side validation checks.", "properties": [ + { + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", + "optional": true + }, + { + "name": "catalogId", + "type": "string", + "description": "Overrides the surface's default catalog for this component.", + "optional": true + }, + { + "name": "checks", + "type": "A2uiCheck[]", + "description": "", + "optional": true + }, { "name": "component", - "type": "A2uiComponentDef", + "type": "\"DateTimeInput\"", "description": "", "optional": false }, + { + "name": "enableDate", + "type": "boolean", + "description": "", + "optional": true + }, + { + "name": "enableTime", + "type": "boolean", + "description": "", + "optional": true + }, { "name": "id", "type": "string", "description": "", "optional": false }, + { + "name": "label", + "type": "DynamicString", + "description": "", + "optional": true + }, + { + "name": "max", + "type": "DynamicString", + "description": "", + "optional": true + }, + { + "name": "min", + "type": "DynamicString", + "description": "", + "optional": true + }, + { + "name": "value", + "type": "DynamicString", + "description": "ISO 8601 value.", + "optional": false + }, { "name": "weight", "type": "number", - "description": "", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", "optional": true } ], "examples": [] }, { - "name": "A2uiDataModelEntry", + "name": "A2uiDeleteSurface", "kind": "interface", "description": "", "properties": [ { - "name": "key", + "name": "surfaceId", "type": "string", "description": "", "optional": false + } + ], + "examples": [] + }, + { + "name": "A2uiDivider", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", + "optional": true }, { - "name": "valueBoolean", - "type": "boolean", + "name": "axis", + "type": "\"vertical\" | \"horizontal\"", "description": "", "optional": true }, { - "name": "valueMap", - "type": "A2uiDataModelEntry[]", - "description": "", + "name": "catalogId", + "type": "string", + "description": "Overrides the surface's default catalog for this component.", "optional": true }, { - "name": "valueNumber", - "type": "number", + "name": "component", + "type": "\"Divider\"", "description": "", - "optional": true + "optional": false }, { - "name": "valueString", + "name": "id", "type": "string", "description": "", + "optional": false + }, + { + "name": "weight", + "type": "number", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", "optional": true } ], "examples": [] }, { - "name": "A2uiDataModelUpdate", + "name": "A2uiErrorMessage", "kind": "interface", "description": "", "properties": [ { - "name": "contents", - "type": "A2uiDataModelEntry[]", + "name": "error", + "type": "object", "description": "", "optional": false }, { - "name": "path", - "type": "string", - "description": "", - "optional": true - }, - { - "name": "surfaceId", + "name": "version", "type": "string", "description": "", "optional": false @@ -320,45 +670,27 @@ "examples": [] }, { - "name": "A2uiDateTimeInput", + "name": "A2uiEventAction", "kind": "interface", - "description": "", + "description": "Dispatches a named event (with optional context) to the agent.", "properties": [ { - "name": "enableDate", - "type": "boolean", - "description": "", - "optional": true - }, - { - "name": "enableTime", - "type": "boolean", - "description": "", - "optional": true - }, - { - "name": "label", - "type": "DynamicString", + "name": "event", + "type": "object", "description": "", "optional": false - }, - { - "name": "value", - "type": "DynamicString", - "description": "", - "optional": true } ], "examples": [] }, { - "name": "A2uiDeleteSurface", + "name": "A2uiFunctionAction", "kind": "interface", - "description": "", + "description": "Executes a client-side function locally (e.g. `openUrl`).", "properties": [ { - "name": "surfaceId", - "type": "string", + "name": "functionCall", + "type": "A2uiFunctionCall", "description": "", "optional": false } @@ -366,13 +698,25 @@ "examples": [] }, { - "name": "A2uiDivider", + "name": "A2uiFunctionCall", "kind": "interface", - "description": "", + "description": "Client-side function invocation (e.g. `formatString`, `required`).", "properties": [ { - "name": "direction", - "type": "\"vertical\" | \"horizontal\"", + "name": "args", + "type": "Record", + "description": "", + "optional": true + }, + { + "name": "call", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "returnType", + "type": "\"string\" | \"number\" | \"boolean\" | \"object\" | \"array\" | \"any\" | \"void\"", "description": "", "optional": true } @@ -385,15 +729,39 @@ "description": "", "properties": [ { - "name": "icon", - "type": "DynamicString", + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", + "optional": true + }, + { + "name": "catalogId", + "type": "string", + "description": "Overrides the surface's default catalog for this component.", + "optional": true + }, + { + "name": "component", + "type": "\"Icon\"", "description": "", "optional": false }, { - "name": "size", - "type": "number", + "name": "id", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "name", + "type": "DynamicString | object", "description": "", + "optional": false + }, + { + "name": "weight", + "type": "number", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", "optional": true } ], @@ -405,17 +773,41 @@ "description": "", "properties": [ { - "name": "alt", + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", + "optional": true + }, + { + "name": "catalogId", + "type": "string", + "description": "Overrides the surface's default catalog for this component.", + "optional": true + }, + { + "name": "component", + "type": "\"Image\"", + "description": "", + "optional": false + }, + { + "name": "description", "type": "DynamicString", "description": "", "optional": true }, { - "name": "height", - "type": "number", + "name": "fit", + "type": "\"contain\" | \"cover\" | \"fill\" | \"none\" | \"scaleDown\"", "description": "", "optional": true }, + { + "name": "id", + "type": "string", + "description": "", + "optional": false + }, { "name": "url", "type": "DynamicString", @@ -423,10 +815,16 @@ "optional": false }, { - "name": "width", - "type": "number", + "name": "variant", + "type": "\"icon\" | \"avatar\" | \"smallFeature\" | \"mediumFeature\" | \"largeFeature\" | \"header\"", "description": "", "optional": true + }, + { + "name": "weight", + "type": "number", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", + "optional": true } ], "examples": [] @@ -436,17 +834,53 @@ "kind": "interface", "description": "", "properties": [ + { + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", + "optional": true + }, + { + "name": "align", + "type": "A2uiAlign", + "description": "", + "optional": true + }, + { + "name": "catalogId", + "type": "string", + "description": "Overrides the surface's default catalog for this component.", + "optional": true + }, { "name": "children", "type": "A2uiChildren", "description": "", "optional": false }, + { + "name": "component", + "type": "\"List\"", + "description": "", + "optional": false + }, { "name": "direction", "type": "\"vertical\" | \"horizontal\"", "description": "", "optional": true + }, + { + "name": "id", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "weight", + "type": "number", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", + "optional": true } ], "examples": [] @@ -479,52 +913,58 @@ "description": "", "properties": [ { - "name": "contentChild", + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", + "optional": true + }, + { + "name": "catalogId", "type": "string", + "description": "Overrides the surface's default catalog for this component.", + "optional": true + }, + { + "name": "component", + "type": "\"Modal\"", "description": "", "optional": false }, { - "name": "entryPointChild", + "name": "content", "type": "string", "description": "", "optional": false }, { - "name": "title", - "type": "DynamicString", + "name": "id", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "trigger", + "type": "string", "description": "", + "optional": false + }, + { + "name": "weight", + "type": "number", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", "optional": true } ], "examples": [] }, { - "name": "A2uiMultipleChoice", + "name": "A2uiPathRef", "kind": "interface", - "description": "", + "description": "JSON-pointer data-model binding. Absolute (`/a/b`) or relative inside templates.", "properties": [ { - "name": "label", - "type": "DynamicString", - "description": "", - "optional": true - }, - { - "name": "maxAllowedSelections", - "type": "number", - "description": "", - "optional": true - }, - { - "name": "options", - "type": "object[]", - "description": "", - "optional": false - }, - { - "name": "selections", - "type": "DynamicStringList", + "name": "path", + "type": "string", "description": "", "optional": false } @@ -537,11 +977,23 @@ "description": "", "properties": [ { - "name": "alignment", - "type": "\"start\" | \"center\" | \"end\" | \"stretch\"", + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", + "optional": true + }, + { + "name": "align", + "type": "A2uiAlign", "description": "", "optional": true }, + { + "name": "catalogId", + "type": "string", + "description": "Overrides the surface's default catalog for this component.", + "optional": true + }, { "name": "children", "type": "A2uiChildren", @@ -549,15 +1001,27 @@ "optional": false }, { - "name": "distribution", - "type": "\"start\" | \"center\" | \"end\" | \"space-between\" | \"space-around\"", + "name": "component", + "type": "\"Row\"", + "description": "", + "optional": false + }, + { + "name": "id", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "justify", + "type": "A2uiJustify", "description": "", "optional": true }, { - "name": "gap", + "name": "weight", "type": "number", - "description": "", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", "optional": true } ], @@ -569,25 +1033,55 @@ "description": "", "properties": [ { - "name": "basePath", - "type": "string", + "name": "basePath", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "item", + "type": "unknown", + "description": "", + "optional": false + } + ], + "examples": [] + }, + { + "name": "A2uiSlider", + "kind": "interface", + "description": "Mixin for input components that support client-side validation checks.", + "properties": [ + { + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", + "optional": true + }, + { + "name": "catalogId", + "type": "string", + "description": "Overrides the surface's default catalog for this component.", + "optional": true + }, + { + "name": "checks", + "type": "A2uiCheck[]", + "description": "", + "optional": true + }, + { + "name": "component", + "type": "\"Slider\"", "description": "", "optional": false }, { - "name": "item", - "type": "unknown", + "name": "id", + "type": "string", "description": "", "optional": false - } - ], - "examples": [] - }, - { - "name": "A2uiSlider", - "kind": "interface", - "description": "", - "properties": [ + }, { "name": "label", "type": "DynamicString", @@ -595,19 +1089,13 @@ "optional": true }, { - "name": "maxValue", - "type": "number", - "description": "", - "optional": true - }, - { - "name": "minValue", + "name": "max", "type": "number", "description": "", - "optional": true + "optional": false }, { - "name": "step", + "name": "min", "type": "number", "description": "", "optional": true @@ -617,6 +1105,12 @@ "type": "DynamicNumber", "description": "", "optional": false + }, + { + "name": "weight", + "type": "number", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", + "optional": true } ], "examples": [] @@ -650,12 +1144,6 @@ "description": "", "optional": true }, - { - "name": "styles", - "type": "object", - "description": "Styles set by the agent via `beginRendering.styles`. The\ncanonical v1 spec defines exactly two fields: `font` (primary\nfont for the UI) and `primaryColor` (hex `#RRGGBB`). The renderer\napplies these as CSS custom properties on the surface root,\noverriding any consumer-set defaults for the duration of the\nsurface's life. Anything richer (typography scale, spacing,\nelevation, etc.) is the renderer's private vocabulary and not\ncommunicated through this field.", - "optional": true - }, { "name": "surfaceId", "type": "string", @@ -672,64 +1160,78 @@ "examples": [] }, { - "name": "A2uiSurfaceUpdate", + "name": "A2uiTabs", "kind": "interface", "description": "", "properties": [ { - "name": "components", - "type": "A2uiComponent[]", - "description": "", - "optional": false + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", + "optional": true }, { - "name": "surfaceId", + "name": "catalogId", "type": "string", + "description": "Overrides the surface's default catalog for this component.", + "optional": true + }, + { + "name": "component", + "type": "\"Tabs\"", "description": "", "optional": false - } - ], - "examples": [] - }, - { - "name": "A2uiTabItem", - "kind": "interface", - "description": "", - "properties": [ + }, { - "name": "child", + "name": "id", "type": "string", "description": "", "optional": false }, { - "name": "title", - "type": "DynamicString", + "name": "tabs", + "type": "object[]", "description": "", "optional": false + }, + { + "name": "weight", + "type": "number", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", + "optional": true } ], "examples": [] }, { - "name": "A2uiTabs", + "name": "A2uiText", "kind": "interface", "description": "", "properties": [ { - "name": "tabItems", - "type": "A2uiTabItem[]", + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", + "optional": true + }, + { + "name": "catalogId", + "type": "string", + "description": "Overrides the surface's default catalog for this component.", + "optional": true + }, + { + "name": "component", + "type": "\"Text\"", "description": "", "optional": false - } - ], - "examples": [] - }, - { - "name": "A2uiText", - "kind": "interface", - "description": "", - "properties": [ + }, + { + "name": "id", + "type": "string", + "description": "", + "optional": false + }, { "name": "text", "type": "DynamicString", @@ -737,10 +1239,16 @@ "optional": false }, { - "name": "usageHint", + "name": "variant", "type": "\"h1\" | \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"caption\" | \"body\"", "description": "", "optional": true + }, + { + "name": "weight", + "type": "number", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", + "optional": true } ], "examples": [] @@ -748,8 +1256,38 @@ { "name": "A2uiTextField", "kind": "interface", - "description": "", + "description": "Mixin for input components that support client-side validation checks.", "properties": [ + { + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", + "optional": true + }, + { + "name": "catalogId", + "type": "string", + "description": "Overrides the surface's default catalog for this component.", + "optional": true + }, + { + "name": "checks", + "type": "A2uiCheck[]", + "description": "", + "optional": true + }, + { + "name": "component", + "type": "\"TextField\"", + "description": "", + "optional": false + }, + { + "name": "id", + "type": "string", + "description": "", + "optional": false + }, { "name": "label", "type": "DynamicString", @@ -757,22 +1295,28 @@ "optional": false }, { - "name": "text", - "type": "DynamicString", + "name": "validationRegexp", + "type": "string", "description": "", "optional": true }, { - "name": "textFieldType", - "type": "\"number\" | \"date\" | \"longText\" | \"shortText\" | \"obscured\"", + "name": "value", + "type": "DynamicString", "description": "", "optional": true }, { - "name": "validationRegexp", - "type": "string", + "name": "variant", + "type": "\"number\" | \"shortText\" | \"longText\" | \"obscured\"", "description": "", "optional": true + }, + { + "name": "weight", + "type": "number", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", + "optional": true } ], "examples": [] @@ -804,84 +1348,190 @@ "examples": [] }, { - "name": "A2uiVideo", + "name": "A2uiUpdateComponents", "kind": "interface", "description": "", "properties": [ { - "name": "autoPlay", - "type": "boolean", + "name": "components", + "type": "A2uiComponent[]", + "description": "", + "optional": false + }, + { + "name": "surfaceId", + "type": "string", "description": "", + "optional": false + } + ], + "examples": [] + }, + { + "name": "A2uiUpdateDataModel", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "path", + "type": "string", + "description": "JSON pointer into the data model. Omitted or `/` targets the whole model.", "optional": true }, { - "name": "controls", - "type": "boolean", + "name": "surfaceId", + "type": "string", "description": "", + "optional": false + }, + { + "name": "value", + "type": "unknown", + "description": "Replacement value at `path`. Omitted value deletes the key at `path`.", + "optional": true + } + ], + "examples": [] + }, + { + "name": "A2uiVideo", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "accessibility", + "type": "Record", + "description": "Accessibility attributes (spec `AccessibilityAttributes`).", + "optional": true + }, + { + "name": "catalogId", + "type": "string", + "description": "Overrides the surface's default catalog for this component.", "optional": true }, + { + "name": "component", + "type": "\"Video\"", + "description": "", + "optional": false + }, + { + "name": "id", + "type": "string", + "description": "", + "optional": false + }, { "name": "url", "type": "DynamicString", "description": "", "optional": false + }, + { + "name": "weight", + "type": "number", + "description": "Flex-grow-like weight; only valid as a direct child of Row/Column.", + "optional": true } ], "examples": [] }, { - "name": "A2uiChildren", + "name": "A2uiAction", "kind": "type", "description": "", - "signature": "object | object", + "signature": "A2uiEventAction | A2uiFunctionAction", "examples": [] }, { - "name": "A2uiComponentDef", + "name": "A2uiCatalogComponent", "kind": "type", - "description": "", - "signature": "object | object | object | object | object | object | object | object | object | object | object | object | object | object | object | object | object | object", + "description": "Union of the basic-catalog component shapes.", + "signature": "A2uiText | A2uiImage | A2uiIcon | A2uiVideo | A2uiAudioPlayer | A2uiRow | A2uiColumn | A2uiList | A2uiCard | A2uiTabs | A2uiModal | A2uiDivider | A2uiButton | A2uiCheckBox | A2uiTextField | A2uiDateTimeInput | A2uiChoicePicker | A2uiSlider", + "examples": [] + }, + { + "name": "A2uiChildren", + "kind": "type", + "description": "Static child-id list, or a template stamped per element of a data-model list.", + "signature": "string[] | object", + "examples": [] + }, + { + "name": "A2uiComponent", + "kind": "type", + "description": "Any component, including non-basic-catalog types. Renderers treat unknown\n`component` strings as unrenderable and fall back gracefully.", + "signature": "A2uiCatalogComponent | A2uiComponentBase & Record", "examples": [] }, { "name": "A2uiMessage", "kind": "type", "description": "", - "signature": "object | object | object | object", + "signature": "A2uiEnvelopeBase & ({ createSurface: A2uiCreateSurface } | { updateComponents: A2uiUpdateComponents } | { updateDataModel: A2uiUpdateDataModel } | { deleteSurface: A2uiDeleteSurface })", "examples": [] }, { "name": "DynamicBoolean", "kind": "type", "description": "", - "signature": "object | object", + "signature": "boolean | A2uiPathRef | A2uiFunctionCall", "examples": [] }, { "name": "DynamicNumber", "kind": "type", "description": "", - "signature": "object | object", + "signature": "number | A2uiPathRef | A2uiFunctionCall", "examples": [] }, { "name": "DynamicString", "kind": "type", "description": "", - "signature": "object | object", + "signature": "string | A2uiPathRef | A2uiFunctionCall", "examples": [] }, { "name": "DynamicStringList", "kind": "type", "description": "", - "signature": "object | object", + "signature": "string[] | A2uiPathRef | A2uiFunctionCall", + "examples": [] + }, + { + "name": "DynamicValue", + "kind": "type", + "description": "Any dynamic value position where the concrete type is component-defined.", + "signature": "unknown", + "examples": [] + }, + { + "name": "A2UI_BASIC_CATALOG_ID", + "kind": "const", + "description": "Catalog id of the standard A2UI basic component catalog.", + "signature": "\"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json\"", + "examples": [] + }, + { + "name": "A2UI_MIME_TYPE", + "kind": "const", + "description": "MIME type for A2UI payloads, standardized in the v0.9.1 release.", + "signature": "\"application/a2ui+json\"", + "examples": [] + }, + { + "name": "A2UI_WIRE_VERSION", + "kind": "const", + "description": "Wire version stamped on every A2UI v0.9-family envelope.", + "signature": "\"v0.9\"", "examples": [] }, { "name": "createA2uiMessageParser", "kind": "function", - "description": "Creates a stateful parser for newline-delimited A2UI message streams.\n\nThe parser buffers incomplete lines, skips malformed JSON, and returns only\nrecognized A2UI envelopes: `surfaceUpdate`, `dataModelUpdate`,\n`beginRendering`, and `deleteSurface`.", + "description": "Creates a stateful parser for newline-delimited A2UI v0.9 message streams.\n\nThe parser buffers incomplete lines, skips malformed JSON, and returns only\nrecognized A2UI envelopes: `createSurface`, `updateComponents`,\n`updateDataModel`, and `deleteSurface`. Unknown envelope keys (e.g. future\nv1.0 messages) are skipped rather than treated as errors. A missing\n`version` field defaults to `v0.9`.", "signature": "createA2uiMessageParser(): A2uiMessageParser", "params": [], "returns": { @@ -889,7 +1539,7 @@ "description": "" }, "examples": [ - "```ts\nconst parser = createA2uiMessageParser();\nconst messages = parser.push('{\"beginRendering\":{\"surfaceId\":\"s1\",\"root\":\"root\"}}\\n');\n```" + "```ts\nconst parser = createA2uiMessageParser();\nconst messages = parser.push(\n '{\"version\":\"v0.9\",\"createSurface\":{\"surfaceId\":\"s1\",\"catalogId\":\"basic\"}}\\n',\n);\n```" ] }, { @@ -947,48 +1597,10 @@ ] }, { - "name": "isLiteralBoolean", - "kind": "function", - "description": "Returns true when `value` is an A2UI boolean literal wrapper.", - "signature": "isLiteralBoolean(value: unknown): value is { literalBoolean: boolean }", - "params": [ - { - "name": "value", - "type": "unknown", - "description": "", - "optional": false - } - ], - "returns": { - "type": "value is { literalBoolean: boolean }", - "description": "" - }, - "examples": [] - }, - { - "name": "isLiteralNumber", - "kind": "function", - "description": "Returns true when `value` is an A2UI number literal wrapper.", - "signature": "isLiteralNumber(value: unknown): value is { literalNumber: number }", - "params": [ - { - "name": "value", - "type": "unknown", - "description": "", - "optional": false - } - ], - "returns": { - "type": "value is { literalNumber: number }", - "description": "" - }, - "examples": [] - }, - { - "name": "isLiteralString", + "name": "isFunctionCall", "kind": "function", - "description": "Returns true when `value` is an A2UI string literal wrapper.", - "signature": "isLiteralString(value: unknown): value is { literalString: string }", + "description": "Returns true when `value` is an A2UI client-side function call.", + "signature": "isFunctionCall(value: unknown): value is { args?: Record; call: string }", "params": [ { "name": "value", @@ -998,7 +1610,7 @@ } ], "returns": { - "type": "value is { literalString: string }", + "type": "value is { args?: Record; call: string }", "description": "" }, "examples": [] @@ -1025,7 +1637,7 @@ { "name": "resolveDynamic", "kind": "function", - "description": "Resolves an A2UI dynamic value against a client data model.\n\nLiteral wrappers unwrap to their inner values, `{ path }` references read\nfrom the model by JSON-pointer path, arrays resolve recursively, and\nunrecognized plain values pass through unchanged.", + "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", "params": [ { @@ -1052,7 +1664,7 @@ "description": "" }, "examples": [ - "```ts\nconst model = { customer: { name: 'Ada' } };\nresolveDynamic({ path: '/customer/name' }, model); // 'Ada'\nresolveDynamic({ literalString: 'Checkout' }, model); // 'Checkout'\n```" + "```ts\nconst model = { customer: { name: 'Ada' } };\nresolveDynamic({ path: '/customer/name' }, model); // 'Ada'\nresolveDynamic('Checkout', model); // 'Checkout'\n```" ] }, { diff --git a/apps/website/content/docs/a2ui/getting-started/introduction.mdx b/apps/website/content/docs/a2ui/getting-started/introduction.mdx index ac74876d9..7d0027f55 100644 --- a/apps/website/content/docs/a2ui/getting-started/introduction.mdx +++ b/apps/website/content/docs/a2ui/getting-started/introduction.mdx @@ -10,10 +10,11 @@ The public entry point exports four groups of tools: | Area | Exports | |------|---------| -| Wire types | `A2uiMessage`, `A2uiComponent`, component prop interfaces, data-model update types, action message types | +| Wire types | `A2uiMessage`, `A2uiComponent`, component prop interfaces, envelope types, action message types | +| Protocol constants | `A2UI_WIRE_VERSION` (`'v0.9'`), `A2UI_MIME_TYPE` (`'application/a2ui+json'`), `A2UI_BASIC_CATALOG_ID` | | Stream parsing | `createA2uiMessageParser()` | | Data access | `getByPointer()`, `setByPointer()`, `deleteByPointer()` | -| Dynamic values | `resolveDynamic()`, `A2uiScope`, literal/path guards | +| Dynamic values | `resolveDynamic()`, `A2uiScope`, `isPathRef` / `isFunctionCall` guards | Use this package when you are building an adapter, validating an agent stream, testing A2UI payloads, or integrating a custom renderer with the same protocol surface that `@threadplane/chat` uses. @@ -22,13 +23,13 @@ Use this package when you are building an adapter, validating an agent stream, t The parser expects newline-delimited JSON. Each line is checked for one known envelope key: ```text -surfaceUpdate -dataModelUpdate -beginRendering +createSurface +updateComponents +updateDataModel deleteSurface ``` -When a line parses and has one of those envelope keys, it is returned as an `A2uiMessage`. Unknown envelopes are ignored. Malformed lines are skipped. Incomplete JSON waits in the internal buffer until a newline arrives. +When a line parses and has one of those envelope keys, it is returned as an `A2uiMessage`. Every envelope carries `"version": "v0.9"`; a missing `version` defaults to `v0.9`. Unknown envelope keys — including future v1.0 messages — are ignored. Malformed lines are skipped. Incomplete JSON waits in the internal buffer until a newline arrives. ```ts import { createA2uiMessageParser } from '@threadplane/a2ui'; @@ -36,7 +37,7 @@ import { createA2uiMessageParser } from '@threadplane/a2ui'; const parser = createA2uiMessageParser(); const messages = parser.push( - '{"beginRendering":{"surfaceId":"checkout","root":"root"}}\n', + '{"version":"v0.9","createSurface":{"surfaceId":"checkout","catalogId":"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"}}\n', ); ``` @@ -59,9 +60,10 @@ That separation matters when debugging: The parser and resolver are deliberately conservative: - malformed JSONL lines are skipped; -- unknown envelope keys are ignored; +- 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. +- unrecognized dynamic-value shapes pass through unchanged; +- `{ call: ... }` function-call values resolve to `undefined` until client-side function execution ships. 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 ce6bfd1a8..1105ecec0 100644 --- a/apps/website/content/docs/a2ui/getting-started/quickstart.mdx +++ b/apps/website/content/docs/a2ui/getting-started/quickstart.mdx @@ -23,13 +23,13 @@ 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. +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`). ```text ---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":{"literalString":"Book a flight"},"usageHint":"h2"}}},{"id":"origin","component":{"MultipleChoice":{"label":{"literalString":"Origin"},"options":[{"label":{"literalString":"LAX"},"value":"LAX"},{"label":{"literalString":"JFK"},"value":"JFK"}],"selections":{"path":"/origin"},"maxAllowedSelections":1}}},{"id":"submit_label","component":{"Text":{"text":{"literalString":"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"}} +{"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. @@ -40,82 +40,70 @@ import { createA2uiMessageParser } from '@threadplane/a2ui'; const parser = createA2uiMessageParser(); const messages = parser.push( - '{"beginRendering":{"surfaceId":"s1","root":"root"}}\n', + '{"version":"v0.9","createSurface":{"surfaceId":"s1","catalogId":"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"}}\n', ); -// messages -> 1 message: { beginRendering: { surfaceId: 's1', root: 'root' } } +// 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: ```ts -parser.push('{"beginRendering":'); // -> [] (incomplete, buffered) -parser.push('{"surfaceId":"s1","root":"root"}}\n'); // -> 1 message +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. +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 -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. +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. -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): +Applying an update is just the pointer helpers: ```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 { - const out: Record = {}; - 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; -} +import { setByPointer, deleteByPointer } from '@threadplane/a2ui'; +import type { A2uiUpdateDataModel } from '@threadplane/a2ui'; -// Apply a dataModelUpdate to a model, honoring its optional `path`. -function applyDataModelUpdate( +function applyUpdateDataModel( model: Record, - update: A2uiDataModelUpdate, + update: A2uiUpdateDataModel, ): Record { - const obj = entriesToObject(update.contents); - return setByPointer(model, update.path ?? '/', obj); + 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; + return setByPointer(model, path, update.value); } ``` -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: +Run it against the booking stream's `updateDataModel` and you get the model back: ```ts let model: Record = {}; -model = applyDataModelUpdate(model, { +model = applyUpdateDataModel(model, { surfaceId: 'booking', - contents: [ - { key: 'origin', valueString: 'LAX' }, - { key: 'dest', valueString: 'JFK' }, - { key: 'passengers', valueNumber: 1 }, - ], + value: { origin: ['LAX'], dest: ['JFK'], passengers: 1 }, }); -// model -> { 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](/docs/a2ui/guides/data-model) covers the reducer, `path` scoping, and the pointer 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 `path` scoping, deletes, 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. +A component's props can be bare literals or path references. `resolveDynamic` collapses both against the model. ```ts import { resolveDynamic } from '@threadplane/a2ui'; -resolveDynamic({ path: '/origin' }, model); // "LAX" -resolveDynamic({ literalString: 'Search flights' }, model); // "Search flights" -resolveDynamic({ path: '/missing' }, model); // undefined +resolveDynamic({ path: '/passengers' }, model); // 1 +resolveDynamic('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. +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. ## Conclusion diff --git a/apps/website/content/docs/a2ui/guides/adapters-and-validation.mdx b/apps/website/content/docs/a2ui/guides/adapters-and-validation.mdx index ea7c7cab7..73608a8de 100644 --- a/apps/website/content/docs/a2ui/guides/adapters-and-validation.mdx +++ b/apps/website/content/docs/a2ui/guides/adapters-and-validation.mdx @@ -23,39 +23,35 @@ for await (const chunk of streamChunks) { The posture (straight from `parser.ts`) is conservative fallback: - Malformed lines are skipped silently — partial JSONL is normal mid-stream. -- Lines whose object has no known envelope key are ignored. +- Lines whose object has no known envelope key are ignored (including future v1.0 envelope kinds). +- A missing `version` field defaults to `'v0.9'`. - Incomplete JSON buffers until a newline arrives. That last point in practice: ```ts -parser.push('{"beginRendering":'); // -> [] (buffers) -parser.push('{"surfaceId":"s1","root":"root"}}\n'); // -> 1 message +parser.push('{"version":"v0.9","deleteSurface":'); // -> [] (buffers) +parser.push('{"surfaceId":"s1"}}\n'); // -> 1 message ``` A line is only attempted once its trailing newline lands, so a split-mid-value chunk never throws. ## Validate and narrow values -When you walk a component's props, you need to tell a literal from a path reference. The package exports four guards: +When you walk a component's props, you need to tell a bare literal from a path reference or a function call. The package exports two guards: ```ts -import { - isPathRef, - isLiteralString, - isLiteralNumber, - isLiteralBoolean, -} from '@threadplane/a2ui'; - -isPathRef({ path: '/x' }); // true -isLiteralString({ literalString: 'x' }); // true +import { isPathRef, isFunctionCall } from '@threadplane/a2ui'; + +isPathRef({ path: '/x' }); // true +isFunctionCall({ call: 'formatString' }); // true ``` - -The package exports `isPathRef`, `isLiteralString`, `isLiteralNumber`, and `isLiteralBoolean` — and no array guard. `resolveDynamic` unwraps `literalArray` internally, but there's no exported predicate for it. If you need to detect a `literalArray` shape yourself, check for the `literalArray` key directly. + +In v0.9, literals are bare JSON values — `"x"`, `5`, `true`, `["a", "b"]` — with no wrapper objects. A `typeof` check (or simply not matching either guard) is all it takes to identify one, so there are no literal guards to import. -For most rendering you don't branch on guards at all — `resolveDynamic` already handles literals, paths, arrays, and passthrough in one call. Reach for the guards when you need to *narrow a type* or make a decision before resolving. +For most rendering you don't branch on guards at all — `resolveDynamic` already handles literals, paths, arrays, function calls, and passthrough in one call. Reach for the guards when you need to *narrow a type* or make a decision before resolving. ## Build payloads for tests @@ -67,18 +63,18 @@ import { createA2uiMessageParser } from '@threadplane/a2ui'; const parser = createA2uiMessageParser(); const chunk = [ - JSON.stringify({ surfaceUpdate: { surfaceId: 's1', components: [] } }), - JSON.stringify({ dataModelUpdate: { surfaceId: 's1', contents: [] } }), - JSON.stringify({ beginRendering: { surfaceId: 's1', root: 'root' } }), + JSON.stringify({ version: 'v0.9', createSurface: { surfaceId: 's1', catalogId: 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json' } }), + JSON.stringify({ version: 'v0.9', updateDataModel: { surfaceId: 's1', value: {} } }), + JSON.stringify({ version: 'v0.9', updateComponents: { surfaceId: 's1', components: [{ id: 'root', component: 'Column', children: [] }] } }), ].join('\n') + '\n'; const messages = parser.push(chunk); -messages.map(m => Object.keys(m)[0]); -// ['surfaceUpdate', 'dataModelUpdate', 'beginRendering'] +messages.map(m => Object.keys(m).find(k => k !== 'version')); +// ['createSurface', 'updateDataModel', 'updateComponents'] ``` -Each `A2uiMessage` is a single-key envelope object, so `Object.keys(m)[0]` is the envelope kind — handy for assertions. +Each `A2uiMessage` carries a `version` plus exactly one envelope key, so the non-`version` key is the envelope kind — handy for assertions. ## Build a custom renderer diff --git a/apps/website/content/docs/a2ui/guides/data-model.mdx b/apps/website/content/docs/a2ui/guides/data-model.mdx index a1b8bc3d1..13ab67b65 100644 --- a/apps/website/content/docs/a2ui/guides/data-model.mdx +++ b/apps/website/content/docs/a2ui/guides/data-model.mdx @@ -44,57 +44,49 @@ deleteByPointer({ a: 1, b: 2 }, '/a'); // { b: 2 } If the parent of the target doesn't exist, `deleteByPointer` returns the original model unchanged rather than fabricating a path to delete from. +One v0.9-specific rule: deleting an **array index** does not splice. The index is set to `undefined` and the array's length is preserved, so sibling indices stay stable for other bindings: + +```ts +deleteByPointer({ items: ['a', 'b', 'c'] }, '/items/1'); +// { items: ['a', undefined, 'c'] } — length still 3 +``` + 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. -## 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 { - const out: Record = {}; - 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; -} +## Applying updateDataModel envelopes -entriesToObject([ - { key: 'name', valueString: 'Ada' }, - { key: 'address', valueMap: [{ key: 'city', valueString: 'London' }] }, -]); -// { name: 'Ada', address: { city: 'London' } } -``` +The pointer helpers are the primitives. The most common real task is one level up: applying an `updateDataModel` envelope to the model. In v0.9 the envelope's `value` is **plain JSON** — no typed entry wrappers — so applying it is a direct mapping onto the helpers: -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`: +- `value` present, `path` present — `setByPointer(model, path, value)` replaces (or creates) the data at `path`. +- `value` present, `path` omitted or `'/'` — the whole model is replaced by `value`. +- `value` omitted — the key at `path` is deleted (`deleteByPointer`); with no `path`, the model resets to `{}`. ```ts -import { setByPointer } from '@threadplane/a2ui'; -import type { A2uiDataModelUpdate } from '@threadplane/a2ui'; +import { setByPointer, deleteByPointer } from '@threadplane/a2ui'; +import type { A2uiUpdateDataModel } from '@threadplane/a2ui'; -function applyDataModelUpdate( +function applyUpdateDataModel( model: Record, - update: A2uiDataModelUpdate, + update: A2uiUpdateDataModel, ): Record { - const obj = entriesToObject(update.contents); - return setByPointer(model, update.path ?? '/', obj); + const path = update.path && update.path !== '/' ? update.path : undefined; + if (!('value' in update) || update.value === undefined) { + return path ? deleteByPointer(model, path) : {}; + } + if (!path) return update.value as Record; + return setByPointer(model, path, update.value); } -applyDataModelUpdate( +applyUpdateDataModel( {}, - { surfaceId: 's1', path: '/customer', contents: [{ key: 'name', valueString: 'Ada' }] }, + { surfaceId: 's1', path: '/customer', value: { name: '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. +Nesting is just JSON: `value: { name: 'Ada', address: { city: 'London' } }` writes the nested object as-is. There is no per-entry typing to reduce — the wire value is already the model shape. ## Resolving dynamic values @@ -102,19 +94,19 @@ When `path` is absent, `setByPointer(model, '/', obj)` replaces the root with th 1. `null` / `undefined` pass through as-is. 2. Arrays are mapped recursively — each element resolved in turn. -3. Literal wrappers unwrap first: `literalString`, `literalNumber`, `literalBoolean`, `literalArray`. +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. 4. A `{ path }` reference reads from the model. -5. Anything else — a plain string, number, or unrecognized object — passes through unchanged. +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. ```ts import { resolveDynamic } from '@threadplane/a2ui'; const model = { name: 'Brian', count: 7, active: true, tags: ['a', 'b'] }; -resolveDynamic({ literalString: 'hello' }, model); // "hello" -resolveDynamic({ path: '/name' }, model); // "Brian" -resolveDynamic({ path: '/tags/0' }, model); // "a" -resolveDynamic({ path: '/missing' }, model); // undefined +resolveDynamic('hello', model); // "hello" +resolveDynamic({ path: '/name' }, model); // "Brian" +resolveDynamic({ path: '/tags/0' }, model); // "a" +resolveDynamic({ path: '/missing' }, model); // undefined ``` A missing path resolves to `undefined`, never an error. That keeps a half-streamed surface renderable while data is still arriving. @@ -141,7 +133,7 @@ Path resolution depends on the leading slash: resolveDynamic({ path: 'name' }, model, { basePath: '', item: undefined }); // "Brian" ``` -With `basePath: ''`, the relative path `name` resolves to `/name`. That's the lever the `template` / `dataBinding` pattern pulls. When a container repeats a template over the array at, say, `/items`, it resolves each instance's props with a per-item scope: +With `basePath: ''`, the relative path `name` resolves to `/name`. That's the lever children **templates** pull. When a container's `children` is `{ "path": "/items", "componentId": "tpl" }`, it repeats the template component over the array at `/items` and resolves each instance's props with a per-item scope: ```ts items.forEach((_, i) => { diff --git a/apps/website/content/docs/a2ui/guides/message-protocol.mdx b/apps/website/content/docs/a2ui/guides/message-protocol.mdx index 185825752..0412cf26d 100644 --- a/apps/website/content/docs/a2ui/guides/message-protocol.mdx +++ b/apps/website/content/docs/a2ui/guides/message-protocol.mdx @@ -2,130 +2,129 @@ A2UI is a declarative, streamed wire format: the agent describes a UI, sends it as newline-delimited JSON, and the client renders it and ships actions back. -This page walks the shapes. Everything here is what `@threadplane/a2ui` types and parses; rendering belongs to `@threadplane/chat`'s [``](/docs/chat/getting-started/introduction). +This page walks the shapes. Everything here is what `@threadplane/a2ui` types and parses; rendering belongs to `@threadplane/chat`'s [``](/docs/chat/getting-started/introduction). Threadplane implements the **A2UI v0.9.1 stable release**: every envelope carries `"version": "v0.9"`, and the standardized MIME type is `application/a2ui+json` (exported as `A2UI_MIME_TYPE`). ## What's a surface? A surface is one self-contained unit of UI. It owns its own component set and its own data model, and it's addressed by a `surfaceId`. -Every envelope carries that `surfaceId`. A `dataModelUpdate` for `"booking"` only touches the `booking` surface's data; a `surfaceUpdate` for `"booking"` only defines its components. One stream can drive several surfaces in parallel, kept separate by id. +Every envelope carries that `surfaceId`. An `updateDataModel` for `"booking"` only touches the `booking` surface's data; an `updateComponents` for `"booking"` only defines its components. One stream can drive several surfaces in parallel, kept separate by id. ## How are components described? -As an **id-keyed adjacency list**. A `surfaceUpdate` carries a flat `components` array. Each entry has an `id` and a single `component` definition. Parent-child links are by id reference, not by nesting. +As an **id-keyed adjacency list**. An `updateComponents` envelope carries a flat `components` array. Each entry is a flat object: an `id`, a `component` string naming the catalog type, and the component's props at the same level. Parent-child links are by id reference, not by nesting. ```json -{"surfaceUpdate":{"surfaceId":"booking","components":[{"id":"root","component":{"Column":{"children":{"explicitList":["title","origin","submit"]}}}},{"id":"title","component":{"Text":{"text":{"literalString":"Book a flight"},"usageHint":"h2"}}}]}} +{"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"}]}} ``` -The `component` value is a **keyed union**: a single-key object where the key names the component type and the value holds its props — `{ "": { props } }`. `{ "Text": { ... } }` is a Text, `{ "Column": { ... } }` is a Column. There's no separate `type` field; the key *is* the type. +There is no type-keyed wrapper — `component: "Text"` is the discriminator, and `text`, `variant`, etc. sit directly on the object. Exactly one component across the surface's `components` lists must have the id `root`; it is the tree root, rendering can begin as soon as it is defined, and the rest of the tree fills in progressively. Later `updateComponents` envelopes merge incrementally by `id` — existing components are replaced, others are kept. Containers reference their children with `A2uiChildren`, which has two forms: -- `explicitList` — a fixed array of child ids, in order. +- a **static list** — a plain array of child ids, in order. ```json - {"children":{"explicitList":["title","origin","submit"]}} + {"children":["title","origin","submit"]} ``` -- `template` — one component repeated per item in a bound array. +- a **template** — one component repeated per item in a bound array. ```json - {"children":{"template":{"componentId":"rowTemplate","dataBinding":"/items"}}} + {"children":{"path":"/items","componentId":"rowTemplate"}} ``` - The container instantiates `componentId` once per element of the array at `dataBinding`. Each instance resolves its dynamic values against that element. The [data model guide](/docs/a2ui/guides/data-model) covers how that per-item resolution works. + The container instantiates `componentId` once per element of the array at `path`. Each instance resolves its dynamic values against that element — relative paths inside the template resolve per item. The [data model guide](/docs/a2ui/guides/data-model) covers how that per-item resolution works. ## What's a dynamic value? -A prop that's either a literal baked into the message or a reference into the data model. +A prop that's either a literal baked into the message, a reference into the data model, or a client-side function call. -In v1 the canonical form for a literal is a wrapper object, matched to the prop's type: +In v0.9 literals are **bare values** — no wrapper objects: -- `{ literalString: "..." }` -- `{ literalNumber: 7 }` -- `{ literalBoolean: true }` -- `{ literalArray: ["a", "b"] }` +```json +{"text":"Book a flight"} +{"value":5} +{"filterable":true} +``` -A reference is `{ path: "/origin" }` — a JSON-Pointer into the surface's data model. +A reference is `{"path":"/origin"}` — a JSON pointer into the surface's data model: ```json -{"Text":{"text":{"literalString":"Book a flight"}}} -{"Text":{"text":{"path":"/headline"}}} +{"text":{"path":"/headline"}} ``` -A raw string in a value slot (`"text": "Book a flight"`) is tolerated — `resolveDynamic` passes plain values through unchanged — but the wrapper is the canonical shape. Prefer the wrapper when you author messages; rely on passthrough only when consuming. - - -There's no `isLiteralArray` guard exported from this package. The resolver unwraps `literalArray` internally, and you get `isLiteralString`, `isLiteralNumber`, `isLiteralBoolean`, and `isPathRef` as exported guards — but not one for arrays. See [Validating and adapting](/docs/a2ui/guides/adapters-and-validation). - +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. ## What are the four envelopes? -The stream is a sequence of single-key envelope objects. The parser recognizes exactly four keys; anything else is ignored. +The stream is a sequence of envelope objects, each with a `version` field and exactly one envelope key. The parser recognizes four keys; anything else is ignored (which keeps the client forward-compatible with future protocol versions). -### `surfaceUpdate` +### `createSurface` -Defines (or replaces) the components for a surface. +Creates a surface and declares which component catalog it uses. This must be the **first** envelope for a surface. For the standard basic catalog, `catalogId` is `https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json` (exported as `A2UI_BASIC_CATALOG_ID`). ```json -{"surfaceUpdate":{"surfaceId":"booking","components":[{"id":"submit_label","component":{"Text":{"text":{"literalString":"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"}}]}}}}]}} +{"version":"v0.9","createSurface":{"surfaceId":"booking","catalogId":"https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json","theme":{"primaryColor":"#2563eb"},"sendDataModel":true}} ``` -### `dataModelUpdate` +- `theme` (optional) carries presentation hints — `primaryColor`, `iconUrl`, `agentDisplayName`. +- `sendDataModel` (optional, default false) — when true, the client attaches the surface's full data model to every outbound message. -Sets data for a surface. `contents` is an array of typed entries — each has a `key` and one of `valueString`, `valueNumber`, `valueBoolean`, or `valueMap` (a nested array of entries). An optional top-level `path` scopes where the entries land. +### `updateComponents` + +Defines or updates components for a surface. Can be sent multiple times; components merge by `id`. One component must have `id: "root"`. ```json -{"dataModelUpdate":{"surfaceId":"booking","contents":[{"key":"origin","valueString":"LAX"},{"key":"dest","valueString":"JFK"},{"key":"passengers","valueNumber":1}]}} +{"version":"v0.9","updateComponents":{"surfaceId":"booking","components":[{"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"}}}}}]}} ``` -`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: +### `updateDataModel` + +Sets or deletes data for a surface. `path` is an optional JSON pointer (omitted or `"/"` targets the whole model). `value` is plain JSON — if present, the data at `path` is replaced (or created); if omitted, the key at `path` is deleted. ```json -{"dataModelUpdate":{"surfaceId":"booking","path":"/customer","contents":[{"key":"name","valueString":"Ada"},{"key":"address","valueMap":[{"key":"city","valueString":"London"}]}]}} +{"version":"v0.9","updateDataModel":{"surfaceId":"booking","value":{"origin":["LAX"],"dest":["JFK"],"passengers":1}}} ``` -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`). +```json +{"version":"v0.9","updateDataModel":{"surfaceId":"booking","path":"/passengers","value":2}} +``` ```json -{"beginRendering":{"surfaceId":"booking","root":"root"}} +{"version":"v0.9","updateDataModel":{"surfaceId":"booking","path":"/promoCode"}} ``` +That last envelope has no `value` — it deletes `promoCode` from the model. One v0.9 nuance: deleting an **array index** sets it to `undefined` while preserving the array's length. The [data model guide](/docs/a2ui/guides/data-model) covers the pointer helpers that implement this. + ### `deleteSurface` Tears a surface down by id. ```json -{"deleteSurface":{"surfaceId":"booking"}} +{"version":"v0.9","deleteSurface":{"surfaceId":"booking"}} ``` -In the booking stream the order is `dataModelUpdate` -> `surfaceUpdate` -> `beginRendering`: data ready, tree defined, then render. +The rendering rule: a surface becomes renderable once its `createSurface` has arrived **and** a component with id `root` has been defined. There is no separate "begin rendering" signal — emit `createSurface` first, put `root` early in the first `updateComponents`, and the client starts painting while the rest of the tree streams in. ## How do actions go back? -A user interacts — clicks the Button — and the client sends an `A2uiActionMessage` back to the agent. The version is `v1`. +A user interacts — clicks the Button — and the client sends an `A2uiActionMessage` back to the agent. The version is `v0.9`. ```json -{"version":"v1","action":{"name":"bookingSubmit","surfaceId":"booking","sourceComponentId":"submit","timestamp":"2026-06-05T12:34:56.789Z","context":{"origin":{"literalString":"LAX"},"dest":{"literalString":"JFK"}},"label":"Search flights"}} +{"version":"v0.9","action":{"name":"bookingSubmit","surfaceId":"booking","sourceComponentId":"submit","timestamp":"2026-06-05T12:34:56.789Z","context":{"origin":["LAX"],"dest":["JFK"]},"label":"Search flights"}} ``` -Two details are worth pinning down, because the inbound and outbound shapes differ: +Details worth pinning down: -- **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. +- **Context is resolved.** The inbound Button's `action.event.context` is a plain object whose values are dynamic values (often `{ path }` bindings). The outbound message's `action.context` is the same keys with each value already resolved against the current data model — here `{ "path": "/origin" }` became `["LAX"]`. +- **`label` is a Threadplane extension.** It's derived from the source component's authored text — for a Button-with-Text-child, the child Text's bare 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. When present, it's an `A2uiClientDataModel` — `{ version: 'v1', surfaces: Record> }`, the per-surface model keyed by `surfaceId`. See [the schema reference](/docs/a2ui/reference/schema#outbound-action-messages) for the full outbound shape. +The client's current data model is only attached as `metadata.a2uiClientDataModel` when the surface's `createSurface` set `sendDataModel: true`. It's omitted otherwise. When present, it's an `A2uiClientDataModel` — `{ surfaces: Record> }`, 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 -Threadplane implements Google's open [A2UI protocol](https://a2ui.org) ([source](https://github.com/google/A2UI)) — the same declarative, catalog-based model: surfaces, a per-surface data model, dynamic values (literals vs paths), and outbound actions. - -Threadplane's implementation version is `v1` — the value you'll see in `A2uiActionMessage` and `A2uiClientDataModel`. That's *our* version tag. It does not claim numbering parity with any particular Google release. Treat `v1` as the Threadplane wire contract, and the linked spec as the conceptual reference. +Threadplane implements Google's open [A2UI protocol](https://a2ui.org) ([source](https://github.com/google/A2UI)) at the **v0.9.1 stable release**: the same envelopes, the same flat component shape, the same basic catalog, and the same `v0.9` wire version you'll see stamped on every message. The linked spec is the normative reference; `@threadplane/a2ui` is its TypeScript type system and parsing layer. ## Next 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 d40b23001..61fb261fb 100644 --- a/apps/website/content/docs/a2ui/reference/parser-resolver-guards.mdx +++ b/apps/website/content/docs/a2ui/reference/parser-resolver-guards.mdx @@ -20,10 +20,11 @@ Important behavior from source: - CRLF works because each line is trimmed; - empty lines are ignored; - malformed lines are skipped silently; -- unknown top-level envelopes are ignored; +- unknown top-level envelopes are ignored (forward compatibility with future protocol versions, e.g. v1.0 message kinds); +- a missing `version` field defaults to `'v0.9'`; a present `version` is preserved; - multiple messages can be returned from one chunk. -The parser checks only for the known envelope key and a non-null object value. It does not validate each nested field. +The recognized envelope keys are `createSurface`, `updateComponents`, `updateDataModel`, and `deleteSurface`. The parser checks only for a known envelope key and a non-null object value. It does not validate each nested field. ## resolveDynamic() @@ -36,23 +37,21 @@ const model = { }; resolveDynamic({ path: '/customer/name' }, model); // "Ada" -resolveDynamic({ literalNumber: 2 }, model); // 2 +resolveDynamic(2, model); // 2 ``` `resolveDynamic(value, model, scope?)` handles: | Input shape | Result | |-------------|--------| -| `{ literalString }` | the wrapped string | -| `{ literalNumber }` | the wrapped number | -| `{ literalBoolean }` | the wrapped boolean | -| `{ literalArray }` | the wrapped array | +| 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 | | arrays | recursively resolved array values | | `null` or `undefined` | returned as-is | -| unrecognized shapes | returned as-is | +| unrecognized plain objects | 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. +Resolution order is fixed: `{ call }` function calls are checked **before** `{ path }` references (so a call's `args` never masquerade as a binding), then path refs resolve, then everything else passes through as a bare literal. Absolute paths start with `/`. @@ -89,7 +88,8 @@ Current behavior is intentionally small: - empty pointer and `/` point at the root; - missing paths read as `undefined`; - `setByPointer()` returns a cloned object path rather than mutating the original root; -- `deleteByPointer()` returns the original model when the parent path does not exist. +- `deleteByPointer()` returns the original model when the parent path does not exist; +- `deleteByPointer()` on an **array index** sets it to `undefined` and preserves the array's length (the v0.9 array-delete rule). These helpers do not implement full RFC 6901 escaping semantics. Avoid keys that require `~0` or `~1` escaping unless you normalize them before they enter A2UI state. @@ -98,13 +98,11 @@ These helpers do not implement full RFC 6901 escaping semantics. Avoid keys that The public guards are: ```ts -isLiteralString(value) -isLiteralNumber(value) -isLiteralBoolean(value) -isPathRef(value) +isPathRef(value) // narrows to { path: string } +isFunctionCall(value) // narrows to { call: string; args?: Record } ``` -They are shape checks for dynamic wrapper objects. The literal guards check for the presence of the wrapper key. `isPathRef()` also verifies that `path` is a string. +`isPathRef()` verifies the value is an object with a string `path`; `isFunctionCall()` verifies an object with a string `call`. Bare literals need no guard in v0.9 — a value that matches neither guard is a literal (or an unrecognized object that passes through unchanged). Use them when you need to branch on protocol values without importing internal renderer code. diff --git a/apps/website/content/docs/a2ui/reference/schema.mdx b/apps/website/content/docs/a2ui/reference/schema.mdx index c85802262..e7a7b7ae5 100644 --- a/apps/website/content/docs/a2ui/reference/schema.mdx +++ b/apps/website/content/docs/a2ui/reference/schema.mdx @@ -1,185 +1,207 @@ # A2UI Schema -The `@threadplane/a2ui` schema is a TypeScript model of the protocol shapes the framework uses. It's a contract for agent output and custom integrations, but it's not a runtime validator. +The `@threadplane/a2ui` schema is a TypeScript model of the A2UI v0.9 protocol shapes the framework uses. It's a contract for agent output and custom integrations, but it's not a runtime validator. + +## Protocol constants + +```ts +const A2UI_WIRE_VERSION = 'v0.9'; +const A2UI_MIME_TYPE = 'application/a2ui+json'; +const A2UI_BASIC_CATALOG_ID = + 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json'; +``` + +`A2UI_WIRE_VERSION` is stamped on every envelope. `A2UI_MIME_TYPE` is the payload MIME type standardized in the v0.9.1 release. `A2UI_BASIC_CATALOG_ID` identifies the standard basic component catalog referenced by `createSurface.catalogId`. ## Dynamic values -Dynamic values are wrapped objects. A value can be literal or resolved from the surface data model by path. +Dynamic values are bare literals, JSON-pointer bindings, or client-side function calls. There are no literal wrapper objects in v0.9. ```ts -type DynamicString = - | { literalString: string } - | { path: string }; - -type DynamicNumber = - | { literalNumber: number } - | { path: string }; +interface A2uiPathRef { + path: string; +} -type DynamicBoolean = - | { literalBoolean: boolean } - | { path: string }; +interface A2uiFunctionCall { + call: string; + args?: Record; + returnType?: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'any' | 'void'; +} -type DynamicStringList = - | { literalArray: string[] } - | { path: string }; +type DynamicString = string | A2uiPathRef | A2uiFunctionCall; +type DynamicNumber = number | A2uiPathRef | A2uiFunctionCall; +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`. +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. ## Children -Layout components use either explicit child IDs or a template declaration. +Container components use either a static child-id list or a template declaration. ```ts type A2uiChildren = - | { explicitList: string[] } - | { template: { componentId: string; dataBinding: string } }; + | string[] + | { path: string; componentId: string }; ``` -The protocol layer only types this shape. Template expansion is renderer behavior. +The template form stamps `componentId` once per element of the data-model array at `path`. The protocol layer only types this shape; template expansion is renderer behavior. ## Actions -An action has a name and optional context entries. Context values use the same dynamic wrappers as component props. +An action either dispatches a named event to the agent or executes a client-side function. ```ts -interface A2uiAction { - name: string; - context?: A2uiActionContextEntry[]; +interface A2uiEventAction { + event: { + name: string; + context?: Record; + }; +} + +interface A2uiFunctionAction { + functionCall: A2uiFunctionCall; } + +type A2uiAction = A2uiEventAction | A2uiFunctionAction; ``` -`@threadplane/a2ui` does not execute actions. It only describes the payload that chat/render code can turn into an outbound `A2uiActionMessage`. +`context` is a plain object; values can be literals or `{ path }` bindings resolved at interaction time. `@threadplane/a2ui` does not execute actions. It only describes the payload that chat/render code can turn into an outbound `A2uiActionMessage`. ## Components -Every component has an `id`, optional `weight`, and a single-key `component` union. +Components are flat objects discriminated by the `component` string. Props sit at the top level of the same object — there are no type-keyed wrappers. ```ts -interface A2uiComponent { +interface A2uiComponentBase { id: string; - weight?: number; - component: A2uiComponentDef; + component: string; + catalogId?: string; // per-component catalog override + weight?: number; // flex-grow-like; only inside Row/Column + accessibility?: Record; } ``` -The exported component definitions are: - -| Definition | Main fields | -|------------|-------------| -| `Text` | `text`, `usageHint` | -| `Image` | `url`, `alt`, `width`, `height` | -| `Icon` | `icon`, `size` | -| `Video` | `url`, `autoPlay`, `controls` | -| `AudioPlayer` | `url`, `autoPlay`, `controls` | -| `Row` | `children`, `gap`, `alignment`, `distribution` | -| `Column` | `children`, `gap`, `alignment` | -| `List` | `children`, `direction` | +Input components additionally mix in `A2uiCheckable` (`checks?: A2uiCheck[]`) for client-side validation rules (typed now, enforced in a later phase). + +The basic-catalog component shapes are: + +| Component | Main fields | +|-----------|-------------| +| `Text` | `text`, `variant` | +| `Image` | `url`, `description`, `fit`, `variant` | +| `Icon` | `name` (string or `{ svgPath }`) | +| `Video` | `url` | +| `AudioPlayer` | `url`, `description` | +| `Row` | `children`, `justify`, `align` | +| `Column` | `children`, `justify`, `align` | +| `List` | `children`, `direction`, `align` | | `Card` | `child` | -| `Tabs` | `tabItems` | -| `Divider` | `direction` | -| `Modal` | `entryPointChild`, `contentChild`, `title` | -| `Button` | `child`, `primary`, `action` | -| `CheckBox` | `label`, `checked`, `action` | -| `TextField` | `label`, `text`, `textFieldType`, `validationRegexp` | -| `DateTimeInput` | `label`, `value`, `enableDate`, `enableTime` | -| `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: +| `Tabs` | `tabs` (array of `{ title, child }`) | +| `Modal` | `trigger`, `content` | +| `Divider` | `axis` | +| `Button` | `child`, `variant`, `action` | +| `CheckBox` | `label`, `value` | +| `TextField` | `label`, `value`, `variant`, `validationRegexp` | +| `DateTimeInput` | `value`, `enableDate`, `enableTime`, `min`, `max`, `label` | +| `ChoicePicker` | `options` (array of `{ label, value }`), `value`, `variant`, `displayStyle`, `filterable`, `label` | +| `Slider` | `value`, `max`, `min`, `label` | + +Note that `Button` has no text prop — its label is a `child` Text component referenced by id. `ChoicePicker` replaces the pre-v0.9 `MultipleChoice` component. + +Several 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'` | +| `variant` | `Text` | `'h1'` \| `'h2'` \| `'h3'` \| `'h4'` \| `'h5'` \| `'caption'` \| `'body'` | +| `variant` | `Image` | `'icon'` \| `'avatar'` \| `'smallFeature'` \| `'mediumFeature'` \| `'largeFeature'` \| `'header'` | +| `fit` | `Image` | `'contain'` \| `'cover'` \| `'fill'` \| `'none'` \| `'scaleDown'` | +| `variant` | `Button` | `'default'` \| `'primary'` \| `'borderless'` | +| `variant` | `TextField` | `'shortText'` \| `'longText'` \| `'number'` \| `'obscured'` | +| `variant` | `ChoicePicker` | `'mutuallyExclusive'` \| `'multipleSelection'` | +| `displayStyle` | `ChoicePicker` | `'checkbox'` \| `'chips'` | +| `justify` | `Row`, `Column` | `'start'` \| `'center'` \| `'end'` \| `'spaceAround'` \| `'spaceBetween'` \| `'spaceEvenly'` \| `'stretch'` | +| `align` | `Row`, `Column`, `List` | `'start'` \| `'center'` \| `'end'` \| `'stretch'` | | `direction` | `List` | `'vertical'` \| `'horizontal'` | -| `direction` | `Divider` | `'horizontal'` \| `'vertical'` | +| `axis` | `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: +`Tabs` is the one container that doesn't use `A2uiChildren`. Its `tabs` field is an array of `{ title: DynamicString; child: string }` pairs. -```ts -interface A2uiTabItem { - title: DynamicString; - child: string; -} -``` +The union of the basic-catalog shapes is exported as `A2uiCatalogComponent`. The broader `A2uiComponent` also admits non-basic-catalog components (`A2uiComponentBase & Record`) — renderers treat unknown `component` strings as unrenderable and fall back gracefully. -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. +The schema exposes `validationRegexp` on `TextField` and `checks` on input components, but validation execution is not implemented in this package. Treat schema fields as protocol data until a renderer wires behavior. ## Message envelopes -The parser recognizes four top-level envelopes: +Every envelope carries a `version` (`'v0.9'` on the current wire) plus exactly one envelope key: ```ts -type A2uiMessage = - | { surfaceUpdate: A2uiSurfaceUpdate } - | { dataModelUpdate: A2uiDataModelUpdate } - | { beginRendering: A2uiBeginRendering } - | { deleteSurface: A2uiDeleteSurface }; +type A2uiMessage = { version: string } & + ( + | { createSurface: A2uiCreateSurface } + | { updateComponents: A2uiUpdateComponents } + | { updateDataModel: A2uiUpdateDataModel } + | { deleteSurface: A2uiDeleteSurface } + ); ``` -### surfaceUpdate +### createSurface -Adds or replaces components for a surface. +Creates a surface and declares its component catalog. Must be the first envelope for a surface. ```json { - "surfaceUpdate": { + "version": "v0.9", + "createSurface": { "surfaceId": "checkout", - "components": [ - { "id": "root", "component": { "Card": { "child": "title" } } }, - { "id": "title", "component": { "Text": { "text": { "literalString": "Checkout" } } } } - ] + "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json", + "theme": { "primaryColor": "#2563eb" }, + "sendDataModel": true } } ``` -### dataModelUpdate +`theme` and `sendDataModel` are optional. When `sendDataModel` is true, the client attaches the surface's full data model to every outbound message. + +### updateComponents -Carries nested data-model entries. `path` is optional. +Adds or replaces components for a surface, merged incrementally by `id`. Exactly one component across the surface must have `id: "root"`; rendering starts once it is defined. ```json { - "dataModelUpdate": { + "version": "v0.9", + "updateComponents": { "surfaceId": "checkout", - "path": "/customer", - "contents": [ - { "key": "name", "valueString": "Ada" }, - { "key": "active", "valueBoolean": true } + "components": [ + { "id": "root", "component": "Card", "child": "title" }, + { "id": "title", "component": "Text", "text": "Checkout" } ] } } ``` -Each `A2uiDataModelEntry` has a `key` plus one of `valueString`, `valueNumber`, `valueBoolean`, or `valueMap`. +### updateDataModel -### beginRendering - -Identifies the root component to render for a surface. +Sets or deletes data at an optional JSON-pointer `path` (omitted or `"/"` targets the whole model). `value` is plain JSON; omitting it deletes the key at `path`. ```json { - "beginRendering": { + "version": "v0.9", + "updateDataModel": { "surfaceId": "checkout", - "root": "root", - "styles": { - "font": "Inter", - "primaryColor": "#2563eb" - } + "path": "/customer", + "value": { "name": "Ada", "active": true } } } ``` -The source comments describe `styles.font` and `styles.primaryColor` as the canonical style fields. - ### deleteSurface Removes a surface by ID. ```json -{ "deleteSurface": { "surfaceId": "checkout" } } +{ "version": "v0.9", "deleteSurface": { "surfaceId": "checkout" } } ``` ## Internal surface model @@ -194,11 +216,10 @@ interface A2uiSurface { sendDataModel?: boolean; components: Map; dataModel: Record; - styles?: { font?: string; primaryColor?: string }; } ``` -`A2uiTheme` carries optional, agent-supplied presentation hints: +`A2uiTheme` carries the optional, agent-supplied presentation hints from `createSurface.theme`: ```ts interface A2uiTheme { @@ -208,7 +229,7 @@ interface A2uiTheme { } ``` -This shape is not constrained to the wire format. Do not assume an agent sends it directly. +The `A2uiSurface` shape is not the wire format. Do not assume an agent sends it directly. ## Outbound action messages @@ -216,19 +237,23 @@ When a rendered surface sends an action back to the agent, the typed outbound sh ```ts interface A2uiActionMessage { - version: 'v1'; + version: string; // 'v0.9' action: { name: string; surfaceId: string; sourceComponentId: string; timestamp: string; - context: Record; + context?: Record; label?: string; }; metadata?: { - a2uiClientDataModel: A2uiClientDataModel; + a2uiClientDataModel?: A2uiClientDataModel; }; } ``` -The outbound action version is `v1`. The optional `label` is a human-readable label derived from the source component's text (for example, a Button's child Text), used to describe the action in transcripts; backends may ignore it. +`context` holds the action's context values already resolved against the current data model. The optional `label` is a Threadplane extension — a human-readable label derived from the source component's text (for example, a Button's child Text), used to describe the action in transcripts; backends may ignore it. + +`A2uiClientDataModel` is `{ surfaces: Record> }` — the per-surface data model snapshot, attached only when the surface's `createSurface` set `sendDataModel: true`. + +Two further client → agent shapes are typed: `A2uiErrorMessage` (`{ version, error: { code, surfaceId?, path?, message? } }`) for reporting client-side protocol errors, and `A2uiClientCapabilities` (`{ supportedCatalogIds, inlineCatalogs? }`) for advertising which catalogs the client can render. diff --git a/apps/website/content/docs/chat/a2ui/catalog.mdx b/apps/website/content/docs/chat/a2ui/catalog.mdx index 27c07d89e..a7e37d848 100644 --- a/apps/website/content/docs/chat/a2ui/catalog.mdx +++ b/apps/website/content/docs/chat/a2ui/catalog.mdx @@ -1,6 +1,6 @@ # Component Catalog -The built-in A2UI catalog provides 18 Angular components covering display, layout, interactive controls, media, and advanced inputs. Pass `a2uiBasicCatalog()` to the `ChatComponent` `views` input to enable A2UI rendering, or instantiate it directly for custom setups. +The built-in A2UI catalog provides 18 Angular components implementing the A2UI v0.9 basic catalog — display, layout, interactive controls, media, and advanced inputs. Pass `a2uiBasicCatalog()` to the `ChatComponent` `views` input to enable A2UI rendering, or instantiate it directly for custom setups. **Import:** @@ -20,6 +20,8 @@ Returns a `ViewRegistry` mapping 18 A2UI type names to their Angular component i const catalog = a2uiBasicCatalog(); ``` +On the wire, components are flat objects: `id`, a `component` string naming the catalog type, and props at the same level. Dynamic props are bare literals or `{ "path": "/pointer" }` data-model bindings. + ## Display Components ### Text @@ -33,9 +35,10 @@ Renders a span of text. | Prop | Type | Description | |------|------|-------------| | `text` | `string` | The text content to display | +| `variant` | `'h1' \| 'h2' \| 'h3' \| 'h4' \| 'h5' \| 'caption' \| 'body'` | Base text style hint. Defaults to `body` | ```json -{"id": "greeting", "component": {"Text": {"text": {"literalString": "Hello, world!"}}}} +{"id": "greeting", "component": "Text", "text": "Hello, world!", "variant": "h2"} ``` ### Image @@ -49,11 +52,13 @@ Renders an `` element. | Prop | Type | Description | |------|------|-------------| | `url` | `string` | Image source URL | -| `alt` | `string` | Alt text for accessibility | +| `description` | `string` | Accessibility text for the image | +| `fit` | `'contain' \| 'cover' \| 'fill' \| 'none' \| 'scaleDown'` | How the image resizes to its container (CSS `object-fit`). Defaults to `fill` | +| `variant` | `'icon' \| 'avatar' \| 'smallFeature' \| 'mediumFeature' \| 'largeFeature' \| 'header'` | Size/style hint. Defaults to `mediumFeature` | ### Icon -Renders an icon glyph as a text span. The `name` prop is displayed directly — use an emoji or a Unicode symbol. +Renders a [Material Symbols](https://fonts.google.com/icons) glyph by name — A2UI's canonical icon set. The v0.9 catalog names are camelCase (`accountCircle`, `shoppingCart`); the component maps them to the matching ligature. An inline `{ "svgPath": "..." }` object renders a raw SVG path instead. | A2UI type | Angular component | Selector | |-----------|-------------------|----------| @@ -61,19 +66,25 @@ Renders an icon glyph as a text span. The `name` prop is displayed directly — | Prop | Type | Description | |------|------|-------------| -| `name` | `string` | Glyph to render (e.g., `"✓"`, `"⚠️"`) | +| `name` | `string \| { svgPath: string }` | Material Symbols name (e.g. `"check"`, `"locationOn"`) or an inline SVG path | + +Host apps must load the Material Symbols Outlined stylesheet for glyphs to render — see the `@threadplane/chat` README. ### Divider -Renders a horizontal rule. Has no props. +Renders a horizontal or vertical rule. | A2UI type | Angular component | Selector | |-----------|-------------------|----------| | `Divider` | `A2uiDividerComponent` | `a2ui-divider` | +| Prop | Type | Description | +|------|------|-------------| +| `axis` | `'horizontal' \| 'vertical'` | Orientation. Defaults to `horizontal` | + ## Layout Components -Layout components receive `childKeys` — an array of component IDs — and render each child via json-render's `RenderElementComponent`. They also receive the full `spec` so that child elements can be looked up by key. +Layout components receive `childKeys` — an array of component IDs — and render each child via json-render's `RenderElementComponent`. They also receive the full `spec` so that child elements can be looked up by key. On the wire, the `children` prop is a plain array of ids (or a `{ path, componentId }` template). Row, Column, Card, and List are below. Two more layout components — [Tabs](#tabs) and [Modal](#modal) — live further down (after the Interactive section) because they also expose interactive state. All six share the same `childKeys` + `spec` rendering model. @@ -89,7 +100,9 @@ Arranges children horizontally with a flex row layout. | Prop | Type | Description | |------|------|-------------| -| `childKeys` | `string[]` | Ordered list of child component IDs | +| `childKeys` | `string[]` | Ordered list of child component IDs (from the wire `children` array) | +| `justify` | `'start' \| 'center' \| 'end' \| 'spaceAround' \| 'spaceBetween' \| 'spaceEvenly' \| 'stretch'` | Main-axis arrangement. Defaults to `start` | +| `align` | `'start' \| 'center' \| 'end' \| 'stretch'` | Cross-axis alignment. Defaults to `stretch` | | `spec` | `Spec` | Injected automatically by the render engine | ### Column @@ -103,11 +116,13 @@ Arranges children vertically with a flex column layout. | Prop | Type | Description | |------|------|-------------| | `childKeys` | `string[]` | Ordered list of child component IDs | +| `justify` | `'start' \| 'center' \| 'end' \| 'spaceAround' \| 'spaceBetween' \| 'spaceEvenly' \| 'stretch'` | Main-axis arrangement. Defaults to `start` | +| `align` | `'start' \| 'center' \| 'end' \| 'stretch'` | Cross-axis alignment. Defaults to `stretch` | | `spec` | `Spec` | Injected automatically by the render engine | ### Card -Renders children inside a rounded bordered card container. +Renders content inside a rounded bordered card container. On the wire, `Card` takes a single `child` id; wrap multiple elements in a `Column` or `Row` first. | A2UI type | Angular component | Selector | |-----------|-------------------|----------| @@ -115,12 +130,12 @@ Renders children inside a rounded bordered card container. | Prop | Type | Description | |------|------|-------------| -| `childKeys` | `string[]` | Ordered list of child component IDs | +| `childKeys` | `string[]` | Child component IDs (the wire `child` id, normalized to an array) | | `spec` | `Spec` | Injected automatically by the render engine | ### List -Renders children in a scrollable vertical list (max height 24rem). +Renders children in a scrollable list. | A2UI type | Angular component | Selector | |-----------|-------------------|----------| @@ -129,22 +144,24 @@ Renders children in a scrollable vertical list (max height 24rem). | Prop | Type | Description | |------|------|-------------| | `childKeys` | `string[]` | Ordered list of child component IDs | +| `direction` | `'vertical' \| 'horizontal'` | Layout direction. Defaults to `vertical` | +| `align` | `'start' \| 'center' \| 'end' \| 'stretch'` | Cross-axis alignment. Defaults to `stretch` | | `spec` | `Spec` | Injected automatically by the render engine | -For data-driven lists, use the `A2uiChildTemplate` form instead of static `childKeys`. Set `children` to `{"template": {"componentId": "item-template", "dataBinding": "/items"}}` and the surface component will expand the template once per array item. See the [Surface Component](/docs/chat/a2ui/surface-component) page for details. +For data-driven lists, use the template form instead of a static id array. Set `children` to `{"path": "/items", "componentId": "item-template"}` and the surface component will expand the template once per array item, resolving relative paths per item. See the [Surface Component](/docs/chat/a2ui/surface-component) page for details. ## Interactive Components Interactive components support **two-way data binding** and **button actions**. They receive two special injected props: -- `_bindings` — a `Record` auto-populated by `surfaceToSpec()` from path references in the component definition. Maps prop names to JSON Pointer paths. When the user changes a bound value, the component emits an `a2ui:datamodel:` event. Agents do **not** write `_bindings` directly — they use path references (e.g., `{"path": "/name"}`) and the render pipeline extracts bindings automatically. +- `_bindings` — a `Record` auto-populated by the surface-to-spec conversion from `{ "path": ... }` references in the component definition. Maps prop names to JSON Pointer paths. When the user changes a bound value, the component writes it back through the render state store. Agents do **not** write `_bindings` directly — they use path references (e.g., `{"path": "/name"}`) and the render pipeline extracts bindings automatically. - `emit` — injected by the render engine; components call it to dispatch events back to the chat. ### Button -Renders a button that dispatches an action when clicked. +Renders a button that dispatches an action when clicked. On the wire, a Button has no text prop — its label is a `child` Text component referenced by id. | A2UI type | Angular component | Selector | |-----------|-------------------|----------| @@ -152,37 +169,37 @@ Renders a button that dispatches an action when clicked. | Prop | Type | Description | |------|------|-------------| -| `childKeys` | `string[]` | Child component IDs whose rendered output is the button's content (e.g., a `Text` label) | -| `primary` | `boolean` | Renders the primary visual style. Defaults to `true` | +| `childKeys` | `string[]` | Child component IDs whose rendered output is the button's content (the wire `child` id, normalized to an array) | +| `variant` | `'default' \| 'primary' \| 'borderless'` | Visual style hint. Defaults to `default` | | `disabled` | `boolean` | Disables the button when `true` | -| `action` | `A2uiAction` | Agent-bound action to emit on click | -| `validationResult` | `A2uiValidationResult` | Pre-computed validation result. The button is disabled if `valid` is `false` | +| `spec` | `Spec` | Injected automatically by the render engine | | `emit` | injected | Event emitter provided by the render engine | - -The Angular `A2uiButtonComponent` input is `childKeys` (an array) — that's the resolved shape your component code sees. On the A2UI wire, a Button definition may use the singular `child` field (e.g. `"child": "submit_label"`, as shown in the [overview](/docs/chat/a2ui/overview)); the surface pipeline normalizes that singular `child` into the `childKeys` array before the component renders. Author surfaces with whichever the agent emits; both resolve to `childKeys` here. - - -**Action types:** +**Action wiring:** the wire `action` prop is protocol structure, not a component input. The surface pipeline converts an `event` action into a render `click` binding on the element: ```json -// Emit a named action with resolved context (sent back to the agent as v1 action) { + "id": "submit", + "component": "Button", + "child": "submit_label", + "variant": "primary", "action": { - "name": "submit", - "context": [ - { "key": "email", "value": { "path": "/email" } }, - { "key": "formId", "value": { "literalString": "contact" } } - ] + "event": { + "name": "submit", + "context": { + "email": { "path": "/email" }, + "formId": "contact" + } + } } } ``` -Validation is data-driven. Send a resolved `validationResult` on the component when the backend has already evaluated validity. +Context values can be path references (resolved at click time) or bare literals. The resulting `A2uiActionMessage` is emitted on ``'s `(action)` output. ### TextField -A single-line text input with optional label and placeholder. +A text input with optional label, supporting single-line, multi-line, numeric, and obscured variants. | A2UI type | Angular component | Selector | |-----------|-------------------|----------| @@ -191,25 +208,23 @@ A single-line text input with optional label and placeholder. | Prop | Type | Description | |------|------|-------------| | `label` | `string` | Input label | -| `text` | `string` | Current value (resolved from path reference). `value` is a read-only computed alias | +| `value` | `string` | Current value (resolved from a path reference) | +| `variant` | `'shortText' \| 'longText' \| 'number' \| 'obscured'` | Input type — `longText` renders a textarea, `number` a numeric input, `obscured` a password input. Defaults to `shortText` | | `placeholder` | `string` | Placeholder text | -| `validationResult` | `A2uiValidationResult` | Validation state — shows errors below input when invalid | -| `_bindings` | `Record` | Auto-populated by `surfaceToSpec()` from path references | +| `validationRegexp` | `string` | Client-side validation pattern | +| `_bindings` | `Record` | Auto-populated from path references | | `emit` | injected | Event emitter provided by the render engine | ```json { "id": "name-field", - "component": { - "TextField": { - "label": {"literalString": "Your name"}, - "text": {"path": "/name"} - } - } + "component": "TextField", + "label": "Your name", + "value": {"path": "/name"} } ``` -The path reference `{"path": "/name"}` is resolved by `surfaceToSpec()`, which also populates `_bindings` automatically. +The path reference `{"path": "/name"}` becomes a live state binding, and `_bindings` is populated automatically. ### CheckBox @@ -222,29 +237,43 @@ A labeled checkbox with two-way binding for its checked state. | Prop | Type | Description | |------|------|-------------| | `label` | `string` | Checkbox label | -| `value` | `boolean` | Current checked state (resolved from path reference) | -| `checked` | `boolean` | Deprecated back-compat alias for `value` | -| `validationResult` | `A2uiValidationResult` | Validation state — shows errors below checkbox when invalid | -| `_bindings` | `Record` | Auto-populated by `surfaceToSpec()` from path references | +| `value` | `boolean` | Current checked state (resolved from a path reference) | +| `_bindings` | `Record` | Auto-populated from path references | | `emit` | injected | Event emitter provided by the render engine | -### MultipleChoice +### ChoicePicker -A single-select dropdown or multi-select checkbox list. Set `maxAllowedSelections` to `1` or less for a dropdown; set it above `1` for a checkbox list. +Selects one or more options from a list. Replaces the pre-v0.9 `MultipleChoice` component. | A2UI type | Angular component | Selector | |-----------|-------------------|----------| -| `MultipleChoice` | `A2uiMultipleChoiceComponent` | `a2ui-multiple-choice` | +| `ChoicePicker` | `A2uiChoicePickerComponent` | `a2ui-choice-picker` | | Prop | Type | Description | |------|------|-------------| | `label` | `string` | Field label | | `options` | `{ label: string; value: string }[]` | Resolved option labels and values | -| `selections` | `string \| string[]` | Current selected value or values (resolved from path reference) | -| `maxAllowedSelections` | `number` | `1` or less renders a dropdown; higher values render checkboxes | -| `_bindings` | `Record` | Auto-populated by `surfaceToSpec()` from path references | +| `value` | `string \| string[]` | Current selection — bind to a string array in the data model | +| `variant` | `'mutuallyExclusive' \| 'multipleSelection'` | Single-select (radio) vs multi-select. Defaults to `mutuallyExclusive` | +| `displayStyle` | `'checkbox' \| 'chips'` | Visual style. Defaults to `checkbox` | +| `filterable` | `boolean` | Shows a client-side option filter input when `true` | +| `_bindings` | `Record` | Auto-populated from path references | | `emit` | injected | Event emitter provided by the render engine | +```json +{ + "id": "origin", + "component": "ChoicePicker", + "label": "Origin", + "options": [ + {"label": "Los Angeles", "value": "LAX"}, + {"label": "New York", "value": "JFK"} + ], + "value": {"path": "/origin"}, + "variant": "mutuallyExclusive" +} +``` + ### DateTimeInput A date, time, or datetime input with two-way binding. @@ -256,13 +285,12 @@ A date, time, or datetime input with two-way binding. | Prop | Type | Description | |------|------|-------------| | `label` | `string` | Input label | -| `value` | `string` | Current value (resolved from path reference) | +| `value` | `string` | Current ISO 8601 value (resolved from a path reference) | | `enableDate` | `boolean` | Include the date portion. Defaults to `true` | | `enableTime` | `boolean` | Include the time portion. Defaults to `false` | -| `min` | `string` | Minimum allowed value | -| `max` | `string` | Maximum allowed value | -| `validationResult` | `A2uiValidationResult` | Validation state — shows errors below input when invalid | -| `_bindings` | `Record` | Auto-populated by `surfaceToSpec()` from path references | +| `min` | `string` | ISO 8601 lower bound (native `min`) | +| `max` | `string` | ISO 8601 upper bound (native `max`) | +| `_bindings` | `Record` | Auto-populated from path references | | `emit` | injected | Event emitter provided by the render engine | The HTML input type (`date`, `time`, or `datetime-local`) is derived internally from `enableDate` and `enableTime`. @@ -270,14 +298,11 @@ The HTML input type (`date`, `time`, or `datetime-local`) is derived internally ```json { "id": "date-field", - "component": { - "DateTimeInput": { - "label": {"literalString": "Appointment date"}, - "value": {"path": "/appointmentDate"}, - "enableDate": true, - "enableTime": false - } - } + "component": "DateTimeInput", + "label": "Appointment date", + "value": {"path": "/appointmentDate"}, + "enableDate": true, + "enableTime": false } ``` @@ -292,26 +317,20 @@ A range slider input with two-way binding. | Prop | Type | Description | |------|------|-------------| | `label` | `string` | Slider label | -| `value` | `number` | Current value (bind via `_bindings`) | -| `minValue` | `number` | Minimum value | -| `maxValue` | `number` | Maximum value | -| `step` | `number` | Step increment | -| `validationResult` | `A2uiValidationResult` | Validation state — shows errors below slider when invalid | -| `_bindings` | `Record` | Auto-populated by `surfaceToSpec()` from path references | +| `value` | `number` | Current value (bind via a path reference) | +| `min` | `number` | Minimum value. Defaults to `0` | +| `max` | `number` | Maximum value | +| `_bindings` | `Record` | Auto-populated from path references | | `emit` | injected | Event emitter provided by the render engine | ```json { "id": "volume", - "component": { - "Slider": { - "label": {"literalString": "Volume"}, - "value": {"path": "/volume"}, - "minValue": 0, - "maxValue": 100, - "step": 1 - } - } + "component": "Slider", + "label": "Volume", + "value": {"path": "/volume"}, + "min": 0, + "max": 100 } ``` @@ -319,7 +338,7 @@ A range slider input with two-way binding. ### Tabs -A tabbed container that shows one child panel at a time based on the selected tab index. +A tabbed container that shows one child panel at a time. On the wire, `tabs` is an array of `{ title, child }` pairs; the surface pipeline resolves the titles into a `tabTitles` string array and the child ids into `childKeys`. | A2UI type | Angular component | Selector | |-----------|-------------------|----------| @@ -327,30 +346,25 @@ A tabbed container that shows one child panel at a time based on the selected ta | Prop | Type | Description | |------|------|-------------| -| `tabs` | `{label: string, childKeys: string[]}[]` | Tab definitions with labels and child component IDs | -| `selected` | `number` | Currently selected tab index. Defaults to `0` | -| `_bindings` | `Record` | Auto-populated by `surfaceToSpec()` from path references | +| `tabTitles` | `string[]` | Resolved tab titles, in order | +| `childKeys` | `string[]` | One child component ID per tab | | `spec` | `Spec` | Injected automatically by the render engine | | `emit` | injected | Event emitter provided by the render engine | ```json { "id": "info-tabs", - "component": { - "Tabs": { - "tabs": [ - {"label": "Overview", "childKeys": ["overview-content"]}, - {"label": "Details", "childKeys": ["detail-list"]} - ], - "selected": {"path": "/activeTab"} - } - } + "component": "Tabs", + "tabs": [ + {"title": "Overview", "child": "overview-content"}, + {"title": "Details", "child": "detail-list"} + ] } ``` ### Modal -A dialog overlay that renders child content when open. Supports an optional title and dismissible close button. +A dialog overlay. On the wire, `Modal` names a `trigger` component (rendered inline; clicking it opens the modal) and a `content` component (rendered inside the overlay). Clicking the backdrop dismisses it. | A2UI type | Angular component | Selector | |-----------|-------------------|----------| @@ -358,25 +372,16 @@ A dialog overlay that renders child content when open. Supports an optional titl | Prop | Type | Description | |------|------|-------------| -| `title` | `string` | Optional modal heading | -| `open` | `boolean` | Whether the modal is visible | -| `childKeys` | `string[]` | Child component IDs rendered inside the modal body | -| `dismissible` | `boolean` | Shows a close button when `true`. Defaults to `true` | -| `_bindings` | `Record` | Bind `open` to a data model path | +| `childKeys` | `string[]` | The wire `trigger` and `content` ids, in that order | | `spec` | `Spec` | Injected automatically by the render engine | | `emit` | injected | Event emitter provided by the render engine | ```json { "id": "confirm-dialog", - "component": { - "Modal": { - "title": {"literalString": "Confirm Action"}, - "open": {"path": "/showConfirm"}, - "childKeys": ["confirm-message", "confirm-buttons"], - "dismissible": true - } - } + "component": "Modal", + "trigger": "open-button", + "content": "confirm-content" } ``` @@ -384,7 +389,7 @@ A dialog overlay that renders child content when open. Supports an optional titl ### Video -Renders an HTML5 `