Fixed Schema A2UI
Pre-defined A2UI schema with dynamic data. The fastest approach — no LLM schema generation needed.
In the fixed-schema approach, you design the UI schema once (by hand, or using the A2UI Composer) and save it as JSON next to your agent. The agent tool only provides the data — the surface appears instantly when the tool returns because nothing has to be generated at runtime.
How it works
- The schema is loaded from a JSON file at startup via
a2ui.load_schema(...)— a thinjson.loadwrapper. - The agent's
display_flighttool receives data from the primary LLM (origin / destination / airline / price). - The tool returns
a2ui.render(...)withcreateSurface+updateComponents+updateDataModeloperations. - The A2UI middleware intercepts the tool result and the frontend renders the surface using the matching 5-component client catalog (Title, Airport, Arrow, AirlineBadge, PriceTag — plus the built-ins).
Schemas as JSON: compositional trees
The showcase's a2ui-fixed-schema cell ships a flight card assembled
compositionally from small sub-components rather than one monolithic
FlightCard:
Card
└─ Column
├─ Title ("Flight Details")
├─ Row (Airport → Arrow → Airport)
├─ Row (AirlineBadge · PriceTag)
└─ Button (Book)
That tree lives in backend/schemas/flight_schema.json. Components
without data bindings (like Title or Arrow) carry their value
inline; components bound to the LLM's data (like Airport) reference
fields via JSON Pointer paths such as { "path": "/origin" }. The
A2UI binder resolves those paths before the React renderer runs, so
renderer props are typed as their resolved values (plain z.string(),
not a path-or-literal union).
The 5-component custom catalog
The frontend catalog declares just the domain-specific primitives —
Title, Airport, Arrow, AirlineBadge, PriceTag — and merges in
CopilotKit's basic catalog (Card, Column, Row, Text, Button, …) via
includeBasicCatalog: true.
Declare the component definitions
Each component declares its props as a Zod schema. Props are the resolved values, never the path expressions:
export const flightDefinitions = {
Title: {
description: "A prominent heading for the flight card.",
props: z.object({
text: DynString,
}),
},
Airport: {
description: "A 3-letter airport code, displayed large.",
props: z.object({
code: DynString,
}),
},
Arrow: {
description: "A right-pointing arrow used between airports.",
props: z.object({}),
},
AirlineBadge: {
description: "A pill-styled airline name tag.",
props: z.object({
name: DynString,
}),
},
PriceTag: {
description: "A stylized price display (e.g. '$289').",
props: z.object({
amount: DynString,
}),
},
/**
* Button override: swaps in an ActionButton renderer that tracks
* its own `done` state so clicking "Book flight" visually updates to
* a "Booked ✓" confirmation. The basic catalog's Button is stateless,
* so without this override the click fires the action but the button
* looks unchanged. Mirrors the pattern in beautiful-chat
* (src/app/demos/beautiful-chat/declarative-generative-ui/renderers.tsx).
*/
Button: {
description:
"An interactive button with an action event. Use 'child' with a Text component ID for the label. After click, the button shows a confirmation state.",
props: z.object({
child: z
.string()
.describe(
"The ID of the child component (e.g. a Text component for the label).",
),
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
// Union with { event } so GenericBinder resolves this as ACTION → callable () => void.
action: z
.union([
z.object({
event: z.object({
name: z.string(),
context: z.record(z.any()).optional(),
}),
}),
z.null(),
])
.optional(),
}),
},
} satisfies CatalogDefinitions;Implement the React renderers
TypeScript enforces that the renderer map's keys and prop shapes match the definitions exactly — refactors stay safe:
export const flightRenderers: CatalogRenderers<FlightDefinitions> = {
Title: ({ props: rawProps }) => {
const props = rawProps as Record<string, any>;
return (
<div
style={{
fontSize: "1.15rem",
fontWeight: 600,
color: "#010507",
}}
>
{props.text}
</div>
);
},
Airport: ({ props: rawProps }) => {
const props = rawProps as Record<string, any>;
return (
<span
style={{
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: "1.5rem",
fontWeight: 600,
letterSpacing: "0.05em",
color: "#010507",
}}
>
{props.code}
</span>
);
},
Arrow: () => <span style={{ color: "#AFAFB7", fontSize: "1.5rem" }}>→</span>,
AirlineBadge: ({ props: rawProps }) => {
const props = rawProps as Record<string, any>;
return (
<span
style={{
display: "inline-block",
padding: "2px 10px",
background: "rgba(190, 194, 255, 0.15)",
color: "#010507",
border: "1px solid #BEC2FF",
borderRadius: 999,
fontSize: "0.75rem",
fontWeight: 600,
letterSpacing: "0.08em",
textTransform: "uppercase",
}}
>
{props.name}
</span>
);
},
PriceTag: ({ props: rawProps }) => {
const props = rawProps as Record<string, any>;
return (
<span
style={{
fontWeight: 600,
fontSize: "1.1rem",
color: "#189370",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
}}
>
{props.amount}
</span>
);
},
/**
* Button override: the basic catalog's Button is stateless. This
* stateful version lets clicking "Book flight" transition to
* "Booked ✓" without a round-trip to the agent.
*/
Button: ({ props, children }) => {
return (
<ActionButton
label="Book flight"
doneLabel="Booked"
action={(props as Record<string, any>).action}
>
{(props as Record<string, any>).child
? children((props as Record<string, any>).child)
: null}
</ActionButton>
);
},
};Wire the catalog
createCatalog(..., { includeBasicCatalog: true }) merges the custom
renderers with CopilotKit's built-ins so the schema can reference
Card, Column, Row, Button alongside the domain primitives:
export const fixedCatalog = createCatalog(flightDefinitions, flightRenderers, {
catalogId: CATALOG_ID,
includeBasicCatalog: true,
});Load the schema JSON at startup
a2ui.load_schema(path) is a thin json.load wrapper — it parses the
schema file once at module-import time. The sibling booked_schema.json
is kept ready for the button-click "booked" optimistic swap (see the
note on action handlers below):
# Schemas are JSON so they can be authored and reviewed independently of the
# Python code. `a2ui.load_schema` is just a thin `json.load` wrapper.
FLIGHT_SCHEMA = a2ui.load_schema(_SCHEMAS_DIR / "flight_schema.json")
BOOKED_SCHEMA = a2ui.load_schema(_SCHEMAS_DIR / "booked_schema.json")Return render operations from the tool
The display_flight tool returns a2ui.render(operations=[…]). The
A2UI middleware detects the operations container in the tool result
and forwards it to the frontend renderer. The LLM only generates the
four data fields (origin, destination, airline, price) — the
schema does the rest:
# The A2UI middleware detects the `a2ui_operations` container in this
# tool result and forwards the ops to the frontend renderer. The frontend
# catalog resolves component names to the local React components.
return a2ui.render(
operations=[
a2ui.create_surface(SURFACE_ID, catalog_id=CATALOG_ID),
a2ui.update_components(SURFACE_ID, FLIGHT_SCHEMA),
a2ui.update_data_model(
SURFACE_ID,
{
"origin": origin,
"destination": destination,
"airline": airline,
"price": price,
},
),
],
# NOTE: The canonical reference (and the docs at
# docs/integrations/langgraph/generative-ui/a2ui/fixed-schema.mdx)
# also pass `action_handlers={...}` here to declare optimistic UI
# transitions — e.g. swapping to BOOKED_SCHEMA when the card's
# `book_flight` button is clicked. The Python SDK's `a2ui.render`
# does not yet accept that kwarg (see sdk-python/copilotkit/a2ui.py),
# so we omit it for now. The `booked_schema.json` sibling is kept
# so the schema is ready to wire up once the SDK exposes handlers.
)Why compositional beats monolithic
A single big FlightCard component would be faster to write but would
lock the design in place. Assembling the card from Card / Column /
Row / Title / Airport / Arrow / AirlineBadge / PriceTag gives you:
- Reusable primitives — the same
Airportrenderer works in search results, booking confirmations, and future seat maps. - Schema-level design iteration — re-arranging rows or swapping a badge requires only a JSON edit; the renderer code is untouched.
- A2UI Composer compatibility — hand-written and Composer-built schemas share the same primitive vocabulary.
Registering the runtime
On the TypeScript side, A2UI's middleware auto-detects the operations
in any tool result — so even with a fixed schema, the minimum setup
is a2ui: {}. The a2ui-fixed-schema cell happens to also keep
injectA2UITool: true so the same agent can be pointed at
dynamic-schema workflows later without re-configuring.
const runtime = new CopilotRuntime({
agents: { "a2ui-fixed-schema": agent },
a2ui: { injectA2UITool: true, agents: ["a2ui-fixed-schema"] },
});
Action handlers (reference)
The canonical reference pairs fixed schemas with
action_handlers={...} to declare optimistic UI swaps (e.g. replacing
the flight schema with BOOKED_SCHEMA when the user clicks "Book").
The Python SDK's a2ui.render does not yet accept action_handlers,
so the cell omits them — the booked_schema.json sibling is retained
so the swap can be wired up the moment the SDK exposes the handler
kwarg.
When available, a button declares its action like this:
{
"Button": {
"label": "Book",
"action": {
"name": "book_flight",
"context": [
{ "key": "flightNumber", "value": { "path": "/flightNumber" } },
{ "key": "price", "value": { "path": "/price" } }
]
}
}
}
And the Python tool matches it with a handler keyed by the action
name (plus a "*" catch-all). Until the SDK lands, see the reference
fixed-schema guide
for the full pattern.
When should I use fixed schemas?
- The surface is well-known — flight cards, product tiles, order summaries, dashboards.
- You want deterministic, designer-controlled UI. No LLM schema drift.
- You want the fastest possible first paint — no secondary LLM call.
If the UI must adapt per prompt, reach for dynamic schemas instead.