Fixed Schema A2UI
Pre-defined A2UI schema with dynamic data. The fastest approach, with no LLM schema generation needed.
In the fixed-schema approach, you design the UI schema once (by hand, or using the A2UI Composer) and keep it on the agent side. The agent tool only provides the data; the surface appears instantly when the tool returns because nothing has to be generated at runtime.
How the schema is delivered to the runtime is the only thing that varies between integrations:
- Schema-loading (langgraph-python, langgraph-typescript,
langgraph-fastapi, llamaindex, crewai-crews, pydantic-ai,
ms-agent-python, google-adk), the schema is saved as a
.jsonfile next to the agent and loaded once at startup. - Schema-inline (spring-ai, ms-agent-dotnet), the schema is
declared inline as a typed literal in source. The host language
doesn't ship a
load_schemaJSON loader, so the structure is compiled in directly. - LLM-driven (mastra, strands), the agent runs a secondary LLM call to produce the operations container per-request. The catalog is still fixed; the schema is generated on demand.
Ask about a flight and the agent renders a fully structured card from a pre-defined schema:
The flight card is an illustrative domain
Everything below uses flight booking so the wiring has something concrete to
render — display_flight, flight-fixed-catalog, and the airport/airline
components are this page's example, not part of the API.
What transfers is the shape: a fixed catalog, a tool that returns data
against it, and a2ui.render(...) with createSurface + updateComponents +
updateDataModel. Keep your own application's domain and substitute your own
components and tool — a page teaching the pattern is not a brief to build a
flight booker.
How it works#
- The schema is made available to the agent, either loaded from a JSON file at startup, declared inline, or generated per-request, depending on the integration.
- 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).
Compositional schemas#
The example below 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 backend-side, as a JSON file, an inline literal, or
a per-request LLM output, depending on the integration. 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
your renderer receives the resolved value and never sees the path — but
the definition still has to declare that prop as a literal-or-binding
union, because that union is the only signal the binder has that the
prop is bindable. See Declare the component
definitions.
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.
Install the renderer package#
The catalog, definitions and renderers below all import from
@copilotkit/a2ui-renderer. It ships separately from
@copilotkit/react-core, and the definitions use zod for prop schemas:
npm install @copilotkit/a2ui-renderer zodDeclare the component definitions#
Each component declares its props as a Zod schema. Any prop the schema
binds to the data model — anything that can arrive as
{ "path": "/origin" } rather than a literal — must be declared as a
union of the literal type and the binding object. That is what the
DynString helper below is for, and why Airport's code uses it
rather than a plain z.string().
The binder decides whether to resolve a prop by inspecting its Zod
type: a union with a { path } member is treated as dynamic and
resolved against the data model, while a plain literal type is treated
as static and passed through untouched. So declaring a bound prop as
z.string() does not merely lose type precision — it tells the binder
not to resolve it, and the raw { path: "/origin" } object reaches your
renderer.
Plain `z.string()` on a bound prop crashes the render
Because the unresolved object reaches the renderer, the first thing
that renders it as text throws React's
error #31:
Objects are not valid as a React child (found: object with keys {path}).
Nothing in that message points at the schema, so it reads as a renderer
bug rather than a missing union. If you hit it, check the prop's
declared type first.
Props that are never bound (Arrow, or a variant enum) are fine as
plain types. This applies only to props the schema binds.
Once the union is declared, the binder resolves the path before your
renderer runs, so the renderer still receives a plain string — the union
describes what the schema may send, not what the renderer must handle.
@copilotkit/a2ui-renderer re-exports A2UI's canonical
DynamicStringSchema (plus DynamicNumberSchema, DynamicBooleanSchema
and the matching types) if you would rather not hand-roll the union:
import { DynamicStringSchema } from "@copilotkit/a2ui-renderer";a2a::a2ui-fixed-schema. Known demos are bundled from manifest demos[i]; check the cell id and framework slug.Implement the React renderers#
TypeScript enforces that the renderer map's keys and prop shapes match the definitions exactly, so refactors stay safe:
a2a::a2ui-fixed-schema. Known demos are bundled from manifest demos[i]; check the cell id and framework slug.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:
a2a::a2ui-fixed-schema. Known demos are bundled from manifest demos[i]; check the cell id and framework slug.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#
Your agent owns the tool in the fixed-schema approach, so you do not want the runtime to inject its own. Enable A2UI but turn injection off.
Passing a catalog on the provider is enough to enable A2UI:
<CopilotKit runtimeUrl="/api/copilotkit" a2ui={{ catalog: myCatalog }}>
{children}
</CopilotKit>Because a catalog auto-injects the A2UI tool by default, set
injectA2UITool: false on the runtime so your agent's own tool is the
only one in play. The middleware still auto-detects the operations the
tool returns and renders the surface, with no subagent involved:
const runtime = new CopilotRuntime({
agents: { "a2ui-fixed-schema": agent },
a2ui: { injectA2UITool: false, 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, handle the click on the
frontend instead — see
Advanced — Action Handlers for the
createA2UIMessageRenderer / onAction 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.