Dynamic Schema A2UI

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


/** * Agent Server for Claude Agent SDK (TypeScript) * * Express server that hosts a Claude-powered agent backend. * The Next.js CopilotKit runtime proxies requests here via AG-UI protocol. */// Cold-start instrumentation: emitted before any side-effect imports so// Railway logs reveal exactly which phase (module load, Anthropic SDK// init, express.listen) consumes the watchdog budget. Paired with the// `[entrypoint] pre-node ...` print in entrypoint.sh so timestamps chain.// Disambiguates the observed failure class where process claims to be// listening but /health probes never succeed.console.log(`[agent_server] module loaded ${new Date().toISOString()}`);import type { Request, Response } from "express";import express from "express";import Anthropic from "@anthropic-ai/sdk";import { EventEncoder } from "@ag-ui/encoder";import type { RunAgentInput, Message } from "@ag-ui/core";import { EventType } from "@ag-ui/core";import {  A2UI_DEFAULT_DESIGN_GUIDELINES,  A2UI_DEFAULT_GENERATION_GUIDELINES,} from "@copilotkit/shared";import * as dotenv from "dotenv";import * as PartialJSON from "partial-json";import { randomUUID } from "crypto";import { BYOC_JSON_RENDER_SYSTEM_PROMPT } from "./agent/byoc-json-render-prompt";import { BYOC_HASHBROWN_SYSTEM_PROMPT } from "./agent/byoc-hashbrown-prompt";import {  AGENT_CONFIG_DEFAULT_SYSTEM_PROMPT,  buildAgentConfigSystemPrompt,} from "./agent/agent-config-prompt";import {  SET_NOTES_TOOL_SCHEMA,  buildSharedStateReadWriteSystemPrompt,  coercePreferences,} from "./agent/shared-state-read-write-prompt";import {  SUBAGENT_SYSTEM_BY_NAME,  SUBAGENT_TOOL_SCHEMAS,  SUPERVISOR_SYSTEM_PROMPT,} from "./agent/subagents-prompts";import type { SubAgentName } from "./agent/subagents-prompts";import {  A2UI_FIXED_SYSTEM_PROMPT,  DISPLAY_FLIGHT_TOOL_SCHEMA,  buildDisplayFlightOperations,} from "./agent/a2ui-fixed-prompt";import {  A2UI_DYNAMIC_SYSTEM_PROMPT,  GENERATE_A2UI_TOOL_SCHEMA,} from "./agent/a2ui-dynamic-prompt";import {  HEADLESS_COMPLETE_SYSTEM_PROMPT,  HEADLESS_GET_REVENUE_CHART_TOOL_SCHEMA,  HEADLESS_GET_STOCK_PRICE_TOOL_SCHEMA,  HEADLESS_GET_WEATHER_TOOL_SCHEMA,  getRevenueChartImpl,  getStockPriceImpl,  getWeatherImpl,} from "./agent/headless-complete-prompt";import {  GEN_UI_AGENT_SYSTEM_PROMPT,  SET_STEPS_TOOL_SCHEMA,} from "./agent/gen-ui-agent-prompt";import {  REASONING_CHAIN_SYSTEM_PROMPT,  ROLL_D20_TOOL_SCHEMA,  ROLL_DICE_TOOL_SCHEMA,  SEARCH_FLIGHTS_TOOL_SCHEMA,  TOOL_RENDERING_SYSTEM_PROMPT,  rollD20Impl,  rollDiceImpl,  searchFlightsImpl as searchFlightsByRouteImpl,} from "./agent/tool-rendering-prompts";import {  runWithClaudeAgentSdk,  shouldUseClaudeAgentSdk,} from "./claude-agent-sdk-adapter";import { queryDataImpl, renderFlightsImpl } from "./agent/beautiful-chat-tools";import type { Flight } from "./agent/beautiful-chat-tools";dotenv.config({ path: ".env.local" });dotenv.config();const app = express();// Increase payload limit so base64-encoded attachments (images, PDFs) up// to the frontend's 20MB cap fit inside the request body after base64// expansion and JSON envelope overhead.app.use(express.json({ limit: "35mb" }));const HOST = process.env.AGENT_HOST || "0.0.0.0";const PORT = parseInt(process.env.AGENT_PORT || "8000", 10);const CLAUDE_MODEL = normalizeAnthropicModel(  process.env.CLAUDE_MODEL ||    process.env.ANTHROPIC_MODEL ||    "claude-sonnet-4.6",);const CLAUDE_VISION_MODEL = normalizeAnthropicModel(  process.env.CLAUDE_VISION_MODEL ||    process.env.ANTHROPIC_VISION_MODEL ||    CLAUDE_MODEL,);console.log(`[agent_server] pre-Anthropic ${new Date().toISOString()}`);const anthropic = new Anthropic({  apiKey: process.env.ANTHROPIC_API_KEY,});console.log("[agent_server] Initializing Claude agent server");console.log(`[agent_server] Model: ${CLAUDE_MODEL}`);console.log(  `[agent_server] ANTHROPIC_API_KEY: ${process.env.ANTHROPIC_API_KEY ? "set" : "NOT SET"}`,);// ---------------------------------------------------------------------------// Helpers// ---------------------------------------------------------------------------/** * Extract inbound diagnostic/context headers that should ride along to the * outbound Anthropic call. Only `x-*` headers are forwarded; never forward * app/session `authorization` tokens to the provider. Notably, * `x-aimock-context` rides via this path so aimock can match the right * fixture; without this, every outbound `/v1/messages` request loses the * discriminator and aimock returns 404. * * Returns a plain Record so it can be spread into Anthropic SDK * `RequestOptions.headers` on every `messages.stream` / `messages.create` * call. We strip `host`, `content-length` and `accept-encoding` because * those are connection-level concerns the SDK manages itself. */function extractForwardedHeaders(req: Request): Record<string, string> {  const out: Record<string, string> = {};  for (const [key, value] of Object.entries(req.headers)) {    if (typeof value !== "string") continue;    const lower = key.toLowerCase();    if (lower.startsWith("x-")) {      out[key] = value;    }  }  // CVDIAG (als-snapshot): record whether the inbound x-aimock-context  // discriminator was present at the moment we capture the inbound  // headers off the Express request. Never log the full value — prefix  // only. Header lookups are case-insensitive against the captured map.  const lookup = (name: string): string | undefined => {    for (const [k, v] of Object.entries(out)) {      if (k.toLowerCase() === name) return v;    }    return undefined;  };  const slug = lookup("x-aimock-context");  const runId = lookup("x-diag-run-id");  const hops = lookup("x-diag-hops");  const hopCount = hops ? hops.split(",").filter(Boolean).length : 0;  console.log(    `CVDIAG component=route-claude-sdk-ts boundary=als-snapshot ` +      `run_id=${runId ?? "none"} slug=${slug ?? "MISSING"} ` +      `header_present=${slug != null} ` +      `header_value_prefix=${slug ? slug.slice(0, 12) : ""} ` +      `hop=${hops ? hopCount : "-"} status=${slug ? "ok" : "miss"} ` +      `test_id=${lookup("x-test-id") ?? "none"} error=`,  );  return out;}/** * CVDIAG (outbound-llm) choke-point for the claude-sdk-ts backend. Returns * a NEW headers map (never mutates the caller's) with this layer's hop tag * appended to the x-diag-hops breadcrumb, and logs header presence at the * moment the outbound Anthropic request is built. x-diag-run-id / * x-diag-hops ride the same x-* forwarding path as x-aimock-context (both * captured by `extractForwardedHeaders`); we only append the breadcrumb hop * here. Returns the augmented map so callers spread it into the SDK * `RequestOptions.headers`. */function diagOutboundHeaders(  forwardedHeaders: Record<string, string>,): Record<string, string> {  const lookup = (name: string): string | undefined => {    for (const [k, v] of Object.entries(forwardedHeaders)) {      if (k.toLowerCase() === name) return v;    }    return undefined;  };  const slug = lookup("x-aimock-context");  const runId = lookup("x-diag-run-id");  // GATING RULE: only deviate from the original control flow (append the  // x-diag-hops breadcrumb, emit the per-outbound CVDIAG log) when a  // diagnostic header is present (x-diag-run-id OR x-aimock-context). On  // non-diagnostic traffic return the forwarded headers UNCHANGED so the  // outbound Anthropic request is byte-identical to pre-instrumentation, and  // skip the noisy per-outbound log.  const diagnosticPresent = runId != null || slug != null;  if (!diagnosticPresent) {    return forwardedHeaders;  }  const priorHops = lookup("x-diag-hops") ?? "";  const nextHops = priorHops    ? `${priorHops},backend-claude-sdk-ts`    : "backend-claude-sdk-ts";  // Build a fresh map so we don't mutate the shared forwardedHeaders that  // may be reused across multiple outbound calls in the agentic loop.  const augmented: Record<string, string> = {    ...forwardedHeaders,    "x-diag-hops": nextHops,  };  const hopCount = nextHops.split(",").filter(Boolean).length;  console.log(    `CVDIAG component=backend-claude-sdk-ts boundary=outbound-llm ` +      `run_id=${runId ?? "none"} slug=${slug ?? "MISSING"} ` +      `header_present=${slug != null} ` +      `header_value_prefix=${slug ? slug.slice(0, 12) : ""} ` +      `hop=${hopCount} status=${slug ? "ok" : "miss"} ` +      `test_id=${lookup("x-test-id") ?? "none"} error=`,  );  return augmented;}/** * Convert an AG-UI `binary` content part into an Anthropic ContentBlock. * Returns `null` if the part cannot be mapped (unsupported mime/no payload). * * Claude's Messages API accepts `image` and `document` blocks natively; * images use `source: { type: "base64", media_type, data }` and PDFs use * `type: "document"` with the same source shape. URL-backed parts are * mapped to `source: { type: "url", url }`. */function binaryPartToAnthropic(part: {  type: "binary";  mimeType: string;  data?: string;  url?: string;}): Anthropic.ContentBlockParam | null {  const mime = part.mimeType || "";  const isImage = isSupportedAnthropicImageMime(mime);  const isPdf =    mime === "application/pdf" || mime.toLowerCase().includes("pdf");  if (mime.startsWith("image/") && !isImage) {    return {      type: "text",      text: `[Attached image: unsupported type ${mime}.]`,    };  }  if (!isImage && !isPdf) return null;  if (part.data) {    if (isImage) {      return {        type: "image",        source: {          type: "base64",          media_type: mime as            | "image/jpeg"            | "image/png"            | "image/gif"            | "image/webp",          data: part.data,        },      };    }    return {      type: "document",      source: {        type: "base64",        media_type: "application/pdf",        data: part.data,      },    };  }  if (part.url) {    if (isImage) {      return {        type: "image",        source: { type: "url", url: part.url },      };    }    return {      type: "document",      source: { type: "url", url: part.url },    };  }  return null;}function isSupportedAnthropicImageMime(mime: string): boolean {  return (    mime === "image/jpeg" ||    mime === "image/png" ||    mime === "image/gif" ||    mime === "image/webp"  );}function sourceBackedPartToAnthropic(part: {  type: "image" | "document";  source?: {    type?: string;    value?: string;    data?: string;    url?: string;    mimeType?: string;    media_type?: string;  };}): Anthropic.ContentBlockParam | null {  const source = part.source ?? {};  const mime = source.mimeType ?? source.media_type ?? "";  const value = source.value ?? source.data;  const url = source.url ?? (source.type === "url" ? source.value : undefined);  if (part.type === "image") {    const imageMime = mime || "image/png";    if (!isSupportedAnthropicImageMime(imageMime)) {      return {        type: "text",        text: `[Attached image: unsupported type ${imageMime}.]`,      };    }    if (source.type === "data" && value) {      return {        type: "image",        source: {          type: "base64",          media_type: imageMime as            | "image/jpeg"            | "image/png"            | "image/gif"            | "image/webp",          data: value,        },      };    }    if (url) {      return { type: "image", source: { type: "url", url } };    }    return null;  }  if (part.type === "document") {    if (source.type === "data" && value) {      return {        type: "document",        source: {          type: "base64",          media_type: "application/pdf",          data: value,        },      };    }    if (url) {      return {        type: "document",        source: { type: "url", url },      } as Anthropic.ContentBlockParam;    }  }  return null;}function buildAnthropicMessages(messages: Message[]): Anthropic.MessageParam[] {  const result: Anthropic.MessageParam[] = [];  const skippedToolUseIds = new Set<string>();  for (const msg of messages) {    if (msg.role === "user") {      const raw = (msg as any).content;      if (Array.isArray(raw)) {        // AG-UI content parts — map text + attachments to Anthropic blocks.        const blocks: Anthropic.ContentBlockParam[] = [];        for (const part of raw) {          if (!part || typeof part !== "object") continue;          if (part.type === "text" && typeof part.text === "string") {            blocks.push({ type: "text", text: part.text });          } else if (part.type === "binary") {            const mapped = binaryPartToAnthropic(part);            if (mapped) blocks.push(mapped);          } else if (part.type === "image" || part.type === "document") {            const mapped = sourceBackedPartToAnthropic(part);            if (mapped) blocks.push(mapped);          }        }        // Guard: Anthropic rejects user messages with empty content.        if (blocks.length === 0) {          blocks.push({            type: "text",            text: "[Unsupported attachment omitted.]",          });        }        result.push({ role: "user", content: blocks });      } else {        result.push({          role: "user",          content: raw ?? "",        });      }    } else if (msg.role === "assistant") {      const toolCalls = (msg as any).toolCalls as        | Array<{ id: string; function: { name: string; arguments: string } }>        | undefined;      if (toolCalls && toolCalls.length > 0) {        const content: Anthropic.ContentBlock[] = [];        const textContent = (msg as any).content;        if (textContent) {          content.push({ type: "text", text: textContent, citations: null });        }        for (const tc of toolCalls) {          let input: Record<string, unknown> = {};          try {            input = JSON.parse(tc.function.arguments);          } catch (parseErr) {            // Surface the failure so we don't silently rewind tool args to            // {}. For tools like `set_notes` that take an array, an empty            // dict translates to an empty list and clears the user's notes.            // Skip the tool_use block so we don't replay corrupted state.            const message =              parseErr instanceof Error ? parseErr.message : String(parseErr);            console.warn(              `[agent_server] failed to parse tool_use arguments for ${tc.function.name} (id=${tc.id}); skipping replay. error=${message}`,            );            if (tc.id) skippedToolUseIds.add(tc.id);            continue;          }          content.push({            type: "tool_use",            id: tc.id,            name: tc.function.name,            input,          });        }        if (content.length > 0) {          result.push({ role: "assistant", content });        }      } else {        result.push({          role: "assistant",          content: (msg as any).content ?? "",        });      }    } else if (msg.role === "tool") {      const toolMsg = msg as any;      const toolCallId = toolMsg.toolCallId ?? "";      if (!toolCallId || skippedToolUseIds.has(toolCallId)) continue;      result.push({        role: "user",        content: [          {            type: "tool_result",            tool_use_id: toolCallId,            content:              typeof toolMsg.content === "string"                ? toolMsg.content                : JSON.stringify(toolMsg.content),          },        ],      });    }    // skip "system" and "developer" roles — handled separately as system prompt  }  return result;}function latestUserMessageOnly(messages: Message[]): Message[] {  for (let index = messages.length - 1; index >= 0; index--) {    if (messages[index]?.role === "user") {      return [messages[index]!];    }  }  return [];}function buildAgentContextString(context: unknown): string {  if (!Array.isArray(context) || context.length === 0) return "";  return context    .map((entry): string => {      if (!entry || typeof entry !== "object") return "";      const record = entry as Record<string, unknown>;      const description =        typeof record.description === "string" ? record.description : "";      const value =        typeof record.value === "string"          ? record.value          : record.value == null            ? ""            : JSON.stringify(record.value);      if (!description && !value) return "";      return description ? `${description}: ${value}` : value;    })    .filter(Boolean)    .join("\n");}function appendContextToSystemPrompt(  systemPrompt: string,  contextString: string,): string {  if (!contextString) return systemPrompt;  return `${systemPrompt}\n\nContext:\n${contextString}`;}function buildTools(tools: RunAgentInput["tools"]): Anthropic.Tool[] {  if (!tools || tools.length === 0) return [];  return tools.map((tool) => {    let inputSchema: Anthropic.Tool.InputSchema = {      type: "object",      properties: {},    };    if (tool.parameters) {      try {        const parsed =          typeof tool.parameters === "string"            ? JSON.parse(tool.parameters)            : tool.parameters;        inputSchema = parsed as Anthropic.Tool.InputSchema;      } catch (parseErr) {        // Don't silently swap in an empty schema — Claude will then accept        // any input shape, which compounds whatever caller bug produced        // the malformed JSON. Warn loudly so the tool definition gets        // fixed instead of being papered over.        const message =          parseErr instanceof Error ? parseErr.message : String(parseErr);        console.warn(          `[agent_server] failed to parse tool.parameters for ${tool.name}; using empty schema. error=${message}`,        );      }    }    return {      name: tool.name,      description: tool.description ?? "",      input_schema: inputSchema,    };  });}/** * Does the user messages contain any binary parts? Used to route the run * to the vision-capable Sonnet model instead of the default Haiku. */function messagesHaveAttachments(messages: Message[]): boolean {  for (const msg of messages) {    if (msg.role !== "user") continue;    const content = (msg as any).content;    if (!Array.isArray(content)) continue;    for (const part of content) {      if (        part &&        typeof part === "object" &&        (part.type === "binary" ||          part.type === "image" ||          part.type === "document")      ) {        return true;      }    }  }  return false;}function normalizeAnthropicModel(model: string): string {  return model === "claude-sonnet-4.6" ? "claude-sonnet-4-6" : model;}interface DemoConfig {  /** Fixed system prompt. Overridden by `buildSystemPrompt` when provided. */  systemPrompt?: string;  /**   * When present, takes precedence over `systemPrompt` and can read the   * per-run `forwardedProps` to compose a dynamic prompt (used by   * the agent-config demo).   */  buildSystemPrompt?: (forwardedProps: Record<string, unknown>) => string;  /** Force vision-capable model regardless of attachment detection. */  forceVisionModel?: boolean;  /**   * Enable Anthropic extended thinking and forward `thinking_delta` events   * as AG-UI REASONING_MESSAGE_* events. Requires a model that supports   * extended thinking (Claude 3.7 Sonnet / Claude 4 family). Sets   * `thinking: { type: "enabled", budget_tokens }`.   */  enableThinking?: boolean;  /** Override model used when `enableThinking` is set. */  thinkingModel?: string;}const DEFAULT_SYSTEM_PROMPT =  "You are a helpful AI assistant powered by Anthropic's Claude.";const BEAUTIFUL_CHAT_SYSTEM_PROMPT =  "You are a helpful CopilotKit demo assistant. Use the available tools " +  "to render rich UI instead of describing UI in prose.\n\n" +  "Routing rules:\n" +  "- Charts: call `query_data` first when the user asks for financial data, " +  "then use the frontend chart tool requested by the user.\n" +  "- Flights: call `search_flights` with exactly two complete flight objects " +  "so the A2UI flight cards can render.\n" +  "- Dashboards: call `query_data`, then `generate_a2ui`.\n" +  "- Todos: call `enableAppMode` first, then `manage_todos` with the full " +  "todo list.\n" +  "- Meetings and theme changes are frontend tools; call the matching " +  "frontend tool when requested.\n\n" +  "After tools complete, summarize the result in one short sentence.";const MCP_APPS_SYSTEM_PROMPT =  "You draw simple diagrams in Excalidraw via the MCP tool. " +  "When the user asks for a diagram, sketch, flowchart, UI mock, or visual " +  "layout, call the available MCP drawing tool exactly once with a concise " +  "description. Do not describe a diagram in prose instead of using the tool. " +  "After the tool result returns, summarize the created drawing in one short sentence.";const QUERY_DATA_TOOL_SCHEMA: Anthropic.Tool = {  name: "query_data",  description:    "Query the financial database for chart and dashboard data. Always call " +    "before showing financial charts or dashboards.",  input_schema: {    type: "object",    properties: {      query: {        type: "string",        description: "Natural language query for financial data.",      },    },    required: ["query"],  },};const MANAGE_TODOS_TOOL_SCHEMA: Anthropic.Tool = {  name: "manage_todos",  description:    "Replace the beautiful-chat task manager todo list. Always include every " +    "todo that should remain visible.",  input_schema: {    type: "object",    properties: {      todos: {        type: "array",        description: "The complete task-manager todo list.",        items: {          type: "object",          properties: {            id: { type: "string" },            title: { type: "string" },            description: { type: "string" },            emoji: { type: "string" },            status: {              type: "string",              enum: ["pending", "completed"],            },          },          required: ["title", "description", "emoji", "status"],        },      },    },    required: ["todos"],  },};const GET_TODOS_TOOL_SCHEMA: Anthropic.Tool = {  name: "get_todos",  description: "Get the current beautiful-chat task manager todo list.",  input_schema: {    type: "object",    properties: {},  },};const BEAUTIFUL_CHAT_SEARCH_FLIGHTS_TOOL_SCHEMA: Anthropic.Tool = {  name: "search_flights",  description:    "Render A2UI flight cards. Provide exactly two complete flights with " +    "airline, logo, flight number, route, date, times, duration, status, " +    "price, and currency.",  input_schema: {    type: "object",    properties: {      flights: {        type: "array",        items: {          type: "object",          properties: {            airline: { type: "string" },            airlineLogo: { type: "string" },            flightNumber: { type: "string" },            origin: { type: "string" },            destination: { type: "string" },            date: { type: "string" },            departureTime: { type: "string" },            arrivalTime: { type: "string" },            duration: { type: "string" },            status: { type: "string" },            statusColor: { type: "string" },            price: { type: "string" },            currency: { type: "string" },          },          required: [            "airline",            "airlineLogo",            "flightNumber",            "origin",            "destination",            "date",            "departureTime",            "arrivalTime",            "duration",            "status",            "statusColor",            "price",            "currency",          ],        },      },    },    required: ["flights"],  },};const DECLARATIVE_GEN_UI_CATALOG_ID = "declarative-gen-ui-catalog";const DECLARATIVE_GEN_UI_CATALOG_GUIDE = `\Registered catalog fallback:- ${DECLARATIVE_GEN_UI_CATALOG_ID}  Extends the basic A2UI catalog. Custom components:  - Card: { title: string, subtitle?: string, child?: string }  - StatusBadge: { text: string, variant?: "success" | "warning" | "error" | "info" }  - Metric: { label: string, value: string, trend?: "up" | "down" | "neutral" }  - InfoRow: { label: string, value: string }  - PrimaryButton: { label: string, action?: object }  - PieChart: { title: string, description: string, data: Array<{ label: string, value: number }> }  - BarChart: { title: string, description: string, data: Array<{ label: string, value: number }> }Use Column or Row from the basic catalog to group multiple Metrics or badges.`;const DECLARATIVE_GEN_UI_SECONDARY_PROMPT = `\You are an A2UI v0.9 component designer for the Declarative Generative UI demo.Call render_a2ui exactly once. Emit only valid tool arguments.Use catalogId "${DECLARATIVE_GEN_UI_CATALOG_ID}".Every component must include a unique "id" and a "component" name.Exactly one component must have id "root"; the renderer starts there.Props go beside "id" and "component" on each flat component object.For static composition, use "child": "component-id" or"children": ["component-id", ...].`;const RENDER_A2UI_TOOL_SCHEMA: Anthropic.Tool = {  name: "render_a2ui",  description: "Render a dynamic A2UI v0.9 surface.",  input_schema: {    type: "object",    properties: {      surfaceId: {        type: "string",        description: "Unique surface identifier.",      },      catalogId: {        type: "string",        description:          "The catalog ID. This demo registers declarative-gen-ui-catalog.",      },      components: {        type: "array",        items: {          type: "object",          properties: {            id: { type: "string" },            component: { type: "string" },          },          required: ["id", "component"],          additionalProperties: true,        },        description:          "A2UI component array in flat { id, component, ...props } format. " +          "Exactly one component must have id 'root'.",      },      data: {        type: "object",        description: "Optional initial data model for the surface.",      },    },    required: ["surfaceId", "catalogId", "components"],  },};function maybeParseJsonField(value: unknown): unknown {  if (typeof value !== "string") return value;  const trimmed = value.trim();  if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value;  try {    return JSON.parse(trimmed);  } catch {    return value;  }}function sanitizeA2uiComponent(  component: unknown,): Record<string, unknown> | null {  if (!component || typeof component !== "object") return null;  const record = component as Record<string, unknown>;  const id = typeof record.id === "string" ? record.id : "";  const componentName =    typeof record.component === "string"      ? record.component      : typeof record.type === "string"        ? record.type        : "";  if (!id || !componentName) return null;  const sanitized: Record<string, unknown> = {    ...record,    id,    component: componentName,  };  delete sanitized.type;  for (const field of ["data", "value", "children"] as const) {    sanitized[field] = maybeParseJsonField(sanitized[field]);  }  return sanitized;}function collectChildRefs(component: Record<string, unknown>): Set<string> {  const refs = new Set<string>();  const visit = (value: unknown) => {    if (typeof value === "string") {      refs.add(value);      return;    }    if (Array.isArray(value)) {      for (const item of value) visit(item);      return;    }    if (!value || typeof value !== "object") return;    const record = value as Record<string, unknown>;    if (typeof record.id === "string") refs.add(record.id);    if (typeof record.componentId === "string") refs.add(record.componentId);  };  visit(component.child);  visit(component.children);  return refs;}function replaceChildRef(value: unknown, from: string, to: string): unknown {  if (value === from) return to;  if (Array.isArray(value)) {    return value.map((item) => replaceChildRef(item, from, to));  }  if (!value || typeof value !== "object") return value;  const record = value as Record<string, unknown>;  const next = { ...record };  if (next.id === from) next.id = to;  if (next.componentId === from) next.componentId = to;  return next;}function normalizeA2uiComponents(  rawComponents: unknown,): Record<string, unknown>[] {  const sanitized = Array.isArray(rawComponents)    ? rawComponents        .map(sanitizeA2uiComponent)        .filter(          (component): component is Record<string, unknown> =>            component !== null,        )    : [];  const uniqueComponents: Record<string, unknown>[] = [];  const seenIds = new Set<string>();  for (const component of sanitized) {    const id = component.id as string;    if (seenIds.has(id)) {      console.warn(`[agent_server] dropping duplicate A2UI component id=${id}`);      continue;    }    seenIds.add(id);    uniqueComponents.push(component);  }  const rootIndex = uniqueComponents.findIndex(    (component) => component.id === "root",  );  if (rootIndex >= 0) return uniqueComponents;  if (uniqueComponents.length === 0) return [];  const referencedIds = new Set<string>();  for (const component of uniqueComponents) {    for (const ref of collectChildRefs(component)) {      referencedIds.add(ref);    }  }  const topLevelComponents = uniqueComponents.filter(    (component) => !referencedIds.has(component.id as string),  );  if (topLevelComponents.length === 1) {    const rootCandidate = topLevelComponents[0]!;    const priorId = rootCandidate.id as string;    console.warn(`[agent_server] normalizing A2UI root id ${priorId} -> root`);    return uniqueComponents.map((component) => ({      ...component,      id: component.id === priorId ? "root" : component.id,      child: replaceChildRef(component.child, priorId, "root"),      children: replaceChildRef(component.children, priorId, "root"),    }));  }  console.warn(    "[agent_server] inserting A2UI root Column for generated components",  );  return [    {      id: "root",      component: "Column",      children: topLevelComponents.length        ? topLevelComponents.map((component) => component.id)        : uniqueComponents.map((component) => component.id),    },    ...uniqueComponents,  ];}function buildDeclarativeA2uiSystemPrompt(agentContext: string): string {  return [    DECLARATIVE_GEN_UI_SECONDARY_PROMPT,    A2UI_DEFAULT_GENERATION_GUIDELINES,    A2UI_DEFAULT_DESIGN_GUIDELINES,    "Registered catalog/context:",    agentContext || DECLARATIVE_GEN_UI_CATALOG_GUIDE,  ].join("\n\n");}function buildA2uiOperationsFromRenderArgs(args: Record<string, unknown>) {  const surfaceId =    typeof args.surfaceId === "string" && args.surfaceId      ? args.surfaceId      : "dynamic-surface";  // The page registers exactly one catalog. LangGraph gets the same  // guarantee from the A2UI runtime's defaultCatalogId; this backend builds  // A2UI operations itself, so normalize the model output here instead of  // trusting a generated catalogId such as "default".  const catalogId = DECLARATIVE_GEN_UI_CATALOG_ID;  const components = normalizeA2uiComponents(args.components);  const data =    args.data && typeof args.data === "object"      ? (args.data as Record<string, unknown>)      : undefined;  // A2UI middleware expects the v0.9 nested operation shape. The legacy  // flat `{ type: "create_surface" }` form looks reasonable but is not  // recognized by `@ag-ui/a2ui-middleware`, so the renderer never sees  // the surface schema.  const a2ui_operations: Array<Record<string, unknown>> = [    {      version: "v0.9",      createSurface: { surfaceId, catalogId },    },    {      version: "v0.9",      updateComponents: { surfaceId, components },    },  ];  if (data) {    a2ui_operations.push({      version: "v0.9",      updateDataModel: {        surfaceId,        path: "/",        value: data,      },    });  }  return { a2ui_operations };}async function generateDeclarativeA2uiOperations(  context: string,  forwardedHeaders: Record<string, string>,  agentContext: string = "",): Promise<string> {  const prompt = context || "Generate a useful dashboard UI.";  const response = await anthropic.messages.create(    {      model: CLAUDE_MODEL,      max_tokens: 4096,      system: buildDeclarativeA2uiSystemPrompt(agentContext),      messages: [{ role: "user", content: prompt }],      tools: [RENDER_A2UI_TOOL_SCHEMA],      tool_choice: { type: "tool", name: "render_a2ui" },    },    { headers: diagOutboundHeaders(forwardedHeaders) },  );  for (const block of response.content) {    if (block.type === "tool_use" && block.name === "render_a2ui") {      return JSON.stringify(        buildA2uiOperationsFromRenderArgs(          (block.input ?? {}) as Record<string, unknown>,        ),      );    }  }  return JSON.stringify({ error: "secondary Claude call did not render A2UI" });}// ---------------------------------------------------------------------------// AG-UI streaming endpoint factory// ---------------------------------------------------------------------------function makeAgentHandler(config: DemoConfig = {}) {  return async (req: Request, res: Response): Promise<void> => {    const input = req.body as RunAgentInput;    // Inbound x-* headers travel from the AG-UI client →    // CopilotRuntime → HttpAgent → here. We forward them to every    // Anthropic call so aimock (and any other downstream observer)    // receives `x-aimock-context` and friends.    const forwardedHeaders = extractForwardedHeaders(req);    const encoder = new EventEncoder();    res.setHeader("Content-Type", "text/event-stream");    res.setHeader("Cache-Control", "no-cache");    res.setHeader("Connection", "keep-alive");    const runId = input.runId ?? randomUUID();    const threadId = input.threadId ?? randomUUID();    const msgId = randomUUID();    const emit = (event: object) => {      res.write(encoder.encodeSSE(event as any));    };    try {      const userMessages = input.messages ?? [];      const messages = buildAnthropicMessages(userMessages);      const tools = buildTools(input.tools);      const forwardedProps = ((input as any).forwardedProps ?? {}) as Record<        string,        unknown      >;      // Resolve the system prompt.      let systemPrompt = DEFAULT_SYSTEM_PROMPT;      if (config.buildSystemPrompt) {        systemPrompt = config.buildSystemPrompt(forwardedProps);      } else if (config.systemPrompt) {        systemPrompt = config.systemPrompt;      }      if (input.context && input.context.length > 0) {        const contextStr = input.context          .map((c: any) => `${c.description}: ${c.value}`)          .join("\n");        systemPrompt += `\n\nContext:\n${contextStr}`;      }      const useVision =        config.forceVisionModel || messagesHaveAttachments(userMessages);      let model = useVision ? CLAUDE_VISION_MODEL : CLAUDE_MODEL;      if (config.enableThinking && config.thinkingModel) {        model = config.thinkingModel;      }      if (        shouldUseClaudeAgentSdk({          input,          forwardedHeaders,          runtimeToolCount: tools.length,          enableThinking: config.enableThinking,        })      ) {        await runWithClaudeAgentSdk({          input,          emit,          runId,          threadId,          systemPrompt,          toolSchemas: [],          initialState: {},          model,          forwardedHeaders,          executeTool: async () => ({            resultText: JSON.stringify({              status: "error",              error: "unknown_tool",            }),            state: null,          }),        });        res.end();        return;      }      emit({ type: EventType.RUN_STARTED, runId, threadId });      const claudeRequest: Anthropic.MessageCreateParamsStreaming = {        model,        max_tokens: config.enableThinking ? 8192 : 4096,        system: systemPrompt,        messages,        stream: true,        ...(tools.length > 0 ? { tools } : {}),        ...(config.enableThinking          ? {              thinking: {                type: "enabled" as const,                budget_tokens: 2048,              },            }          : {}),      };      let toolCallId: string | null = null;      let toolCallName: string | null = null;      let toolCallArgs = "";      // Per-content-block text lifecycle (R3-A9): a single Claude turn can      // emit multiple text blocks interleaved with tool_use / thinking.      // Each text block owns its own AG-UI TEXT_MESSAGE_* triplet, opened      // at content_block_start (text) and closed at content_block_stop —      // never deferred to message_stop / finally, which would interleave      // TOOL_CALL_START inside an open text bubble for text→tool_use      // sequences. `textMessageStarted` here tracks whether the CURRENT      // active block has emitted START yet (text_delta is the first      // signal a non-empty block exists); reset per block.      let activeTextBlockId: string | null = null;      let textMessageStarted = false;      let reasoningMsgId: string | null = null;      let reasoningStarted = false;      let reasoningEnded = false;      try {        const stream = await anthropic.messages.stream(claudeRequest, {          headers: diagOutboundHeaders(forwardedHeaders),        });        for await (const event of stream) {          if (event.type === "message_start") {            // wait for text_delta to emit TEXT_MESSAGE_START          } else if (event.type === "content_block_start") {            if (event.content_block.type === "tool_use") {              toolCallId = event.content_block.id;              toolCallName = event.content_block.name;              toolCallArgs = "";              emit({                type: EventType.TOOL_CALL_START,                toolCallId,                toolCallName,                parentMessageId: msgId,              });            } else if ((event.content_block as any).type === "text") {              // Open a fresh text block. The first delta opens the              // TEXT_MESSAGE_START; content_block_stop closes it. Each              // text content_block gets its own messageId so multi-text              // turns (e.g. text→tool_use→text) emit distinct AG-UI              // lifecycles instead of reusing the outer turn-scoped msgId              // (mirrors runAgenticLoop's per-block randomUUID, R5-A1).              activeTextBlockId = randomUUID();              textMessageStarted = false;            } else if (              (event.content_block as any).type === "thinking" &&              config.enableThinking            ) {              reasoningMsgId = randomUUID();              reasoningStarted = false;              reasoningEnded = false;            }          } else if (event.type === "content_block_delta") {            if (event.delta.type === "text_delta") {              if (!textMessageStarted) {                emit({                  type: EventType.TEXT_MESSAGE_START,                  messageId: activeTextBlockId ?? msgId,                  role: "assistant",                });                textMessageStarted = true;              }              emit({                type: EventType.TEXT_MESSAGE_CONTENT,                messageId: activeTextBlockId ?? msgId,                delta: event.delta.text,              });            } else if (event.delta.type === "input_json_delta") {              toolCallArgs += event.delta.partial_json;              emit({                type: EventType.TOOL_CALL_ARGS,                toolCallId,                delta: event.delta.partial_json,              });            } else if (              (event.delta as any).type === "thinking_delta" &&              config.enableThinking &&              reasoningMsgId            ) {              const thinkingText = (event.delta as any).thinking as string;              if (!reasoningStarted) {                emit({                  type: EventType.REASONING_MESSAGE_START,                  messageId: reasoningMsgId,                  role: "reasoning",                });                reasoningStarted = true;              }              emit({                type: EventType.REASONING_MESSAGE_CONTENT,                messageId: reasoningMsgId,                delta: thinkingText,              });            }          } else if (event.type === "content_block_stop") {            if (toolCallId) {              emit({                type: EventType.TOOL_CALL_END,                toolCallId,              });              toolCallId = null;              toolCallName = null;              toolCallArgs = "";            } else if (activeTextBlockId && textMessageStarted) {              // Close THIS text block now so any following tool_use /              // thinking block doesn't interleave inside an open bubble.              emit({                type: EventType.TEXT_MESSAGE_END,                messageId: activeTextBlockId,              });              activeTextBlockId = null;              textMessageStarted = false;            } else if (activeTextBlockId) {              // Empty text block (no text_delta arrived); nothing to close,              // just clear the active marker.              activeTextBlockId = null;            } else if (reasoningMsgId && reasoningStarted && !reasoningEnded) {              emit({                type: EventType.REASONING_MESSAGE_END,                messageId: reasoningMsgId,              });              reasoningEnded = true;              reasoningMsgId = null;              reasoningStarted = false;            }          }        }      } finally {        // Lifecycle guarantee: every *_START we emit MUST be paired with a        // matching *_END, even when the stream throws mid-token. Without        // this, AG-UI clients tracking message-id / tool-call lifecycle        // render a permanently in-flight assistant bubble, reasoning        // bubble, or tool-call card.        if (activeTextBlockId && textMessageStarted) {          emit({            type: EventType.TEXT_MESSAGE_END,            messageId: activeTextBlockId,          });          activeTextBlockId = null;          textMessageStarted = false;        }        if (reasoningMsgId && reasoningStarted && !reasoningEnded) {          emit({            type: EventType.REASONING_MESSAGE_END,            messageId: reasoningMsgId,          });          reasoningEnded = true;        }        if (toolCallId) {          emit({            type: EventType.TOOL_CALL_END,            toolCallId,          });          toolCallId = null;          toolCallName = null;          toolCallArgs = "";        }      }      emit({ type: EventType.RUN_FINISHED, runId, threadId });    } catch (error: unknown) {      const err = error as Error;      console.error(`[agent_server] ERROR: ${err.message}`);      emit({        type: EventType.RUN_ERROR,        runId,        threadId,        message: err.message,        code: "AGENT_ERROR",      });    }    res.end();  };}// ---------------------------------------------------------------------------// State-aware demos (Shared State Read+Write, Sub-Agents)// ---------------------------------------------------------------------------// Sub-agent model is overridable so ops can swap a faster/cheaper model// for the secondary calls without bumping the supervisor's model. See// the showcase parity-notes for why we don't pin a single global model// here.//// Precedence: `CLAUDE_SUBAGENT_MODEL` first to match the supervisor's// `CLAUDE_MODEL` prefix (a deployment that sets `CLAUDE_*` everywhere// shouldn't have to also set the legacy `ANTHROPIC_*` form). The// `ANTHROPIC_SUBAGENT_MODEL` form is kept as a legacy fallback so we// don't break existing deployments.const SUBAGENT_MODEL = normalizeAnthropicModel(  process.env.CLAUDE_SUBAGENT_MODEL ||    process.env.ANTHROPIC_SUBAGENT_MODEL ||    CLAUDE_MODEL,);interface Delegation {  id: string;  sub_agent: SubAgentName;  task: string;  status: "running" | "completed" | "failed";  result: string;}function partialJsonStringProperty(source: string, key: string): string | null {  try {    const parsed = PartialJSON.parse(source);    if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {      return null;    }    const value = (parsed as Record<string, unknown>)[key];    return typeof value === "string" ? value : null;  } catch {    return null;  }}const SHARED_STATE_STREAMING_SYSTEM_PROMPT =  "You are a collaborative writing assistant. Whenever the user asks " +  "you to write, draft, or revise text, call `write_document` with the " +  "full content in the `document` argument. Do not paste the document " +  "into the chat message directly; the UI renders shared state.";const WRITE_DOCUMENT_TOOL_SCHEMA: Anthropic.Tool = {  name: "write_document",  description:    "Write a document into shared agent state. Use for poems, emails, " +    "summaries, explainers, and other drafted text.",  input_schema: {    type: "object",    properties: {      document: {        type: "string",        description: "The full document text to render in shared state.",      },    },    required: ["document"],  },};/** * Run a single Anthropic Messages API call for a sub-agent. No tools, * no streaming — we just want the final text back so the supervisor can * read it on its next step. Mirrors `_invoke_sub_agent` in * `google-adk/src/agents/subagents_agent.py`. */async function invokeSubAgent(  systemPrompt: string,  task: string,  forwardedHeaders: Record<string, string> = {},): Promise<string> {  const response = await anthropic.messages.create(    {      model: SUBAGENT_MODEL,      max_tokens: 1024,      system: systemPrompt,      messages: [{ role: "user", content: task }],    },    { headers: diagOutboundHeaders(forwardedHeaders) },  );  const parts = response.content    .filter((c): c is Anthropic.TextBlock => c.type === "text")    .map((c) => c.text);  const text = parts.join("").trim();  if (!text) {    throw new Error("sub-agent returned empty text");  }  return text;}interface ExecuteToolResult {  resultText: string;  state: Record<string, unknown> | null;}interface BeautifulChatTodo {  id: string;  title: string;  description: string;  emoji: string;  status: "pending" | "completed";}function coerceBeautifulChatTodos(value: unknown): BeautifulChatTodo[] {  if (!Array.isArray(value)) return [];  return value    .filter(      (todo): todo is Record<string, unknown> =>        !!todo && typeof todo === "object",    )    .map((todo) => ({      id: typeof todo.id === "string" && todo.id ? todo.id : randomUUID(),      title: typeof todo.title === "string" ? todo.title : "",      description: typeof todo.description === "string" ? todo.description : "",      emoji: typeof todo.emoji === "string" && todo.emoji ? todo.emoji : "*",      status: todo.status === "completed" ? "completed" : "pending",    }));}/** * Execute a backend-implemented tool. Returns the JSON-encoded result * the supervisor will receive AND the new state snapshot to emit to * the UI (or `null` if state is unchanged). * * For sub-agent delegations we update `state.delegations` twice: *   - once with `status: "running"` BEFORE the secondary Anthropic call *   - once with `status: "completed"` (or `"failed"`) AFTER it returns * * The first STATE_SNAPSHOT is emitted by the caller via `onRunningEntry`; * we return the final state from this function. */async function executeBackendTool(  toolName: string,  toolInput: Record<string, unknown>,  state: Record<string, unknown>,  emit: (event: object) => void,  forwardedHeaders: Record<string, string> = {},  agentContext: string = "",): Promise<ExecuteToolResult> {  if (toolName === "query_data") {    const query = typeof toolInput.query === "string" ? toolInput.query : "";    return {      resultText: JSON.stringify(queryDataImpl(query)),      state: null,    };  }  if (toolName === "manage_todos") {    const todos = coerceBeautifulChatTodos(toolInput.todos);    return {      resultText: JSON.stringify({ status: "updated", count: todos.length }),      state: { ...state, todos },    };  }  if (toolName === "get_todos") {    return {      resultText: JSON.stringify(coerceBeautifulChatTodos(state.todos)),      state: null,    };  }  if (toolName === "display_flight") {    const origin = typeof toolInput.origin === "string" ? toolInput.origin : "";    const destination =      typeof toolInput.destination === "string" ? toolInput.destination : "";    const airline =      typeof toolInput.airline === "string" ? toolInput.airline : "";    const price = typeof toolInput.price === "string" ? toolInput.price : "";    const ops = buildDisplayFlightOperations({      origin,      destination,      airline,      price,    });    return {      resultText: JSON.stringify(ops),      state: null,    };  }  if (toolName === "generate_a2ui") {    const context =      typeof toolInput.context === "string" ? toolInput.context : "";    return {      resultText: await generateDeclarativeA2uiOperations(        context,        forwardedHeaders,        agentContext,      ),      state: null,    };  }  if (toolName === "get_weather") {    const location =      typeof toolInput.location === "string" ? toolInput.location : "";    return {      resultText: JSON.stringify(getWeatherImpl(location)),      state: null,    };  }  if (toolName === "get_stock_price") {    const ticker = typeof toolInput.ticker === "string" ? toolInput.ticker : "";    // Echo value-carrying args when the model provides them (the    // tool-rendering aimock fixtures pass price_usd/change_pct so the    // card and the narration agree); fall back to the canned impl.    const base = getStockPriceImpl(ticker);    const result = {      ...base,      ...(typeof toolInput.price_usd === "number"        ? { price_usd: toolInput.price_usd }        : {}),      ...(typeof toolInput.change_pct === "number"        ? { change_pct: toolInput.change_pct }        : {}),    };    return {      resultText: JSON.stringify(result),      state: null,    };  }  if (toolName === "get_revenue_chart") {    return {      resultText: JSON.stringify(getRevenueChartImpl()),      state: null,    };  }  if (toolName === "search_flights") {    if (Array.isArray(toolInput.flights)) {      return {        resultText: JSON.stringify(          renderFlightsImpl(toolInput.flights as Flight[]),        ),        state: null,      };    }    const origin = typeof toolInput.origin === "string" ? toolInput.origin : "";    const destination =      typeof toolInput.destination === "string" ? toolInput.destination : "";    return {      resultText: JSON.stringify(searchFlightsByRouteImpl(origin, destination)),      state: null,    };  }  if (toolName === "roll_d20") {    const value =      typeof toolInput.value === "number" ? toolInput.value : undefined;    return {      resultText: JSON.stringify(rollD20Impl(value)),      state: null,    };  }  if (toolName === "roll_dice") {    const sides = typeof toolInput.sides === "number" ? toolInput.sides : 6;    return {      resultText: JSON.stringify(rollDiceImpl(sides)),      state: null,    };  }  if (toolName === "set_steps") {    // Gen UI (Agent-based): each call REPLACES state.steps wholesale    // (last-write-wins, mirroring the langgraph-typescript reducer). Keep    // the raw step objects — the UI consumes { id, title, status } as-is.    const steps = Array.isArray(toolInput.steps)      ? (toolInput.steps as unknown[]).filter(          (s): s is Record<string, unknown> => !!s && typeof s === "object",        )      : [];    const next = { ...state, steps };    return {      resultText: JSON.stringify({ status: "ok", count: steps.length }),      state: next,    };  }  if (toolName === "set_notes") {    const notes = Array.isArray(toolInput.notes)      ? (toolInput.notes as unknown[]).filter(          (n): n is string => typeof n === "string",        )      : [];    const next = { ...state, notes };    return {      resultText: JSON.stringify({ status: "ok", count: notes.length }),      state: next,    };  }  if (toolName === "write_document") {    const document =      typeof toolInput.document === "string" ? toolInput.document : "";    const next = { ...state, document };    return {      resultText: JSON.stringify({ status: "ok", length: document.length }),      state: next,    };  }  if (    toolName === "research_agent" ||    toolName === "writing_agent" ||    toolName === "critique_agent"  ) {    const subAgentName = toolName as SubAgentName;    const task = typeof toolInput.task === "string" ? toolInput.task : "";    const id = randomUUID();    const existing = Array.isArray(state.delegations)      ? (state.delegations as Delegation[])      : [];    const runningEntry: Delegation = {      id,      sub_agent: subAgentName,      task,      status: "running",      result: "",    };    const stateWithRunning = {      ...state,      delegations: [...existing, runningEntry],    };    // Emit the in-flight state so the UI's delegation log shows a    // "running" row immediately, before we await the secondary call.    emit({ type: EventType.STATE_SNAPSHOT, snapshot: stateWithRunning });    try {      const result = await invokeSubAgent(        SUBAGENT_SYSTEM_BY_NAME[subAgentName],        task,        forwardedHeaders,      );      const finalEntry: Delegation = {        ...runningEntry,        status: "completed",        result,      };      const nextState = {        ...state,        delegations: [...existing, finalEntry],      };      return {        resultText: JSON.stringify({ status: "completed", result }),        state: nextState,      };    } catch (err) {      const errorClass =        err instanceof Error ? err.constructor.name : typeof err;      const fullMessage = err instanceof Error ? err.message : String(err);      // Scrub raw error.message from anything that crosses the wire to the      // UI or back to the supervisor LLM. Anthropic SDK errors can contain      // request ids, partial prompt text, and rate-limit detail an end user      // shouldn't see (and that the supervisor doesn't need either —      // matching the cohort, we surface only the error class). Full      // message + stack still go to server logs below for ops.      const scrubbed = `sub-agent call failed: ${errorClass} (see server logs)`;      const failedEntry: Delegation = {        ...runningEntry,        status: "failed",        result: scrubbed,      };      const nextState = {        ...state,        delegations: [...existing, failedEntry],      };      console.error(        `[agent_server] sub-agent ${subAgentName} failed: ${errorClass}: ${fullMessage}`,        err instanceof Error && err.stack ? err.stack : undefined,      );      return {        resultText: JSON.stringify({ status: "failed", error: scrubbed }),        state: nextState,      };    }  }  return {    resultText: JSON.stringify({ status: "error", error: "unknown_tool" }),    state: null,  };}interface AgenticLoopConfig {  systemPrompt: string;  toolSchemas: Anthropic.Tool[];  initialState: Record<string, unknown>;  /** Override the model for every call in the loop (defaults to   *  CLAUDE_MODEL). Used by tool-rendering-reasoning-chain, which needs   *  a thinking-capable model. */  model?: string;  /**   * Enable Anthropic extended thinking and forward `thinking_delta`   * events as AG-UI REASONING_MESSAGE_* events (same mapping as   * `makeAgentHandler`). Note: the loop replays only text + tool_use   * blocks into subsequent turns — sufficient for aimock-backed demo   * runs; a real-API multi-leg thinking run would additionally require   * replaying the signed thinking blocks.   */  enableThinking?: boolean;  /**   * Start each request from the newest user message only. This is required for   * extended-thinking demos because AG-UI conversation history cannot replay   * Anthropic's signed thinking blocks on a later HTTP request.   */  latestUserMessageOnly?: boolean;}/** * Run a full agentic loop: stream Claude, execute backend tools when * the model emits tool_use blocks, push tool_result back into the * conversation, and continue until Claude stops calling tools. * * Used by the demos that own their tools server-side: * /shared-state-read-write, /subagents, /gen-ui-agent, /a2ui-fixed-schema, * /headless-complete, /tool-rendering, and /tool-rendering-reasoning-chain * (seven consumers; see the route wiring below). The default pass-through * handler stays unchanged — frontend-registered tools never reach this path. */async function runAgenticLoop(  req: Request,  res: Response,  config: AgenticLoopConfig,): Promise<void> {  const input = req.body as RunAgentInput;  // See `makeAgentHandler` — same forwarding contract applies to the  // agentic-loop demos (shared-state-read-write, subagents, a2ui-fixed,  // headless-complete). Without this, the secondary Anthropic calls  // inside the loop (and the supervisor's stream) all miss  // x-aimock-context and aimock returns 404.  const forwardedHeaders = extractForwardedHeaders(req);  const encoder = new EventEncoder();  res.setHeader("Content-Type", "text/event-stream");  res.setHeader("Cache-Control", "no-cache");  res.setHeader("Connection", "keep-alive");  const runId = input.runId ?? randomUUID();  const threadId = input.threadId ?? randomUUID();  const emit = (event: object) => {    res.write(encoder.encodeSSE(event as any));  };  let state = { ...config.initialState };  const backendToolNames = new Set(config.toolSchemas.map((t) => t.name));  const runtimeTools = buildTools(input.tools);  const runtimeToolNames = new Set(runtimeTools.map((tool) => tool.name));  const contextString = buildAgentContextString((input as any).context);  const systemPrompt = appendContextToSystemPrompt(    config.systemPrompt,    contextString,  );  if (    shouldUseClaudeAgentSdk({      input,      forwardedHeaders,      runtimeToolCount: runtimeTools.length,      enableThinking: config.enableThinking,    })  ) {    await runWithClaudeAgentSdk({      input,      emit,      runId,      threadId,      systemPrompt,      toolSchemas: config.toolSchemas,      initialState: state,      model: config.model ?? CLAUDE_MODEL,      forwardedHeaders,      executeTool: (toolName, toolInput, currentState, toolEmit) =>        executeBackendTool(          toolName,          toolInput,          currentState,          toolEmit,          forwardedHeaders,          contextString,        ),    });    res.end();    return;  }  try {    emit({ type: EventType.RUN_STARTED, runId, threadId });    const sourceMessages = config.latestUserMessageOnly      ? latestUserMessageOnly(input.messages ?? [])      : (input.messages ?? []);    const messages = buildAnthropicMessages(sourceMessages);    // Merge runtime tools (frontend-registered via useFrontendTool /    // useRenderTool) with the demo's backend tools. Runtime tools win on    // name collisions because the browser-registered handler must be    // intercepted by the AG-UI client, not executed by the backend.    const tools: Anthropic.Tool[] = [      ...config.toolSchemas.filter((tool) => !runtimeToolNames.has(tool.name)),      ...runtimeTools,    ];    // Maximum tool iterations per run. The supervisor demo can fan out    // to research -> write -> critique, but we cap turns to prevent a    // misbehaving model from running unbounded.    const MAX_TOOL_ITERATIONS = 10;    for (let iter = 0; iter < MAX_TOOL_ITERATIONS; iter++) {      const msgId = randomUUID();      const pendingToolCalls: Array<{        id: string;        name: string;        argsJson: string;      }> = [];      let activeToolCallId: string | null = null;      let activeToolCallName: string | null = null;      let activeToolArgs = "";      let lastStreamedDocument =        typeof (state as Record<string, unknown>).document === "string"          ? ((state as Record<string, unknown>).document as string)          : "";      let reasoningMsgId: string | null = null;      let reasoningStarted = false;      // Per-content-block ordered array (R3-A8): Claude's canonical      // pattern for tool-use turns under extended thinking is      // "thinking → text → tool_use → text → tool_use" (which      // tool-rendering-reasoning-chain explicitly trains for). We      // accumulate ONE entry per content block in original stream order      // and replay it as `assistantContent` below — both aimock strict      // mode and the real Anthropic API reject the continuation      // otherwise. Merging text from multiple blocks into a single      // accumulator (or buckets keyed by type) reorders the turn on      // replay and breaks content-order verification.      //      // Each thinking block carries its own signature (per-content-block      // signed); each text block carries its own optional id so we can      // emit per-block TEXT_MESSAGE_* lifecycles (R3-A9). Tool_use      // entries are appended at content_block_stop alongside being      // pushed to `pendingToolCalls`.      type AssistantBlock =        | { kind: "text"; messageId: string; text: string; started: boolean }        | { kind: "thinking"; thinking: string; signature: string }        | { kind: "redacted_thinking"; data: string }        | { kind: "tool_use"; id: string; name: string; argsJson: string };      const assistantBlocks: AssistantBlock[] = [];      let activeTextBlock: Extract<AssistantBlock, { kind: "text" }> | null =        null;      let activeThinkingBlock: Extract<        AssistantBlock,        { kind: "thinking" }      > | null = null;      try {        const stream = await anthropic.messages.stream(          {            model: config.model ?? CLAUDE_MODEL,            max_tokens: config.enableThinking ? 8192 : 4096,            system: systemPrompt,            messages,            stream: true,            ...(tools.length > 0 ? { tools } : {}),            ...(config.enableThinking              ? {                  thinking: {                    type: "enabled" as const,                    budget_tokens: 2048,                  },                }              : {}),          },          { headers: diagOutboundHeaders(forwardedHeaders) },        );        for await (const event of stream) {          if (event.type === "content_block_start") {            if (event.content_block.type === "tool_use") {              activeToolCallId = event.content_block.id;              activeToolCallName = event.content_block.name;              activeToolArgs = "";              emit({                type: EventType.TOOL_CALL_START,                toolCallId: activeToolCallId,                toolCallName: activeToolCallName,                parentMessageId: msgId,              });            } else if ((event.content_block as any).type === "text") {              // Open a fresh text block. Each text content_block gets its              // own AG-UI message lifecycle AND its own entry in the              // ordered `assistantBlocks` replay array — preserving              // text→tool_use→text order across multiple text blocks per              // turn (R3-A8).              activeTextBlock = {                kind: "text",                messageId: randomUUID(),                text: "",                started: false,              };            } else if (              (event.content_block as any).type === "thinking" &&              config.enableThinking            ) {              reasoningMsgId = randomUUID();              reasoningStarted = false;              activeThinkingBlock = {                kind: "thinking",                thinking: "",                signature: "",              };            } else if (              (event.content_block as any).type === "redacted_thinking" &&              config.enableThinking            ) {              assistantBlocks.push({                kind: "redacted_thinking",                data: ((event.content_block as any).data ?? "") as string,              });            }          } else if (event.type === "content_block_delta") {            if (event.delta.type === "text_delta") {              if (!activeTextBlock) {                // Defensive: text_delta arrived without a content_block_start                // (legacy event ordering). Open a block on the fly so the                // ordering contract still holds.                activeTextBlock = {                  kind: "text",                  messageId: randomUUID(),                  text: "",                  started: false,                };              }              if (!activeTextBlock.started) {                emit({                  type: EventType.TEXT_MESSAGE_START,                  messageId: activeTextBlock.messageId,                  role: "assistant",                });                activeTextBlock.started = true;              }              activeTextBlock.text += event.delta.text;              emit({                type: EventType.TEXT_MESSAGE_CONTENT,                messageId: activeTextBlock.messageId,                delta: event.delta.text,              });            } else if (event.delta.type === "input_json_delta") {              if (activeToolCallId) {                activeToolArgs += event.delta.partial_json;                emit({                  type: EventType.TOOL_CALL_ARGS,                  toolCallId: activeToolCallId,                  delta: event.delta.partial_json,                });                if (activeToolCallName === "write_document") {                  const streamedDocument = partialJsonStringProperty(                    activeToolArgs,                    "document",                  );                  if (                    streamedDocument !== null &&                    streamedDocument !== lastStreamedDocument                  ) {                    state = { ...state, document: streamedDocument };                    lastStreamedDocument = streamedDocument;                    emit({ type: EventType.STATE_SNAPSHOT, snapshot: state });                  }                }              }            } else if (              (event.delta as any).type === "thinking_delta" &&              config.enableThinking &&              reasoningMsgId            ) {              const delta = (event.delta as any).thinking as string;              if (activeThinkingBlock) {                activeThinkingBlock.thinking += delta;              }              if (!reasoningStarted) {                emit({                  type: EventType.REASONING_MESSAGE_START,                  messageId: reasoningMsgId,                  role: "reasoning",                });                reasoningStarted = true;              }              emit({                type: EventType.REASONING_MESSAGE_CONTENT,                messageId: reasoningMsgId,                delta,              });            } else if (              (event.delta as any).type === "signature_delta" &&              config.enableThinking &&              activeThinkingBlock            ) {              activeThinkingBlock.signature += ((event.delta as any)                .signature ?? "") as string;            }          } else if (event.type === "content_block_stop") {            if (activeToolCallId && activeToolCallName) {              emit({                type: EventType.TOOL_CALL_END,                toolCallId: activeToolCallId,              });              pendingToolCalls.push({                id: activeToolCallId,                name: activeToolCallName,                argsJson: activeToolArgs,              });              // Preserve block-order: append tool_use to the ordered              // replay array at the moment its content block closes, so              // a "text → tool_use → text" stream replays in that exact              // order rather than "all-text → all-tool_use".              assistantBlocks.push({                kind: "tool_use",                id: activeToolCallId,                name: activeToolCallName,                argsJson: activeToolArgs,              });              activeToolCallId = null;              activeToolCallName = null;              activeToolArgs = "";            } else if (activeTextBlock) {              // Close this text block now (R3-A9): emit TEXT_MESSAGE_END              // for every text block that STARTED — including genuinely              // empty-string blocks the client already saw START/END for              // (R3-A10). Append to the ordered replay array unless the              // block carries no signal at all (no START emitted AND              // empty text), in which case it's a no-op for both UI and              // replay.              if (activeTextBlock.started) {                emit({                  type: EventType.TEXT_MESSAGE_END,                  messageId: activeTextBlock.messageId,                });                assistantBlocks.push(activeTextBlock);              } else if (activeTextBlock.text) {                // Belt-and-suspenders: text accumulated without a START                // (shouldn't happen given the delta path opens it), but                // we'd still want the replay entry.                assistantBlocks.push(activeTextBlock);              }              activeTextBlock = null;            } else if (reasoningMsgId && reasoningStarted) {              emit({                type: EventType.REASONING_MESSAGE_END,                messageId: reasoningMsgId,              });              reasoningMsgId = null;              reasoningStarted = false;              if (activeThinkingBlock) {                assistantBlocks.push(activeThinkingBlock);                activeThinkingBlock = null;              }            } else if (activeThinkingBlock) {              // Thinking block stopped before any thinking_delta arrived              // (e.g. zero-token thinking). Preserve it for replay so the              // continuation turn keeps the same block sequence Claude              // produced.              assistantBlocks.push(activeThinkingBlock);              activeThinkingBlock = null;            }          }        }      } finally {        // Lifecycle guarantee: every *_START we emit MUST be paired with a        // matching *_END, even if anthropic.messages.stream throws        // mid-token. Without this, the AG-UI client renders a permanently        // in-flight assistant bubble, reasoning bubble, or tool-call card.        // The outer try/catch still emits RUN_ERROR for the caller to        // surface the failure.        if (activeTextBlock && activeTextBlock.started) {          emit({            type: EventType.TEXT_MESSAGE_END,            messageId: activeTextBlock.messageId,          });          assistantBlocks.push(activeTextBlock);          activeTextBlock = null;        } else if (activeTextBlock) {          activeTextBlock = null;        }        if (reasoningMsgId && reasoningStarted) {          emit({            type: EventType.REASONING_MESSAGE_END,            messageId: reasoningMsgId,          });          reasoningMsgId = null;          reasoningStarted = false;        }        if (activeToolCallId) {          emit({            type: EventType.TOOL_CALL_END,            toolCallId: activeToolCallId,          });          activeToolCallId = null;          activeToolCallName = null;          activeToolArgs = "";        }      }      // No tool calls — we're done.      if (pendingToolCalls.length === 0) {        break;      }      // Append the assistant turn (thinking + text + tool_use blocks) to      // the conversation so the next call sees the supervisor's plan.      //      // `assistantBlocks` preserves Claude's original stream order across      // all three block kinds (thinking / text / tool_use), so a turn      // shaped "thinking → text → tool_use → text → tool_use" replays in      // that exact order rather than the all-text-then-all-tool_use      // shape the prior single-accumulator code produced (R3-A8). Both      // aimock strict mode and the real Anthropic API verify content      // order on the continuation turn.      const assistantContent: Anthropic.ContentBlockParam[] = [];      for (const block of assistantBlocks) {        if (block.kind === "thinking") {          if (!config.enableThinking) continue;          // Signature-only blocks (zero-thinking but signed) are real:          // Anthropic verifies signatures per content block, so a block          // preserved at content_block_stop with empty `thinking` but a          // non-empty `signature` must still be replayed to keep the per-          // block ordering contract. Only skip blocks that are entirely          // empty (no thinking, no signature) — those carry no state.          if (!block.thinking && !block.signature) continue;          assistantContent.push({            type: "thinking",            thinking: block.thinking,            signature: block.signature,          } as Anthropic.ContentBlockParam);        } else if (block.kind === "redacted_thinking") {          if (!config.enableThinking || !block.data) continue;          assistantContent.push({            type: "redacted_thinking",            data: block.data,          } as Anthropic.ContentBlockParam);        } else if (block.kind === "text") {          // Replay parity (R3-A10): include genuinely-empty-string text          // blocks that emitted START/CONTENT/END to the client, so the          // conversation history matches what the UI rendered. Drop only          // blocks that never STARTED and carry no text — those produced          // nothing on either side.          if (!block.started && !block.text) continue;          assistantContent.push({ type: "text", text: block.text });        } else if (block.kind === "tool_use") {          let parsed: Record<string, unknown> = {};          try {            parsed = block.argsJson ? JSON.parse(block.argsJson) : {};          } catch (parseErr) {            // The streamed input_json_delta concatenated into invalid JSON.            // Logging is essential — without it, the next iteration sees            // empty args and the model is told its tool call succeeded with            // no parameters, which is silently wrong. We still replay the            // tool_use (Anthropic requires every tool_use to be followed by            // a tool_result of the same id), but with empty input. The            // matching execute branch below also skips with a clear error.            const message =              parseErr instanceof Error ? parseErr.message : String(parseErr);            console.warn(              `[agent_server] failed to parse streamed tool args for ${block.name} (id=${block.id}); replaying with empty input. error=${message}`,            );          }          assistantContent.push({            type: "tool_use",            id: block.id,            name: block.name,            input: parsed,          });        }      }      messages.push({ role: "assistant", content: assistantContent });      // Execute backend tools and push their tool_result blocks. Frontend      // tools (anything not in `backendToolNames`) are NOT executed here      // — they're meant to be handled by the AG-UI client. The frontend-tool      // branch is LOAD-BEARING for /headless-complete, whose `highlight_note`      // flow is registered on the frontend (see the route wiring below) and      // depends on this branch breaking the agentic loop so the AG-UI client      // can execute and re-invoke. Other consumers (e.g. /tool-rendering)      // also benefit from the defensive merging when their pages register      // additional `useFrontendTool` calls.      const toolResults: Anthropic.ContentBlockParam[] = [];      let sawFrontendTool = false;      for (const tc of pendingToolCalls) {        if (!backendToolNames.has(tc.name) || runtimeToolNames.has(tc.name)) {          sawFrontendTool = true;          continue;        }        let parsed: Record<string, unknown> = {};        try {          parsed = tc.argsJson ? JSON.parse(tc.argsJson) : {};        } catch (parseErr) {          // CRITICAL: do NOT fall through to `{}` here. For tools like          // `set_notes` that take an array of notes, an empty dict is          // coerced to an empty list and silently clears the user's          // notes. Surface a tool_result with an explicit error so the          // model sees its call failed and the supervisor can retry,          // rather than seeing a "successful" no-op.          const message =            parseErr instanceof Error ? parseErr.message : String(parseErr);          console.warn(            `[agent_server] failed to parse streamed tool args for backend tool ${tc.name} (id=${tc.id}); skipping execution. error=${message}`,          );          const errorResult = JSON.stringify({            status: "error",            error: "invalid_tool_arguments",            detail:              "Tool arguments failed to parse as JSON; tool was not executed. " +              "Re-issue the call with valid JSON.",          });          emit({            type: EventType.TOOL_CALL_RESULT,            toolCallId: tc.id,            content: errorResult,            messageId: randomUUID(),          });          toolResults.push({            type: "tool_result",            tool_use_id: tc.id,            content: errorResult,          });          continue;        }        const exec = await executeBackendTool(          tc.name,          parsed,          state,          emit,          forwardedHeaders,          contextString,        );        if (exec.state) {          state = exec.state;          emit({ type: EventType.STATE_SNAPSHOT, snapshot: state });        }        emit({          type: EventType.TOOL_CALL_RESULT,          toolCallId: tc.id,          content: exec.resultText,          messageId: randomUUID(),        });        toolResults.push({          type: "tool_result",          tool_use_id: tc.id,          content: exec.resultText,        });      }      if (toolResults.length > 0) {        messages.push({ role: "user", content: toolResults });      }      // If Claude called a frontend tool, stop the loop and let the      // AG-UI client handle execution + re-invocation.      if (sawFrontendTool) {        break;      }    }    emit({ type: EventType.RUN_FINISHED, runId, threadId });  } catch (error: unknown) {    const err = error as Error;    console.error(`[agent_server] ERROR (agentic loop): ${err.message}`);    emit({      type: EventType.RUN_ERROR,      runId,      threadId,      message: err.message,      code: "AGENT_ERROR",    });  }  res.end();}// ---------------------------------------------------------------------------// Route wiring// ---------------------------------------------------------------------------// Default pass-through agent.app.post("/", makeAgentHandler());// BYOC demos — each has its own system prompt that instructs Claude to// emit structured JSON consumed by the dedicated frontend renderer.app.post(  "/byoc-json-render",  makeAgentHandler({ systemPrompt: BYOC_JSON_RENDER_SYSTEM_PROMPT }),);app.post(  "/byoc-hashbrown",  makeAgentHandler({ systemPrompt: BYOC_HASHBROWN_SYSTEM_PROMPT }),);app.post(  "/mcp-apps",  makeAgentHandler({ systemPrompt: MCP_APPS_SYSTEM_PROMPT }),);// Multimodal — always use the vision model so images + PDFs work.app.post(  "/multimodal",  makeAgentHandler({    systemPrompt:      "You are a helpful assistant. The user may attach images or documents (PDFs). " +      "When they do, analyze the attachment carefully and answer the user's question. " +      "If no attachment is present, answer the text question normally. Keep responses " +      "concise (1-3 sentences) unless asked to go deep.",    forceVisionModel: true,  }),);// Agent-config — dynamic system prompt built from forwardedProps.app.post(  "/agent-config",  makeAgentHandler({    buildSystemPrompt: (fp) =>      buildAgentConfigSystemPrompt(fp) || AGENT_CONFIG_DEFAULT_SYSTEM_PROMPT,  }),);// Auth and voice reuse the default pass-through — the gate / transcription// service lives on the Next.js route, not the agent itself.// Beautiful Chat — flagship combined runtime. This cell mixes backend-owned// tools (query_data, search_flights, generate_a2ui, manage_todos) with// frontend tools (charts, scheduleTime, toggleTheme, enableAppMode) and MCP// apps. Run it through the agentic loop so backend tools produce tool results// and state snapshots instead of being left as unresolved frontend calls.app.post(  "/beautiful-chat",  async (req: Request, res: Response): Promise<void> => {    const input = req.body as RunAgentInput;    const incomingState =      ((input as any).state as Record<string, unknown> | undefined) ?? {};    await runAgenticLoop(req, res, {      systemPrompt: BEAUTIFUL_CHAT_SYSTEM_PROMPT,      toolSchemas: [        QUERY_DATA_TOOL_SCHEMA,        MANAGE_TODOS_TOOL_SCHEMA,        GET_TODOS_TOOL_SCHEMA,        BEAUTIFUL_CHAT_SEARCH_FLIGHTS_TOOL_SCHEMA,        GENERATE_A2UI_TOOL_SCHEMA,      ] as Anthropic.Tool[],      initialState: {        todos: coerceBeautifulChatTodos(incomingState.todos),      },    });  },);// Reasoning demos — enable Anthropic extended-thinking and forward// `thinking_delta` events as AG-UI REASONING_MESSAGE_* events. Theconst CLAUDE_REASONING_MODEL = normalizeAnthropicModel(  process.env.CLAUDE_REASONING_MODEL || CLAUDE_MODEL,);const REASONING_SYSTEM_PROMPT =  "You are a helpful assistant. For each user question, first think " +  "step-by-step about the approach, then give a concise answer.";app.post(  "/reasoning",  makeAgentHandler({    systemPrompt: REASONING_SYSTEM_PROMPT,    enableThinking: true,    thinkingModel: CLAUDE_REASONING_MODEL,  }),);// Shared State (Read + Write) — UI writes preferences via agent.setState,// the agent reads them out of input.state every turn and prepends them to// the system prompt; the backend `set_notes` tool writes notes back into// shared state, emitted via STATE_SNAPSHOT.app.post(  "/shared-state-read-write",  async (req: Request, res: Response): Promise<void> => {    const input = req.body as RunAgentInput;    const incomingState =      ((input as any).state as Record<string, unknown> | undefined) ?? {};    const prefs = coercePreferences(incomingState.preferences);    const notes = Array.isArray(incomingState.notes)      ? (incomingState.notes as unknown[]).filter(          (n): n is string => typeof n === "string",        )      : [];    await runAgenticLoop(req, res, {      systemPrompt: buildSharedStateReadWriteSystemPrompt(prefs),      toolSchemas: [SET_NOTES_TOOL_SCHEMA] as Anthropic.Tool[],      initialState: { preferences: prefs, notes },    });  },);// Shared State Streaming — copy Claude's streamed write_document argument// into shared state on each input_json_delta, then emit the final snapshot// when the tool completes.app.post(  "/shared-state-streaming",  async (req: Request, res: Response): Promise<void> => {    const input = req.body as RunAgentInput;    const incomingState =      ((input as any).state as Record<string, unknown> | undefined) ?? {};    await runAgenticLoop(req, res, {      systemPrompt: SHARED_STATE_STREAMING_SYSTEM_PROMPT,      toolSchemas: [WRITE_DOCUMENT_TOOL_SCHEMA] as Anthropic.Tool[],      initialState: {        document:          typeof incomingState.document === "string"            ? incomingState.document            : "",      },    });  },);// Sub-Agents — supervisor with three sub-agent-as-tool delegations,// each a single secondary Anthropic Messages call. Every delegation is// recorded in state.delegations (running -> completed/failed) and// streamed to the UI via STATE_SNAPSHOT.app.post("/subagents", async (req: Request, res: Response): Promise<void> => {  const input = req.body as RunAgentInput;  const incomingState =    ((input as any).state as Record<string, unknown> | undefined) ?? {};  const delegations = Array.isArray(incomingState.delegations)    ? incomingState.delegations    : [];  await runAgenticLoop(req, res, {    systemPrompt: SUPERVISOR_SYSTEM_PROMPT,    toolSchemas: SUBAGENT_TOOL_SCHEMAS as Anthropic.Tool[],    initialState: { delegations },  });});// Gen UI (Agent-based) — backend owns the `set_steps` tool. The model// plans 3 steps and calls set_steps after every status transition// (~7 calls per run, see the fixture chain in// showcase/aimock/d6/claude-sdk-typescript/gen-ui-agent.json); each call// replaces `state.steps` and is streamed to the UI via STATE_SNAPSHOT so// the InlineAgentStateCard animates pending -> in_progress -> completed.// Without this dedicated endpoint the demo used the pass-through handler:// the model's set_steps call was forwarded to the frontend (which// registers no such tool), the tool result never materialized, and the// multi-leg loop never completed.app.post(  "/gen-ui-agent",  async (req: Request, res: Response): Promise<void> => {    const input = req.body as RunAgentInput;    const incomingState =      ((input as any).state as Record<string, unknown> | undefined) ?? {};    const steps = Array.isArray(incomingState.steps) ? incomingState.steps : [];    await runAgenticLoop(req, res, {      systemPrompt: GEN_UI_AGENT_SYSTEM_PROMPT,      toolSchemas: [SET_STEPS_TOOL_SCHEMA] as Anthropic.Tool[],      initialState: { steps },    });  },);// A2UI Fixed Schema — backend ships flight_schema.json and exposes a// single `display_flight` tool that emits an `a2ui_operations` container.// The dedicated runtime route at `/api/copilotkit-a2ui-fixed-schema` runs// the A2UI middleware with `injectA2UITool: false` because this backend// owns the rendering tool itself.app.post(  "/a2ui-fixed-schema",  async (req: Request, res: Response): Promise<void> => {    await runAgenticLoop(req, res, {      systemPrompt: A2UI_FIXED_SYSTEM_PROMPT,      toolSchemas: [DISPLAY_FLIGHT_TOOL_SCHEMA] as Anthropic.Tool[],      initialState: {},    });  },);// Declarative Generative UI (A2UI Dynamic Schema) - backend owns// generate_a2ui, then uses a secondary Claude call to produce render_a2ui// args and returns them as an a2ui_operations container.app.post(  "/declarative-gen-ui",  async (req: Request, res: Response): Promise<void> => {    await runAgenticLoop(req, res, {      systemPrompt: A2UI_DYNAMIC_SYSTEM_PROMPT,      toolSchemas: [GENERATE_A2UI_TOOL_SCHEMA] as Anthropic.Tool[],      initialState: {},    });  },);// Headless Chat (Complete) — backend exposes weather, stock, and revenue// chart tools the frontend renders via per-tool useRenderTool renderers, plus// participates in the frontend `highlight_note` tool flow.app.post(  "/headless-complete",  async (req: Request, res: Response): Promise<void> => {    await runAgenticLoop(req, res, {      systemPrompt: HEADLESS_COMPLETE_SYSTEM_PROMPT,      toolSchemas: [        HEADLESS_GET_WEATHER_TOOL_SCHEMA,        HEADLESS_GET_STOCK_PRICE_TOOL_SCHEMA,        HEADLESS_GET_REVENUE_CHART_TOOL_SCHEMA,      ] as Anthropic.Tool[],      initialState: {},    });  },);// Tool Rendering (+ default/custom catchall variants) — the pages register// RENDER-ONLY hooks (useRenderTool / useDefaultRenderTool) with no handlers,// so on the pass-through the model's tool calls were forwarded to the// frontend, no result ever materialized, and every card sat in its loading// state forever. Backend owns the four tools here (same treatment as// /headless-complete and /gen-ui-agent); all three demo agents point at// this endpoint from the main runtime route.app.post(  "/tool-rendering",  async (req: Request, res: Response): Promise<void> => {    await runAgenticLoop(req, res, {      systemPrompt: TOOL_RENDERING_SYSTEM_PROMPT,      toolSchemas: [        HEADLESS_GET_WEATHER_TOOL_SCHEMA,        HEADLESS_GET_STOCK_PRICE_TOOL_SCHEMA,        SEARCH_FLIGHTS_TOOL_SCHEMA,        ROLL_D20_TOOL_SCHEMA,      ] as Anthropic.Tool[],      initialState: {},    });  },);// Tool Rendering — Reasoning Chain. Same backend-owned-tools treatment as// /tool-rendering, plus extended thinking on a reasoning-capable model so// the demo's reasoning-block renders between chained tool calls// (stocks AAPL→MSFT, dice d20→d6, flights→destination weather).app.post(  "/tool-rendering-reasoning-chain",  async (req: Request, res: Response): Promise<void> => {    await runAgenticLoop(req, res, {      systemPrompt: REASONING_CHAIN_SYSTEM_PROMPT,      toolSchemas: [        HEADLESS_GET_WEATHER_TOOL_SCHEMA,        HEADLESS_GET_STOCK_PRICE_TOOL_SCHEMA,        SEARCH_FLIGHTS_TOOL_SCHEMA,        ROLL_DICE_TOOL_SCHEMA,      ] as Anthropic.Tool[],      initialState: {},      model: CLAUDE_REASONING_MODEL,      enableThinking: true,      latestUserMessageOnly: true,    });  },);// ---------------------------------------------------------------------------// Health check// ---------------------------------------------------------------------------app.get("/health", (_req: Request, res: Response) => {  res.json({    status: "ok",    model: CLAUDE_MODEL,    vision_model: CLAUDE_VISION_MODEL,    anthropic_api_key: process.env.ANTHROPIC_API_KEY ? "set" : "NOT SET",  });});app.listen(PORT, HOST, () => {  console.log(    `[agent_server] listening ${new Date().toISOString()} http://${HOST}:${PORT}`,  );});

In the dynamic-schema approach, a secondary LLM generates the entire UI (schema, data, and layout) based on the conversation context. It's the most flexible A2UI flavor; the agent can render any UI for any request without pre-defined schemas.

How it works#

  1. The agent calls the A2UI tool to draw a surface, made available when injectA2UITool: true.
  2. The runtime serializes your client-side catalog (component names + Zod prop schemas) into the agent's copilotkit.context so the LLM knows which components it may emit.
  3. The tool call streams through LangGraph as TOOL_CALL_ARGS events.
  4. The A2UI middleware intercepts the stream and renders cards progressively as data items arrive.

The 3-file split#

The canonical Bring-Your-Own-Catalog (BYOC) layout keeps three files side-by-side under frontend/src/app/a2ui/:

FileWhat lives there
definitions.tsZod props schema + human-readable descriptions for each custom component. Platform-agnostic, so the runtime can serialise it to the LLM.
renderers.tsxReact implementations keyed by the same names. TypeScript enforces that every definition has a renderer.
catalog.tscreateCatalog(definitions, renderers, { includeBasicCatalog: true }): merges your custom components with CopilotKit's built-in primitives.

Declare your custom component definitions#

Each entry pairs a Zod prop schema with a description. The description is crucial; the LLM reads it to decide which component to emit. The example below ships a small dashboard catalog (Card / StatusBadge / Metric / InfoRow / PrimaryButton):

definitions.ts
import { z } from "zod";import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";export const myDefinitions = {  Card: {    description:      "A titled card container with an optional subtitle and a single child slot. Use it to group related content (metrics, rows, buttons).",    props: z.object({      title: z.string(),      subtitle: z.string().optional(),      child: z.string().optional(),    }),  },  StatusBadge: {    description:      "A small coloured pill communicating the state of something (healthy/degraded/down, online/offline, open/closed). Choose `variant` to match the intent.",    props: z.object({      text: z.string(),      variant: z.enum(["success", "warning", "error", "info"]).optional(),    }),  },  Metric: {    description:      "A key/value KPI display with an optional trend indicator. Ideal for dashboards (e.g. 'Revenue • $12.4k • up').",    props: z.object({      label: z.string(),      value: z.string(),      trend: z.enum(["up", "down", "neutral"]).optional(),    }),  },  InfoRow: {    description:      "A compact two-column 'label: value' row. Good for stacks of facts inside a Card (owner, region, last updated, etc.).",    props: z.object({      label: z.string(),      value: z.string(),    }),  },  PrimaryButton: {    description:      "A styled primary call-to-action button. Attach an optional `action` that will be dispatched back to the agent when the user clicks it.",    props: z.object({      label: z.string(),      action: z.any().optional(),    }),  },  PieChart: {    description:      "A pie/donut chart with a brand-coloured legend. Provide `title`, `description`, and `data` as an array of `{ label, value }` objects. Great for part-of-whole breakdowns (sales by region, traffic sources, portfolio allocation).",    props: z.object({      title: z.string(),      description: z.string(),      data: z.array(        z.object({          label: z.string(),          value: z.number(),        }),      ),    }),  },  BarChart: {    description:      "A vertical bar chart built on Recharts. Provide `title`, `description`, and `data` as an array of `{ label, value }` objects. Great for comparing series across categories (quarterly revenue, headcount by team, signups per month).",    props: z.object({      title: z.string(),      description: z.string(),      data: z.array(        z.object({          label: z.string(),          value: z.number(),        }),      ),    }),  },} satisfies CatalogDefinitions;

Implement the React renderers#

Every key in myDefinitions must have a matching renderer. Props are statically typed against the Zod schema, so refactors stay safe:

renderers.tsx
export const myRenderers: CatalogRenderers<MyDefinitions> = {  Card: ({ props, children }) => (    <Card      className="w-full min-w-0 overflow-hidden"      data-testid="declarative-card"    >      <CardHeader>        <CardTitle>{props.title}</CardTitle>        {props.subtitle && <CardDescription>{props.subtitle}</CardDescription>}      </CardHeader>      {props.child && (        <CardContent className="flex flex-col gap-4">          {children(props.child)}        </CardContent>      )}    </Card>  ),  StatusBadge: ({ props }) => (    <Badge      variant={props.variant ?? "info"}      data-testid="declarative-status-badge"    >      {props.text}    </Badge>  ),  Metric: ({ props }) => {    const trend = props.trend ?? "neutral";    const arrow = trend === "up" ? "↑" : trend === "down" ? "↓" : "";    const trendClass =      trend === "up"        ? "text-emerald-600"        : trend === "down"          ? "text-rose-600"          : "text-[var(--foreground)]";    return (      // `flex-1 min-w-[120px]` lets a row of Metrics distribute evenly      // inside the basic catalog's gap-less Row — 3 metrics in a 600px      // card column get ~200px each instead of squishing to content width.      <div        data-testid="declarative-metric"        className="flex flex-1 min-w-[120px] flex-col gap-1"      >        <div className="text-xs font-medium uppercase tracking-wider text-[var(--muted-foreground)]">          {props.label}        </div>        <div          className={`flex items-baseline gap-1.5 text-2xl font-semibold tabular-nums ${trendClass}`}        >          <span>{props.value}</span>          {arrow && <span className="text-base">{arrow}</span>}        </div>      </div>    );  },  InfoRow: ({ props }) => (    // Divider via `border-b last:border-b-0` so the final row doesn't dangle    // a trailing line, regardless of whether the agent wraps these in a    // Column or drops them directly into a Card's child slot.    <div 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>  ),  PrimaryButton: ({ props, dispatch }) => (    <Button      onClick={() => {        if (props.action && dispatch) dispatch(props.action);      }}    >      {props.label}    </Button>  ),  PieChart: ({ props }) => {    const data = props.data ?? [];    const safeData = Array.isArray(data) ? data : [];    const total = safeData.reduce((sum, d) => sum + (Number(d.value) || 0), 0);    return (      // `flex-1 min-w-0` so multiple charts in a basic-catalog Row      // distribute the available width evenly instead of each insisting      // on its content size and overflowing.      <Card        className="w-full flex-1 min-w-0 overflow-hidden"        data-testid="declarative-pie-chart"      >        <CardHeader>          <CardTitle>{props.title}</CardTitle>          <CardDescription>{props.description}</CardDescription>        </CardHeader>        <CardContent className="flex flex-col gap-4">          {safeData.length === 0 ? (            <div className="py-8 text-center text-sm text-[var(--muted-foreground)]">              No data available            </div>          ) : (            <>              <DonutChart data={safeData} />              <div className="flex flex-col gap-2 pt-2">                {safeData.map((item, index) => {                  const val = Number(item.value) || 0;                  const pct =                    total > 0 ? ((val / total) * 100).toFixed(0) : "0";                  return (                    <div                      key={index}                      className="flex items-center gap-3 text-sm"                    >                      <span                        className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"                        style={{                          backgroundColor:                            CHART_COLORS[index % CHART_COLORS.length],                        }}                      />                      <span className="flex-1 truncate text-[var(--foreground)]">                        {item.label}                      </span>                      <span className="tabular-nums text-[var(--muted-foreground)]">                        {val.toLocaleString()}                      </span>                      <span className="w-10 text-right tabular-nums text-[var(--muted-foreground)]">                        {pct}%                      </span>                    </div>                  );                })}              </div>            </>          )}        </CardContent>      </Card>    );  },  BarChart: ({ props }) => {    const { isNew } = useSeenIndices();    const data = props.data ?? [];    const safeData = Array.isArray(data) ? data : [];    return (      <Card        className="w-full flex-1 min-w-0 overflow-hidden"        data-testid="declarative-bar-chart"      >        {/* Scoped keyframe — no globals.css needed */}        <style>{`          @keyframes barSlideIn {            from { transform: translateY(40px); opacity: 0; }            20% { opacity: 1; }            to { transform: translateY(0); opacity: 1; }          }        `}</style>        <CardHeader>          <CardTitle>{props.title}</CardTitle>          <CardDescription>{props.description}</CardDescription>        </CardHeader>        <CardContent>          {safeData.length === 0 ? (            <div className="py-8 text-center text-sm text-[var(--muted-foreground)]">              No data available            </div>          ) : (            <ResponsiveContainer width="100%" height={260}>              <RechartsBarChart                data={safeData}                margin={{ top: 12, right: 12, bottom: 4, left: -8 }}              >                <CartesianGrid                  strokeDasharray="3 3"                  stroke="var(--border)"                  vertical={false}                />                <XAxis                  dataKey="label"                  tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}                  stroke="var(--border)"                  tickLine={false}                  axisLine={false}                />                <YAxis                  tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}                  stroke="var(--border)"                  tickLine={false}                  axisLine={false}                />                <Tooltip                  contentStyle={CHART_TOOLTIP_STYLE}                  cursor={{ fill: "var(--muted)", opacity: 0.5 }}                />                <Bar                  isAnimationActive={false}                  dataKey="value"                  radius={[6, 6, 0, 0]}                  maxBarSize={48}                  // eslint-disable-next-line @typescript-eslint/no-explicit-any                  shape={                    ((barProps: any) => (                      <AnimatedBar                        {...barProps}                        isNew={isNew(barProps.index as number)}                      />                      // eslint-disable-next-line @typescript-eslint/no-explicit-any                    )) as any                  }                >                  {safeData.map((_, index) => (                    <Cell                      key={index}                      fill={CHART_COLORS[index % CHART_COLORS.length]}                    />                  ))}                </Bar>              </RechartsBarChart>            </ResponsiveContainer>          )}        </CardContent>      </Card>    );  },};

Wire definitions × renderers into a catalog#

createCatalog is what you hand to the provider. Flip includeBasicCatalog: true to merge CopilotKit's built-ins (Column, Row, Text, Image, Card, Button, List, Tabs, …), so the LLM can compose custom + basic components interchangeably:

catalog.ts
import { createCatalog } from "@copilotkit/a2ui-renderer";import { myDefinitions } from "./definitions";import { myRenderers } from "./renderers";export const myCatalog = createCatalog(myDefinitions, myRenderers, {  catalogId: "declarative-gen-ui-catalog",  includeBasicCatalog: true,});

Pass the catalog to the provider#

A single prop (a2ui={{ catalog }}) is all the frontend needs; the provider registers the catalog and wires up the built-in A2UI activity-message renderer:

page.tsx
import React from "react";import { CopilotKit } from "@copilotkit/react-core/v2";import { myCatalog } from "./a2ui/catalog";import { Chat } from "./chat";export default function DeclarativeGenUIDemo() {  return (    <CopilotKit      runtimeUrl="/api/copilotkit-declarative-gen-ui"      agent="declarative-gen-ui"      a2ui={{ catalog: myCatalog }}    >      <div className="flex justify-center items-center h-screen w-full">        <div className="h-full w-full max-w-4xl">          <Chat />        </div>      </div>    </CopilotKit>

That is all the default path needs. The catalog auto-enables A2UI and injects the generate_a2ui tool, so the runtime needs no a2ui block. (No catalog? Turn it on from the runtime instead with a2ui: { injectA2UITool: true }.)

I opted out of auto-inject, now what?#

By not passing a catalog, not setting injectA2UITool, or by passing a catalog and setting injectA2UITool: false, you have opted out entirely. That means you hook up two pieces yourself: the generate_a2ui tool which lets your agent generate A2UI surfaces, and the A2UIMiddleware which lets those surfaces render.

The A2UIMiddleware#

The A2UIMiddleware is what turns the agent's a2ui_operations into rendered surfaces. Without it, the agent's output never becomes UI; it falls through as a plain tool result. It can also inject the generate_a2ui tool for you (injectA2UITool: true), letting you skip the next step. Attach it to the AG-UI agent:

import { A2UIMiddleware } from "@ag-ui/a2ui-middleware";

agent.use(new A2UIMiddleware({ injectA2UITool: false }));

The A2UI agent tool#

The generate_a2ui tool runs a secondary LLM (a subagent) that designs the surface, which is why you hand it a model. Build it with the AG-UI factory and add it to your agent's tools:

agent.py
from ag_ui_langgraph import get_a2ui_tools
from langchain_openai import ChatOpenAI

generate_a2ui = get_a2ui_tools({
    "model": ChatOpenAI(model="gpt-4o"),
    "default_catalog_id": "copilotkit://app-dashboard-catalog",
})

tools = [my_other_tool, generate_a2ui]

Progressive streaming#

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

  1. Waits for the full components array before emitting anything; the schema must be complete before rendering starts.
  2. Extracts surfaceId + root from the partial JSON.
  3. Emits createSurface + updateComponents once the schema is complete.
  4. Extracts complete items objects progressively and emits an updateDataModel for each, so cards appear one by one as data streams in.

A built-in progress indicator shows while the schema is still generating and hides automatically once data items start arriving.

When should I use dynamic schemas?#

  • You don't know the UI shape ahead of time; the agent decides what to show based on the user's request.
  • You want to prototype A2UI without committing to a schema file yet.
  • You're building a conversational dashboard where the layout varies per turn.

If the surface is well-known (e.g. a product card, a flight result), prefer a fixed schema; it's faster, cheaper, and the UI is deterministic.