showcase/shell-docs/src/content/docs/generative-ui/a2ui/fixed-schema.mdx
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:
.json
file next to the agent and loaded once at startup.load_schema JSON loader, so the structure is
compiled in directly.Ask about a flight and the agent renders a fully structured card from a pre-defined schema:
display_flight tool receives data from the primary LLM
(origin / destination / airline / price).a2ui.render(...) with createSurface +
updateComponents + updateDataModel operations.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 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.
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 zod
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.
Props that are never bound (Arrow, or a variant enum) are fine as
plain types. This applies only to props the schema binds.
</Callout>
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";
TypeScript enforces that the renderer map's keys and prop shapes match the definitions exactly, so refactors stay safe:
<Snippet region="renderers-tsx" /> </Step> <Step> ### Wire the catalogcreateCatalog(..., { includeBasicCatalog: true }) merges the custom
renderers with CopilotKit's built-ins so the schema can reference
Card, Column, Row, Button alongside the domain primitives:
a2ui.load_schema(path) (or the framework's equivalent thin json.load
wrapper) 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):
The agent 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:
Nothing about A2UI depends on how the agent itself is built — the operations container is just the tool's return value, so the tool drops into whatever agent you already have. </Step> </WhenFrameworkHas>
<WhenFrameworkHas flag="a2ui_agent_form" equals="langgraph-state-graph"> <Step> ### Attach the tool to an existing `StateGraph`The snippets above stop at the tool. The reference cell builds its agent with
langchain.agents.create_agent plus CopilotKitMiddleware — that is what
those imports are for — but the construction itself is not shown. If you added
A2UI to an agent you already wrote, you probably have a hand-built StateGraph
instead. The tool is unchanged; put it in a ToolNode and leave the rest of the
graph alone:
from langchain_openai import ChatOpenAI
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition
# `display_flight` is the tool defined above — unchanged.
model = ChatOpenAI(model="gpt-4.1-mini").bind_tools([display_flight])
def call_model(state: MessagesState):
return {"messages": [model.invoke(state["messages"])]}
builder = StateGraph(MessagesState)
builder.add_node("call_model", call_model)
builder.add_node("tools", ToolNode([display_flight]))
builder.add_edge(START, "call_model")
builder.add_conditional_edges("call_model", tools_condition)
builder.add_edge("tools", "call_model")
# No `checkpointer=` — the LangGraph API server owns persistence and
# rejects a custom one. See the LangGraph quickstart for the FastAPI case,
# where you host the graph yourself and do need a checkpointer.
graph = builder.compile()
Keep whatever system prompt your agent already has. The reference cell's prompt
tells the model to call display_flight exactly once and stop, because the tool
result is the rendered card. Without it, the model tends to call the tool
again, looking for a status code.
CopilotKitMiddleware is a create_agent middleware, so it has no
StateGraph equivalent — and the fixed-schema path does not need one: the
A2UI middleware that turns the tool result into a surface runs in the
TypeScript runtime (see Registering the runtime
below), not in the graph. Note that dropping it also drops the other things it
does — frontend-tool injection and exposing agent state to the model — so keep
create_agent if your agent relies on those.
</Step>
</WhenFrameworkHas>
Spring AI / .NET don't ship a load_schema JSON helper, so the
component tree is declared inline as a typed literal in source,
equivalent to deserialising a flight_schema.json but compiled into
the agent class. The structure is identical to the JSON form; only
the surface syntax changes:
The agent tool builds the same createSurface + updateComponents +
updateDataModel operations container and returns it. The A2UI
middleware detects the operations in the tool result and forwards
them to the frontend renderer; the LLM only supplies the four data
fields:
Mastra and Strands take a different route: the agent tool runs a
secondary LLM call with a forced tool choice that produces the
operations container per-request. The frontend catalog is still fixed
(same Title/Airport/Arrow/AirlineBadge/PriceTag primitives),
but the schema is built on the fly. Schema construction and render
emission happen in the same tool call:
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:
Airport renderer works in
search results, booking confirmations, and future seat maps.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"] },
});
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.
If the UI must adapt per prompt, reach for dynamic schemas instead.
<IntegrationGrid path="generative-ui/a2ui" />