Dynamic Schema A2UI
LLM-generated A2UI — a secondary LLM creates both the schema and data from any prompt.
In the dynamic-schema approach, a secondary LLM generates the entire UI — schema, data, and layout — based on the conversation context. It's the most flexible A2UI flavor: the agent can render any UI for any request without pre-defined schemas.
How it works
- The primary LLM decides to call
render_a2ui(the tool the runtime auto-injects wheninjectA2UITool: true). - The runtime serializes your client-side catalog (component names +
Zod prop schemas) into the agent's
copilotkit.contextso the LLM knows which components it may emit. - The tool call streams through LangGraph as
TOOL_CALL_ARGSevents. - The A2UI middleware intercepts the stream and renders cards progressively as data items arrive.
The 3-file split
The canonical Bring-Your-Own-Catalog (BYOC) layout keeps three files
side-by-side under frontend/src/app/a2ui/:
| File | What lives there |
|---|---|
definitions.ts | Zod props schema + human-readable descriptions for each custom component — platform-agnostic so the runtime can serialise it to the LLM. |
renderers.tsx | React implementations keyed by the same names. TypeScript enforces that every definition has a renderer. |
catalog.ts | createCatalog(definitions, renderers, { includeBasicCatalog: true }) — merges your custom components with CopilotKit's built-in primitives. |
Declare your custom component definitions
Each entry pairs a Zod prop schema with a description. The description
is crucial — the LLM reads it to decide which component to emit. The
showcase's declarative-gen-ui cell ships a small dashboard catalog
(Card / StatusBadge / Metric / InfoRow / PrimaryButton):
export const myDefinitions = {
Card: {
description:
"A titled card container with an optional subtitle and a single child slot. Use it to group related content (metrics, rows, buttons).",
props: z.object({
title: z.string(),
subtitle: z.string().optional(),
child: z.string().optional(),
}),
},
StatusBadge: {
description:
"A small coloured pill communicating the state of something (healthy/degraded/down, online/offline, open/closed). Choose `variant` to match the intent.",
props: z.object({
text: z.string(),
variant: z.enum(["success", "warning", "error", "info"]).optional(),
}),
},
Metric: {
description:
"A key/value KPI display with an optional trend indicator. Ideal for dashboards (e.g. 'Revenue • $12.4k • up').",
props: z.object({
label: z.string(),
value: z.string(),
trend: z.enum(["up", "down", "neutral"]).optional(),
}),
},
InfoRow: {
description:
"A compact two-column 'label: value' row. Good for stacks of facts inside a Card (owner, region, last updated, etc.).",
props: z.object({
label: z.string(),
value: z.string(),
}),
},
PrimaryButton: {
description:
"A styled primary call-to-action button. Attach an optional `action` that will be dispatched back to the agent when the user clicks it.",
props: z.object({
label: z.string(),
action: z.any().optional(),
}),
},
PieChart: {
description:
"A pie/donut chart with a brand-coloured legend. Provide `title`, `description`, and `data` as an array of `{ label, value }` objects. Great for part-of-whole breakdowns (sales by region, traffic sources, portfolio allocation).",
props: z.object({
title: z.string(),
description: z.string(),
data: z.array(
z.object({
label: z.string(),
value: z.number(),
}),
),
}),
},
BarChart: {
description:
"A vertical bar chart built on Recharts. Provide `title`, `description`, and `data` as an array of `{ label, value }` objects. Great for comparing series across categories (quarterly revenue, headcount by team, signups per month).",
props: z.object({
title: z.string(),
description: z.string(),
data: z.array(
z.object({
label: z.string(),
value: z.number(),
}),
),
}),
},
} satisfies CatalogDefinitions;Implement the React renderers
Every key in myDefinitions must have a matching renderer. Props are
statically typed against the Zod schema, so refactors stay safe:
export const myRenderers: CatalogRenderers<MyDefinitions> = {
Card: ({ props, children }) => (
<div
style={{
border: "1px solid #DBDBE5",
borderRadius: 16,
padding: 20,
background: "white",
boxShadow: "0 1px 3px rgba(1, 5, 7, 0.04)",
display: "flex",
flexDirection: "column",
gap: 12,
minWidth: 260,
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<div style={{ fontWeight: 600, fontSize: "1rem", color: "#010507" }}>
{props.title}
</div>
{props.subtitle && (
<div style={{ color: "#57575B", fontSize: "0.85rem" }}>
{props.subtitle}
</div>
)}
</div>
{props.child && children(props.child)}
</div>
),
StatusBadge: ({ props }) => {
const variant = props.variant ?? "info";
const { bg, fg, border } = badgePalette[variant];
return (
<span
style={{
display: "inline-block",
padding: "2px 10px",
background: bg,
color: fg,
border: `1px solid ${border}`,
borderRadius: 999,
fontSize: "0.7rem",
fontWeight: 600,
letterSpacing: "0.1em",
textTransform: "uppercase",
}}
>
{props.text}
</span>
);
},
Metric: ({ props }) => {
const trend = props.trend ?? "neutral";
const arrow = trend === "up" ? "↑" : trend === "down" ? "↓" : "";
const color =
trend === "up" ? "#189370" : trend === "down" ? "#FA5F67" : "#010507";
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<div
style={{
fontSize: "0.7rem",
color: "#838389",
textTransform: "uppercase",
letterSpacing: "0.12em",
}}
>
{props.label}
</div>
<div
style={{
fontSize: "1.5rem",
fontWeight: 600,
color,
display: "flex",
gap: 6,
alignItems: "baseline",
}}
>
<span>{props.value}</span>
{arrow && <span style={{ fontSize: "1rem" }}>{arrow}</span>}
</div>
</div>
);
},
InfoRow: ({ props }) => (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
gap: 16,
paddingTop: 6,
paddingBottom: 6,
borderBottom: "1px solid #E9E9EF",
}}
>
<span style={{ color: "#57575B", fontSize: "0.85rem" }}>
{props.label}
</span>
<span style={{ color: "#010507", fontWeight: 500, fontSize: "0.9rem" }}>
{props.value}
</span>
</div>
),
PrimaryButton: ({ props, dispatch }) => (
<button
onClick={() => {
if (props.action && dispatch) dispatch(props.action);
}}
style={{
padding: "10px 16px",
borderRadius: 12,
border: "none",
background: "#010507",
color: "white",
fontWeight: 500,
fontSize: "0.9rem",
cursor: "pointer",
transition: "background 0.15s ease",
}}
onMouseEnter={(e) =>
((e.currentTarget as HTMLButtonElement).style.background = "#2B2B2B")
}
onMouseLeave={(e) =>
((e.currentTarget as HTMLButtonElement).style.background = "#010507")
}
>
{props.label}
</button>
),
PieChart: ({ props }) => {
const data = props.data ?? [];
const safeData = Array.isArray(data) ? data : [];
const total = safeData.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
return (
<div
style={{
border: "1px solid #DBDBE5",
borderRadius: 16,
padding: 20,
background: "white",
boxShadow: "0 1px 3px rgba(1, 5, 7, 0.04)",
maxWidth: 520,
margin: "0 auto",
display: "flex",
flexDirection: "column",
gap: 12,
overflow: "hidden",
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<div style={{ fontWeight: 600, fontSize: "1rem", color: "#010507" }}>
{props.title}
</div>
<div style={{ color: "#57575B", fontSize: "0.85rem" }}>
{props.description}
</div>
</div>
{safeData.length === 0 ? (
<div
style={{
color: "#838389",
textAlign: "center",
padding: "32px 0",
fontSize: "0.85rem",
}}
>
No data available
</div>
) : (
<>
<DonutChart data={safeData} />
{/* Legend */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
paddingTop: 8,
}}
>
{safeData.map((item, index) => {
const val = Number(item.value) || 0;
const pct = total > 0 ? ((val / total) * 100).toFixed(0) : "0";
return (
<div
key={index}
style={{
display: "flex",
alignItems: "center",
gap: 12,
fontSize: "0.85rem",
}}
>
<span
style={{
display: "inline-block",
width: 12,
height: 12,
borderRadius: 999,
flexShrink: 0,
backgroundColor:
CHART_COLORS[index % CHART_COLORS.length],
}}
/>
<span
style={{
flex: 1,
color: "#010507",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.label}
</span>
<span
style={{
color: "#57575B",
fontVariantNumeric: "tabular-nums",
}}
>
{val.toLocaleString()}
</span>
<span
style={{
color: "#57575B",
width: 40,
textAlign: "right",
fontVariantNumeric: "tabular-nums",
}}
>
{pct}%
</span>
</div>
);
})}
</div>
</>
)}
</div>
);
},
BarChart: ({ props }) => {
const { isNew } = useSeenIndices();
const data = props.data ?? [];
const safeData = Array.isArray(data) ? data : [];
return (
<div
style={{
border: "1px solid #DBDBE5",
borderRadius: 16,
padding: 20,
background: "white",
boxShadow: "0 1px 3px rgba(1, 5, 7, 0.04)",
maxWidth: 640,
margin: "0 auto",
display: "flex",
flexDirection: "column",
gap: 12,
overflow: "hidden",
}}
>
{/* Scoped keyframe — no globals.css needed */}
<style>{`
@keyframes barSlideIn {
from { transform: translateY(40px); opacity: 0; }
20% { opacity: 1; }
to { transform: translateY(0); opacity: 1; }
}
`}</style>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<div style={{ fontWeight: 600, fontSize: "1rem", color: "#010507" }}>
{props.title}
</div>
<div style={{ color: "#57575B", fontSize: "0.85rem" }}>
{props.description}
</div>
</div>
{safeData.length === 0 ? (
<div
style={{
color: "#838389",
textAlign: "center",
padding: "32px 0",
fontSize: "0.85rem",
}}
>
No data available
</div>
) : (
<ResponsiveContainer width="100%" height={280}>
<RechartsBarChart
data={safeData}
margin={{ top: 12, right: 12, bottom: 4, left: -8 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="#E9E9EF"
vertical={false}
/>
<XAxis
dataKey="label"
tick={{ fontSize: 12, fill: "#57575B" }}
stroke="#E9E9EF"
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 12, fill: "#57575B" }}
stroke="#E9E9EF"
tickLine={false}
axisLine={false}
/>
<Tooltip
contentStyle={CHART_TOOLTIP_STYLE}
cursor={{ fill: "#F4F4F7", opacity: 0.5 }}
/>
<Bar
isAnimationActive={false}
dataKey="value"
radius={[6, 6, 0, 0]}
maxBarSize={48}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
shape={
((barProps: any) => (
<AnimatedBar
{...barProps}
isNew={isNew(barProps.index as number)}
/>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
)) as any
}
>
{safeData.map((_, index) => (
<Cell
key={index}
fill={CHART_COLORS[index % CHART_COLORS.length]}
/>
))}
</Bar>
</RechartsBarChart>
</ResponsiveContainer>
)}
</div>
);
},
};Wire definitions × renderers into a catalog
createCatalog is what you hand to the provider. Flip
includeBasicCatalog: true to merge CopilotKit's built-ins
(Column, Row, Text, Image, Card, Button, List, Tabs, …), so the LLM
can compose custom + basic components interchangeably:
export const myCatalog = createCatalog(myDefinitions, myRenderers, {
catalogId: "declarative-gen-ui-catalog",
includeBasicCatalog: true,
});Pass the catalog to the provider
A single prop (a2ui={{ catalog }}) is all the frontend needs — the
provider registers the catalog and wires up the built-in A2UI
activity-message renderer:
<CopilotKit
runtimeUrl="/api/copilotkit-declarative-gen-ui"
agent="declarative-gen-ui"
a2ui={{ catalog: myCatalog }}
>
<div className="flex justify-center items-center h-screen w-full">
<div className="h-full w-full max-w-4xl">
<Chat />
</div>
</div>
</CopilotKit>Inject the render tool on the runtime
On the TypeScript runtime, injectA2UITool: true tells CopilotKit to
add the render_a2ui tool to the agent's tool list at request time
and serialise your client catalog into the agent's
copilotkit.context. No backend code to write — the agent can be an
empty create_agent(tools=[]):
runtime-inject-tool not found in langgraph-python::declarative-gen-ui. Tag the relevant source lines with // @region[runtime-inject-tool] / // @endregion[runtime-inject-tool].Progressive streaming
The secondary LLM's render_a2ui tool call streams through LangGraph
as TOOL_CALL_ARGS events. The A2UI middleware:
- Waits for the full
componentsarray before emitting anything — the schema must be complete before rendering starts. - Extracts
surfaceId+rootfrom the partial JSON. - Emits
surfaceUpdate+beginRenderingonce the schema is complete. - Extracts complete
itemsobjects progressively and emits adataModelUpdatefor each — cards appear one by one as data streams in.
A built-in progress indicator shows while the schema is still generating and hides automatically once data items start arriving.
When should I use dynamic schemas?
- You don't know the UI shape ahead of time — the agent decides what to show based on the user's request.
- You want to prototype A2UI without committing to a schema file yet.
- You're building a conversational dashboard where the layout varies per turn.
If the surface is well-known (e.g. a product card, a flight result), prefer a fixed schema — it's faster, cheaper, and the UI is deterministic.