A2UI spec convergence¶
Status: Draft | Last reviewed: 2026-09-01
This doc compares the shipped A2UI dialect — A2UI_SCHEMA_VERSION = "0.9" in a2ui_tool.py — against the a2ui.org specification: v0.9.1 (stable) and the v1.0 release candidate. It recommends where wire-format compliance pays off and where the internal dialect should stay proprietary, and lists the concrete v1.0-readiness steps. Read it before changing the A2UI message shapes, the catalog pipeline, or the ui-action return path. The runtime rendering flow itself is documented in A2UI rendering; the pipecat-side architecture analysis is the companion doc, Pipecat-native alignment.
Dialect inventory: where the implementation diverges from spec¶
The dialect keeps the spec's v0.9 message vocabulary — createSurface, updateComponents, updateDataModel, deleteSurface, flat adjacency-list components, JSON Pointer data bindings — but everything around it is proprietary. The orchestrator's send_a2ui_json_to_client tool emits it (a2ui_tool.py).
| Divergence | Spec says | Implementation does |
|---|---|---|
| Version field | Every envelope carries "version": "v0.9.1" |
No version field anywhere on the wire; "v0.9" exists only as a default catalogId string and an internal constant |
| Component props | Nested props: {} object per component |
Flat props on the component object; A2UIRenderer.tsx normalizes spec shape away in normalizeComponent |
| Message set | Four envelopes | Adds enable_multimodal (permission handshake / SMS link), batch (atomic multi-op, used by reframe), plus envelope fields treatment, layout, advancedFrom, clicked, proxyCapabilities |
| Transport | A2A, AG-UI, MCP, SSE+JSON-RPC, WS, REST bindings | Protobuf UIRenderEntry (double-encoded payload_json) → Redis entries:{id} stream → named SSE event UI_RENDER; the payload also rides the tool_call channel redundantly (clients parse both) |
| Action return path | action message with resolved context; optional data model in transport metadata |
POST /conversations/{id}/ui-action → gRPC TYPE_UI_ACTION + Redis pub/sub → injected as a synthetic user text turn (label smuggled as context.__label) |
| Catalogs | JSON catalog documents, negotiated via a2uiClientCapabilities transport metadata |
Tenant-scoped versioned ESM bundles built by catalog-builder, served via a per-conversation manifest with sha384 integrity — richer than spec catalogs; no capability negotiation |
| Capability negotiation | Transport metadata exchange | None — catalog set comes from agent config |
None of this breaks anything today, because both ends of the wire are TrueServe code. It matters the moment a non-TrueServe renderer or agent enters the picture.
v0.9.1 convergence: cheap wins¶
Three changes are near-free and make the dialect a documented superset of v0.9.1 instead of a silent fork:
- Emit
"version"on every message in a2ui_tool.py's canonical-ordering pass. The renderer ignores unknown keys, so this is additive. Effort S. - Emit spec-shaped
props: {}— the client already normalizes both shapes, so producers can switch to spec shape with no renderer change, then the flat-shape normalization becomes legacy tolerance. Effort S. (Note: LLM-generated payloads will emit whatever the prompt teaches; this is a prompt + normalization-direction change, not a validator.) - Document the custom fields as namespaced dialect extensions (
treatment,layout,advancedFrom,clicked,batch,enable_multimodal,proxyCapabilities) in A2UI rendering, so the delta from spec is explicit and reviewable rather than archaeological. Effort S.
v1.0 readiness¶
The v1.0 candidate's changes, mapped to the implementation:
| v1.0 concept | What it is | Implementation status / step |
|---|---|---|
| Single-message instantiation | createSurface carries optional components + dataModel |
Renderer already supports it — A2UIRenderer.tsx#L235-L244 seeds csInner.components/dataModel. The remaining work is the orchestrator prompt: a2ui_tool.py still teaches the two-message pattern. Effort S |
surfaceId unique per renderer lifetime |
Re-creating an existing surface id is an error | Tension: the dialect deliberately allows re-issuing createSurface to replace a surface, and the tool prompt warns against it for a different reason (state loss). Decide: keep replace-semantics as a documented extension, or move replacement onto deleteSurface + create. Effort S–M |
actionResponse |
Agent replies synchronously to a renderer action | The synthetic-user-turn injection stays (it is what makes actions drive the LLM), but add a correlation id to the ui-action path so an actionResponse envelope can be synthesized for spec clients. Effort M |
callFunction / functionResponse, callableFrom boundaries |
Agent invokes renderer-side functions, with runtime-enforced execution boundaries | Closest analog is proxyCapabilities + the server proxy endpoint (capability-scoped API calls declared at createSurface). Map proxy capabilities onto callFunction semantics with callableFrom: "agentOnly"; the permission model already matches the spirit. Effort M |
surfaceProperties (theme removed) |
Extensible per-surface properties; framework-native theming | Low impact — the dialect never used spec theme. treatment and layout are natural candidates to carry as surfaceProperties entries instead of bare envelope fields. Effort S |
| Strict catalog schema | Functions as object maps, UAX #31 identifiers, no custom $defs |
Do not replace internal catalogs — versioned tenant ESM bundles with integrity pinning are strictly richer than spec JSON catalogs. Instead, generate a spec-compliant catalog JSON from a2ui_catalogs/a2ui_components (props_schema is already JSON Schema per component) as an interop artifact. Effort M |
| Standard validation errors | {code, surfaceId, path, message} error messages |
Nothing emits these; relevant only at an interop boundary. Defer |
Recommendation¶
Full wire compliance across the internal stack is not worth it: the protobuf→Redis→SSE transport, the tool_call redundancy, the synthetic-turn action path, and the ESM catalog pipeline all exist for product reasons (recording, replay, optimistic locking, tenant scoping) that the spec's transport bindings do not cover, and no external renderer consumes the internal stream.
Compliance is worth it at exactly one boundary: A2A. a2a_server.py already exposes agents to external A2A clients, and A2UI's primary transport binding is A2A message parts — a spec-compliant remote client that calls a TrueServe agent should be able to receive its surfaces. That makes conversation-api the natural home for an A2UI-over-A2A adapter: egress translates internal UI_RENDER entries into spec v1.0 envelopes (version field, props:{}, surfaceProperties, generated catalog manifest), ingress translates spec action messages into the ui-action path. The internal wire stays the dialect; the adapter is the compliance surface.
Sequenced plan:
| Step | What | Effort | Risk |
|---|---|---|---|
| 1 | v0.9.1 cheap wins: version field, spec-shaped props, documented extensions | S | Low — additive; renderer tolerates both |
| 2 | v1.0 prep that pays for itself internally: single-message createSurface prompt, surfaceProperties carrying treatment/layout, action correlation ids |
M | Low–M |
| 3 | Spec catalog JSON generated from a2ui_catalogs/a2ui_components |
M | Low — additive artifact |
| 4 | A2UI-over-A2A adapter in conversation-api — build when a partner needs it, not speculatively | L | M — first genuinely external protocol surface |
See also¶
- Pipecat-native alignment — companion runtime analysis
- A2UI rendering — the shipped rendering flow this doc measures against spec
- conversation-api — the A2A host for the proposed adapter