Common A2UI Integration Mistakes
Scope. The schema, lifecycle, and theming mistakes are generally applicable to any A2UI v0.9 integration. The error-handling section ("Treating every
RespondParseErroras fatal") is Beach-specific — it covers Beach'scallActorretry behaviour. Both are clearly flagged.
A field guide for "my surface renders blank" and other failure modes that A2UI's silent-by-default behaviour makes hard to debug.
Source. Distilled from real integrations during Beach Travel Assistant's A2UI v0.9 rollout. Each mistake is something the documentation didn't tell us not to do.
My surface renders blank
The single most common symptom. Five most likely causes, in rough order of probability:
1. Schema mismatch on updateComponents
The basic catalogue's component schemas are .strict(). If you send a prop the component doesn't know about (items instead of children, text instead of a child Text component, etc.), the component mounts but renders nothing. No console error. No DOM marker. Silent.
Fix. Read the catalogue's Zod schema for the component you're emitting. For @a2ui/web_core/v0_9/basic_catalog, the schemas live at node_modules/@a2ui/web_core/src/v0_9/basic_catalog/components/<name>.d.ts.
Better fix. When MessageProcessor's strict-mode validation is available, enable it so unknown props throw a structured error.
2. The <a2ui-surface> element has zero height
The <a2ui-surface> Lit element has no default :host { display } rule. Used as <a2ui-surface>-as-direct-child it lays out as inline and is zero-height regardless of shadow content.
Fix. Add to your CSS:
a2ui-surface {
display: block;
}
3. SSE event arrived before the bundle initialised
The A2UI bundle is loaded with <script type="module">, which is async. If your SSE listener is faster than the bundle, the event hits a window.__a2uiIntegration that's still undefined.
Symptom. First event after fresh page load disappears; subsequent events render.
Fix. Either guard with window.__a2uiIntegration?.handleDomainEvent(…) and accept the first-event drop, or use a bridge wrapper that buffers events until ready.
4. The component you registered isn't in the catalogue you passed to MessageProcessor
const processor = new MessageProcessor([basicCatalog]);
// emits { component: 'DestinationCard', … }
// → DestinationCard isn't in basicCatalog → renders nothing
Fix. Register every catalogue your agent emits components from:
const processor = new MessageProcessor([basicCatalog, beachBasicsCatalog, myAppCatalog]);
5. Markdown renderer not registered, and Text components are silent
Text components route through a markdown renderer by default. If you haven't registered one, every Text logs:
[MarkdownDirective] can't render markdown because no markdown renderer is configured.
Symptom: raw text including ### City renders literally. With 50 cards, the console fills with the same warning.
Fix. Register a renderer once at startup:
import { withMarkdown } from '@a2ui/markdown-it';
withMarkdown(basicCatalog);
Or opt out per-surface if you don't want markdown processing.
Schema mistakes
List.children is a polymorphic union, not items + template
The most common schema miss. The natural-feeling shape is wrong:
// ❌ Wrong — silently mounts an empty list
{
component: 'List',
items: { path: '/destinations' },
template: 'destinationCardTemplate',
}
// ✅ Right
{
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.
Card.child is singular, not children
// ❌ Wrong
{ component: 'Card', children: ['inner-1', 'inner-2'] }
// ✅ Right — Card has exactly one child
{ component: 'Card', child: 'inner-1' }
Card, Button, and a few others wrap a single child. Use the singular child prop. Row, Column, List accept multiple — use plural children.
Button.child is a component ID, not a label string
// ❌ Wrong — Button doesn't have a `text` prop
{ component: 'Button', text: 'Click me', onClick: { /* … */ } }
// ✅ Right — the button's label is itself a Text component
{ component: 'Button', child: 'button-label', onClick: { /* … */ } }
{ id: 'button-label', component: 'Text', text: 'Click me' }
This is the principal A2UI design choice: every visible thing is a component. Buttons don't have prose props.
Image.src and Text.text accept data bindings
A Text component's text prop is DynamicString — it accepts a literal, a path, or a function call:
{ component: 'Text', text: 'Hello' } // literal
{ component: 'Text', text: { path: '/user/name' } } // binding
{ component: 'Text', text: { call: 'currentTime', args: {} } } // function
Same for Image.src, Image.alt, every DynamicString field. Use the binding form to pull from the data model; use a function call for derived values.
Lifecycle mistakes
Mutating el.surface directly does not trigger re-render
// ❌ Wrong — Lit only reacts to identity changes, not deep mutations
el.surface.components.someComponent.text = 'new text';
Lit's @property({ type: Object }) watches the property's identity, not its inner state. Mutating in place won't trigger requestUpdate().
Fix. Send updateComponents or updateDataModel messages through MessageProcessor.processMessages(). The processor mutates the surface model, and Lit re-renders because ComponentContext re-evaluates bindings on its own re-render cycle.
Setting el.surface after appendChild may miss the first render
// ❌ Risky — Lit's `willUpdate` may have already run with `surface = undefined`
const el = document.createElement('a2ui-surface');
this.root.appendChild(el);
el.surface = this.processor.model.getSurface(surfaceId);
// ✅ Safer — set surface before connection, lit defers willUpdate until connected
const el = document.createElement('a2ui-surface');
el.surface = this.processor.model.getSurface(surfaceId);
this.root.appendChild(el);
This works in practice because Lit defers willUpdate until connected. But it depends on Lit-specific scheduling — a different Web Components framework might not behave the same.
Domain-event vs surface-message mistakes
Creating one surface per domain event when one surface should update
The naive integration: every domain event creates a new surface, stacking up in the workspace. For most UX patterns this is wrong.
Strategic pattern. One surface per UX block. Subsequent domain events route to updateDataModel against that surface, not to new surfaces.
Example from Travel Assistant's discover grid:
discover_suggestionsarrives early with 4 destinations → createSurface +updateDataModelto populate the grid.package_resultsarrives later, one per destination → updateDataModel to fill in the hotels for the matching index. Same surface; no new surface.
The integration layer holds a small map of "presented IDs → index" so subsequent events can target by JSON-Pointer index. Beach ships createIdOrderTracker() from @cool-ai/beach-a2ui/bridge for exactly this.
Encoding presentation in the wire protocol
Domain events (flight_results, package_results) are what happened in the system. Surface messages (createSurface, updateComponents) are how we present it. The mapping is many-to-many, not one-to-one.
Don't. Encode { surfaceId, components, dataModel } on the wire alongside your domain payload. That couples the protocol to the renderer; a future email or A2A peer can't consume the same event.
Do. Send the domain event over the wire. Convert to surface messages at the renderer — typically in the browser integration layer. Same domain event drives a chat surface today, an email body tomorrow, an A2A peer surface the day after.
Theming mistakes
Trying to reach into shadow DOM with outside CSS
/* ❌ Doesn't work — <a2ui-list> is in <a2ui-surface>'s shadow root */
#workspace a2ui-list { display: grid; }
Outside CSS cannot reach into a custom element's shadow DOM. Three real options, in order of recommendation:
CSS custom properties. They inherit through shadow boundaries. Set them on a parent and they cascade in:
#workspace { --a2ui-card-padding: 1.5rem; }::part(...)(where the component exposes parts):bca-pill::part(label) { font-weight: 700; }Custom catalogue. Write your own Lit element with the visual you want. The documented "total control" path. See a2ui-catalogue-authoring.md.
Trying to override layout (e.g. display: grid) via custom properties
The basic catalogue exposes custom properties for colours, typography, spacing, basic borders/shadows but not for layout (display, grid-template-columns, flex-direction).
If you want a <a2ui-list> to lay out as a CSS grid, you cannot do it via the basic catalogue. You need either:
- A different basic-catalogue arrangement (Column root + Row children) that achieves the layout via the catalogue's own structure.
- A custom catalogue component that implements the layout you need.
This is by design — A2UI separates structural primitives from layout, and the basic catalogue intentionally doesn't expose layout knobs. Layout lives in custom catalogues.
Brittle shadow-DOM injection workarounds (adoptedStyleSheets walking the tree)
Tempting trap: walk the shadow tree, find every component instance, inject a per-instance stylesheet via adoptedStyleSheets. Don't. It depends on knowing the surface's component structure (often via brittle selectors like :nth-of-type(2)). It blows past the abstraction. It breaks the moment the surface builder changes order.
If you find yourself reaching for this, write a custom catalogue component instead. That's the documented path.
Error handling mistakes
Treating every RespondParseError as fatal
Most respond() parse errors are transient — the model emitted a typo, dropped a bracket. Killing the entire turn surfaces "Sorry, something went wrong" to the user for a glitch the next request would have produced correctly.
Fix. Beach's callActor defaults to onParseError: 'retry'. One automatic retry with a corrective tool_result catches the majority of transient errors. Only persistent shape mismatches (real schema bugs) escape the retry.
If you need different behaviour:
// Disable retry — fail immediately on the first parse error
callActor({ /* … */, onParseError: 'fail' });
// Custom strategy — bound retries, log diagnostics
callActor({
/* … */,
onParseError: (err, attempt) => {
metrics.increment('actor.parse_error', { attempt });
return attempt < 2 ? 'retry' : 'fail';
},
});
Per-session toggles wiped on server restart
Beach session state lives in process-local memory by default. A user toggle (mock mode, feature flag) set before a restart vanishes after.
Fix. Persist user-toggleable feature flags to Redis (or your session store of choice) alongside session state. Document defaults clearly so users notice when they reset.
Diagnostic checklist
When debugging "my surface renders blank," ask:
- Is the bundle loaded? Open DevTools → Network → confirm the A2UI bundle returned 200.
- Is the surface mounted?
document.querySelector('a2ui-surface')returns an element. - Does the surface have a height?
el.getBoundingClientRect().height > 0. - Are there shadow children?
el.shadowRoot.children.length > 0. - Does the surface model exist? Reach into the processor:
processor.model.getSurface(surfaceId). - Is the catalogue you emit components from registered? Check the
MessageProcessorconstructor. - Does the markdown renderer exist? If
Textcomponents show literal markdown, register one. - Did SSE arrive before the bundle? Reload and check timing.
If steps 1–5 pass and the surface still renders blank, you almost certainly have a schema mismatch (rule 1 above). The component is mounted but its props don't match what its renderer expects.
Related
- Authoring an A2UI catalogue — when the basic catalogue isn't enough.
- Beach design principles — why surface messages and domain events are deliberately decoupled.
@cool-ai/beach-a2ui/bridge—createIdOrderTracker()for the index-by-id pattern.