From a3af4c327c1ad4c3b7a0bee327fdba63bd2a61a4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 10 Apr 2026 11:52:53 -0700 Subject: [PATCH 1/9] feat(a2ui): add v0.9 A2uiActionMessage and A2uiClientDataModel types Add sendDataModel flag to A2uiSurface runtime state, new A2uiClientDataModel and A2uiActionMessage envelope types, export them from the public API, and fix a pre-existing TS7017 error in functions.ts that was blocking the build. Co-Authored-By: Claude Sonnet 4.6 --- libs/a2ui/src/index.ts | 1 + libs/a2ui/src/lib/functions.ts | 6 ++++-- libs/a2ui/src/lib/types.ts | 24 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/libs/a2ui/src/index.ts b/libs/a2ui/src/index.ts index b2c38fcfc..e0efc8fe0 100644 --- a/libs/a2ui/src/index.ts +++ b/libs/a2ui/src/index.ts @@ -7,6 +7,7 @@ export type { A2uiComponent, A2uiCreateSurface, A2uiUpdateComponents, A2uiUpdateDataModel, A2uiDeleteSurface, A2uiMessage, A2uiSurface, + A2uiClientDataModel, A2uiActionMessage, } from './lib/types'; export { getByPointer, setByPointer, deleteByPointer } from './lib/pointer'; export { createA2uiMessageParser } from './lib/parser'; diff --git a/libs/a2ui/src/lib/functions.ts b/libs/a2ui/src/lib/functions.ts index 01d689ab1..37815e7d7 100644 --- a/libs/a2ui/src/lib/functions.ts +++ b/libs/a2ui/src/lib/functions.ts @@ -57,8 +57,10 @@ const FUNCTIONS: Record = { // Navigation openUrl: (args) => { - if (typeof globalThis.window !== 'undefined') { - globalThis.window.open(String(args['url']), '_blank'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const win = globalThis as any; + if (typeof win['window'] !== 'undefined') { + win['window'].open(String(args['url']), '_blank'); } return null; }, diff --git a/libs/a2ui/src/lib/types.ts b/libs/a2ui/src/lib/types.ts index 225626546..cd2c694a0 100644 --- a/libs/a2ui/src/lib/types.ts +++ b/libs/a2ui/src/lib/types.ts @@ -106,6 +106,30 @@ export interface A2uiSurface { surfaceId: string; catalogId: string; theme?: A2uiTheme; + sendDataModel?: boolean; components: Map; dataModel: Record; } + +// --- v0.9 Outbound Action --- + +/** v0.9 client data model envelope — attached when sendDataModel is true. */ +export interface A2uiClientDataModel { + version: 'v0.9'; + surfaces: Record>; +} + +/** v0.9 outbound action message — sent when a component's event action fires. */ +export interface A2uiActionMessage { + version: 'v0.9'; + action: { + name: string; + surfaceId: string; + sourceComponentId: string; + timestamp: string; + context: Record; + }; + metadata?: { + a2uiClientDataModel: A2uiClientDataModel; + }; +} From eb9d8fcb7562e1ec1f9a6cd496615d310dbc3bc5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 10 Apr 2026 11:54:11 -0700 Subject: [PATCH 2/9] feat(chat): preserve sendDataModel flag in A2UI surface store --- libs/chat/src/lib/a2ui/surface-store.spec.ts | 12 ++++++++++++ libs/chat/src/lib/a2ui/surface-store.ts | 1 + 2 files changed, 13 insertions(+) diff --git a/libs/chat/src/lib/a2ui/surface-store.spec.ts b/libs/chat/src/lib/a2ui/surface-store.spec.ts index eb21a8396..39015bbdb 100644 --- a/libs/chat/src/lib/a2ui/surface-store.spec.ts +++ b/libs/chat/src/lib/a2ui/surface-store.spec.ts @@ -96,4 +96,16 @@ describe('createA2uiSurfaceStore', () => { store.apply({ type: 'updateComponents', surfaceId: 'nope', components: [] }); expect(store.surfaces().size).toBe(0); }); + + it('preserves sendDataModel flag from createSurface', () => { + const store = setup(); + store.apply({ type: 'createSurface', surfaceId: 's1', catalogId: 'basic', sendDataModel: true }); + expect(store.surfaces().get('s1')!.sendDataModel).toBe(true); + }); + + it('defaults sendDataModel to undefined when not set', () => { + const store = setup(); + store.apply({ type: 'createSurface', surfaceId: 's1', catalogId: 'basic' }); + expect(store.surfaces().get('s1')!.sendDataModel).toBeUndefined(); + }); }); diff --git a/libs/chat/src/lib/a2ui/surface-store.ts b/libs/chat/src/lib/a2ui/surface-store.ts index 5492bc683..1b3972dbd 100644 --- a/libs/chat/src/lib/a2ui/surface-store.ts +++ b/libs/chat/src/lib/a2ui/surface-store.ts @@ -22,6 +22,7 @@ export function createA2uiSurfaceStore(): A2uiSurfaceStore { surfaceId: message.surfaceId, catalogId: message.catalogId, theme: message.theme, + sendDataModel: message.sendDataModel, components: new Map(), dataModel: {}, }); From 4a7e84af84bf6e8acbd6e2cd9b05c77eecc2dd05 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 10 Apr 2026 11:55:56 -0700 Subject: [PATCH 3/9] feat(chat): resolve context DynamicValues and add sourceComponentId in surfaceToSpec Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/a2ui/surface.component.spec.ts | 93 ++++++++++++++++++- libs/chat/src/lib/a2ui/surface.component.ts | 13 ++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/libs/chat/src/lib/a2ui/surface.component.spec.ts b/libs/chat/src/lib/a2ui/surface.component.spec.ts index 021aa045a..12f3a383c 100644 --- a/libs/chat/src/lib/a2ui/surface.component.spec.ts +++ b/libs/chat/src/lib/a2ui/surface.component.spec.ts @@ -79,7 +79,7 @@ describe('surfaceToSpec — action mapping', () => { expect(btnElement.on).toBeDefined(); expect(btnElement.on!['click']).toEqual({ action: 'a2ui:event', - params: { surfaceId: 's1', name: 'formSubmit', context: { formId: 'signup' } }, + params: { surfaceId: 's1', sourceComponentId: 'btn', name: 'formSubmit', context: { formId: 'signup' } }, }); expect(btnElement.props['action']).toBeUndefined(); }); @@ -154,6 +154,97 @@ describe('A2uiSurfaceComponent — consumer handlers', () => { }); }); +describe('surfaceToSpec — v0.9 event action', () => { + function makeSurface(components: A2uiComponent[], dataModel: Record = {}): A2uiSurface { + const map = new Map(); + for (const c of components) map.set(c.id, c); + return { surfaceId: 's1', catalogId: 'basic', components: map, dataModel }; + } + + it('resolves context DynamicValue paths against data model', () => { + const surface = makeSurface( + [ + { id: 'root', component: 'Column', children: ['btn'] }, + { + id: 'btn', + component: 'Button', + label: 'Submit', + action: { event: { name: 'formSubmit', context: { email: { path: '/email' } } } }, + }, + ], + { email: 'alice@example.com' }, + ); + const spec = surfaceToSpec(surface)!; + const params = spec.elements['btn'].on!['click'].params; + expect(params['context']).toEqual({ email: 'alice@example.com' }); + }); + + it('resolves context FunctionCall values', () => { + const surface = makeSurface( + [ + { id: 'root', component: 'Column', children: ['btn'] }, + { + id: 'btn', + component: 'Button', + label: 'Format', + action: { event: { name: 'show', context: { price: { call: 'formatCurrency', args: { value: { path: '/amount' } } } } } }, + }, + ], + { amount: 42 }, + ); + const spec = surfaceToSpec(surface)!; + const params = spec.elements['btn'].on!['click'].params; + expect(params['context']).toEqual({ price: '$42.00' }); + }); + + it('passes literal context values through unchanged', () => { + const surface = makeSurface( + [ + { id: 'root', component: 'Column', children: ['btn'] }, + { + id: 'btn', + component: 'Button', + label: 'Go', + action: { event: { name: 'navigate', context: { page: 'home' } } }, + }, + ], + ); + const spec = surfaceToSpec(surface)!; + const params = spec.elements['btn'].on!['click'].params; + expect(params['context']).toEqual({ page: 'home' }); + }); + + it('includes sourceComponentId in event action params', () => { + const surface = makeSurface([ + { id: 'root', component: 'Column', children: ['submit-btn'] }, + { + id: 'submit-btn', + component: 'Button', + label: 'Submit', + action: { event: { name: 'formSubmit' } }, + }, + ]); + const spec = surfaceToSpec(surface)!; + const params = spec.elements['submit-btn'].on!['click'].params; + expect(params['sourceComponentId']).toBe('submit-btn'); + }); + + it('defaults context to empty object when not specified', () => { + const surface = makeSurface([ + { id: 'root', component: 'Column', children: ['btn'] }, + { + id: 'btn', + component: 'Button', + label: 'Click', + action: { event: { name: 'clicked' } }, + }, + ]); + const spec = surfaceToSpec(surface)!; + const params = spec.elements['btn'].on!['click'].params; + expect(params['context']).toEqual({}); + }); +}); + describe('surfaceToSpec — validation', () => { function makeSurface(components: A2uiComponent[], dataModel: Record = {}): A2uiSurface { const map = new Map(); diff --git a/libs/chat/src/lib/a2ui/surface.component.ts b/libs/chat/src/lib/a2ui/surface.component.ts index 9091c606f..fc84de13f 100644 --- a/libs/chat/src/lib/a2ui/surface.component.ts +++ b/libs/chat/src/lib/a2ui/surface.component.ts @@ -41,10 +41,21 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null { if (comp.action) { if ('event' in comp.action) { const evt = comp.action.event; + const resolvedContext: Record = {}; + if (evt.context) { + for (const [key, value] of Object.entries(evt.context)) { + resolvedContext[key] = resolveDynamic(value, surface.dataModel); + } + } on = { click: { action: 'a2ui:event', - params: { surfaceId: surface.surfaceId, name: evt.name, context: evt.context }, + params: { + surfaceId: surface.surfaceId, + sourceComponentId: id, + name: evt.name, + context: resolvedContext, + }, }, }; } else if ('functionCall' in comp.action) { From 4a145ebe6e6b96008e3f2e64db18a3013073ebdc Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 10 Apr 2026 11:57:54 -0700 Subject: [PATCH 4/9] feat(chat): add (action) output and build v0.9 A2uiActionMessage envelope Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/a2ui/surface.component.spec.ts | 70 ++++++++++++++++++- libs/chat/src/lib/a2ui/surface.component.ts | 33 ++++++++- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/libs/chat/src/lib/a2ui/surface.component.spec.ts b/libs/chat/src/lib/a2ui/surface.component.spec.ts index 12f3a383c..cbd565971 100644 --- a/libs/chat/src/lib/a2ui/surface.component.spec.ts +++ b/libs/chat/src/lib/a2ui/surface.component.spec.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { describe, it, expect } from 'vitest'; import type { A2uiSurface, A2uiComponent } from '@cacheplane/a2ui'; -import { surfaceToSpec } from './surface.component'; +import { surfaceToSpec, buildA2uiActionMessage } from './surface.component'; describe('A2uiSurfaceComponent — data flow', () => { function makeSurface(components: A2uiComponent[], dataModel: Record = {}): A2uiSurface { @@ -337,3 +337,71 @@ describe('surfaceToSpec — validation', () => { expect(spec.elements['root'].props['checks']).toBeUndefined(); }); }); + +describe('buildA2uiActionMessage', () => { + function makeSurface( + components: A2uiComponent[], + dataModel: Record = {}, + sendDataModel?: boolean, + ): A2uiSurface { + const map = new Map(); + for (const c of components) map.set(c.id, c); + return { surfaceId: 's1', catalogId: 'basic', sendDataModel, components: map, dataModel }; + } + + it('builds a v0.9 action message with all required fields', () => { + const surface = makeSurface([{ id: 'root', component: 'Text' }]); + const params = { + surfaceId: 's1', + sourceComponentId: 'submit-btn', + name: 'formSubmit', + context: { email: 'alice@example.com' }, + }; + const msg = buildA2uiActionMessage(params, surface); + expect(msg.version).toBe('v0.9'); + expect(msg.action.name).toBe('formSubmit'); + expect(msg.action.surfaceId).toBe('s1'); + expect(msg.action.sourceComponentId).toBe('submit-btn'); + expect(msg.action.context).toEqual({ email: 'alice@example.com' }); + expect(msg.action.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(msg.metadata).toBeUndefined(); + }); + + it('attaches data model when sendDataModel is true', () => { + const surface = makeSurface( + [{ id: 'root', component: 'Text' }], + { name: 'Alice', email: 'alice@co.com' }, + true, + ); + const params = { surfaceId: 's1', sourceComponentId: 'btn', name: 'submit', context: {} }; + const msg = buildA2uiActionMessage(params, surface); + expect(msg.metadata).toBeDefined(); + expect(msg.metadata!.a2uiClientDataModel.version).toBe('v0.9'); + expect(msg.metadata!.a2uiClientDataModel.surfaces['s1']).toEqual({ name: 'Alice', email: 'alice@co.com' }); + }); + + it('does not attach data model when sendDataModel is false', () => { + const surface = makeSurface( + [{ id: 'root', component: 'Text' }], + { name: 'Alice' }, + false, + ); + const params = { surfaceId: 's1', sourceComponentId: 'btn', name: 'submit', context: {} }; + const msg = buildA2uiActionMessage(params, surface); + expect(msg.metadata).toBeUndefined(); + }); + + it('does not attach data model when sendDataModel is undefined', () => { + const surface = makeSurface([{ id: 'root', component: 'Text' }], { name: 'Alice' }); + const params = { surfaceId: 's1', sourceComponentId: 'btn', name: 'submit', context: {} }; + const msg = buildA2uiActionMessage(params, surface); + expect(msg.metadata).toBeUndefined(); + }); + + it('defaults context to empty object when not provided in params', () => { + const surface = makeSurface([{ id: 'root', component: 'Text' }]); + const params = { surfaceId: 's1', sourceComponentId: 'btn', name: 'click' } as any; + const msg = buildA2uiActionMessage(params, surface); + expect(msg.action.context).toEqual({}); + }); +}); diff --git a/libs/chat/src/lib/a2ui/surface.component.ts b/libs/chat/src/lib/a2ui/surface.component.ts index fc84de13f..896b190e2 100644 --- a/libs/chat/src/lib/a2ui/surface.component.ts +++ b/libs/chat/src/lib/a2ui/surface.component.ts @@ -3,7 +3,7 @@ import { Component, computed, input, output, ChangeDetectionStrategy, } from '@angular/core'; import type { Spec } from '@json-render/core'; -import type { A2uiSurface, A2uiChildTemplate } from '@cacheplane/a2ui'; +import type { A2uiSurface, A2uiChildTemplate, A2uiActionMessage } from '@cacheplane/a2ui'; import { resolveDynamic, getByPointer, evaluateCheckRules } from '@cacheplane/a2ui'; import { RenderSpecComponent, toRenderRegistry } from '@cacheplane/render'; import type { ViewRegistry, RenderEvent } from '@cacheplane/render'; @@ -113,6 +113,32 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null { return { root: 'root', elements, state: surface.dataModel } as Spec; } +/** Builds a v0.9 A2uiActionMessage from handler params and the current surface. */ +export function buildA2uiActionMessage( + params: Record, + surface: A2uiSurface, +): A2uiActionMessage { + const message: A2uiActionMessage = { + version: 'v0.9', + action: { + name: params['name'] as string, + surfaceId: surface.surfaceId, + sourceComponentId: params['sourceComponentId'] as string, + timestamp: new Date().toISOString(), + context: (params['context'] as Record) ?? {}, + }, + }; + if (surface.sendDataModel) { + message.metadata = { + a2uiClientDataModel: { + version: 'v0.9', + surfaces: { [surface.surfaceId]: surface.dataModel }, + }, + }; + } + return message; +} + @Component({ selector: 'a2ui-surface', standalone: true, @@ -134,6 +160,7 @@ export class A2uiSurfaceComponent { readonly catalog = input.required(); readonly handlers = input) => unknown | Promise>>({}); readonly events = output(); + readonly action = output(); /** Convert the A2UI surface to a json-render Spec for rendering. */ readonly spec = computed(() => surfaceToSpec(this.surface())); @@ -146,7 +173,9 @@ export class A2uiSurfaceComponent { const consumerHandlers = this.handlers(); return { 'a2ui:event': (params: Record) => { - return params; + const message = buildA2uiActionMessage(params, this.surface()); + this.action.emit(message); + return message; }, 'a2ui:localAction': (params: Record) => { const call = params['call'] as string; From 457af9813cd90c0d1a0d7c1a86db52fff5c4b389 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 10 Apr 2026 11:58:54 -0700 Subject: [PATCH 5/9] feat(chat): route A2UI actions via dedicated (action) output in ChatComponent --- .../lib/compositions/chat/chat.component.ts | 25 ++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index c5ecd584a..671ab40a0 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -15,6 +15,7 @@ import { import { DomSanitizer } from '@angular/platform-browser'; import type { AgentRef } from '@cacheplane/angular'; import type { ViewRegistry, RenderEvent } from '@cacheplane/render'; +import type { A2uiActionMessage } from '@cacheplane/a2ui'; import type { StateStore } from '@json-render/core'; import { ChatMessagesComponent } from '../../primitives/chat-messages/chat-messages.component'; import { MessageTemplateDirective } from '../../primitives/chat-messages/message-template.directive'; @@ -154,6 +155,7 @@ import { KeyValuePipe } from '@angular/common'; [surface]="entry.value" [catalog]="catalog" [handlers]="handlers()" + (action)="onA2uiAction($event)" (events)="onA2uiEvent($event, index, entry.key)" /> } @@ -292,24 +294,13 @@ export class ChatComponent { this.renderEvent.emit({ messageIndex, event }); } - onA2uiEvent(event: RenderEvent, messageIndex: number, surfaceId: string): void { - // Auto-route A2UI event actions back to the agent - if (event.type === 'handler' && event.action === 'a2ui:event') { - const params = event.params as Record; - this.ref().submit({ - messages: [{ - role: 'human', - content: JSON.stringify({ - type: 'a2ui_event', - surfaceId: params['surfaceId'], - name: params['name'], - context: params['context'], - }), - }], - }); - } + onA2uiAction(message: A2uiActionMessage): void { + this.ref().submit({ + messages: [{ role: 'human', content: JSON.stringify(message) }], + }); + } - // Still emit for consumer observation/logging + onA2uiEvent(event: RenderEvent, messageIndex: number, surfaceId: string): void { this.renderEvent.emit({ messageIndex, surfaceId, event }); } } From c5e7071761f78fbe476fa8d2841ab4473cbb55ee Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 10 Apr 2026 11:59:57 -0700 Subject: [PATCH 6/9] feat(cockpit): update A2UI contact form with sendDataModel and v0.9 action parsing Co-Authored-By: Claude Sonnet 4.6 --- cockpit/chat/a2ui/python/src/graph.py | 34 ++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/cockpit/chat/a2ui/python/src/graph.py b/cockpit/chat/a2ui/python/src/graph.py index f66894a8e..ec5a33cd2 100644 --- a/cockpit/chat/a2ui/python/src/graph.py +++ b/cockpit/chat/a2ui/python/src/graph.py @@ -12,7 +12,7 @@ A2UI_PREFIX = "---a2ui_JSON---" CONTACT_FORM_JSONL = A2UI_PREFIX + "\n" + "\n".join([ - json.dumps({"type": "createSurface", "surfaceId": "contact", "catalogId": "basic"}), + json.dumps({"type": "createSurface", "surfaceId": "contact", "catalogId": "basic", "sendDataModel": True}), json.dumps({"type": "updateDataModel", "surfaceId": "contact", "value": { "name": "", "email": "", "department": "Engineering", "consent": False, }}), @@ -56,7 +56,11 @@ ]}}, "message": "Complete all required fields and agree to be contacted"}, ], - "action": {"event": {"name": "formSubmit", "context": {"formId": "contact"}}}}, + "action": {"event": {"name": "formSubmit", "context": { + "name": {"path": "/name"}, + "email": {"path": "/email"}, + "department": {"path": "/department"}, + }}}}, ]}), ]) @@ -71,10 +75,10 @@ def build_a2ui_graph(): async def create_form(state: MessagesState) -> dict: last = state["messages"][-1] - # If this is an a2ui_event, route to event handling + # If this is a v0.9 action message, route to event handling try: payload = json.loads(last.content) - if isinstance(payload, dict) and payload.get("type") == "a2ui_event": + if isinstance(payload, dict) and payload.get("version") == "v0.9" and "action" in payload: return await handle_event(state, payload) except (json.JSONDecodeError, AttributeError): pass @@ -83,9 +87,27 @@ async def create_form(state: MessagesState) -> dict: return {"messages": [AIMessage(content=CONTACT_FORM_JSONL)]} async def handle_event(state: MessagesState, payload: dict) -> dict: - name = payload.get("context", {}).get("formId", "unknown") + action = payload["action"] + context = action.get("context", {}) + name = context.get("name", "Unknown") + email = context.get("email", "not provided") + department = context.get("department", "not specified") + + # Data model is available via metadata when sendDataModel is true + data_model = ( + payload.get("metadata", {}) + .get("a2uiClientDataModel", {}) + .get("surfaces", {}) + .get(action["surfaceId"], {}) + ) + return {"messages": [AIMessage( - content=f"Thanks for submitting the **{name}** form! We'll be in touch soon.", + content=( + f"Thanks **{name}**! We received your submission:\n\n" + f"- **Email:** {email}\n" + f"- **Department:** {department}\n\n" + f"We'll be in touch soon." + ), )]} graph = StateGraph(MessagesState) From a93102c46fab946fd028d5636829e00331f6422a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 10 Apr 2026 12:01:02 -0700 Subject: [PATCH 7/9] docs: add A2UI events & data model transport section to overview Co-Authored-By: Claude Sonnet 4.6 --- .../content/docs/render/a2ui/overview.mdx | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/apps/website/content/docs/render/a2ui/overview.mdx b/apps/website/content/docs/render/a2ui/overview.mdx index a1b35dd86..9aa7b0ace 100644 --- a/apps/website/content/docs/render/a2ui/overview.mdx +++ b/apps/website/content/docs/render/a2ui/overview.mdx @@ -289,6 +289,111 @@ Validation styling uses CSS custom properties: | `--a2ui-input-bg` | `rgba(255,255,255,0.05)` | Input background | | `--a2ui-label` | `rgba(255,255,255,0.6)` | Label text color | +## Events & Data Model Transport + +When a user triggers an event action (e.g., clicking a button with `action.event`), the Angular renderer builds a v0.9-compliant action message and sends it back to the agent. Local actions (`action.functionCall`) execute client-side only — the agent never sees them. + +### Action Message Shape + +The outbound message follows the [v0.9 spec](https://a2ui.org): + +```json +{ + "version": "v0.9", + "action": { + "name": "formSubmit", + "surfaceId": "contact", + "sourceComponentId": "submit-btn", + "timestamp": "2026-04-10T14:30:00.000Z", + "context": { + "name": "Alice", + "email": "alice@example.com" + } + } +} +``` + +| Field | Description | +|-------|-------------| +| `version` | Always `"v0.9"` | +| `action.name` | The event name from the component's `action.event.name` | +| `action.surfaceId` | The surface that owns this component | +| `action.sourceComponentId` | The `id` of the component that triggered the event | +| `action.timestamp` | ISO 8601 timestamp of when the action was dispatched | +| `action.context` | Resolved values from `action.event.context` — path refs and function calls are evaluated against the current data model | + +### Context Resolution + +Context values in `action.event.context` are `DynamicValue`s — they can be literals, path references, or function calls. They are resolved at dispatch time against the current data model: + +```json +{ + "action": { + "event": { + "name": "formSubmit", + "context": { + "name": {"path": "/name"}, + "email": {"path": "/email"}, + "total": {"call": "formatCurrency", "args": {"value": {"path": "/amount"}}} + } + } + } +} +``` + +When the user clicks the button, the renderer resolves `/name` and `/email` from the data model and calls `formatCurrency` on `/amount`, producing a flat `context` object with concrete values. + +### sendDataModel + +Set `sendDataModel: true` on `createSurface` to attach the full data model snapshot to every outbound action: + +```json +{"type": "createSurface", "surfaceId": "contact", "catalogId": "basic", "sendDataModel": true} +``` + +When enabled, the action message includes a `metadata` field: + +```json +{ + "version": "v0.9", + "action": { "..." : "..." }, + "metadata": { + "a2uiClientDataModel": { + "version": "v0.9", + "surfaces": { + "contact": { + "name": "Alice", + "email": "alice@example.com", + "department": "Engineering" + } + } + } + } +} +``` + +The data model is only sent with event actions — there are no passive change notifications on input changes. This matches the v0.9 spec requirement that the data model piggybacks on outbound messages. + +### Angular Integration + +`A2uiSurfaceComponent` exposes two outputs: + +| Output | Type | Description | +|--------|------|-------------| +| `(action)` | `A2uiActionMessage` | Agent-bound action messages — the complete v0.9 envelope | +| `(events)` | `RenderEvent` | All render events (state changes, handler calls, lifecycle) for observation | + +`ChatComponent` auto-routes `(action)` events to the agent as human messages. For standalone usage, bind `(action)` directly: + +```html + +``` + ## What's Next From e95efd8cb30b0068cd3cec6575f2052042b83a69 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 10 Apr 2026 12:01:34 -0700 Subject: [PATCH 8/9] docs: update Button action examples with context path refs --- apps/website/content/docs/render/a2ui/catalog.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/website/content/docs/render/a2ui/catalog.mdx b/apps/website/content/docs/render/a2ui/catalog.mdx index 4110af97c..cc1213e53 100644 --- a/apps/website/content/docs/render/a2ui/catalog.mdx +++ b/apps/website/content/docs/render/a2ui/catalog.mdx @@ -159,10 +159,10 @@ Renders a button that dispatches an action when clicked. **Action types:** ```json -// Emit a named event (sent back to the agent) -{"action": {"event": {"name": "submit", "context": {"formId": "contact"}}}} +// Emit a named event with resolved context (sent back to the agent as v0.9 action) +{"action": {"event": {"name": "submit", "context": {"email": {"path": "/email"}, "formId": "contact"}}}} -// Execute a local function (e.g., open a URL) +// Execute a local function (e.g., open a URL) — agent never sees this {"action": {"functionCall": {"call": "openUrl", "args": {"url": "https://example.com"}}}} ``` From d8c7b8f268e3802c8c99da0a064854834afe8705 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 10 Apr 2026 12:05:42 -0700 Subject: [PATCH 9/9] fix(chat): export A2uiActionMessage from public API and fix unused variable Co-Authored-By: Claude Opus 4.6 --- cockpit/chat/a2ui/python/src/graph.py | 5 +++-- libs/chat/src/public-api.ts | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cockpit/chat/a2ui/python/src/graph.py b/cockpit/chat/a2ui/python/src/graph.py index ec5a33cd2..07f1e5613 100644 --- a/cockpit/chat/a2ui/python/src/graph.py +++ b/cockpit/chat/a2ui/python/src/graph.py @@ -93,8 +93,9 @@ async def handle_event(state: MessagesState, payload: dict) -> dict: email = context.get("email", "not provided") department = context.get("department", "not specified") - # Data model is available via metadata when sendDataModel is true - data_model = ( + # Full data model is available via metadata when sendDataModel is true. + # Use it when you need values beyond what context provides. + data_model = ( # noqa: F841 payload.get("metadata", {}) .get("a2uiClientDataModel", {}) .get("surfaces", {}) diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 0f513baef..471c4403c 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -68,6 +68,8 @@ export type { A2uiSurfaceStore } from './lib/a2ui/surface-store'; export { A2uiSurfaceComponent } from './lib/a2ui/surface.component'; export { a2uiBasicCatalog } from './lib/a2ui/catalog/index'; export { A2uiValidationErrorsComponent } from './lib/a2ui/catalog/validation-errors.component'; +export { buildA2uiActionMessage } from './lib/a2ui/surface.component'; +export type { A2uiActionMessage, A2uiClientDataModel } from '@cacheplane/a2ui'; // Test utilities export { createMockAgentRef } from './lib/testing/mock-agent-ref';