Fixed Schema A2UI

Pre-defined A2UI schema with dynamic data. The fastest approach, with no LLM schema generation needed.


"use client";/** * Declarative Generative UI — A2UI Fixed Schema demo. * * In the fixed-schema flavor of A2UI, the component tree (schema) lives on * the frontend and the agent only streams *data* into the data model. The * flight card is ASSEMBLED from small sub-components in * `src/agents/a2ui_schemas/flight_schema.json` (Card > Column > [Title, Row, …]). * * - Definitions (zod schemas): `./a2ui/definitions.ts` * - Renderers (React): `./a2ui/renderers.tsx` * - Catalog wiring: `./a2ui/catalog.ts` (includes the basic catalog) * - Agent: `src/agents/a2ui_fixed.py` (emits an `a2ui_operations` container) * * Reference: * https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui/fixed-schema */import React from "react";import { CopilotKit } from "@copilotkit/react-core/v2";import { catalog } from "./a2ui/catalog";import { Chat } from "./chat";export default function A2UIFixedSchemaDemo() {  return (    // `a2ui.catalog` wires the fixed catalog into the A2UI activity renderer.    <CopilotKit      runtimeUrl="/api/copilotkit-a2ui-fixed-schema"      agent="a2ui-fixed-schema"      a2ui={{ catalog: catalog }}    >      <div className="flex justify-center items-center h-screen w-full bg-neutral-50">        <div className="h-full w-full max-w-4xl border-x border-neutral-200 bg-white">          <Chat />        </div>      </div>    </CopilotKit>  );}

In the fixed-schema approach, you design the UI schema once (by hand, or using the A2UI Composer) and keep it on the agent side. The agent tool only provides the data; the surface appears instantly when the tool returns because nothing has to be generated at runtime.

How the schema is delivered to the runtime is the only thing that varies between integrations:

  • Schema-loading (langgraph-python, langgraph-typescript, langgraph-fastapi, llamaindex, crewai-crews, pydantic-ai, ms-agent-python, google-adk), the schema is saved as a .json file next to the agent and loaded once at startup.
  • Schema-inline (spring-ai, ms-agent-dotnet), the schema is declared inline as a typed literal in source. The host language doesn't ship a load_schema JSON loader, so the structure is compiled in directly.
  • LLM-driven (mastra, strands), the agent runs a secondary LLM call to produce the operations container per-request. The catalog is still fixed; the schema is generated on demand.

Ask about a flight and the agent renders a fully structured card from a pre-defined schema:

How it works#

  1. The schema is made available to the agent, either loaded from a JSON file at startup, declared inline, or generated per-request, depending on the integration.
  2. The agent's display_flight tool receives data from the primary LLM (origin / destination / airline / price).
  3. The tool returns a2ui.render(...) with createSurface + updateComponents + updateDataModel operations.
  4. The A2UI middleware intercepts the tool result and the frontend renders the surface using the matching 5-component client catalog (Title, Airport, Arrow, AirlineBadge, PriceTag, plus the built-ins).

Compositional schemas#

The example below ships a flight card assembled compositionally from small sub-components rather than one monolithic FlightCard:

Card
 └─ Column
     ├─ Title        ("Flight Details")
     ├─ Row          (Airport → Arrow → Airport)
     ├─ Row          (AirlineBadge · PriceTag)
     └─ Button       (Book)

That tree lives backend-side, as a JSON file, an inline literal, or a per-request LLM output, depending on the integration. Components without data bindings (like Title or Arrow) carry their value inline; components bound to the LLM's data (like Airport) reference fields via JSON Pointer paths such as { "path": "/origin" }. The A2UI binder resolves those paths before the React renderer runs, so renderer props are typed as their resolved values (plain z.string(), not a path-or-literal union).

The 5-component custom catalog#

The frontend catalog declares just the domain-specific primitives (Title, Airport, Arrow, AirlineBadge, PriceTag) and merges in CopilotKit's basic catalog (Card, Column, Row, Text, Button, …) via includeBasicCatalog: true.

Declare the component definitions#

Each component declares its props as a Zod schema. Props are the resolved values, never the path expressions:

definitions.ts
import { z } from "zod";import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";/** * Dynamic string: literal OR a data-model path binding. The GenericBinder * resolves path bindings to the actual value at render time. */const DynString = z.union([z.string(), z.object({ path: z.string() })]);export const definitions = {  /**   * Card override: gives the outer flight-card container a ShadCN look   * (rounded-xl, neutral-200 border, soft shadow). The basic catalog's   * Card uses inline styles; overriding here lets the demo's renderer   * adopt the demo's Tailwind aesthetic without touching the schema JSON.   */  Card: {    description: "A container card with a single child.",    props: z.object({      child: z.string(),    }),  },  Title: {    description: "A prominent heading for the flight card.",    props: z.object({      text: DynString,    }),  },  Airport: {    description: "A 3-letter airport code, displayed large.",    props: z.object({      code: DynString,    }),  },  Arrow: {    description: "A right-pointing arrow used between airports.",    props: z.object({}),  },  AirlineBadge: {    description: "A pill-styled airline name tag.",    props: z.object({      name: DynString,    }),  },  PriceTag: {    description: "A stylized price display (e.g. '$289').",    props: z.object({      amount: DynString,    }),  },  /**   * Button override: swaps in an ActionButton renderer that tracks   * its own `done` state so clicking "Book flight" visually updates to   * a "Booked ✓" confirmation. The basic catalog's Button is stateless,   * so without this override the click fires the action but the button   * looks unchanged. Mirrors the pattern in beautiful-chat   * (src/app/demos/beautiful-chat/declarative-generative-ui/renderers.tsx).   */  Button: {    description:      "An interactive button with an action event. Use 'child' with a Text component ID for the label. After click, the button shows a confirmation state.",    props: z.object({      child: z        .string()        .describe(          "The ID of the child component (e.g. a Text component for the label).",        ),      variant: z.enum(["primary", "secondary", "ghost"]).optional(),      // Union with { event } so GenericBinder resolves this as ACTION → callable () => void.      action: z        .union([          z.object({            event: z.object({              name: z.string(),              context: z.record(z.any()).optional(),            }),          }),          z.null(),        ])        .optional(),    }),  },} satisfies CatalogDefinitions;

Implement the React renderers#

TypeScript enforces that the renderer map's keys and prop shapes match the definitions exactly, so refactors stay safe:

renderers.tsx
export const renderers: CatalogRenderers<Definitions> = {  /**   * Card override: ShadCN-style outer container. The basic catalog's Card   * uses inline styles; overriding here keeps the demo's tailwind aesthetic.   * The flight schema renders Card > Column > [Title, Row, …]; the inner   * Column adds the vertical spacing.   */  Card: ({ props, children }) => (    <Card className="w-full max-w-md p-5" data-testid="a2ui-fixed-card">      {props.child ? children(props.child) : null}    </Card>  ),  Title: ({ props }) => (    <div className="flex items-center justify-between">      <div className="space-y-1">        <p className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">          Itinerary        </p>        <h3 className="text-base font-semibold leading-none tracking-tight text-neutral-900">          {s(props.text)}        </h3>      </div>      <Badge variant="outline" className="font-mono">        1-stop · economy      </Badge>    </div>  ),  Airport: ({ props }) => (    <div className="flex flex-col items-center">      <span className="font-mono text-2xl font-semibold tracking-wider text-neutral-900">        {s(props.code)}      </span>    </div>  ),  Arrow: () => (    <div className="flex flex-1 items-center px-3">      <Separator className="flex-1 bg-neutral-200" />      <svg        width="16"        height="16"        viewBox="0 0 24 24"        fill="none"        stroke="currentColor"        strokeWidth="2"        strokeLinecap="round"        strokeLinejoin="round"        className="mx-1 text-neutral-400"        aria-hidden      >        <line x1="5" y1="12" x2="19" y2="12" />        <polyline points="12 5 19 12 12 19" />      </svg>      <Separator className="flex-1 bg-neutral-200" />    </div>  ),  AirlineBadge: ({ props }) => (    <Badge variant="secondary" className="uppercase tracking-[0.08em]">      {s(props.name)}    </Badge>  ),  PriceTag: ({ props }) => (    <div className="flex items-baseline gap-1">      <span className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">        Total      </span>      <span className="font-mono text-base font-semibold text-neutral-900">        {s(props.amount)}      </span>    </div>  ),  /**   * Button override: wires the schema `action` to the click and shows a   * "Booked" confirmation after click (see ActionButton above). Mirrors the   * built-in-agent a2ui-fixed-schema renderer.   */  Button: ({ props, children }) => (    <ActionButton action={(props as { action?: unknown }).action}>      {props.child ? children(props.child) : null}    </ActionButton>  ),};

Wire the catalog#

createCatalog(..., { includeBasicCatalog: true }) merges the custom renderers with CopilotKit's built-ins so the schema can reference Card, Column, Row, Button alongside the domain primitives:

catalog.ts
import { createCatalog } from "@copilotkit/a2ui-renderer";import { definitions } from "./definitions";import { renderers } from "./renderers";export const CATALOG_ID = "copilotkit://flight-fixed-catalog";export const catalog = createCatalog(definitions, renderers, {  catalogId: CATALOG_ID,  includeBasicCatalog: true,});

Generate the schema dynamically#

Mastra and Strands take a different route: the agent tool runs a secondary LLM call with a forced tool choice that produces the operations container per-request. The frontend catalog is still fixed (same Title/Airport/Arrow/AirlineBadge/PriceTag primitives), but the schema is built on the fly. Schema construction and render emission happen in the same tool call:

index.ts
import { createTool } from "@mastra/core/tools";import { z } from "zod";// Use the header-forwarding `openai` so backend tool LLM calls (e.g.// generateText inside generateA2ui) carry the inbound aimock context// headers. See `_header_forwarding.ts`.import { openai } from "@/mastra/_header_forwarding";import { generateText, tool as aiTool } from "ai";import {  getWeatherImpl,  queryDataImpl,  scheduleMeetingImpl,  searchFlightsImpl,  generateA2uiImpl,  buildA2uiOperationsFromToolCall,} from "@copilotkit/showcase-shared-tools";// `manage_todos` writes the todo list into working memory so the Beautiful// Chat app-mode canvas (which reads `agent.state.todos`) renders it; `get_todos`// reads it back. See working-memory.ts / OSS-452.import {  writeTodosToWorkingMemory,  readTodosFromWorkingMemory,} from "./working-memory";// Re-export the dedicated tool sets defined in their own modules so the// barrel keeps a single import surface for callers under `@/mastra/tools`.export { setNotesTool } from "./shared-state-read-write";export { setStepsTool } from "./gen-ui-agent";export { scheduleMeetingInterruptTool } from "./interrupt";export { browseWebTool } from "./browse-web";export { runDeepResearchTool } from "./background-research";export {  researchAgentTool,  writingAgentTool,  critiqueAgentTool,} from "./subagents";export const weatherTool = createTool({  id: "get_weather",  description: "Get current weather for a location",  inputSchema: z.object({    location: z.string().describe("City name"),    temperature: z      .number()      .optional()      .describe(        "Optional scripted temperature (°F); echoed back when provided",      ),    conditions: z      .string()      .optional()      .describe("Optional scripted conditions; echoed back when provided"),    humidity: z      .number()      .optional()      .describe("Optional scripted humidity; echoed back when provided"),    wind_speed: z      .number()      .optional()      .describe("Optional scripted wind speed; echoed back when provided"),  }),  // Optional temperature/conditions/humidity/wind_speed let an aimock fixture  // script a deterministic snapshot (mirrors get_stock_price's scripted  // price_usd — e.g. headless-complete pins Tokyo to a fixed "Sunny / 68°F"  // card matching gold langgraph-python). When omitted, the seeded  // getWeatherImpl(location) values are used (e.g. tool-rendering's SF pill).  //  // Return the OBJECT, not JSON.stringify: the @ag-ui/mastra bridge encodes the  // tool result exactly once on the way to the frontend, so stringifying here  // double-encodes it and the typed cards' single-parse (parseJsonResult) reads  // back a string with undefined fields ("--%"). Single-encode by returning the  // object (same rule as browse_web / the Mastra capability-map memory).  execute: async ({    location,    temperature,    conditions,    humidity,    wind_speed,  }) => ({    ...getWeatherImpl(location),    ...(typeof temperature === "number" ? { temperature } : {}),    ...(conditions ? { conditions } : {}),    ...(typeof humidity === "number" ? { humidity } : {}),    ...(typeof wind_speed === "number" ? { wind_speed } : {}),  }),});// Mock stock-price tool used by the headless-complete demo to exercise the// manual `useRenderTool` path alongside `get_weather`. Returns a fixed// payload so the StockCard renders deterministically without a real market// data API.export const stockPriceTool = createTool({  id: "get-stock-price",  description: "Get a mock current price for a stock ticker",  inputSchema: z.object({    ticker: z.string().describe("Stock ticker symbol, e.g. AAPL"),    price_usd: z      .number()      .optional()      .describe("Optional scripted price; echoed back verbatim when provided"),    change_pct: z      .number()      .optional()      .describe(        "Optional scripted percentage change; echoed back verbatim when provided",      ),  }),  // Optional price_usd / change_pct let the LLM (or an aimock fixture) script a  // deterministic quote — mirrors gold tool_rendering_agent.py get_stock_price.  // When omitted, fall back to the fixed mock values. Object (single-encode) —  // see weatherTool.  execute: async ({ ticker: rawTicker, price_usd, change_pct }) => {    const ticker = (rawTicker ?? "").toUpperCase();    return {      ticker,      price_usd: typeof price_usd === "number" ? price_usd : 189.42,      change_pct: typeof change_pct === "number" ? change_pct : 1.27,    };  },});// Mock six-month revenue series for the headless-complete ChartCard. Mirrors// the langgraph-python `get_revenue_chart` tool (headless_complete.py) — returns// a title/subtitle + {label,value} points. Object (single-encode) — see// weatherTool; the ChartCard/useRenderTool reads { title, subtitle, data }.export const revenueChartTool = createTool({  id: "get-revenue-chart",  description:    "Get a mock six-month revenue series for a chart visualization. Use whenever the user asks for a chart, graph, or visualization of revenue, sales, or other quarterly/monthly metrics.",  inputSchema: z.object({}),  execute: async () => ({    title: "Quarterly revenue",    subtitle: "Last six months · USD thousands",    data: [      { label: "Jan", value: 38 },      { label: "Feb", value: 47 },      { label: "Mar", value: 52 },      { label: "Apr", value: 49 },      { label: "May", value: 63 },      { label: "Jun", value: 71 },    ],  }),});// Mock dice-roll tool used by the tool-rendering-reasoning-chain demo. Mirrors// the langgraph-python `roll_dice` tool so the shared aimock fixtures can// script a deterministic d20 → d6 chain. Rendered by the catch-all renderer.export const rollDiceTool = createTool({  id: "roll-dice",  description: "Roll a single die with the given number of sides",  inputSchema: z.object({    sides: z      .number()      .optional()      .describe("Number of sides on the die (default 6)"),  }),  execute: async ({ sides }) => {    const s = typeof sides === "number" && sides > 1 ? Math.floor(sides) : 6;    // Return an object (single-encode) — see weatherTool. The catchall    // renderers (default/custom/reasoning-chain) JSON.parse once, so this also    // renders cleanly there instead of an escaped double-encoded string.    return {      sides: s,      result: Math.floor(Math.random() * s) + 1,    };  },});// Deterministic 20-sided die used by the tool-rendering demo. Mirrors gold// tool_rendering_agent.py roll_d20: the optional `value` lets an aimock fixture// script the exact roll (1-20) the e2e sequence asserts; otherwise a random// natural roll. Object (single-encode) — see weatherTool.export const rollD20Tool = createTool({  id: "roll_d20",  description: "Roll a 20-sided die",  inputSchema: z.object({    value: z      .number()      .optional()      .describe(        "Optional scripted roll (1-20); echoed back verbatim when provided",      ),  }),  execute: async ({ value }) => {    const rolled =      typeof value === "number" && value >= 1 && value <= 20        ? Math.floor(value)        : Math.floor(Math.random() * 20) + 1;    return { sides: 20, value: rolled, result: rolled };  },});export const queryDataTool = createTool({  id: "query-data",  description: "Query financial database for chart data",  inputSchema: z.object({    query: z.string().describe("Natural language query"),  }),  // Return the object (single-encode) — see weatherTool.  execute: async ({ query }) => queryDataImpl(query),});// Beautiful Chat task-manager tools. Ports langgraph-python's// `manage_todos` / `get_todos` (src/agents/beautiful_chat.py) — the shared// beautiful-chat frontend reads `agent.state.todos` (shape// {id,title,description,emoji,status}) and the north-star uses these exact tool// names. `manage_todos` WRITES the list into working memory so the @ag-ui/mastra// adapter emits a STATE_SNAPSHOT and the app-mode canvas renders it — returning// the JSON alone leaves the data only in the tool result, which never reaches// agent state (OSS-452). Mastra previously shipped a mismatched sales-CRM tool// (`manage_sales_todos`, shape {stage,value,completed}) that the frontend could// not render and the recorded fixtures did not call.const todoItemSchema = z.object({  id: z.string().optional(),  title: z.string(),  description: z.string().optional(),  emoji: z.string().optional(),  status: z.enum(["pending", "completed"]).optional(),});/** Fill missing ids and default the frontend-required fields. */function normalizeTodos(  todos: Array<z.infer<typeof todoItemSchema>>,): Array<Record<string, unknown>> {  return todos.map((t, i) => ({    id: t.id && t.id.length > 0 ? t.id : `todo-${Date.now()}-${i}`,    title: t.title,    description: t.description ?? "",    emoji: t.emoji ?? "📝",    status: t.status ?? "pending",  }));}export const manageTodosTool = createTool({  id: "manage_todos",  description:    "Create or update the task-manager todo list. Pass the FULL updated list " +    "of todos; each todo has a title and optionally a description, emoji, and " +    "status ('pending' or 'completed').",  inputSchema: z.object({    todos: z.array(todoItemSchema).describe("Full updated todo list"),  }),  execute: async (inputData, executionContext) => {    const todos = normalizeTodos(inputData.todos ?? []);    await writeTodosToWorkingMemory(executionContext, todos);    return JSON.stringify({ todos, updated: true as const });  },});export const getTodosTool = createTool({  id: "get_todos",  description: "Get the current task-manager todo list.",  inputSchema: z.object({}),  execute: async (_inputData, executionContext) => {    const todos = await readTodosFromWorkingMemory(executionContext);    return JSON.stringify({ todos });  },});export const scheduleMeetingTool = createTool({  id: "schedule-meeting",  description: "Schedule a meeting (requires user approval via HITL)",  inputSchema: z.object({    reason: z.string().describe("Reason for the meeting"),    durationMinutes: z.number().optional().describe("Duration in minutes"),  }),  execute: async ({ reason, durationMinutes }) =>    JSON.stringify(scheduleMeetingImpl(reason, durationMinutes)),});export const searchFlightsTool = createTool({  id: "search-flights",  description: "Search for available flights from an origin to a destination",  // Gold parity (tool_rendering_agent.py search_flights): accept `origin` +  // `destination` and GENERATE a deterministic flights list rather than having  // the caller pass the flights array. Returns mastra-shaped flight objects so  // the existing flight-list renderer is unchanged. Object (single-encode) —  // see weatherTool.  inputSchema: z.object({    origin: z.string().optional().describe("Origin airport or city"),    destination: z.string().optional().describe("Destination airport or city"),    // Legacy shape: some D5 harness probes still pass a pre-built flights    // array. Accept it so those keep rendering; the gold path below is the    // origin/destination generator that tool-rendering + reasoning-chain use.    flights: z      .array(z.record(z.any()))      .optional()      .describe("Pre-built flight list (legacy caller-supplied shape)"),  }),  execute: async ({ origin, destination, flights: provided }) => {    if (Array.isArray(provided) && provided.length > 0) {      // Legacy passthrough — caller already built the list.      return searchFlightsImpl(provided as never);    }    const from = origin ?? "SFO";    const to = destination ?? "JFK";    const flights = [      {        airline: "United",        airlineLogo:          "https://www.google.com/s2/favicons?domain=united.com&sz=128",        flightNumber: "UA231",        origin: from,        destination: to,        date: "Tue, May 6",        departureTime: "08:15",        arrivalTime: "16:45",        duration: "5h 30m",        status: "On Time",        statusColor: "#22c55e",        price: "$348",        currency: "USD",      },      {        airline: "Delta",        airlineLogo:          "https://www.google.com/s2/favicons?domain=delta.com&sz=128",        flightNumber: "DL412",        origin: from,        destination: to,        date: "Tue, May 6",        departureTime: "11:20",        arrivalTime: "19:55",        duration: "5h 35m",        status: "On Time",        statusColor: "#22c55e",        price: "$312",        currency: "USD",      },      {        airline: "JetBlue",        airlineLogo:          "https://www.google.com/s2/favicons?domain=jetblue.com&sz=128",        flightNumber: "B6722",        origin: from,        destination: to,        date: "Tue, May 6",        departureTime: "17:05",        arrivalTime: "01:30",        duration: "5h 25m",        status: "On Time",        statusColor: "#22c55e",        price: "$289",        currency: "USD",      },    ];    return searchFlightsImpl(flights);  },});// The `generate-a2ui` tool runs a secondary LLM call with a forced// `render_a2ui` tool, then converts that tool call's args into the// A2UI `a2ui_operations` container that the middleware forwards to// the frontend renderer. Mastra returns the operations as a JSON// string from the tool body; the catalog// (`copilotkit://generative-catalog`) resolves component names to// React renderers on the client.export const generateA2uiTool = createTool({  id: "generate-a2ui",  description: "Generate dynamic A2UI surface components",  inputSchema: z.object({    messages: z.array(z.record(z.unknown())).describe("Chat messages"),    contextEntries: z      .array(z.record(z.unknown()))      .optional()      .describe("Context entries"),  }),  execute: async ({ messages, contextEntries }) => {    const prep = generateA2uiImpl({      messages,      contextEntries,    });    // Normalize each incoming message role to the `user`/`assistant` pair    // `generateText` accepts here. An unsound `as "user" | "assistant"` cast    // would let a `system`/`tool` role slip through mis-typed (the `??` only    // guards null/undefined), so map explicitly: anything that is not    // `assistant` collapses to `user`.    const toRole = (role: unknown): "user" | "assistant" =>      role === "assistant" ? "assistant" : "user";    const result = await generateText({      model: openai("gpt-4.1"),      system: prep.systemPrompt,      messages: prep.messages.map((m) => ({        role: toRole(m.role),        content: (m.content as string) ?? "",      })),      tools: {        render_a2ui: aiTool({          description: "Render a dynamic A2UI v0.9 surface.",          // AI SDK v5 renamed the tool schema key from `parameters` to          // `inputSchema`; under v5 a `parameters` key is ignored, so the          // render_a2ui schema would never reach the model.          inputSchema: z.object({            surfaceId: z.string().describe("Unique surface identifier."),            catalogId: z.string().describe("The catalog ID."),            components: z              .array(z.record(z.unknown()))              .describe("A2UI v0.9 component array."),            data: z              .record(z.unknown())              .optional()              .describe("Optional initial data model."),          }),        }),      },      toolChoice: { type: "tool", toolName: "render_a2ui" },    });    const toolCall = result.toolCalls?.[0];    if (!toolCall) {      // The forced `render_a2ui` tool was not called, so there are no      // operations to forward. Returning a `{ error }` JSON string would look      // like a successful tool result to the frontend/runtime, which cannot      // then distinguish it from a real A2UI payload. Throw instead so the      // Mastra runtime surfaces this as a genuine tool error.      const message = "generate-a2ui: LLM did not call render_a2ui";      console.error(message, { finishReason: result.finishReason });      throw new Error(message);    }    // AI SDK v5 renamed the typed tool-call arguments from `.args` to    // `.input` (the `ai` v4 shape was `toolCall.args`). Read `.input` so the    // a2ui builder gets the render_a2ui arguments instead of `undefined`.    return JSON.stringify(      buildA2uiOperationsFromToolCall(        toolCall.input as Record<string, unknown>,      ),    );  },});

Why compositional beats monolithic#

A single big FlightCard component would be faster to write but would lock the design in place. Assembling the card from Card / Column / Row / Title / Airport / Arrow / AirlineBadge / PriceTag gives you:

  • Reusable primitives the same Airport renderer works in search results, booking confirmations, and future seat maps.
  • Schema-level design iteration re-arranging rows or swapping a badge requires only a JSON edit; the renderer code is untouched.
  • A2UI Composer compatibility hand-written and Composer-built schemas share the same primitive vocabulary.

Registering the runtime#

Your agent owns the tool in the fixed-schema approach, so you do not want the runtime to inject its own. Enable A2UI but turn injection off.

Passing a catalog on the provider is enough to enable A2UI:

app/page.tsx
<CopilotKit runtimeUrl="/api/copilotkit" a2ui={{ catalog: myCatalog }}>
  {children}
</CopilotKit>

Because a catalog auto-injects the A2UI tool by default, set injectA2UITool: false on the runtime so your agent's own tool is the only one in play. The middleware still auto-detects the operations the tool returns and renders the surface, with no subagent involved:

app/api/copilotkit/route.ts
const runtime = new CopilotRuntime({
  agents: { "a2ui-fixed-schema": agent },
  a2ui: { injectA2UITool: false, agents: ["a2ui-fixed-schema"] },
});

Action handlers (reference)#

The canonical reference pairs fixed schemas with action_handlers={...} to declare optimistic UI swaps (e.g. replacing the flight schema with BOOKED_SCHEMA when the user clicks "Book"). The Python SDK's a2ui.render does not yet accept action_handlers, so the cell omits them; the booked_schema.json sibling is retained so the swap can be wired up the moment the SDK exposes the handler kwarg.

When available, a button declares its action like this:

{
  "Button": {
    "label": "Book",
    "action": {
      "name": "book_flight",
      "context": [
        { "key": "flightNumber", "value": { "path": "/flightNumber" } },
        { "key": "price", "value": { "path": "/price" } }
      ]
    }
  }
}

And the Python tool matches it with a handler keyed by the action name (plus a "*" catch-all). Until the SDK lands, see the reference fixed-schema guide for the full pattern.

When should I use fixed schemas?#

  • The surface is well-known: flight cards, product tiles, order summaries, dashboards.
  • You want deterministic, designer-controlled UI. No LLM schema drift.
  • You want the fastest possible first paint; no secondary LLM call.

If the UI must adapt per prompt, reach for dynamic schemas instead.