Dynamic Schema A2UI

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

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.

Live Demo: LangGraph (Python)declarative-gen-uiOpen full demo →

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 showcase's declarative-gen-ui cell ships a small dashboard catalog (Card / StatusBadge / Metric / InfoRow / PrimaryButton):

src/app/demos/declarative-gen-ui/a2ui/definitions.ts
L17–94
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:

src/app/demos/declarative-gen-ui/a2ui/renderers.tsx
L169–527
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:

src/app/demos/declarative-gen-ui/a2ui/catalog.ts
L19–22
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:

src/app/demos/declarative-gen-ui/page.tsx
L37–47
    <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=[]):

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 — 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.

Choose your AI backend

See Integrations for all available frameworks (generative-ui/a2ui).