Dynamic Schema A2UI
LLM-generated A2UI. A secondary LLM creates both the schema and data from any prompt.
"""LlamaIndex agent for the Declarative Generative UI (A2UI — Dynamic Schema) demo.Mirrors `langgraph-python/src/agents/a2ui_dynamic.py`:- The agent binds a single `generate_a2ui` backend tool.- When called, `generate_a2ui` kicks off a secondary OpenAI chat completion with a forced `_design_a2ui_surface` tool call. The registered client catalog is expected to surface through the system prompt (the LlamaIndex router does not yet auto-inject `copilotkit.context`, so the catalog description is inlined into the system prompt for parity).- The tool result carries the planner's A2UI component payload, which the workflow then RE-EMITS as a streamed ``render_a2ui`` tool-CALL on the outbound AG-UI stream so ``@ag-ui/a2ui-middleware`` mounts the surface.Pairs with the dedicated runtime route`src/app/api/copilotkit-declarative-gen-ui/route.ts` which sets`a2ui.injectA2UITool: true` so the middleware WATCHES the ``render_a2ui``tool-call name (the watched-names set is only populated when the tool isinjected).INTEGRATION-LEVEL STREAMED render_a2ui FIX------------------------------------------The A2UI middleware (``@ag-ui/a2ui-middleware``) mounts the surface from aSTREAMED render-tool CALL, NOT from a tool result: - On ``TOOL_CALL_START``: it tracks the call ONLY when ``toolCallName`` is in its watched set (populated when ``injectA2UITool: true``, watching ``render_a2ui``). - On ``TOOL_CALL_ARGS`` deltas: it accumulates the args, parses ``components`` out of the streamed args, and emits the ``a2ui-surface`` activity (``createSurface`` / ``updateComponents``) when ``components`` is present.A ``TOOL_CALL_RESULT`` does NOT mount the surface. The upstream llama-indexAG-UI adapter only appends backend tool results to chat history and re-emits via``MESSAGES_SNAPSHOT``, which the middleware ignores — so the surface was nevermounted (``reason=surface-missing``).We close the gap at the integration level by mirroring how google-adk drivesthe middleware — emitting a streamed ``render_a2ui`` tool-CALL: 1. ``generate_a2ui`` (the backend tool) runs the secondary forced planner call and returns the planner's ``render_a2ui`` args (``surfaceId`` / ``catalogId`` / ``components`` / ``data``) as JSON. The backend still PRODUCES the components — nothing is stubbed. 2. The workflow override (``_A2UIRenderToolCallWorkflow``) parses that backend tool result and writes a discrete streamed ``render_a2ui`` tool-CALL to the AG-UI stream: ``TOOL_CALL_START`` (toolCallName=``render_a2ui``), one or more ``TOOL_CALL_ARGS`` deltas carrying the args JSON (whose ``components`` array the middleware parses), and ``TOOL_CALL_END``. The upstream ``MESSAGES_SNAPSHOT`` behaviour is preserved (super() body still runs).The streamed ``TOOL_CALL_START`` / ``TOOL_CALL_ARGS`` / ``TOOL_CALL_END`` eventsare already in the upstream ``AG_UI_EVENTS`` allow-list the router streamsagainst, so they pass through the SSE shim unmodified.Both changes live entirely in this integration; no shared/`@ag-ui` package istouched."""import jsonimport loggingimport osimport uuidfrom typing import Annotated, Awaitable, Callable, List, Optional, Unionfrom ag_ui.core import RunAgentInputfrom fastapi import APIRouterfrom fastapi.responses import StreamingResponsefrom llama_index.core.llms import ChatMessagefrom llama_index.core.workflow import Context, Workflow, stepfrom llama_index.llms.openai import OpenAIfrom llama_index.protocols.ag_ui.agent import ( AGUIChatWorkflow, LoopEvent, ToolCallResultEvent,)from llama_index.protocols.ag_ui.events import ( RunErrorWorkflowEvent, RunFinishedWorkflowEvent, RunStartedWorkflowEvent, ToolCallArgsWorkflowEvent, ToolCallEndWorkflowEvent, ToolCallStartWorkflowEvent,)from llama_index.protocols.ag_ui.router import AG_UI_EVENTSfrom llama_index.protocols.ag_ui.utils import timestamp, workflow_event_to_ssefrom llama_index.core.workflow.events import StopEventlogger = logging.getLogger(__name__)CUSTOM_CATALOG_ID = "declarative-gen-ui-catalog"# The render-tool name `@ag-ui/a2ui-middleware` watches (when# `injectA2UITool: true`) and mounts the surface from. We synthesise a STREAMED# tool-CALL by this name on the outbound stream.RENDER_A2UI_TOOL_NAME = "render_a2ui"# Inner planner tool name. Deliberately NOT `render_a2ui`: the secondary forced# planner call is an internal OpenAI tool-call, and naming it `render_a2ui`# would risk the middleware/frontend intercepting that internal call. The# planner emits `_design_a2ui_surface`; the OUTER workflow then RE-EMITS its# component args as a STREAMED `render_a2ui` tool-CALL (see# `_A2UIRenderToolCallWorkflow`) that the middleware actually watches.DESIGN_TOOL_NAME = "_design_a2ui_surface"# Allow-list the router streams against. The streamed `render_a2ui` tool-CALL# events (TOOL_CALL_START / TOOL_CALL_ARGS / TOOL_CALL_END) are already part of# the upstream `AG_UI_EVENTS` tuple, so no extension is required._A2UI_AG_UI_EVENTS = AG_UI_EVENTSdef _emit_render_a2ui_tool_call(ctx: Context, args: dict) -> None: """Write a STREAMED ``render_a2ui`` tool-CALL to the AG-UI stream. Emits ``TOOL_CALL_START`` (toolCallName=``render_a2ui``), the args JSON as one or more ``TOOL_CALL_ARGS`` deltas (the middleware parses ``components`` out of the accumulated args), then ``TOOL_CALL_END``. This is the exact event sequence ``@ag-ui/a2ui-middleware`` watches to mount the surface. """ tool_call_id = f"render_a2ui-{uuid.uuid4().hex}" payload = json.dumps(args) ctx.write_event_to_stream( ToolCallStartWorkflowEvent( tool_call_id=tool_call_id, tool_call_name=RENDER_A2UI_TOOL_NAME, ) ) # Chunk the args so the middleware exercises its streaming-args accumulator # (it parses `components` from the concatenated deltas). chunk_size = 256 for start in range(0, len(payload), chunk_size): ctx.write_event_to_stream( ToolCallArgsWorkflowEvent( tool_call_id=tool_call_id, delta=payload[start : start + chunk_size], ) ) ctx.write_event_to_stream(ToolCallEndWorkflowEvent(tool_call_id=tool_call_id))class _A2UIRenderToolCallWorkflow(AGUIChatWorkflow): """Upstream workflow that RE-EMITS each backend `generate_a2ui` result as a STREAMED ``render_a2ui`` tool-CALL so ``@ag-ui/a2ui-middleware`` mounts the surface. Only ``aggregate_tool_calls`` is overridden. The override re-applies ``@step`` (a plain override drops the method from llama-index's step registry) and is functionally equivalent to upstream llama-index-protocols-ag-ui 0.2.2 ``aggregate_tool_calls`` (verified; two cosmetic differences — an ``Optional`` type hint and a list comprehension — both behaviorally identical), with one additive step that re-emits backend results as a streamed ``render_a2ui`` tool-CALL — preserving the ``MESSAGES_SNAPSHOT`` history update and the loop/stop control flow. It parses each backend tool result (the planner's ``render_a2ui`` args carrying ``components``) and streams a discrete ``render_a2ui`` tool-CALL. This mirrors how google-adk drives the middleware without altering any other adapter behaviour. """ @step async def aggregate_tool_calls( self, ctx: Context, ev: ToolCallResultEvent ) -> Optional[Union[StopEvent, LoopEvent]]: num_tool_calls = await ctx.store.get("num_tool_calls") tool_call_results: Optional[List[ToolCallResultEvent]] = ctx.collect_events( ev, [ToolCallResultEvent] * num_tool_calls ) if tool_call_results is None: # Not all sibling tool results have arrived yet. return None frontend_tool_calls = [ r for r in tool_call_results if r.tool_name in self.frontend_tools ] backend_tool_calls = [ r for r in tool_call_results if r.tool_name in self.backend_tools ] # ADDITION: for every backend `generate_a2ui` result, RE-EMIT the # planner's component args as a STREAMED `render_a2ui` tool-CALL. The # middleware mounts the surface from this streamed call (it does NOT # inspect tool results or MESSAGES_SNAPSHOT). Frontend-tool results are # resolved on the client and must NOT be re-emitted here. for result in backend_tool_calls: content = result.tool_output.content render_args = None if isinstance(content, str): try: render_args = json.loads(content) except json.JSONDecodeError as exc: # A non-JSON backend result means the planner produced no # parseable surface; without this log a planner regression # presents as a blank UI with zero diagnostic trail. logger.warning( "a2ui_dynamic: backend tool %r returned non-JSON content " "(%s); no render_a2ui surface emitted. raw=%r", result.tool_name, exc, content, ) else: logger.warning( "a2ui_dynamic: backend tool %r returned non-str content " "(type=%s); no render_a2ui surface emitted.", result.tool_name, type(content).__name__, ) if isinstance(render_args, dict) and render_args.get("error"): # `generate_a2ui` returns `{"error": ...}` when the planner LLM # did not call the design tool. It parses as valid JSON but has # no components, so it would otherwise be skipped silently. logger.warning( "a2ui_dynamic: backend tool %r reported an error (%s); " "no render_a2ui surface emitted.", result.tool_name, render_args.get("error"), ) continue if isinstance(render_args, dict) and render_args.get("components"): _emit_render_a2ui_tool_call(ctx, render_args) elif isinstance(render_args, dict): # Valid JSON but no `components` — the middleware mounts nothing, # so surface the empty/missing-components case for debugging. logger.warning( "a2ui_dynamic: backend tool %r returned no components " "(keys=%s); no render_a2ui surface emitted.", result.tool_name, sorted(render_args.keys()), ) # --- upstream aggregate_tool_calls body (unchanged) --- new_tool_messages = [ ChatMessage( role="tool", content=r.tool_output.content, additional_kwargs={"tool_call_id": r.tool_call_id}, ) for r in backend_tool_calls ] chat_history = await ctx.store.get("chat_history") if new_tool_messages: chat_history.extend(new_tool_messages) self._snapshot_messages(ctx, [*chat_history]) await ctx.store.set("chat_history", chat_history) if len(frontend_tool_calls) > 0: return StopEvent() return LoopEvent(messages=chat_history)def _make_a2ui_router( workflow_factory: Callable[[], Awaitable[Workflow]],) -> APIRouter: """SSE router mirroring upstream ``AGUIWorkflowRouter``. Upstream ``AGUIWorkflowRouter.run`` filters ``handler.stream_events()`` against a fixed ``AG_UI_EVENTS`` tuple; this shim is functionally identical (it filters against ``_A2UI_AG_UI_EVENTS``, which equals ``AG_UI_EVENTS``). The streamed ``render_a2ui`` tool-CALL events the workflow emits (TOOL_CALL_START / TOOL_CALL_ARGS / TOOL_CALL_END) are already in that allow-list, so they pass through unmodified. """ router = APIRouter() async def run(input: RunAgentInput): workflow = await workflow_factory() handler = workflow.run(input_data=input) async def stream_response(): try: yield workflow_event_to_sse( RunStartedWorkflowEvent( timestamp=timestamp(), thread_id=input.thread_id, run_id=input.run_id, ) ) async for stream_ev in handler.stream_events(): if isinstance(stream_ev, _A2UI_AG_UI_EVENTS): yield workflow_event_to_sse(stream_ev) _ = await handler yield workflow_event_to_sse( RunFinishedWorkflowEvent( timestamp=timestamp(), thread_id=input.thread_id, run_id=input.run_id, ) ) except Exception as exc: # noqa: BLE001 — mirror upstream error path yield workflow_event_to_sse( RunErrorWorkflowEvent( timestamp=timestamp(), message=str(exc), code=str(type(exc)), ) ) await handler.cancel_run() raise return StreamingResponse(stream_response(), media_type="text/event-stream") router.add_api_route("/run", run, methods=["POST"]) return routerasync def generate_a2ui( context: Annotated[ str, "Short description of what the UI should show; mirrors the last user " "message so the secondary LLM has full context.", ],) -> str: """Generate dynamic A2UI components based on the conversation. Invokes a secondary LLM bound to `_design_a2ui_surface` (tool_choice forced) and returns the planner's `render_a2ui` args (surfaceId, catalogId, components, data) as JSON. The workflow override (`_A2UIRenderToolCallWorkflow.aggregate_tool_calls`) parses this result and RE-EMITS it as a streamed `render_a2ui` tool-CALL that the A2UI middleware watches and mounts the surface from. """ from openai import OpenAI as OpenAIClient client = OpenAIClient() tool_schema = { "type": "function", "function": { "name": DESIGN_TOOL_NAME, "description": "Render a dynamic A2UI v0.9 surface.", "parameters": { "type": "object", "properties": { "surfaceId": {"type": "string"}, "catalogId": {"type": "string"}, "components": {"type": "array", "items": {"type": "object"}}, "data": {"type": "object"}, }, "required": ["surfaceId", "catalogId", "components"], }, }, } response = client.chat.completions.create( model="gpt-4.1", messages=[ { "role": "system", "content": ( "You design dynamic A2UI v0.9 surfaces for the " "declarative-gen-ui demo. Use catalogId " f"'{CUSTOM_CATALOG_ID}'. Components: Card (title, " "subtitle?, child?), StatusBadge (text, variant: " "success|warning|error|info), Metric (label, value, " "trend: up|down|neutral), InfoRow (label, value), " "PrimaryButton (label, action?), PieChart (title, " "description, data: [{label, value}]), BarChart (title, " "description, data: [{label, value}]), DataTable (columns: " "[{key, label}], rows: [{<key>: string|number}]; row keys " "must match columns[].key — ideal for rankings and " "per-person/per-item breakdowns like rep performance vs " "quota). Basic primitives " "(Column, Row, Text, Image, Card, Button) are also " "available. The root component id must be 'root'." ), }, {"role": "user", "content": context or "Generate a useful dashboard UI."}, ], tools=[tool_schema], tool_choice={"type": "function", "function": {"name": DESIGN_TOOL_NAME}}, ) if not response.choices[0].message.tool_calls: return json.dumps({"error": f"LLM did not call {DESIGN_TOOL_NAME}"}) tool_call = response.choices[0].message.tool_calls[0] args = json.loads(tool_call.function.arguments) if not args.get("catalogId"): args["catalogId"] = CUSTOM_CATALOG_ID if not args.get("surfaceId"): args["surfaceId"] = "dynamic-surface" # Return the planner's render_a2ui args verbatim. The workflow re-emits these # as a streamed `render_a2ui` tool-CALL (the middleware parses `components` # from the streamed args to mount the surface). return json.dumps(args)SYSTEM_PROMPT = ( "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` with a short `context` describing what to render. " "Keep chat replies to one short sentence; let the UI do the talking.")_openai_kwargs = {}if os.environ.get("OPENAI_BASE_URL"): _openai_kwargs["api_base"] = os.environ["OPENAI_BASE_URL"]def _a2ui_dynamic_workflow_factory() -> Callable[[], Awaitable[Workflow]]: async def factory() -> Workflow: return _A2UIRenderToolCallWorkflow( llm=OpenAI(model="gpt-4.1", **_openai_kwargs), frontend_tools=[], backend_tools=[generate_a2ui], system_prompt=SYSTEM_PROMPT, initial_state={}, timeout=120, ) return factorya2ui_dynamic_router = _make_a2ui_router(_a2ui_dynamic_workflow_factory())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(), }), }, 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.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> = { 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 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 }) => { 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.