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#

  1. The agent calls the A2UI tool to draw a surface, made available when injectA2UITool: true.
  2. The runtime serializes your client-side catalog (component names + Zod prop schemas) into the agent's copilotkit.context so the LLM knows which components it may emit.
  3. The tool call streams through LangGraph as TOOL_CALL_ARGS events.
  4. 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/:

FileWhat lives there
definitions.tsZod props schema + human-readable descriptions for each custom component. Platform-agnostic, so the runtime can serialise it to the LLM.
renderers.tsxReact implementations keyed by the same names. TypeScript enforces that every definition has a renderer.
catalog.tscreateCatalog(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):

definitions.ts
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:

renderers.tsx
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:

catalog.ts
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:

page.tsx
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:

agent.py
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:

  1. Waits for the full components array before emitting anything; the schema must be complete before rendering starts.
  2. Extracts surfaceId + root from the partial JSON.
  3. Emits createSurface + updateComponents once the schema is complete.
  4. Extracts complete items objects progressively and emits an updateDataModel for 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.