Dynamic Schema A2UI
LLM-generated A2UI — a secondary LLM creates both the schema and data from any prompt.
"use client";/** * Declarative Generative UI (A2UI — Dynamic Schema) demo. * * Pattern: * 1. Define a small set of branded React components + Zod schemas in * `./a2ui/definitions.ts` and `./a2ui/renderers.tsx` (the latter calls * `createCatalog(..., { includeBasicCatalog: true })` and exports * `myCatalog`). * 2. Pass that catalog to the provider via * `<CopilotKit a2ui={{ catalog: myCatalog }}>`. * 3. The dedicated runtime at `/api/copilotkit-declarative-gen-ui` is * configured with `injectA2UITool: false` — the backend factory * (`src/lib/factory/a2ui-factory.ts`) owns the `generate_a2ui` tool * explicitly. The A2UI middleware still serialises the registered * catalog schema into the agent's `input.context` so the secondary LLM * inside `generate_a2ui` knows which components are available. * * Reference: * https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui */import React from "react";import { CopilotKit, CopilotChat, useConfigureSuggestions,} from "@copilotkit/react-core/v2";import { myCatalog } from "./a2ui/catalog";import { useSalesAnalystContext } from "./sales-context";export default function DeclarativeGenUIDemo() { return ( <CopilotKit runtimeUrl="/api/copilotkit-declarative-gen-ui" agent="default" 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> );}function Chat() { // Grounding data + composition rules for the sales-analyst persona. Flows // to the secondary `generate_a2ui` planner LLM (the A2UI middleware // serialises frontend context entries into the catalog context the tool // reads), so prompts like "Show me my sales dashboard" produce grounded, // composed surfaces instead of empty ones. useSalesAnalystContext(); useConfigureSuggestions({ suggestions: [ { title: "Show a KPI dashboard", message: "Show me a quick KPI dashboard with 3-4 metrics (revenue, signups, churn).", }, { title: "Pie chart — sales by region", message: "Show a pie chart of sales by region.", }, { title: "Bar chart — quarterly revenue", message: "Render a bar chart of quarterly revenue.", }, { title: "Status report", message: "Give me a status report on system health — API, database, and background workers.", }, ], available: "always", }); return <CopilotChat agentId="default" className="h-full rounded-2xl" />;}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 agent calls the A2UI tool to draw a surface — made available
when
injectA2UITool: 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 example below ships a small dashboard catalog (Card / StatusBadge / Metric / InfoRow / PrimaryButton):
// ZOD VERSION: stays on root zod@4 (NOT the `zod-v3` alias) because this catalog// declares NO {path} dynamic bindings — only inline literals — so @a2ui/web_core's// Zod-3 schema scraper never needs to classify a binding here. If a path-bound prop// is ever added, switch to the `zod-v3` alias like sibling a2ui-fixed-schema/a2ui/// definitions.ts (whose ZOD VERSION comment explains the React #31 crash otherwise).import { z } from "zod";import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";export const myDefinitions = { // Override the basic catalog's Row/Column so `gap` is honoured — the // built-in versions ignore it, which makes composed dashboards cramped. Row: { description: "Horizontal layout container. Children share the width evenly. Use `gap` (px) to space dashboard tiles.", props: z.object({ gap: z.number().optional(), // Enum mirrors the keys the renderer actually maps to CSS. Anything // outside this set silently falls back at render time, so we reject // it at schema-parse time to surface LLM typos early. align: z .enum(["start", "center", "end", "stretch", "baseline"]) .optional(), justify: z.enum(["start", "center", "end", "spaceBetween"]).optional(), children: z.array(z.string()), }), }, Column: { description: "Vertical layout container. Use `gap` (px) to space stacked sections.", props: z.object({ gap: z.number().optional(), align: z .enum(["start", "center", "end", "stretch", "baseline"]) .optional(), children: z.array(z.string()), }), }, // Override the basic catalog's Text so it aligns flush with sibling // components (the built-in version carries an 8px outer margin). Text: { description: "A plain text line. Use for short explanations inside cards.", props: z.object({ text: z.string(), }), }, 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 tile with an optional trend indicator and trend delta. Ideal for dashboard KPI rows (e.g. 'Revenue • $4.2M • up 12%').", props: z.object({ label: z.string(), value: z.string(), trend: z.enum(["up", "down", "neutral"]).optional(), trendValue: z.string().optional(), }), }, InfoRow: { description: "A compact two-column 'label: value' row. Good for stacks of facts inside a Card (owner, region, ARR, renewal date, etc.).", props: z.object({ label: z.string(), value: z.string(), }), }, DataTable: { description: "A data table with column headers and rows. Ideal for rankings and per-person/per-item breakdowns (rep performance vs quota, deal lists). Each row's keys MUST appear in `columns[].key`; unknown row keys render as blank cells and indicate model/schema drift.", props: z.object({ columns: z.array(z.object({ key: z.string(), label: z.string() })), // Cells may be strings or numbers — the renderer stringifies at // render time, but accepting both lets the LLM emit raw numerics // (e.g. attainment 124) instead of being forced to stringify. rows: z.array(z.record(z.string(), z.union([z.string(), z.number()]))), }), }, 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> = { // Gap-honouring Row/Column overrides — the basic catalog's versions ignore // `gap`, which makes composed dashboards cramped. Children share width // evenly in a Row (flex: 1 1 0) and stack in a Column. Row: ({ props, children }) => { const justifyMap: Record<string, string> = { start: "flex-start", center: "center", end: "flex-end", spaceBetween: "space-between", }; const items = Array.isArray(props.children) ? props.children : []; return ( <div style={{ display: "flex", flexDirection: "row", gap: `${props.gap ?? 16}px`, alignItems: props.align ?? "stretch", justifyContent: justifyMap[props.justify ?? "start"] ?? "flex-start", flexWrap: "wrap", width: "100%", }} > {items.map((id, i) => ( <div key={`${id}-${i}`} style={{ flex: "1 1 0", minWidth: 0 }}> {children(id)} </div> ))} </div> ); }, Column: ({ props, children }) => { const items = Array.isArray(props.children) ? props.children : []; return ( <div style={{ display: "flex", flexDirection: "column", gap: `${props.gap ?? 12}px`, width: "100%", }} > {items.map((id, i) => ( <React.Fragment key={`${id}-${i}`}>{children(id)}</React.Fragment> ))} </div> ); }, Text: ({ props }) => ( <span style={{ fontSize: "0.85rem", color: "#010507", lineHeight: 1.5 }}> {props.text} </span> ), Card: ({ props, children }) => ( <div data-testid="declarative-card" 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 data-testid="declarative-status-badge" 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 data-testid="declarative-metric" 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 || props.trendValue) && ( <span style={{ fontSize: "0.85rem", fontWeight: 500 }}> {arrow} {props.trendValue ? arrow ? ` ${props.trendValue}` : props.trendValue : ""} </span> )} </div> </div> ); }, InfoRow: ({ props }) => ( <div data-testid="declarative-info-row" 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> ), DataTable: ({ props }) => { const cols = Array.isArray(props.columns) ? props.columns : []; const rows = Array.isArray(props.rows) ? props.rows : []; return ( <div data-testid="declarative-data-table" style={{ width: "100%", overflowX: "auto" }} > <table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.85rem", }} > <thead> <tr> {cols.map((col) => ( <th key={col.key} style={{ borderBottom: "2px solid #DBDBE5", padding: "8px 12px", textAlign: "left", fontSize: "0.7rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.08em", color: "#838389", }} > {col.label} </th> ))} </tr> </thead> <tbody> {rows.map((row, i) => { // Stable row key: prefer the first column's value (primary-key-ish), // suffix with index in case values repeat, fall back to a JSON // stringify of the row when columns is empty. const pk = cols.length > 0 ? row[cols[0].key] : undefined; const rowKey = pk !== undefined ? `${pk}-${i}` : `row-${i}`; return ( <tr key={rowKey} style={{ borderBottom: "1px solid #E9E9EF" }}> {cols.map((col) => ( <td key={col.key} style={{ padding: "8px 12px", color: "#010507", fontVariantNumeric: "tabular-nums", }} > {String(row[col.key] ?? "")} </td> ))} </tr> ); })} </tbody> </table> </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 data-testid="declarative-pie-chart" 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 data-testid="declarative-bar-chart" 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:
import { createCatalog } from "@copilotkit/a2ui-renderer";import { myDefinitions } from "./definitions";import { myRenderers } from "./renderers";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:
import React from "react";import { CopilotKit, CopilotChat, useConfigureSuggestions,} from "@copilotkit/react-core/v2";import { myCatalog } from "./a2ui/catalog";import { useSalesAnalystContext } from "./sales-context";export default function DeclarativeGenUIDemo() { return ( <CopilotKit runtimeUrl="/api/copilotkit-declarative-gen-ui" agent="default" 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>Turn A2UI on (runtime)#
On the TypeScript runtime, injectA2UITool: true turns A2UI on:
CopilotKit renders A2UI output, serialises your client catalog into
the agent's copilotkit.context, and makes the A2UI tool available to
the agent. The frontend wiring is identical across integrations:
const runtime = new CopilotRuntime({
agents: { default: myAgent },
a2ui: {
injectA2UITool: true,
},
});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
createSurface+updateComponentsonce the schema is complete. - Extracts complete
itemsobjects progressively and emits anupdateDataModelfor each, so 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.