Dynamic Schema A2UI
LLM-generated A2UI — a secondary LLM creates both the schema and data from any prompt.
"""LangGraph agent for the Declarative Generative UI (A2UI — Dynamic Schema) demo."""from __future__ import annotationsimport osfrom copilotkit import CopilotKitMiddlewarefrom langchain.agents import create_agentfrom langchain.tools import toolfrom langchain_openai import ChatOpenAI# Cross-reference: showcase/integrations/google-adk/src/agents/declarative_gen_ui_agent.py# Both integrations register the same a2ui catalog (Card / Row / Column /# Text / Metric / PieChart / BarChart / DataTable / StatusBadge / InfoRow /# PrimaryButton — see each integration's# src/app/demos/declarative-gen-ui/a2ui/definitions.ts, which are# byte-identical across LP and ADK).## The fictional sales dataset and the per-question composition rules# are injected via App Context from# showcase/integrations/langgraph-python/src/app/demos/declarative-gen-ui/sales-context.ts# (a frontend file shared byte-for-byte with the ADK integration — see# its DUPLICATION NOTICE).## Keep this SYSTEM_PROMPT and the ADK `_INSTRUCTION` aligned in spirit.# Minor wording differences are tolerated (e.g. this prompt uses shape# words — "table"/"pie"/"bar" — as question-category descriptors, while# ADK names the rendered components — "DataTable"/"PieChart"/"BarChart"# — in the analogous slot), but the structural rules and the component# name set must match the catalog above.SYSTEM_PROMPT = ( "You are the embedded sales analyst for Vantage Threads, the fictional " "B2B apparel company described in your App Context. Answer every " "business question by calling `generate_a2ui` to draw a rich visual " "surface, and keep the chat reply to one short sentence.\n" "\n" "Ground every number in the sales dataset from App Context — never " "invent figures that contradict it. Follow the dashboard composition " "rules from App Context when choosing components: pick the component " "by the shape of the question (snapshot → composed KPI dashboard with " "charts; team performance → table; risk → status badges; single " "account → info rows; part-of-whole → pie; trend/comparison → bar). " "Never ask the user which chart they want. `generate_a2ui` takes no " "arguments and handles the rendering automatically. Compose " "generously — a dashboard should feel like a real analytics product, " "not a single widget.")@tooldef generate_a2ui() -> dict: """Generate a dynamic A2UI dashboard surface from the current conversation. Takes no arguments. The CopilotKit runtime middleware (`a2ui.injectA2UITool: true`) intercepts the call and drives a secondary-LLM `render_a2ui` planner to emit the surface ops; this Python body should NEVER execute in normal operation. It exists only so the LP agent's declared `tools=` list mirrors the ADK sibling (`declarative_gen_ui_agent.py`) and the SYSTEM_PROMPT's `generate_a2ui` reference resolves to a registered tool name. If this body actually runs, the CopilotKit a2ui middleware is misconfigured and silently returning an empty surface would hide the real bug — fail loud per `fail-loud-discipline`. """ raise RuntimeError( "generate_a2ui called directly — CopilotKit a2ui.injectA2UITool " "middleware should intercept this call before it reaches the " "agent. Check the route configuration at " "app/api/copilotkit-declarative-gen-ui/route.ts." )graph = create_agent( model=ChatOpenAI(model=os.getenv("OPENAI_MODEL", "gpt-4o")), tools=[generate_a2ui], middleware=[CopilotKitMiddleware()], system_prompt=SYSTEM_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#
First, add CopilotKitMiddleware to your create_agent call. The middleware
is what makes every CopilotKit feature on the frontend — frontend tools,
shared state, agent context, and generative UI components — visible to your
LangGraph agent on every turn.
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from copilotkit import CopilotKitMiddleware
graph = create_agent(
model=ChatOpenAI(model="gpt-5.4"),
tools=[],
middleware=[CopilotKitMiddleware()],
system_prompt="You are a helpful, concise assistant.",
)Install the SDK
If copilotkit isn't already in your project, add it so the import
above resolves. Pick the package manager that matches your project:
uv add copilotkitpoetry add copilotkitpip install copilotkit --extra-index-url https://copilotkit.gateway.scarf.sh/simple/conda install copilotkit -c copilotkit-channel- 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):
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/at-risk, on-track/behind). 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.", // NOTE on B12 (row-keys ⊆ columns[].key): we'd normally enforce this // with `z.object(...).refine(...)`, but the host catalog package's // `CatalogComponentDefinition` type requires `props: ZodObject<…>` // (it inspects `.shape` at runtime), and `.refine` returns a // `ZodEffects` that breaks both the `satisfies CatalogDefinitions` // type assertion and the runtime `.shape` access. Until the host // type is broadened, we encode the constraint in the description // above so the LLM sees the rule, and leave hard enforcement to // the rendering pipeline (which already shows the empty cell — // detection is the gap, not behaviour). 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.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(), // The renderer hands `action` opaquely to the A2UI `dispatch` helper, // which forwards it back to the agent. We don't constrain the shape // (different demos use different action payloads), but `z.unknown()` // is strictly better than `z.any()` here because it forces any // consumer that touches the value to narrow it explicitly. action: z.unknown().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 (revenue by region, pipeline by stage).", 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 or time (monthly revenue, 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> = { 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: c.cardFg, lineHeight: 1.5 }}> {props.text} </span> ), Card: ({ props, children }) => ( // `data-testid="declarative-card"` stays shared so existing e2e selectors // still find every card; `data-card-id={props.title}` disambiguates // sibling cards (e.g. the at-risk pill's 3 severity cards) so test // assertions can target a specific card by title. <CardShell title={props.title} subtitle={props.subtitle} testid="declarative-card" cardId={props.title} > {props.child && children(props.child)} </CardShell> ), StatusBadge: ({ props }) => { const variant = props.variant ?? "info"; const Icon = { error: TriangleAlert, warning: CircleAlert, success: CircleCheck, info: Info, }[variant]; return ( // `alignSelf: flex-start` keeps the pill content-sized — flex parents // (our Column override) default to stretch, which inflates it into a // full-width banner. <Badge variant={variant} style={{ alignSelf: "flex-start" }} data-testid="declarative-status-badge" > <Icon size={12} strokeWidth={2.5} style={{ marginRight: 4 }} /> {props.text} </Badge> ); }, Metric: ({ props }) => { const trendColors: Record<string, string> = { up: "#059669", down: "#dc2626", neutral: c.muted, }; const trendIcons: Record<string, string> = { up: "↑", down: "↓", neutral: "→", }; return ( <div data-testid="declarative-metric" style={{ display: "flex", flexDirection: "column", gap: "4px", minWidth: "120px", }} > <span style={{ fontSize: "0.75rem", color: c.muted, fontWeight: 500, textTransform: "uppercase", letterSpacing: "0.05em", }} > {props.label} </span> <div style={{ display: "flex", alignItems: "baseline", gap: "8px" }}> <span style={{ fontSize: "1.5rem", fontWeight: 700, color: c.cardFg, letterSpacing: "-0.02em", }} > {props.value} </span> {props.trend && ( <span style={{ fontSize: "0.8rem", fontWeight: 500, color: trendColors[props.trend] ?? c.muted, }} > {trendIcons[props.trend]} {props.trendValue ? ` ${props.trendValue}` : ""} </span> )} </div> </div> ); }, InfoRow: ({ props }) => ( // Divider via `border-b last:border-b-0` so the final row doesn't dangle // a trailing line, regardless of whether the agent wraps these in a // Column or drops them directly into a Card's child slot. <div data-testid="declarative-info-row" className="flex items-baseline justify-between gap-4 py-2 border-b border-[var(--border)] last:border-b-0 last:pb-0 first:pt-0" > <span className="text-sm text-[var(--muted-foreground)]"> {props.label} </span> <span className="text-sm font-medium text-[var(--foreground)] text-right tabular-nums"> {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" className="w-full overflow-x-auto" > <table className="w-full border-collapse text-sm"> <thead> <tr> {cols.map((col) => ( <th key={col.key} className="border-b-2 border-[var(--border)] px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-[var(--muted-foreground)]" > {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. Stable keys prevent // React from re-mounting every row when the agent re-emits a // slightly different table. const pk = cols.length > 0 ? row[cols[0].key] : undefined; const rowKey = pk !== undefined ? `${pk}-${i}` : JSON.stringify(row); return ( <tr key={rowKey} className="border-b border-[var(--border)] last:border-b-0" > {cols.map((col) => ( <td key={col.key} className="px-3 py-2 tabular-nums text-[var(--foreground)]" > {String(row[col.key] ?? "")} </td> ))} </tr> ); })} </tbody> </table> </div> ); }, PrimaryButton: ({ props, dispatch }) => ( <Button onClick={() => { if (props.action && dispatch) dispatch(props.action); }} > {props.label} </Button> ), PieChart: ({ props }) => { // Coerce values to numbers — the LLM sometimes emits them as strings. // Use a strict finite check so null/undefined/NaN/non-numeric strings are // surfaced via console.warn rather than silently collapsed to 0 (which // masks schema/data drift). Recharts requires a numeric value to render, // so we fall back to 0 only after logging. const data = (Array.isArray(props.data) ? props.data : []).map((d) => { const raw = (d as { value?: unknown }).value; const n = typeof raw === "number" ? raw : parseFloat(raw as string); let value: number; if (Number.isFinite(n)) { value = n; } else { console.warn("Invalid chart value", { component: "PieChart", key: "value", raw, }); value = 0; } return { ...d, value }; }); return ( <CardShell title={props.title} subtitle={props.description} testid="declarative-pie-chart" > {data.length === 0 ? ( <div className="py-8 text-center text-sm text-[var(--muted-foreground)]"> No data available </div> ) : ( <div style={{ width: "100%", height: 200 }}> <ResponsiveContainer> <RechartsPieChart> <Pie data={data} dataKey="value" nameKey="label" cx="50%" cy="50%" innerRadius={40} outerRadius={80} paddingAngle={2} > {data.map((_, i) => ( <Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} /> ))} </Pie> <Tooltip /> </RechartsPieChart> </ResponsiveContainer> </div> )} </CardShell> ); }, BarChart: ({ props }) => { // Coerce values to numbers — the LLM sometimes emits them as strings, // which recharts treats as categorical (unordered Y-axis ticks). Use a // strict finite check so null/undefined/NaN/non-numeric strings are // surfaced via console.warn rather than silently collapsed to 0 (which // masks schema/data drift). Recharts requires a numeric value to render, // so we fall back to 0 only after logging. const data = (Array.isArray(props.data) ? props.data : []).map((d) => { const raw = (d as { value?: unknown }).value; const n = typeof raw === "number" ? raw : parseFloat(raw as string); let value: number; if (Number.isFinite(n)) { value = n; } else { console.warn("Invalid chart value", { component: "BarChart", key: "value", raw, }); value = 0; } return { ...d, value }; }); return ( <CardShell title={props.title} subtitle={props.description} testid="declarative-bar-chart" > {data.length === 0 ? ( <div className="py-8 text-center text-sm text-[var(--muted-foreground)]"> No data available </div> ) : ( <div style={{ width: "100%", height: 200 }}> <ResponsiveContainer> <RechartsBarChart data={data}> <CartesianGrid strokeDasharray="3 3" stroke={c.divider} /> <XAxis dataKey="label" tick={{ fontSize: 11, fill: c.muted }} /> <YAxis tick={{ fontSize: 11, fill: c.muted }} /> <Tooltip /> <Bar dataKey="value" fill="#3b82f6" radius={[4, 4, 0, 0]} /> </RechartsBarChart> </ResponsiveContainer> </div> )} </CardShell> ); },};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 } from "@copilotkit/react-core/v2";import { myCatalog } from "./a2ui/catalog";import { Chat } from "./chat";export default function DeclarativeGenUIDemo() { return ( <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>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.