Dynamic Schema A2UI
LLM-generated A2UI. A secondary LLM creates both the schema and data from any prompt.
"""Claude Agent SDK backend for the Declarative Generative UI (A2UI Dynamic) demo.The agent exposes a single `generate_a2ui(context: str)` tool. When called,it invokes a secondary Claude call bound to the `render_a2ui` tool schema(forced via `tool_choice`) and returns an `a2ui_operations` container whichthe runtime's A2UI middleware detects and forwards to the frontend renderer.The dedicated runtime route (`api/copilotkit-declarative-gen-ui/route.ts`)sets `injectA2UITool: false` so the runtime does not double-bind a secondA2UI tool on top of this one — the registered client catalog is stillserialised into `copilotkit.context` so the secondary LLM knows what'savailable.Mirrors the langgraph-python and ag2 references."""from __future__ import annotationsimport jsonimport osimport tracebackfrom collections.abc import AsyncIteratorfrom textwrap import dedentfrom typing import Anyimport anthropicfrom ag_ui.core import ( EventType, RunAgentInput, RunFinishedEvent, RunStartedEvent, TextMessageContentEvent, TextMessageEndEvent, TextMessageStartEvent, ToolCallArgsEvent, ToolCallEndEvent, ToolCallResultEvent, ToolCallStartEvent,)from ag_ui.encoder import EventEncoderfrom tools import ( RENDER_A2UI_TOOL_SCHEMA, build_a2ui_operations_from_tool_call,)from agents._anthropic_message_safety import sanitize_unresolved_tool_usesfrom agents.claude_agent_sdk_adapter import normalize_claude_modelSYSTEM_PROMPT = dedent(""" You are a demo assistant for Declarative Generative UI (A2UI — Dynamic Schema). Whenever a response would benefit from a rich visual — a dashboard, status report, KPI summary, card layout, info grid, a pie/donut chart of part-of-whole breakdowns, a bar chart comparing values across categories, or anything more structured than plain text — call `generate_a2ui` to draw it. The registered catalog includes `Card`, `StatusBadge`, `Metric`, `InfoRow`, `PrimaryButton`, `PieChart`, and `BarChart` (in addition to the basic A2UI primitives). Prefer `PieChart` for part-of-whole breakdowns and `BarChart` for comparisons across categories. `generate_a2ui` takes a single `context` argument summarising the conversation. Keep chat replies to one short sentence; let the UI do the talking.""").strip()GENERATE_A2UI_TOOL = { "name": "generate_a2ui", "description": ( "Generate dynamic A2UI components based on the conversation. " "A secondary LLM designs the UI schema and data using the registered catalog." ), "input_schema": { "type": "object", "properties": { "context": { "type": "string", "description": "Conversation context summary the secondary LLM should design UI from.", }, }, "required": ["context"], },}def _generate_a2ui( context: str, conversation_messages: list[dict[str, Any]] | None = None) -> dict[str, Any]: """Invoke a secondary LLM bound to render_a2ui and return an operations container.""" client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY", "")) render_tool_schema = { "name": RENDER_A2UI_TOOL_SCHEMA["name"], "description": RENDER_A2UI_TOOL_SCHEMA["description"], "input_schema": RENDER_A2UI_TOOL_SCHEMA["parameters"], } llm_messages = ( sanitize_unresolved_tool_uses(conversation_messages) if conversation_messages else [ { "role": "user", "content": "Generate a dynamic A2UI dashboard based on the conversation.", } ] ) response = client.messages.create( model=normalize_claude_model(os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4.6")), max_tokens=4096, system=context or "Generate a useful dashboard UI.", messages=llm_messages, tools=[render_tool_schema], tool_choice={"type": "tool", "name": "render_a2ui"}, ) for block in response.content: if getattr(block, "type", None) == "tool_use" and block.name == "render_a2ui": return build_a2ui_operations_from_tool_call(dict(block.input)) return {"error": "LLM did not call render_a2ui"}async def run_a2ui_dynamic_agent(input_data: RunAgentInput) -> AsyncIterator[str]: """Stream a Claude conversation that may call `generate_a2ui`.""" encoder = EventEncoder() client = anthropic.AsyncAnthropic(api_key=os.getenv("ANTHROPIC_API_KEY", "")) messages: list[dict[str, Any]] = [] for msg in input_data.messages or []: role = msg.role.value if hasattr(msg.role, "value") else str(msg.role) if role not in ("user", "assistant"): continue raw = getattr(msg, "content", None) content = "" if isinstance(raw, str): content = raw elif isinstance(raw, list): parts = [] for part in raw: if hasattr(part, "text"): parts.append(part.text) elif isinstance(part, dict) and "text" in part: parts.append(part["text"]) content = "".join(parts) if content: messages.append({"role": role, "content": content}) thread_id = input_data.thread_id or "default" run_id = input_data.run_id or "run-1" yield encoder.encode( RunStartedEvent(type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id) ) while True: msg_id = f"msg-{run_id}-{len(messages)}" yield encoder.encode( TextMessageStartEvent( type=EventType.TEXT_MESSAGE_START, message_id=msg_id, role="assistant", ) ) response_text = "" tool_calls: list[dict[str, Any]] = [] try: async with client.messages.stream( model=normalize_claude_model( os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4.6") ), max_tokens=2048, system=SYSTEM_PROMPT, messages=messages, tools=[GENERATE_A2UI_TOOL], ) as stream: current_tool_id: str | None = None current_tool_name: str | None = None current_tool_args = "" async for event in stream: etype = type(event).__name__ if etype == "RawContentBlockStartEvent": block = event.content_block # type: ignore[attr-defined] if block.type == "tool_use": current_tool_id = block.id current_tool_name = block.name current_tool_args = "" yield encoder.encode( ToolCallStartEvent( type=EventType.TOOL_CALL_START, tool_call_id=current_tool_id, tool_call_name=current_tool_name, parent_message_id=msg_id, ) ) elif etype == "RawContentBlockDeltaEvent": delta = event.delta # type: ignore[attr-defined] if delta.type == "text_delta": response_text += delta.text yield encoder.encode( TextMessageContentEvent( type=EventType.TEXT_MESSAGE_CONTENT, message_id=msg_id, delta=delta.text, ) ) elif delta.type == "input_json_delta": current_tool_args += delta.partial_json yield encoder.encode( ToolCallArgsEvent( type=EventType.TOOL_CALL_ARGS, tool_call_id=current_tool_id or "", delta=delta.partial_json, ) ) elif etype in ( "RawContentBlockStopEvent", "ParsedContentBlockStopEvent", ): if current_tool_id and current_tool_name: yield encoder.encode( ToolCallEndEvent( type=EventType.TOOL_CALL_END, tool_call_id=current_tool_id, ) ) try: parsed = ( json.loads(current_tool_args) if current_tool_args else {} ) except json.JSONDecodeError: parsed = {} tool_calls.append( { "id": current_tool_id, "name": current_tool_name, "input": parsed, } ) current_tool_id = None current_tool_name = None current_tool_args = "" except Exception: err_text = f"Agent error: {traceback.format_exc()}" yield encoder.encode( TextMessageContentEvent( type=EventType.TEXT_MESSAGE_CONTENT, message_id=msg_id, delta=err_text, ) ) yield encoder.encode( TextMessageEndEvent( type=EventType.TEXT_MESSAGE_END, message_id=msg_id, ) ) if not tool_calls: break # Build assistant turn with tool_use blocks. assistant_content: list[dict[str, Any]] = [] if response_text: assistant_content.append({"type": "text", "text": response_text}) for tc in tool_calls: assistant_content.append( { "type": "tool_use", "id": tc["id"], "name": tc["name"], "input": tc["input"], } ) messages.append({"role": "assistant", "content": assistant_content}) # Execute generate_a2ui and emit tool_result. tool_results: list[dict[str, Any]] = [] for tc in tool_calls: if tc["name"] == "generate_a2ui": ctx = tc["input"].get("context", "") try: result_obj = _generate_a2ui(ctx, conversation_messages=messages) result_text = json.dumps(result_obj) except Exception as exc: # noqa: BLE001 - surface as tool result result_text = json.dumps( { "error": "generate_a2ui failed", "detail": exc.__class__.__name__, } ) else: result_text = json.dumps({"error": f"unknown tool {tc['name']}"}) yield encoder.encode( ToolCallResultEvent( type=EventType.TOOL_CALL_RESULT, tool_call_id=tc["id"], message_id=f"{msg_id}-tool-result-{tc['id']}", content=result_text, ) ) tool_results.append( { "type": "tool_result", "tool_use_id": tc["id"], "content": result_text, } ) messages.append({"role": "user", "content": tool_results}) yield encoder.encode( RunFinishedEvent( type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id ) )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):
import { z } from "zod";import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";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 }) => ( <Card className="w-full min-w-0 overflow-hidden" data-testid="declarative-card" > <CardHeader> <CardTitle>{props.title}</CardTitle> {props.subtitle && <CardDescription>{props.subtitle}</CardDescription>} </CardHeader> {props.child && ( <CardContent className="flex flex-col gap-4"> {children(props.child)} </CardContent> )} </Card> ), StatusBadge: ({ props }) => ( <Badge variant={props.variant ?? "info"} data-testid="declarative-status-badge" > {props.text} </Badge> ), Metric: ({ props }) => { const trend = props.trend ?? "neutral"; const arrow = trend === "up" ? "↑" : trend === "down" ? "↓" : ""; const trendClass = trend === "up" ? "text-emerald-600" : trend === "down" ? "text-rose-600" : "text-[var(--foreground)]"; return ( // `flex-1 min-w-[120px]` lets a row of Metrics distribute evenly // inside the basic catalog's gap-less Row — 3 metrics in a 600px // card column get ~200px each instead of squishing to content width. <div data-testid="declarative-metric" className="flex flex-1 min-w-[120px] flex-col gap-1" > <div className="text-xs font-medium uppercase tracking-wider text-[var(--muted-foreground)]"> {props.label} </div> <div className={`flex items-baseline gap-1.5 text-2xl font-semibold tabular-nums ${trendClass}`} > <span>{props.value}</span> {arrow && <span className="text-base">{arrow}</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 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> ), PrimaryButton: ({ props, dispatch }) => ( <Button onClick={() => { if (props.action && dispatch) dispatch(props.action); }} > {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 ( // `flex-1 min-w-0` so multiple charts in a basic-catalog Row // distribute the available width evenly instead of each insisting // on its content size and overflowing. <Card className="w-full flex-1 min-w-0 overflow-hidden" data-testid="declarative-pie-chart" > <CardHeader> <CardTitle>{props.title}</CardTitle> <CardDescription>{props.description}</CardDescription> </CardHeader> <CardContent className="flex flex-col gap-4"> {safeData.length === 0 ? ( <div className="py-8 text-center text-sm text-[var(--muted-foreground)]"> No data available </div> ) : ( <> <DonutChart data={safeData} /> <div className="flex flex-col gap-2 pt-2"> {safeData.map((item, index) => { const val = Number(item.value) || 0; const pct = total > 0 ? ((val / total) * 100).toFixed(0) : "0"; return ( <div key={index} className="flex items-center gap-3 text-sm" > <span className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm" style={{ backgroundColor: CHART_COLORS[index % CHART_COLORS.length], }} /> <span className="flex-1 truncate text-[var(--foreground)]"> {item.label} </span> <span className="tabular-nums text-[var(--muted-foreground)]"> {val.toLocaleString()} </span> <span className="w-10 text-right tabular-nums text-[var(--muted-foreground)]"> {pct}% </span> </div> ); })} </div> </> )} </CardContent> </Card> ); }, BarChart: ({ props }) => { const { isNew } = useSeenIndices(); const data = props.data ?? []; const safeData = Array.isArray(data) ? data : []; return ( <Card className="w-full flex-1 min-w-0 overflow-hidden" data-testid="declarative-bar-chart" > {/* 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> <CardHeader> <CardTitle>{props.title}</CardTitle> <CardDescription>{props.description}</CardDescription> </CardHeader> <CardContent> {safeData.length === 0 ? ( <div className="py-8 text-center text-sm text-[var(--muted-foreground)]"> No data available </div> ) : ( <ResponsiveContainer width="100%" height={260}> <RechartsBarChart data={safeData} margin={{ top: 12, right: 12, bottom: 4, left: -8 }} > <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} /> <XAxis dataKey="label" tick={{ fontSize: 12, fill: "var(--muted-foreground)" }} stroke="var(--border)" tickLine={false} axisLine={false} /> <YAxis tick={{ fontSize: 12, fill: "var(--muted-foreground)" }} stroke="var(--border)" tickLine={false} axisLine={false} /> <Tooltip contentStyle={CHART_TOOLTIP_STYLE} cursor={{ fill: "var(--muted)", 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> )} </CardContent> </Card> ); },};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>That is all the default path needs. The catalog auto-enables A2UI and
injects the generate_a2ui tool, so the runtime needs no a2ui block.
(No catalog? Turn it on from the runtime instead with
a2ui: { injectA2UITool: true }.)
I opted out of auto-inject, now what?#
By not passing a catalog, not setting injectA2UITool, or by passing
a catalog and setting injectA2UITool: false, you have opted out
entirely. That means you hook up two pieces yourself: the
generate_a2ui tool which lets your agent generate A2UI surfaces, and
the A2UIMiddleware which lets those surfaces render.
The A2UIMiddleware#
The A2UIMiddleware is what turns the agent's a2ui_operations into
rendered surfaces. Without it, the agent's output never becomes UI; it
falls through as a plain tool result. It can also inject the
generate_a2ui tool for you (injectA2UITool: true), letting you skip
the next step. Attach it to the AG-UI agent:
import { A2UIMiddleware } from "@ag-ui/a2ui-middleware";
agent.use(new A2UIMiddleware({ injectA2UITool: false }));The A2UI agent tool#
The generate_a2ui tool runs a secondary LLM (a subagent) that designs
the surface, which is why you hand it a model. Build it with the AG-UI
factory and add it to your agent's tools:
from ag_ui_langgraph import get_a2ui_tools
from langchain_openai import ChatOpenAI
generate_a2ui = get_a2ui_tools({
"model": ChatOpenAI(model="gpt-4o"),
"default_catalog_id": "copilotkit://app-dashboard-catalog",
})
tools = [my_other_tool, generate_a2ui]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.