Authoring an A2UI Catalogue
Scope. Most of this guide applies to any A2UI v0.9 integration regardless of whether you're using Beach. The Beach-specific sections (catalogue layering,
@cool-ai/beach-a2ui-basics) are flagged inline; everything else is portable.
How to design and ship a custom A2UI v0.9 catalogue for a Beach application.
Audience. Engineers building a domain-specific UI for an agentic application. You are deciding what semantic components your agent emits, and what visual language your consumer renders them in.
Prerequisite. Read Beach's design principles and skim the A2UI v0.9 README. This guide assumes you understand the surface message format and the basic catalogue's role.
Catalogue layering
A Beach application typically registers three catalogues, in order:
basicCatalogfrom@a2ui/web_core/v0_9/basic_catalog— structural primitives (Card,Row,Text,List,Button,Image). Intentionally close to raw HTML. Not where brand or wireframe-faithful design lives.beachBasicsCatalogfrom@cool-ai/beach-a2ui-basics— generic semantic primitives (Pill,Heading,Hero,Badge,Avatar,RatingStars,Price,Tag,EmptyState,Skeleton,CallToAction). Cross-domain; brand-overridable via tokens, parts, and class.- Your domain catalogue — components specific to your application's domain (
DestinationCard,TaskCard,EmailDigestCard,BasketTimeline). Composed from Beach primitives where possible; bespoke Lit elements where the visual is genuinely domain-specific.
Each layer has a different lifetime and a different audience. The basic catalogue follows A2UI's release cadence. Beach's basics follows Beach's release cadence. Your domain catalogue follows your application's release cadence — independent of either.
import { MessageProcessor } from '@a2ui/web_core/v0_9';
import { basicCatalog } from '@a2ui/web_core/v0_9/basic_catalog';
import { beachBasicsCatalog } from '@cool-ai/beach-a2ui-basics';
import { myAppCatalogue } from './a2ui-catalogue.js';
const processor = new MessageProcessor([basicCatalog, beachBasicsCatalog, myAppCatalogue]);
Pick the right layer. If the component you need is generic enough that a Travel app, a Personal Organiser, and a Legal app would all use it the same way, propose it for beach-a2ui-basics instead of putting it in your domain catalogue. If it carries domain-specific semantics — a flight, a task, a contract — keep it private to your app.
Designing a component
A catalogue component is two artefacts:
- The API — a Zod schema describing its props. This is the contract every agent emitting your component must conform to. Schema = contract.
- The Lit element — the visual implementation, with
static stylesand arender()method. Implementation = brand.
The same schema can have multiple implementations. A consumer can swap your default implementation for their own (a brand-themed variant) without the agent emitting different surface messages. That's the design-system-by-protocol thesis.
Define the schema first
Start with the props the component needs to render correctly, not the props that happen to be convenient. Resist the temptation to make every prop a string — A2UI's DynamicString accepts a static string, a path binding, or a function call, so you can be precise about types.
import { z } from 'zod';
import {
DynamicStringSchema,
DynamicNumberSchema,
DynamicBooleanSchema,
type ComponentApi,
} from '@a2ui/web_core/v0_9';
const DestinationCardSchema = z.object({
city: DynamicStringSchema,
country: DynamicStringSchema,
whySuggested: DynamicStringSchema,
recommended: DynamicBooleanSchema,
heroImageUrl: DynamicStringSchema.optional(),
averageRating: DynamicNumberSchema.optional(),
hotels: DynamicStringSchema, // path to a list — see "Lists and bindings" below
onOpen: z.object({ /* ButtonAction shape */ }).optional(),
class: DynamicStringSchema.optional(),
}).strict();
export type DestinationCardProps = z.infer<typeof DestinationCardSchema>;
export const DestinationCardApi: ComponentApi<typeof DestinationCardSchema> = {
name: 'DestinationCard',
schema: DestinationCardSchema,
};
Always use .strict(). Without it, unknown props are silently dropped at runtime — exactly the L1 failure mode that cost the Travel Assistant team two hours of debugging during their first integration. Strict mode rejects unknown props at validation time, which surfaces typos and rename drift immediately.
Always include class. Pass-through to the host element's HTML class attribute. Lets consumers attach brand stylesheets without forking your implementation.
Implement the Lit element
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
import { A2uiLitElement, A2uiController } from '@a2ui/lit/v0_9';
import { DestinationCardApi } from './api.js';
@customElement('my-destination-card')
export class MyDestinationCardElement extends A2uiLitElement<typeof DestinationCardApi> {
static override styles = css`
:host {
display: flex;
flex-direction: column;
gap: var(--my-card-gap, 0.75rem);
/* … */
}
[part='hero'] { /* … */ }
[part='body'] { /* … */ }
[part='footer'] { /* … */ }
`;
protected override createController() {
return new A2uiController(this, DestinationCardApi);
}
public override render() {
const { city, country, whySuggested, recommended, heroImageUrl, hotels } = this.controller.props;
return html`
${heroImageUrl ? html`<img part="hero" src="${heroImageUrl}" alt=""/>` : ''}
<div part="body">
${recommended ? html`<bca-pill intent="recommended" label="Recommended"></bca-pill>` : ''}
<h3 part="title">${city}, ${country}</h3>
<p part="why">${whySuggested}</p>
</div>
<!-- hotels rendered as children — see below -->
`;
}
}
A2uiLitElement and A2uiController together resolve every DynamicString / DynamicNumber / DynamicBoolean field in your schema, including bindings ({ path: '/destinations/0/city' }). Your render() method receives the resolved values via this.controller.props. You do not need to subscribe to the data model directly.
Compose Beach primitives instead of reinventing them
Where the visual concept already exists in @cool-ai/beach-a2ui-basics, compose rather than reimplement. The example above uses <bca-pill> for the "Recommended" badge — saves writing a pill, gets brand consistency for free, and means consumer-side overrides of --bca-pill-recommended-bg cascade through your card automatically.
<bca-pill intent="recommended" label="Recommended" icon="✦"></bca-pill>
<bca-rating-stars value="${rating}"></bca-rating-stars>
<bca-price amount="${price}" currency="GBP"></bca-price>
If a primitive you want isn't in beach-a2ui-basics yet, propose it via a Beach CR. Don't fork it locally and live with drift.
Theming surfaces
Every catalogue component should expose two non-conflicting theming surfaces:
1. HTML class via the schema
{ component: 'DestinationCard', class: 'brand-card', city: 'Paris', /* … */ }
Lands as <my-destination-card class="brand-card"> in the DOM. Consumers attach styles via standard CSS:
.brand-card {
border: 2px solid var(--my-brand-navy);
background: var(--my-brand-paper);
}
This is the outer-container theming surface. Best for whole-component overrides, layout adjustments, brand stylesheets.
2. ::part(...) for internal anatomy
Mark every meaningful internal element with a part attribute:
<img part="hero" src="…" />
<div part="body">
<h3 part="title">…</h3>
<p part="why">…</p>
</div>
<div part="footer">
<slot part="hotel-list"></slot>
</div>
Consumers target via ::part(...):
my-destination-card::part(title) {
font-family: var(--my-brand-serif);
color: var(--my-brand-navy);
}
my-destination-card::part(hero) {
aspect-ratio: 16 / 9;
border-radius: 0;
}
This is the internal-anatomy theming surface. Best for surgical overrides — a single label, a specific section.
3. CSS custom properties (your own namespace)
Expose CSS custom properties for tokens you want consumers to override without writing selectors at all:
:host {
--my-card-gap: 0.75rem;
--my-card-radius: 0.5rem;
/* … */
display: flex;
flex-direction: column;
gap: var(--my-card-gap);
}
Pick a namespace prefix and stick to it. @cool-ai/beach-a2ui-basics uses --bca-*. Your domain catalogue should use its own — --ta-* for Travel Assistant, --po-* for Personal Organiser, etc. Avoid collisions; document them in your README.
The three surfaces are complementary, not redundant. Class for outer container. Part for internal anatomy. Custom properties for tokens. Consumers pick the right one for their override.
Lists and bindings
Most catalogue components either accept a list directly (an array of child component IDs) or accept a path that points to a list in the data model.
// Static — three pre-built component IDs
{
component: 'List',
children: ['child-1', 'child-2', 'child-3'],
}
// Templated — render one component per item under /destinations
{
component: 'List',
children: { componentId: 'destinationCardTemplate', path: '/destinations' },
}
The templated form is the L1 lesson. List.children is not items + template — that's a natural-shape miss many integrators make. Read @a2ui/web_core/v0_9/schema/common-types's ChildListSchema to see the polymorphic union and use the right shape.
For your own list-shaped components, model the same way: children: ChildListSchema (imported from @a2ui/web_core/v0_9).
Loading states — use Skeleton
The first instinct for "data hasn't arrived yet" is to add a loading: boolean prop to every component. Resist it.
The canonical pattern: build the surface with a Skeleton placeholder, then updateDataModel swaps real data in:
// Initial createSurface — show skeletons
{
component: 'DestinationCard',
city: { path: '/destinations/0/city' }, // resolves to "" when path absent
country: { path: '/destinations/0/country' },
// …
hotels: 'destinations-0-skeleton', // a Skeleton component
}
// updateDataModel arrives — Skeleton's path data populates, rendering real content
{
updateDataModel: {
path: '/destinations/0/hotels',
value: [{ name: 'Hilton', stars: 4, price: 220 }, /* … */],
},
}
Every consumer gets consistent loading behaviour without per-component implementation drift.
Accessibility
A2UI components run inside Shadow DOM, which means consumers can't attach ARIA roles from outside. Every component must be accessible by default. A few rules:
- Headings carry semantic level (
h1–h6), not just visual styling. If your component renders a heading, render the right level. - Buttons are
<button>, not<div role="button">. Lit'scustomElementdecorator doesn't take anextendsargument for<button>cleanly, so internal buttons inside the shadow root are fine. - Images require
alttext. Either accept it as a schema prop or document that the schema's text label doubles as alt. - Interactive components must be keyboard-reachable.
tabindex="0"if the host element is focusable; key event handlers for Enter and Space. - Status colour must not be the only signal. A "danger" pill needs a danger icon or "Danger:" label, not just red.
Test with the OS screen reader before declaring the component done. Beach has no current automated a11y test infrastructure; manual verification is the discipline.
Performance
A surface message can declare hundreds of components. Catalogue elements run in real DOM. Some tactics:
- Don't compute in
render(). Move expensive work into a memoised method or a derived signal.render()runs on every prop change. - Use
static styles. A static stylesheet is shared across instances. Per-instance<style>blocks create one stylesheet per component instance — wasteful at scale. - Lazy-load heavy components. If a component is rarely used (a video player, a chart), define its element via a dynamic import on first use.
- Skeleton early, not late. Putting a
Skeletonplaceholder up front and filling viaupdateDataModelis faster than waiting for full data and rendering everything at once.
Measure before optimising. Lit elements are fast enough for ~hundreds of components on modern devices without any of the above.
When to split a component
Five signals that a component should be split into smaller components:
- It's used in multiple places with the same internal structure. The internal structure has become a component.
- It accepts more than ~10 props. The schema has become a god-object; some props belong on a child.
- Theming overrides keep needing more
::parts. The internal anatomy has its own identity. - It contains domain logic that other components also need (a "rating display" used in three different cards). That logic belongs in
beach-a2ui-basicsor your shared internal package. - Two consumers want to override different parts. Splitting lets each override what they care about without touching what they don't.
Don't split speculatively. Wait for the second use site. The L11 lesson — "extract HotelMiniRow / RatingStars / Price from DestinationCard when the second use site appears" — applies generally.
Versioning your catalogue
Your catalogue has two version dimensions:
- The catalogue ID URL — the contract.
- The npm package version — the implementation.
export const MY_APP_CATALOGUE_ID = 'https://my-app.example.com/a2ui/v0.9/catalogue.json';
Bump the URL when:
- A2UI itself upgrades to a new protocol version (
/v0.9/→/v1.0/). - A schema-breaking change is introduced (add
/vN/suffix:/v0.9/v2/).
Do not bump the URL for:
- Patch fixes to component implementations.
- New components (additive — old agents continue to emit only what they know about).
- Theme changes.
The URL is the contract; the npm package is the implementation. Two consumers running different patch versions of your package, both registering the same catalogue ID, can render messages from the same agent — that's the durability promise. Don't break it by bumping the URL on every release.
Publishing
If your catalogue is an internal application package, you don't need to publish it — it's a local workspace package consumed by your application's frontend. If you want to share it across applications or organisations, publish to npm:
{
"name": "@my-org/a2ui-catalogue",
"exports": {
".": "./dist/index.js",
"./tokens.css": "./src/tokens.css"
},
"peerDependencies": {
"@a2ui/lit": "^0.9.0",
"@a2ui/web_core": "^0.9.0"
}
}
Peer-deps for @a2ui/lit and @a2ui/web_core keep your catalogue compatible across A2UI minor versions; consumers bring their own. Mark lit as a regular dep — it's small and version-stable.
Document the catalogue ID URL prominently in your README so adopters know which contract they're integrating against.
Related
@cool-ai/beach-a2ui-basics— the generic primitives Beach blesses.- A2UI v0.9 specification — the protocol your catalogue implements.
@a2ui/lit— the Lit-based reference renderer.- Common A2UI mistakes — failure modes and fixes from real integrations.