Dynamic Schema A2UI

LLM-generated A2UI. A secondary LLM creates both the schema and data from any prompt.

Open your coding agent in your project's folder, or in an empty folder for a new app.This runs in a coding agent on your computer.

"""A2UI dynamic schema: a secondary Gemini call designs the whole surface.Backend-owned, like google-adk's a2ui-recovery wiring rather than the runtimeauto-injection langgraph-python's declarative-gen-ui uses. ``generate_a2ui``is an ordinary Antigravity server tool. Its body:1. reads the run's context with the adapter's ``experimental_get_context()``: the page's   sales dataset and composition rules (``useAgentContext``) and the A2UI   component schema the provider sends for its catalog;2. assembles the sub-agent prompt with the A2UI toolkit   (``prepare_a2ui_request``: generation + design guidelines, the context   entries, ``## Available Components``);3. makes its own ``generateContent`` call, forced onto a single   ``render_a2ui`` function (``toolConfig`` mode ``ANY``);4. validates the result and retries with the errors appended to the prompt   (the toolkit's ``run_a2ui_generation_with_recovery``, up to   ``MAX_ATTEMPTS``);5. returns the ``a2ui_operations`` envelope, or the   ``a2ui_recovery_exhausted`` envelope once every attempt failed.The adapter JSON-encodes the return value into ``TOOL_CALL_RESULT``, wherethe runtime's A2UI middleware paints the surface or shows the failure card.The route sets ``injectA2UITool: false`` so the runtime does not also hand themodel a ``render_a2ui`` frontend tool, which this harness would park.``render_a2ui`` declares ``components`` and ``data`` as JSON *strings*, asag-ui-adk's ``A2UISubAgentTool`` does: Gemini fills a property-lessarray-of-object schema with ``{}``. A model may still answer with structuredvalues; both shapes are accepted.Validation is structural only (no catalog): the provider's schema lists onlythe custom components, so checking membership against it would reject thebasic-catalog Row/Column/Text the surfaces are built from. Structural checks(ids, root, child references, cycles, bindings) are what the recovery demoexercises.The tool is ``async def`` for the reason given in ``subagents.py``: ablocking HTTP call in a server tool stalls the event loop. The toolkit'srecovery loop is synchronous, so it runs on a worker thread and each attempt'sHTTP call is scheduled back onto the loop.No ``from __future__ import annotations``: the SDK derives the tool schemafrom the live annotations."""import asyncioimport jsonimport loggingfrom typing import Anyimport httpxfrom ag_ui_a2ui_toolkit import (    RENDER_A2UI_TOOL_DEF,    build_a2ui_envelope,    prepare_a2ui_request,    resolve_a2ui_catalog,    run_a2ui_generation_with_recovery,    split_a2ui_schema_context,)from ag_ui_antigravity import experimental_get_contextfrom agents._common import MODEL, aimock_headers, api_key, gemini_base_urllogger = logging.getLogger(__name__)# The catalog the declarative-gen-ui page registers (a2ui/catalog.ts); the# a2ui-recovery page reuses it. Used when the run carries no schema entry# naming a catalog.CATALOG_ID = "declarative-gen-ui-catalog"DEFAULT_SURFACE_ID = "sales-surface"RENDER_TOOL_NAME = "render_a2ui"# Initial try + retries; matches the toolkit default and the renderer's# "Retrying… (N/M)" label.MAX_ATTEMPTS = 3RENDER_A2UI_DECLARATION = {    "name": RENDER_TOOL_NAME,    "description": RENDER_A2UI_TOOL_DEF["function"]["description"],    "parameters": {        "type": "OBJECT",        "properties": {            "surfaceId": {                "type": "STRING",                "description": "Unique surface identifier.",            },            "components": {                "type": "STRING",                "description": (                    "The A2UI v0.9 component array as a JSON string, e.g. "                    '\'[{"id":"root","component":"Text","text":"Hi"}]\'. '                    "The root component must have id 'root'."                ),            },            "data": {                "type": "STRING",                "description": (                    "Optional surface data model as a JSON string, e.g. "                    "'{\"items\":[...]}'. Use '{}' when there is none."                ),            },        },        "required": ["surfaceId", "components"],    },}# One connection pool for the process, created on the first call so importing# this module never touches the network stack._HTTP_CLIENT: httpx.AsyncClient | None = Nonedef _http_client() -> httpx.AsyncClient:    global _HTTP_CLIENT    if _HTTP_CLIENT is None:        _HTTP_CLIENT = httpx.AsyncClient(timeout=120.0)    return _HTTP_CLIENTasync def close_http_client() -> None:    """Closes the shared client; safe to call when it was never created."""    global _HTTP_CLIENT    client, _HTTP_CLIENT = _HTTP_CLIENT, None    if client is not None:        await client.aclose()def _parse_json_arg(value: Any, expect: type) -> Any:    """Parses a JSON-string argument; anything unparseable is returned as is.    A value left as a string fails validation, so the recovery loop retries    instead of painting garbage. A lone component object is wrapped in a list.    """    if not isinstance(value, str):        parsed = value    else:        try:            parsed = json.loads(value)        except ValueError:            return value    if expect is list and isinstance(parsed, dict):        return [parsed]    return parseddef coerce_render_args(args: dict) -> dict:    """``render_a2ui`` arguments with ``components``/``data`` as list/dict."""    coerced = dict(args)    if "components" in coerced:        coerced["components"] = _parse_json_arg(coerced["components"], list)    if "data" in coerced:        coerced["data"] = _parse_json_arg(coerced["data"], dict)    return coercedasync def render_once(system_prompt: str, request: str) -> dict | None:    """One forced ``render_a2ui`` call. ``None`` when the model did not call it.    ``request`` is the user turn. The recovery fixtures match the retry by    the error block the toolkit appends to ``system_prompt``.    """    base = gemini_base_url() or "https://generativelanguage.googleapis.com"    headers = aimock_headers()    key = api_key()    if key:        headers["x-goog-api-key"] = key    body = {        "systemInstruction": {"parts": [{"text": system_prompt}]},        "contents": [{"role": "user", "parts": [{"text": request}]}],        "tools": [{"functionDeclarations": [RENDER_A2UI_DECLARATION]}],        "toolConfig": {            "functionCallingConfig": {                "mode": "ANY",                "allowedFunctionNames": [RENDER_TOOL_NAME],            }        },    }    try:        response = await _http_client().post(            f"{base}/v1beta/models/{MODEL}:generateContent",            json=body,            headers=headers,        )        response.raise_for_status()    except httpx.HTTPError:        # Counted as a failed attempt; the loop retries and, if every attempt        # fails, the user sees the recovery-exhausted card instead of a hang.        logger.exception("render_a2ui call failed")        return None    candidates = response.json().get("candidates") or [{}]    parts = (candidates[0].get("content") or {}).get("parts") or []    for part in parts:        call = part.get("functionCall")        if call and call.get("name") == RENDER_TOOL_NAME:            return coerce_render_args(call.get("args") or {})    return Nonedef _log_attempt(record: dict) -> None:    logger.info(        "[a2ui recovery] attempt %s: %s %s",        record.get("attempt"),        "valid" if record.get("ok") else "invalid",        record.get("errors"),    )def _subagent_state() -> dict:    """The run's context in the ``state["ag-ui"]`` shape the toolkit reads."""    schema, regular = split_a2ui_schema_context(experimental_get_context())    ag_ui: dict = {"context": regular}    if schema:        ag_ui["a2ui_schema"] = schema    return {"ag-ui": ag_ui}def _catalog_id(state: dict) -> str:    """The catalog the page registered (its schema entry's ``catalogId``).    Falls back to ``CATALOG_ID`` when the schema entry is absent or carries    no id, so a surface always names a catalog the page can resolve.    """    resolved = resolve_a2ui_catalog(state)    return (resolved[1] if resolved else None) or CATALOG_IDasync def generate_a2ui(request: str) -> dict:    """Draw a rich visual A2UI surface (dashboard, table, cards) for a request.    Pass the user's question verbatim as `request`. A secondary model designs    the surface from the sales data and composition rules in the app context,    so do not add figures or layout instructions yourself.    The JSON returned is the surface descriptor the UI has already rendered    (or a failure the UI has already shown), not something to repeat. Do NOT    call this tool again for the same question. Reply with one short sentence    and stop.    """    state = _subagent_state()    catalog_id = _catalog_id(state)    prep = prepare_a2ui_request(        intent="create",        target_surface_id=None,        changes=None,        messages=[],        state=state,    )    loop = asyncio.get_running_loop()    def invoke_subagent(prompt: str, _attempt: int) -> dict | None:        future = asyncio.run_coroutine_threadsafe(render_once(prompt, request), loop)        return future.result()    def build_envelope(generated: dict) -> str:        return build_a2ui_envelope(            args=generated,            is_update=False,            target_surface_id=None,            prior=None,            default_surface_id=DEFAULT_SURFACE_ID,            default_catalog_id=catalog_id,        )    result = await asyncio.to_thread(        run_a2ui_generation_with_recovery,        base_prompt=prep["prompt"],        invoke_subagent=invoke_subagent,        build_envelope=build_envelope,        config={"maxAttempts": MAX_ATTEMPTS},        on_attempt=_log_attempt,    )    return json.loads(result["envelope"])# Same persona and rules as langgraph-python's a2ui_dynamic.py SYSTEM_PROMPT;# only the tool contract differs (this generate_a2ui takes the request).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"    "Pass the user's question verbatim as `request`: the tool reads the "    "sales dataset and the dashboard composition rules from the app context "    "itself and picks the components 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. "    "Call `generate_a2ui` exactly once per question; it has already "    "rendered the surface when it returns.")def declarative_gen_ui_agent():    from agents._common import build    return build(system_instructions=SYSTEM_PROMPT, tools=[generate_a2ui])

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 = {  // 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:

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

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.