CopilotKit

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 `render_a2ui` 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 returns an `a2ui_operations` container which the A2UI  middleware on the Next.js runtime detects and forwards to the frontend  renderer.Pairs with the dedicated runtime route`src/app/api/copilotkit-declarative-gen-ui/route.ts` which sets`a2ui.injectA2UITool: false` so the runtime does not double-bind the tool."""import jsonimport osfrom typing import Annotatedfrom llama_index.llms.openai import OpenAIfrom llama_index.protocols.ag_ui.router import get_ag_ui_workflow_routerfrom tools import build_a2ui_operations_from_tool_callCUSTOM_CATALOG_ID = "declarative-gen-ui-catalog"async 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 `render_a2ui` (tool_choice forced). The    result is returned as an `a2ui_operations` container for the A2UI    middleware to detect and forward to the frontend renderer.    """    from openai import OpenAI as OpenAIClient    client = OpenAIClient()    tool_schema = {        "type": "function",        "function": {            "name": "render_a2ui",            "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}]). 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": "render_a2ui"}},    )    if not response.choices[0].message.tool_calls:        return json.dumps({"error": "LLM did not call render_a2ui"})    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    result = build_a2ui_operations_from_tool_call(args)    return json.dumps(result)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"]a2ui_dynamic_router = get_ag_ui_workflow_router(    llm=OpenAI(model="gpt-4.1", **_openai_kwargs),    frontend_tools=[],    backend_tools=[generate_a2ui],    system_prompt=SYSTEM_PROMPT,    initial_state={},)

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 primary LLM decides to call render_a2ui (the tool the runtime auto-injects 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(),    }),  },  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 }) => (    <div      style={{        border: "1px solid #DBDBE5",        borderRadius: 16,        padding: 20,        background: "white",        boxShadow: "0 1px 3px rgba(1, 5, 7, 0.04)",        display: "flex",        flexDirection: "column",        gap: 12,        minWidth: 260,      }}    >      <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>        <div style={{ fontWeight: 600, fontSize: "1rem", color: "#010507" }}>          {props.title}        </div>        {props.subtitle && (          <div style={{ color: "#57575B", fontSize: "0.85rem" }}>            {props.subtitle}          </div>        )}      </div>      {props.child && children(props.child)}    </div>  ),  StatusBadge: ({ props }) => {    const variant = props.variant ?? "info";    const { bg, fg, border } = badgePalette[variant];    return (      <span        style={{          display: "inline-block",          padding: "2px 10px",          background: bg,          color: fg,          border: `1px solid ${border}`,          borderRadius: 999,          fontSize: "0.7rem",          fontWeight: 600,          letterSpacing: "0.1em",          textTransform: "uppercase",        }}      >        {props.text}      </span>    );  },  Metric: ({ props }) => {    const trend = props.trend ?? "neutral";    const arrow = trend === "up" ? "↑" : trend === "down" ? "↓" : "";    const color =      trend === "up" ? "#189370" : trend === "down" ? "#FA5F67" : "#010507";    return (      <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>        <div          style={{            fontSize: "0.7rem",            color: "#838389",            textTransform: "uppercase",            letterSpacing: "0.12em",          }}        >          {props.label}        </div>        <div          style={{            fontSize: "1.5rem",            fontWeight: 600,            color,            display: "flex",            gap: 6,            alignItems: "baseline",          }}        >          <span>{props.value}</span>          {arrow && <span style={{ fontSize: "1rem" }}>{arrow}</span>}        </div>      </div>    );  },  InfoRow: ({ props }) => (    <div      style={{        display: "flex",        justifyContent: "space-between",        alignItems: "baseline",        gap: 16,        paddingTop: 6,        paddingBottom: 6,        borderBottom: "1px solid #E9E9EF",      }}    >      <span style={{ color: "#57575B", fontSize: "0.85rem" }}>        {props.label}      </span>      <span style={{ color: "#010507", fontWeight: 500, fontSize: "0.9rem" }}>        {props.value}      </span>    </div>  ),  PrimaryButton: ({ props, dispatch }) => (    <button      onClick={() => {        if (props.action && dispatch) dispatch(props.action);      }}      style={{        padding: "10px 16px",        borderRadius: 12,        border: "none",        background: "#010507",        color: "white",        fontWeight: 500,        fontSize: "0.9rem",        cursor: "pointer",        transition: "background 0.15s ease",      }}      onMouseEnter={(e) =>        ((e.currentTarget as HTMLButtonElement).style.background = "#2B2B2B")      }      onMouseLeave={(e) =>        ((e.currentTarget as HTMLButtonElement).style.background = "#010507")      }    >      {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 (      <div        style={{          border: "1px solid #DBDBE5",          borderRadius: 16,          padding: 20,          background: "white",          boxShadow: "0 1px 3px rgba(1, 5, 7, 0.04)",          maxWidth: 520,          margin: "0 auto",          display: "flex",          flexDirection: "column",          gap: 12,          overflow: "hidden",        }}      >        <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>          <div style={{ fontWeight: 600, fontSize: "1rem", color: "#010507" }}>            {props.title}          </div>          <div style={{ color: "#57575B", fontSize: "0.85rem" }}>            {props.description}          </div>        </div>        {safeData.length === 0 ? (          <div            style={{              color: "#838389",              textAlign: "center",              padding: "32px 0",              fontSize: "0.85rem",            }}          >            No data available          </div>        ) : (          <>            <DonutChart data={safeData} />            {/* Legend */}            <div              style={{                display: "flex",                flexDirection: "column",                gap: 8,                paddingTop: 8,              }}            >              {safeData.map((item, index) => {                const val = Number(item.value) || 0;                const pct = total > 0 ? ((val / total) * 100).toFixed(0) : "0";                return (                  <div                    key={index}                    style={{                      display: "flex",                      alignItems: "center",                      gap: 12,                      fontSize: "0.85rem",                    }}                  >                    <span                      style={{                        display: "inline-block",                        width: 12,                        height: 12,                        borderRadius: 999,                        flexShrink: 0,                        backgroundColor:                          CHART_COLORS[index % CHART_COLORS.length],                      }}                    />                    <span                      style={{                        flex: 1,                        color: "#010507",                        overflow: "hidden",                        textOverflow: "ellipsis",                        whiteSpace: "nowrap",                      }}                    >                      {item.label}                    </span>                    <span                      style={{                        color: "#57575B",                        fontVariantNumeric: "tabular-nums",                      }}                    >                      {val.toLocaleString()}                    </span>                    <span                      style={{                        color: "#57575B",                        width: 40,                        textAlign: "right",                        fontVariantNumeric: "tabular-nums",                      }}                    >                      {pct}%                    </span>                  </div>                );              })}            </div>          </>        )}      </div>    );  },  BarChart: ({ props }) => {    const { isNew } = useSeenIndices();    const data = props.data ?? [];    const safeData = Array.isArray(data) ? data : [];    return (      <div        style={{          border: "1px solid #DBDBE5",          borderRadius: 16,          padding: 20,          background: "white",          boxShadow: "0 1px 3px rgba(1, 5, 7, 0.04)",          maxWidth: 640,          margin: "0 auto",          display: "flex",          flexDirection: "column",          gap: 12,          overflow: "hidden",        }}      >        {/* 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>        <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>          <div style={{ fontWeight: 600, fontSize: "1rem", color: "#010507" }}>            {props.title}          </div>          <div style={{ color: "#57575B", fontSize: "0.85rem" }}>            {props.description}          </div>        </div>        {safeData.length === 0 ? (          <div            style={{              color: "#838389",              textAlign: "center",              padding: "32px 0",              fontSize: "0.85rem",            }}          >            No data available          </div>        ) : (          <ResponsiveContainer width="100%" height={280}>            <RechartsBarChart              data={safeData}              margin={{ top: 12, right: 12, bottom: 4, left: -8 }}            >              <CartesianGrid                strokeDasharray="3 3"                stroke="#E9E9EF"                vertical={false}              />              <XAxis                dataKey="label"                tick={{ fontSize: 12, fill: "#57575B" }}                stroke="#E9E9EF"                tickLine={false}                axisLine={false}              />              <YAxis                tick={{ fontSize: 12, fill: "#57575B" }}                stroke="#E9E9EF"                tickLine={false}                axisLine={false}              />              <Tooltip                contentStyle={CHART_TOOLTIP_STYLE}                cursor={{ fill: "#F4F4F7", 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>        )}      </div>    );  },};

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 {  CopilotChat,  useConfigureSuggestions,} from "@copilotkit/react-core/v2";import { CopilotKit } from "@copilotkit/react-core";import { myCatalog } from "./a2ui/catalog";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>

Inject the render tool on the runtime#

On the TypeScript runtime, injectA2UITool: true tells CopilotKit to add the render_a2ui tool to the agent's tool list at request time and serialise your client catalog into the agent's copilotkit.context. No backend code to write; the agent can be an empty create_agent(tools=[]):

app/api/copilotkit/route.ts
const runtime = new CopilotRuntime({
  agents: { default: myAgent },
  a2ui: {
    injectA2UITool: true,
  },
});

Progressive streaming#

The secondary LLM's render_a2ui tool call streams through LangGraph as TOOL_CALL_ARGS events. The A2UI middleware:

  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 surfaceUpdate + beginRendering once the schema is complete.
  4. Extracts complete items objects progressively and emits a dataModelUpdate 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.