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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/website/content/docs/a2ui/guides/message-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ Details worth pinning down:
- **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's `createSurface` set `sendDataModel: true`. It's omitted otherwise. When present, it's an `A2uiClientDataModel` — `{ surfaces: Record<surfaceId, Record<string, unknown>> }`, the per-surface model keyed by `surfaceId`. See [the schema reference](/docs/a2ui/reference/schema#outbound-action-messages) for the full outbound shape.
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<surfaceId, Record<string, unknown>> }`, the per-surface **live** model keyed by `surfaceId` — user edits included, renderer-internal keys stripped. See [the schema reference](/docs/a2ui/reference/schema#outbound-action-messages) for the full outbound shape.

## Relationship to Google's A2UI

Expand Down
4 changes: 2 additions & 2 deletions apps/website/content/docs/chat/a2ui/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ onInput(event: Event): void {
}
```

The write-back protocol is client-side state. If the surface's `createSurface` set `sendDataModel: true`, outgoing action messages also include the current surface data model snapshot under `metadata.a2uiClientDataModel`.
The write-back protocol is client-side state. If the surface's `createSurface` set `sendDataModel: true`, outgoing action messages also include the current **live** surface data model (user edits included) under `metadata.a2uiClientDataModel`.

## Actions

Expand Down Expand Up @@ -186,7 +186,7 @@ The surface-to-spec conversion turns this into a render `click` binding that cal
}
```

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

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

Expand Down
14 changes: 14 additions & 0 deletions apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -8639,6 +8639,20 @@
"```ts\nproviders: [provideViews({ ...a2uiBasicCatalog(), MyWidget: MyWidgetComponent })]\n```"
]
},
{
"name": "a2uiClientCapabilities",
"kind": "function",
"description": "The A2UI client capabilities this renderer supports — the typed\n`a2uiClientCapabilities` metadata a host attaches to agent requests so\nthe agent knows which component catalogs it may target\n(catalog negotiation, A2UI v0.9 transport metadata).",
"signature": "a2uiClientCapabilities(): A2uiClientCapabilities",
"params": [],
"returns": {
"type": "A2uiClientCapabilities",
"description": ""
},
"examples": [
"```ts\nconst metadata = { a2uiClientCapabilities: a2uiClientCapabilities() };\n```"
]
},
{
"name": "action",
"kind": "function",
Expand Down
32 changes: 32 additions & 0 deletions docs/superpowers/plans/2026-08-17-a2ui-v09-phase4-senddatamodel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# A2UI v0.9 Phase 4 — sendDataModel Round-Trip Implementation Plan

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

**Goal:** Complete the `createSurface.sendDataModel` round-trip: outbound action messages attach the **live** per-surface data model (user edits included, internal keys stripped), and hosts get a typed `a2uiClientCapabilities()` helper for catalog negotiation metadata.

**Architecture:** Phase 1 already attaches `metadata.a2uiClientDataModel` when `surface.sendDataModel` — but from the agent-seeded snapshot. Phase 3 gave `<a2ui-surface>` the live store; this phase passes the merged live model (minus the reserved `_a2uiChecks` key) into `buildA2uiActionMessage`, and adds a tiny capabilities helper. Base: main after #819.

---

### Task 1: live model in the action metadata

**Files:** Modify `libs/chat/src/lib/a2ui/surface.component.ts` (+spec).

- [ ] Failing spec: a surface created with `sendDataModel: true` whose bound TextField was edited via the live store → emitted action's `metadata.a2uiClientDataModel.surfaces[surfaceId]` contains the live-typed value and does NOT contain `_a2uiChecks`.
- [ ] Implement: in the `a2ui:event` success path, call `buildA2uiActionMessage({...params, context}, { ...surf, dataModel: publicModel })` where `publicModel` = merged live model with `_a2uiChecks` removed. Green + commit.

### Task 2: capabilities helper

**Files:** Create `libs/chat/src/lib/a2ui/capabilities.ts` (+spec); modify `libs/chat/src/public-api.ts`.

- [ ] `a2uiClientCapabilities(): A2uiClientCapabilities` returning `{ supportedCatalogIds: [A2UI_BASIC_CATALOG_ID] }` — the typed metadata hosts attach to requests for catalog negotiation. Export + api-docs.

### Task 3: prompts + docs

**Files:** `examples/*/python/src/schemas/a2ui_v09.py` twins (sendDataModel note), `apps/website/content/docs/a2ui/guides/message-protocol.mdx` + `chat/a2ui/overview.mdx` (live-model metadata wording), `npm run generate-api-docs`.

### Task 4: verification + PR

- [ ] `nx run-many -t lint test build -p a2ui chat`; pytest twins; affected sweep.
- [ ] Live Chrome smoke: prompt for a `sendDataModel: true` form; submit and verify (via thread state) that the outbound action carries `metadata.a2uiClientDataModel` with the live-typed values.
- [ ] PR `feat(a2ui): live sendDataModel round-trip + capabilities helper (Phase 4)`; merge on green.
6 changes: 5 additions & 1 deletion examples/ag-ui/python/src/schemas/a2ui_v09.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@
event action fires: failing checks block the action, display "message" under
the offending input, and send the agent an error message
{"version":"v0.9","error":{"code":"VALIDATION_FAILED",...}} instead. Bind the
checked value to the same {"path": ...} as the input's value. Example:
checked value to the same {"path": ...} as the input's value.

Set "sendDataModel": true on createSurface when the agent should receive the
surface's full LIVE data model (including user edits) in the metadata of
every action message ({"metadata":{"a2uiClientDataModel":{"surfaces":{...}}}}). Example:

{"id": "email", "component": "TextField", "label": "Email",
"value": {"path": "/email"},
Expand Down
6 changes: 5 additions & 1 deletion examples/chat/python/src/schemas/a2ui_v09.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@
event action fires: failing checks block the action, display "message" under
the offending input, and send the agent an error message
{"version":"v0.9","error":{"code":"VALIDATION_FAILED",...}} instead. Bind the
checked value to the same {"path": ...} as the input's value. Example:
checked value to the same {"path": ...} as the input's value.

Set "sendDataModel": true on createSurface when the agent should receive the
surface's full LIVE data model (including user edits) in the metadata of
every action message ({"metadata":{"a2uiClientDataModel":{"surfaces":{...}}}}). Example:

{"id": "email", "component": "TextField", "label": "Email",
"value": {"path": "/email"},
Expand Down
12 changes: 12 additions & 0 deletions libs/chat/src/lib/a2ui/capabilities.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
import { describe, expect, it } from 'vitest';
import { A2UI_BASIC_CATALOG_ID } from '@threadplane/a2ui';
import { a2uiClientCapabilities } from './capabilities';

describe('a2uiClientCapabilities', () => {
it('advertises the basic catalog', () => {
expect(a2uiClientCapabilities()).toEqual({
supportedCatalogIds: [A2UI_BASIC_CATALOG_ID],
});
});
});
17 changes: 17 additions & 0 deletions libs/chat/src/lib/a2ui/capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: MIT
import { A2UI_BASIC_CATALOG_ID, type A2uiClientCapabilities } from '@threadplane/a2ui';

/**
* The A2UI client capabilities this renderer supports — the typed
* `a2uiClientCapabilities` metadata a host attaches to agent requests so
* the agent knows which component catalogs it may target
* (catalog negotiation, A2UI v0.9 transport metadata).
*
* @example
* ```ts
* const metadata = { a2uiClientCapabilities: a2uiClientCapabilities() };
* ```
*/
export function a2uiClientCapabilities(): A2uiClientCapabilities {
return { supportedCatalogIds: [A2UI_BASIC_CATALOG_ID] };
}
40 changes: 40 additions & 0 deletions libs/chat/src/lib/a2ui/surface.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,43 @@ describe('A2uiSurfaceComponent — validation gate + live context (Phase 3)', ()
expect(fx.componentInstance.liveStore.get('/email')).toBe('user@typed.io');
});
});

describe('A2uiSurfaceComponent — sendDataModel live round-trip (Phase 4)', () => {
beforeEach(() => TestBed.configureTestingModule({ imports: [A2uiSurfaceComponent] }));

it('attaches the live data model (internal keys stripped) when sendDataModel is true', () => {
const store = createA2uiSurfaceStore();
store.apply({ version: 'v0.9', createSurface: {
surfaceId: 's3', catalogId: 'basic', sendDataModel: true,
} } as never);
store.apply({ version: 'v0.9', updateComponents: {
surfaceId: 's3',
components: [
{ id: 'root', component: 'Column', children: ['name', 'go'] },
{ id: 'name', component: 'TextField', label: 'Name', value: { path: '/name' },
checks: [{ condition: { call: 'required', args: { value: { path: '/name' } } }, message: 'Required' }] },
{ id: 'go', component: 'Button', child: 'go-lbl', action: { event: { name: 'save' } } },
{ id: 'go-lbl', component: 'Text', text: 'Save' },
],
} } as never);
store.apply({ version: 'v0.9', updateDataModel: { surfaceId: 's3', path: '/name', value: '' } } as never);

const fx = TestBed.createComponent(A2uiSurfaceComponent);
fx.componentRef.setInput('state', store.surfaceState('s3')()!);
fx.componentRef.setInput('catalog', a2uiBasicCatalog());
const actions: { metadata?: { a2uiClientDataModel?: { surfaces: Record<string, Record<string, unknown>> } } }[] = [];
fx.componentInstance.action.subscribe((a) => actions.push(a));
fx.detectChanges();

// User edits the bound field via the live store.
fx.componentInstance.liveStore.set('/name', 'Ada Lovelace');

const handler = fx.componentInstance.internalHandlers()['a2ui:event'];
handler({ surfaceId: 's3', sourceComponentId: 'go', name: 'save', context: {} });

expect(actions).toHaveLength(1);
const model = actions[0].metadata!.a2uiClientDataModel!.surfaces['s3'];
expect(model['name']).toBe('Ada Lovelace');
expect('_a2uiChecks' in model).toBe(false);
});
});
9 changes: 8 additions & 1 deletion libs/chat/src/lib/a2ui/surface.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,14 @@ export class A2uiSurfaceComponent {
}
}

const message = buildA2uiActionMessage({ ...params, context }, surf);
// sendDataModel metadata must carry the LIVE model (user edits
// included), minus renderer-internal keys.
const { _a2uiChecks, ...publicModel } = liveModel;
void _a2uiChecks;
const message = buildA2uiActionMessage(
{ ...params, context },
{ ...surf, dataModel: publicModel },
);
this.action.emit(message);
return message;
},
Expand Down
1 change: 1 addition & 0 deletions libs/chat/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ export { A2uiSurfaceComponent } from './lib/a2ui/surface.component';
export { buildA2uiActionMessage } from './lib/a2ui/build-action-message';
export { a2uiBasicCatalog } from './lib/a2ui/catalog/index';
export { emitBinding } from './lib/a2ui/catalog/emit-binding';
export { a2uiClientCapabilities } from './lib/a2ui/capabilities';

// A2UI catalog components (for custom catalog composition via withViews)
export { A2uiTextFieldComponent } from './lib/a2ui/catalog/text-field.component';
Expand Down
Loading