A2UI Integration — Field Notes
Real lessons from Beach applications that wired A2UI v0.9 into production-shaped surfaces. Most of the analysis applies to any A2UI integration regardless of Beach. Beach-specific bits are flagged inline.
Headline finding — the basic catalogue is the wrong place to fight design
A2UI v0.9 ships a basic catalogue with structural primitives: Card, Row, Text, List, Button, Image. Intentionally close to raw HTML elements. Brand-specific UIs are not what it's for.
The first instinct of every team that adopts A2UI is to make the basic catalogue render their wireframe. Hours fighting CSS custom properties, shadow-DOM injection, and structural selectors. Then they find the documented "total control" path: custom catalogues.
The intended pattern, from the @a2ui/lit README:
// 1. Define the schema (the contract)
export const DestinationCardApi = {
name: 'DestinationCard',
schema: z.object({
city: CommonSchemas.DynamicString,
country: CommonSchemas.DynamicString,
whySuggested: CommonSchemas.DynamicString,
recommended: CommonSchemas.DynamicBoolean,
heroImageUrl: CommonSchemas.DynamicString.optional(),
hotels: CommonSchemas.DynamicValue,
onOpen: CommonSchemas.Action,
}),
};
// 2. Implement the Lit element (the brand)
@customElement('destination-card')
export class DestinationCardElement extends A2uiLitElement<typeof DestinationCardApi> {
static styles = css`/* full visual control */`;
protected createController() { return new A2uiController(this, DestinationCardApi); }
render() { /* hero, recommended pill, gold stars, italic narrative — anything */ }
}
// 3. Group into a catalogue
export const myCatalog = new Catalog('https://my-app.example.com/catalogue.json', [DestinationCard]);
// 4. Register
const processor = new MessageProcessor([basicCatalog, myCatalog]);
// 5. Emit semantic surface messages
{ component: 'DestinationCard', city: 'Rome', country: 'Italy', recommended: true, ... }
Schema is the contract; implementation is the brand. Two consumers can implement the same DestinationCard schema with totally different visuals. Two agents emitting DestinationCard to those consumers both render appropriately.
The basic catalogue is fine for prototypes and API-style demos. Anything wireframe-faithful or brand-specific needs a custom catalogue.
Beach-specific.
@cool-ai/beach-a2ui-basicsships a generic semantic catalogue (Pill,Heading,Hero,Badge,Avatar,RatingStars,Price,Tag,EmptyState,Skeleton,CallToAction) on top of basic. Domain catalogues compose it. See Authoring a custom catalogue.
L1 — List.children is a polymorphic union, not items + template
// ❌ Mounts an empty list, no errors
{ id: 'root', component: 'List', items: { path: '/destinations' }, template: 'destinationCardTemplate' }
// ✅ Right
{ id: 'root', component: 'List', children: { componentId: 'destinationCardTemplate', path: '/destinations' } }
children accepts either an array of static IDs OR an object { componentId, path } for templated rendering. Read ChildListSchema in @a2ui/web_core/v0_9/schema/common-types.
L2 — Components are silent when shaped wrong
When the schema mismatches, components mount but render nothing. No console warning, no validation error, no DOM marker. Always read the basic catalogue's Zod schemas before authoring a surface; they're at node_modules/@a2ui/web_core/src/v0_9/basic_catalog/components/<ComponentName>.d.ts.
L3 — <a2ui-surface> host has no default display
a2ui-surface { display: block; }
Without this, used as a direct child it lays out as inline and ends up zero-height regardless of shadow content.
L4 — SSE events arrive before the bundle initialises
The A2UI bundle is loaded with <script type="module">, async by spec. SSE events arriving before the bundle is initialised hit a window.__a2uiIntegration that's still undefined.
window.__a2uiIntegration?.handleDomainEvent(type, data);
Optional chaining catches the early case. Accept the very first event after a fresh page load may drop, or buffer SSE events until a "bundle ready" signal.
L5 — MarkdownDirective fails closed (and noisy)
Text components route through a markdown renderer by default. Without one registered, every Text component logs:
[MarkdownDirective] can't render markdown because no markdown renderer is configured.
50 cards = 50 identical warnings. Fix:
import { withMarkdown } from '@a2ui/markdown-it';
withMarkdown(basicCatalog);
L6 — Mutating el.surface doesn't trigger re-render
Lit's @property({ type: Object }) watches identity, not deep mutation. Send messages through MessageProcessor.processMessages() — the processor mutates the surface model, and Lit re-renders because ComponentContext re-evaluates bindings.
L7 — Mounting timing: assign el.surface before appendChild
const el = document.createElement('a2ui-surface');
el.surface = this.processor.model.getSurface(surfaceId); // first
this.root.appendChild(el); // then
Lit defers willUpdate until connected; assigning surface before connection is safer than after.
L8 — Domain events vs surface messages — the boundary
Convert domain events → surface messages in the browser, not on the wire:
- Same domain event can drive different surfaces (browser today, email tomorrow, A2A peer the day after).
- Browser already has the data via SSE filter rules.
- Server's routing logic stays identical to the pre-A2UI architecture.
A2UI surface messages are for rendering. Assemble them close to the renderer, not at the protocol boundary.
L9 — Catalogue prop names aren't always intuitive
Card.child(singular ID) vsColumn.children/Row.children(plural array).List.children— polymorphic union (static array OR template object).Image.srcandImage.altaccept{ path: '…' }data bindings.Text.textaccepts{ path: '…' }.Button.child(singular ID) accepts a child component (typically a Text); the button label is not atextstring prop.
Always read the schemas before authoring.
L10 — The "loading slot" is silent
Before root exists, <a2ui-surface> shows the slot fallback. Easy to mistake for a render failure. If you need to distinguish, add your own data-state attribute (loading | ready | empty | error) in a custom catalogue wrapper.
L11 — One surface, many updateDataModel events
The first instinct when wiring a new domain event to A2UI is "create a surface for it." Wrong for any UX where data arrives async into a layout already on screen.
Concrete example. A discover-grid wireframe with 4 destination cards, each with a hotel mini-list. Two domain events:
discover_suggestions— early, with city/country/why-suggested for 4 destinations.package_results— later, one per destination, carrying hotel name + stars + price.
Naive integration: one surface for discover_suggestions, one per package_results, stacked. Visually wrong.
Strategic pattern: one surface, route subsequent events to updateDataModel.
// discover_suggestions → create surface, populate top-level data
[
...buildDestinationGridSurface('discover-grid'),
buildDestinationGridData('discover-grid', suggestions),
]
// package_results → in-place update of one card's hotel mini-list
[
buildDestinationHotelsUpdate('discover-grid', destinationIndex, hotelRows),
]
The integration layer holds an ordered array of destination IDs from the most recent discover_suggestions. When package_results arrives keyed by sectionId, it maps to the array index and updates /destinations/<idx>/hotels.
Beach-specific.
@cool-ai/beach-a2ui/bridgeexportscreateIdOrderTracker()— a small utility that captures presented-id order so subsequent updates can target by index.
Domain events are production events; surfaces are presentation state. The mapping is many-to-many. A domain event may create a surface (first time) OR update a surface (subsequent times). Decide based on UX, not protocol.
L12 — Theming the basic catalogue is CSS-custom-properties only
Outside CSS cannot reach into <a2ui-list> or <a2ui-card> shadow roots. #workspace a2ui-list { display: grid; } does nothing.
The catalogue exposes CSS custom properties at the host level — --a2ui-card-padding, --a2ui-list-gap, etc. — that inherit through shadow DOM. Layout properties (display, grid-template-columns) are not exposed.
For wireframe-faithful styling, write a custom catalogue. See Authoring a custom catalogue.
L13 — Per-session toggles vanish on server restart
Persist user-toggleable per-session feature flags to your session store (Redis, SQLite). In-memory Map is fine for prototypes; not for production.
L14 — Actor parser failures lose the entire turn
When the LLM produces output that doesn't match Beach's expected respond() schema, the whole turn dies with RespondParseError.
Beach-specific.
callActordefaults toonParseError: 'retry'— one automatic retry with a corrective tool_result catches the majority. Configurable:'retry' | 'fail' | (err, attempt) => 'retry' | 'fail'.
For more debugging guidance, see Common A2UI mistakes.
The upstream-consumer perspective
A team that writes both the agent and the renderer is a privileged debugger — they can read every internal source, inspect shadow DOM, shape the surface builder. A consumer who imports an A2UI library and emits surface messages cannot.
Failure modes look like:
- The page renders but the right pane is blank. No errors. Why?
- One result type renders, others don't. They look identical. Why?
- I copy-pasted the example from the README and nothing rendered. Why?
What helps upstream consumers:
- Strict-mode validation by default in development. Validate every
updateComponentsandupdateDataModelagainst catalogue Zod schemas. Throw or log a structured error pointing at the offending component ID, prop, expected shape. - Visible failure markers. Render a red box in dev mode with component ID + reason when a component can't render. Silent zero-height failures are the worst possible UX.
- Schema discovery at runtime. A
bridge.describe('List')API that returns the JSON-Schema-shaped contract. - Surface inspector. A panel exposing surfaces, state, data model, computed bindings.
- A
data-stateattribute on<a2ui-surface>. Values:loading | ready | empty | error. CSS-targetable.
Related
- Authoring a custom catalogue — when the basic catalogue isn't enough.
- Common A2UI mistakes — quick-reference field guide for the most common failures.