chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -0,0 +1,48 @@
import {
CopilotRuntime,
createCopilotRuntimeHandler,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import { createA2UIFixedSchemaAgent } from "@/lib/factory/a2ui-fixed-schema-factory";
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
// LLM calls. See @/lib/header-forwarding for the full rationale.
import { withForwardedHeaders } from "@/lib/header-forwarding";
// Dedicated runtime for the A2UI — Fixed Schema demo.
//
// `a2ui.injectA2UITool: false` — the backend factory owns the
// `display_flight` tool which emits its own `a2ui_operations` container
// (see src/lib/factory/a2ui-fixed-schema-factory.ts). The A2UI middleware
// still runs so it detects the container in tool results and forwards the
// rendered surface to the frontend renderer; we just don't want it to also
// inject a runtime `render_a2ui` tool on top of our own.
const runtime = new CopilotRuntime({
agents: { default: createA2UIFixedSchemaAgent() },
runner: new InMemoryAgentRunner(),
a2ui: {
injectA2UITool: false,
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-a2ui-fixed-schema",
mode: "single-route",
});
async function withProbeCompat(req: Request): Promise<Response> {
const res = await handler(req);
if (res.status === 404) {
const body = await res.text();
return new Response(body, { status: 400, headers: res.headers });
}
return res;
}
export const GET = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
export const POST = (req: Request) =>
withForwardedHeaders(req, () => withProbeCompat(req));
export const OPTIONS = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
@@ -0,0 +1,113 @@
// Dedicated runtime for the Agent Config Object demo.
//
// The factory reads `input.forwardedProps` (which the CopilotKit provider
// populates from its `properties` prop) and prepends a tone/expertise/
// length-tuned system prompt per turn.
import {
BuiltInAgent,
CopilotRuntime,
convertInputToTanStackAI,
createCopilotRuntimeHandler,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
// `withForwardedHeaders` snapshots inbound x-* headers (e.g.
// x-aimock-context) into an AsyncLocalStorage scope; `forwardingFetch`
// re-attaches them on every outbound LLM call. Required because
// `@tanstack/ai-openai`'s `openaiText()` adapter has no per-request
// header hook. See @/lib/header-forwarding for the full rationale.
import { forwardingFetch, withForwardedHeaders } from "@/lib/header-forwarding";
const TONE_GUIDANCE: Record<string, string> = {
professional:
"Use a measured, professional tone. Avoid slang and exclamation marks.",
casual:
"Use a friendly, conversational tone — like talking to a coworker over coffee.",
enthusiastic:
"Use an upbeat, energetic tone. Show genuine excitement about the topic.",
};
const EXPERTISE_GUIDANCE: Record<string, string> = {
beginner:
"Assume the user is new to this topic. Avoid jargon; define terms inline.",
intermediate:
"Assume the user has working familiarity. You can use common technical terms without defining each one.",
expert:
"Assume the user is an expert. You can use precise jargon and skip introductory framing.",
};
const RESPONSE_LENGTH_GUIDANCE: Record<string, string> = {
concise:
"Keep responses tight — 1-3 short sentences, or a 3-bullet list at most.",
detailed:
"Provide a thorough answer. Use headings, paragraphs, or longer lists when warranted.",
};
function buildConfigSystemPrompt(props: Record<string, unknown>): string {
const tone = typeof props.tone === "string" ? props.tone : "professional";
const expertise =
typeof props.expertise === "string" ? props.expertise : "intermediate";
const responseLength =
typeof props.responseLength === "string" ? props.responseLength : "concise";
const toneLine = TONE_GUIDANCE[tone] ?? TONE_GUIDANCE.professional;
const expertiseLine =
EXPERTISE_GUIDANCE[expertise] ?? EXPERTISE_GUIDANCE.intermediate;
const lengthLine =
RESPONSE_LENGTH_GUIDANCE[responseLength] ??
RESPONSE_LENGTH_GUIDANCE.concise;
return [
"You adapt your responses based on the active agent config:",
`- tone=${tone}: ${toneLine}`,
`- expertise=${expertise}: ${expertiseLine}`,
`- responseLength=${responseLength}: ${lengthLine}`,
"Mention the active config (tone / expertise / responseLength) at the start of each reply so the user can see it took effect.",
].join("\n");
}
function createAgentConfigAgent() {
return new BuiltInAgent({
type: "tanstack",
factory: ({ input, abortController }) => {
const props = (input.forwardedProps ?? {}) as Record<string, unknown>;
const { messages, systemPrompts } = convertInputToTanStackAI(input);
return chat({
adapter: openaiText("gpt-4o", { fetch: forwardingFetch }),
messages,
systemPrompts: [buildConfigSystemPrompt(props), ...systemPrompts],
tools: [],
abortController,
});
},
});
}
const runtime = new CopilotRuntime({
agents: { default: createAgentConfigAgent() },
runner: new InMemoryAgentRunner(),
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-agent-config",
mode: "single-route",
});
async function withProbeCompat(req: Request): Promise<Response> {
const res = await handler(req);
if (res.status === 404) {
const body = await res.text();
return new Response(body, { status: 400, headers: res.headers });
}
return res;
}
export const GET = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
export const POST = (req: Request) =>
withForwardedHeaders(req, () => withProbeCompat(req));
export const OPTIONS = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
@@ -0,0 +1,61 @@
// Dedicated runtime for the /demos/auth cell.
//
// Demonstrates framework-native request authentication via the V2 runtime's
// `onRequest` hook, which runs before routing and short-circuits with a 401
// Response when the Authorization header is missing or invalid.
import type { NextRequest } from "next/server";
import {
CopilotRuntime,
createCopilotRuntimeHandler,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import { createBuiltInAgent } from "@/lib/factory/tanstack-factory";
import { DEMO_AUTH_HEADER } from "@/app/demos/auth/demo-token";
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
// LLM calls. See @/lib/header-forwarding for the full rationale.
import { withForwardedHeaders } from "@/lib/header-forwarding";
const runtime = new CopilotRuntime({
agents: { default: createBuiltInAgent() },
runner: new InMemoryAgentRunner(),
});
const BASE_PATH = "/api/copilotkit-auth";
const handler = createCopilotRuntimeHandler({
runtime,
basePath: BASE_PATH,
hooks: {
onRequest: ({ request }) => {
const authHeader = request.headers.get("authorization");
if (authHeader !== DEMO_AUTH_HEADER) {
// Throwing a Response short-circuits the pipeline — the runtime maps
// the thrown Response to the HTTP response verbatim.
throw new Response(
JSON.stringify({
error: "unauthorized",
message:
"Missing or invalid Authorization header. Click Authenticate above to send messages.",
}),
{
status: 401,
headers: { "content-type": "application/json" },
},
);
}
},
},
});
export const POST = (req: NextRequest) =>
withForwardedHeaders(req, () => handler(req));
export const GET = (req: NextRequest) =>
withForwardedHeaders(req, () => handler(req));
export const PUT = (req: NextRequest) =>
withForwardedHeaders(req, () => handler(req));
export const DELETE = (req: NextRequest) =>
withForwardedHeaders(req, () => handler(req));
export const OPTIONS = (req: NextRequest) =>
withForwardedHeaders(req, () => handler(req));
@@ -0,0 +1,43 @@
// Dedicated runtime for the BYOC hashbrown demo.
//
// Built-in-agent factory with a sales-dashboard system prompt and OpenAI
// `response_format: { type: "json_object" }` so the model can only emit a
// single JSON object — exactly what the hashbrown `useJsonParser` consumes.
import {
CopilotRuntime,
createCopilotRuntimeHandler,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import { createByocHashbrownAgent } from "@/lib/factory/byoc-hashbrown-factory";
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
// LLM calls. See @/lib/header-forwarding for the full rationale.
import { withForwardedHeaders } from "@/lib/header-forwarding";
const runtime = new CopilotRuntime({
agents: { default: createByocHashbrownAgent() },
runner: new InMemoryAgentRunner(),
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-byoc-hashbrown",
mode: "single-route",
});
async function withProbeCompat(req: Request): Promise<Response> {
const res = await handler(req);
if (res.status === 404) {
const body = await res.text();
return new Response(body, { status: 400, headers: res.headers });
}
return res;
}
export const GET = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
export const POST = (req: Request) =>
withForwardedHeaders(req, () => withProbeCompat(req));
export const OPTIONS = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
@@ -0,0 +1,44 @@
// Dedicated runtime for the BYOC json-render demo.
//
// Built-in-agent factory with a sales-dashboard system prompt and OpenAI
// `response_format: { type: "json_object" }` so the model emits exactly
// one JSON object — what `<Renderer />` consumes as a flat
// `{ root, elements }` spec.
import {
CopilotRuntime,
createCopilotRuntimeHandler,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import { createByocJsonRenderAgent } from "@/lib/factory/byoc-json-render-factory";
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
// LLM calls. See @/lib/header-forwarding for the full rationale.
import { withForwardedHeaders } from "@/lib/header-forwarding";
const runtime = new CopilotRuntime({
agents: { default: createByocJsonRenderAgent() },
runner: new InMemoryAgentRunner(),
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-byoc-json-render",
mode: "single-route",
});
async function withProbeCompat(req: Request): Promise<Response> {
const res = await handler(req);
if (res.status === 404) {
const body = await res.text();
return new Response(body, { status: 400, headers: res.headers });
}
return res;
}
export const GET = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
export const POST = (req: Request) =>
withForwardedHeaders(req, () => withProbeCompat(req));
export const OPTIONS = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
@@ -0,0 +1,55 @@
import {
CopilotRuntime,
createCopilotRuntimeHandler,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import { createDeclarativeGenUIAgent } from "@/lib/factory/a2ui-factory";
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
// LLM calls. See @/lib/header-forwarding for the full rationale.
import { withForwardedHeaders } from "@/lib/header-forwarding";
// Dedicated runtime for the Declarative Generative UI (A2UI — Dynamic
// Schema) demo.
//
// `a2ui.injectA2UITool: false` — the backend factory owns the
// `generate_a2ui` tool itself (see `src/lib/factory/a2ui-factory.ts`), so
// the runtime MUST NOT auto-inject its own A2UI tool on top. The A2UI
// middleware still runs — it serialises the registered client catalog
// schema into the agent's `input.context` so the secondary LLM inside
// `generate_a2ui` knows which components to emit, and it still detects the
// `a2ui_operations` container in the tool result and streams rendered
// surfaces to the frontend.
const runtime = new CopilotRuntime({
agents: { default: createDeclarativeGenUIAgent() },
runner: new InMemoryAgentRunner(),
a2ui: {
injectA2UITool: false,
// Models follow the tool-usage guide and omit `catalogId`, and the
// middleware then falls back to the unregistered spec basic catalog
// ("Catalog not found" render error). Pin the catalog the page registers.
defaultCatalogId: "declarative-gen-ui-catalog",
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-declarative-gen-ui",
mode: "single-route",
});
async function withProbeCompat(req: Request): Promise<Response> {
const res = await handler(req);
if (res.status === 404) {
const body = await res.text();
return new Response(body, { status: 400, headers: res.headers });
}
return res;
}
export const GET = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
export const POST = (req: Request) =>
withForwardedHeaders(req, () => withProbeCompat(req));
export const OPTIONS = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
@@ -0,0 +1,56 @@
import {
CopilotRuntime,
createCopilotRuntimeHandler,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import { createMcpAppsAgent } from "@/lib/factory/mcp-apps-factory";
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
// LLM calls. See @/lib/header-forwarding for the full rationale.
import { withForwardedHeaders } from "@/lib/header-forwarding";
// @region[runtime-mcpapps-config]
// Dedicated runtime for the MCP Apps demo.
//
// `mcpApps.servers` auto-applies the MCP Apps middleware to every registered
// agent: the middleware exposes the remote MCP server's tools to the agent at
// request time and emits the activity events that CopilotKit's built-in
// `MCPAppsActivityRenderer` renders inline as a sandboxed iframe.
const runtime = new CopilotRuntime({
agents: { default: createMcpAppsAgent() },
runner: new InMemoryAgentRunner(),
mcpApps: {
servers: [
{
type: "http",
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
// Always pin a stable serverId — without it CopilotKit hashes the URL
// and a URL change silently breaks restoration of persisted MCP apps.
serverId: "excalidraw",
},
],
},
});
// @endregion[runtime-mcpapps-config]
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-mcp-apps",
mode: "single-route",
});
async function withProbeCompat(req: Request): Promise<Response> {
const res = await handler(req);
if (res.status === 404) {
const body = await res.text();
return new Response(body, { status: 400, headers: res.headers });
}
return res;
}
export const GET = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
export const POST = (req: Request) =>
withForwardedHeaders(req, () => withProbeCompat(req));
export const OPTIONS = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
@@ -0,0 +1,43 @@
// Dedicated runtime for the Multimodal Attachments demo.
//
// Reuses the base built-in-agent factory (which already uses gpt-4o, a
// vision-capable model). AG-UI image / document parts flow through
// `convertInputToTanStackAI` natively; no agent-side rewrite is required.
import {
CopilotRuntime,
createCopilotRuntimeHandler,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import { createBuiltInAgent } from "@/lib/factory/tanstack-factory";
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
// LLM calls. See @/lib/header-forwarding for the full rationale.
import { withForwardedHeaders } from "@/lib/header-forwarding";
const runtime = new CopilotRuntime({
agents: { default: createBuiltInAgent() },
runner: new InMemoryAgentRunner(),
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-multimodal",
mode: "single-route",
});
async function withProbeCompat(req: Request): Promise<Response> {
const res = await handler(req);
if (res.status === 404) {
const body = await res.text();
return new Response(body, { status: 400, headers: res.headers });
}
return res;
}
export const GET = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
export const POST = (req: Request) =>
withForwardedHeaders(req, () => withProbeCompat(req));
export const OPTIONS = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
@@ -0,0 +1,57 @@
import {
CopilotRuntime,
createCopilotRuntimeHandler,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import { createOguiAgent } from "@/lib/factory/ogui-factory";
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
// LLM calls. See @/lib/header-forwarding for the full rationale.
import { withForwardedHeaders } from "@/lib/header-forwarding";
// Dedicated runtime for the Open Generative UI demo.
//
// Isolated because the `openGenerativeUI` runtime flag advertises
// `openGenerativeUIEnabled: true` on the probe, which causes the
// CopilotKit provider's setTools effect to behave differently from the
// default tools-only runtime. Keeping it on its own basePath avoids
// cross-talk with other demos.
//
// Server-side config is identical for the minimal and advanced cells —
// the advanced behaviour (sandbox -> host function calls) is wired
// entirely on the frontend via `openGenerativeUI.sandboxFunctions` on
// the provider. The single `openGenerativeUI` flag below turns on
// Open Generative UI for the listed agent(s).
// @region[minimal-runtime-flag]
// @region[advanced-runtime-config]
const runtime = new CopilotRuntime({
agents: { default: createOguiAgent() },
runner: new InMemoryAgentRunner(),
openGenerativeUI: {
agents: ["default"],
},
});
// @endregion[advanced-runtime-config]
// @endregion[minimal-runtime-flag]
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-ogui",
mode: "single-route",
});
async function withProbeCompat(req: Request): Promise<Response> {
const res = await handler(req);
if (res.status === 404) {
const body = await res.text();
return new Response(body, { status: 400, headers: res.headers });
}
return res;
}
export const GET = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
export const POST = (req: Request) =>
withForwardedHeaders(req, () => withProbeCompat(req));
export const OPTIONS = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
@@ -0,0 +1,50 @@
import {
CopilotRuntime,
createCopilotRuntimeHandler,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import {
createAgenticChatReasoningAgent,
createReasoningDefaultRenderAgent,
createToolRenderingReasoningChainAgent,
} from "@/lib/factory/reasoning-factory";
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
// LLM calls. See @/lib/header-forwarding for the full rationale.
import { withForwardedHeaders } from "@/lib/header-forwarding";
// Shared runtime for the three reasoning demos. The default tanstack
// factory uses a non-reasoning model (gpt-4o) — these demos need a
// reasoning-capable variant so REASONING_* events flow. They live on
// their own basePath so a single page only spins up the reasoning model
// when actually viewing a reasoning demo.
const runtime = new CopilotRuntime({
agents: {
"agentic-chat-reasoning": createAgenticChatReasoningAgent(),
"reasoning-default-render": createReasoningDefaultRenderAgent(),
"tool-rendering-reasoning-chain": createToolRenderingReasoningChainAgent(),
},
runner: new InMemoryAgentRunner(),
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-reasoning",
mode: "single-route",
});
async function withProbeCompat(req: Request): Promise<Response> {
const res = await handler(req);
if (res.status === 404) {
const body = await res.text();
return new Response(body, { status: 400, headers: res.headers });
}
return res;
}
export const GET = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
export const POST = (req: Request) =>
withForwardedHeaders(req, () => withProbeCompat(req));
export const OPTIONS = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
@@ -0,0 +1,81 @@
// Dedicated runtime for the voice demo.
//
// 1. Advertises `audioFileTranscriptionEnabled: true` on `/info` so the chat
// composer renders the mic button.
// 2. Handles `POST /transcribe` by invoking an OpenAI-backed
// `TranscriptionServiceOpenAI` (from `@copilotkit/voice`).
// 3. Returns a deterministic 401 when `OPENAI_API_KEY` is not configured —
// `handleTranscribe` maps "api key" / "unauthorized" messages to
// AUTH_FAILED → HTTP 401.
//
// Lives at `[[...slug]]/route.ts` because the V2 router URL-routes on
// `/info`, `/transcribe`, etc., under the same base path.
// @region[voice-runtime]
// @region[transcription-service-guard]
import type { NextRequest } from "next/server";
import {
CopilotRuntime,
TranscriptionService,
createCopilotRuntimeHandler,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import type { TranscribeFileOptions } from "@copilotkit/runtime/v2";
import { TranscriptionServiceOpenAI } from "@copilotkit/voice";
import OpenAI from "openai";
import { createBuiltInAgent } from "@/lib/factory/tanstack-factory";
// Wrap handlers so inbound x-* headers (e.g. x-aimock-context) are bound
// into ALS for the factory's `forwardingFetch` to re-attach on outbound
// LLM calls. See @/lib/header-forwarding for the full rationale.
import { withForwardedHeaders } from "@/lib/header-forwarding";
class GuardedOpenAITranscriptionService extends TranscriptionService {
private delegate: TranscriptionServiceOpenAI | null;
constructor() {
super();
const apiKey = process.env.OPENAI_API_KEY;
this.delegate = apiKey
? new TranscriptionServiceOpenAI({ openai: new OpenAI({ apiKey }) })
: null;
}
async transcribeFile(options: TranscribeFileOptions): Promise<string> {
if (!this.delegate) {
// "api key" substring → handleTranscribe maps to AUTH_FAILED → 401.
throw new Error(
"OPENAI_API_KEY not configured for this deployment (api key missing). " +
"Set OPENAI_API_KEY to enable voice transcription.",
);
}
return this.delegate.transcribeFile(options);
}
}
// @endregion[transcription-service-guard]
let cachedHandler: ((req: Request) => Promise<Response>) | null = null;
function getHandler(): (req: Request) => Promise<Response> {
if (cachedHandler) return cachedHandler;
const runtime = new CopilotRuntime({
agents: { default: createBuiltInAgent() },
runner: new InMemoryAgentRunner(),
transcriptionService: new GuardedOpenAITranscriptionService(),
});
cachedHandler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-voice",
});
return cachedHandler;
}
export const POST = (req: NextRequest) =>
withForwardedHeaders(req, () => getHandler()(req));
export const GET = (req: NextRequest) =>
withForwardedHeaders(req, () => getHandler()(req));
export const PUT = (req: NextRequest) =>
withForwardedHeaders(req, () => getHandler()(req));
export const DELETE = (req: NextRequest) =>
withForwardedHeaders(req, () => getHandler()(req));
// @endregion[voice-runtime]
@@ -0,0 +1,51 @@
import {
CopilotRuntime,
createCopilotRuntimeHandler,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import { createBuiltInAgent } from "@/lib/factory/tanstack-factory";
// `withForwardedHeaders` snapshots inbound x-* headers (e.g.
// x-aimock-context) into an AsyncLocalStorage scope so the wrapped
// OpenAI client's custom fetch can re-attach them on every outbound
// LLM call. Required because `@tanstack/ai-openai`'s `openaiText()`
// adapter has no per-request header hook of its own.
import { withForwardedHeaders } from "@/lib/header-forwarding";
// CVDIAG backend instrumentation (L1-E). No-op pass-through unless
// CVDIAG_BACKEND_EMITTER is set truthy (default OFF).
import { withCvdiagBackend } from "@/cvdiag-backend";
const runtime = new CopilotRuntime({
agents: { default: createBuiltInAgent() },
runner: new InMemoryAgentRunner(),
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
mode: "single-route",
});
async function withProbeCompat(req: Request): Promise<Response> {
const res = await handler(req);
if (res.status === 404) {
const body = await res.text();
return new Response(body, { status: 400, headers: res.headers });
}
return res;
}
const copilotkitPost = async (req: Request): Promise<Response> =>
withForwardedHeaders(req, () => withProbeCompat(req));
export const GET = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
// Wrap POST with CVDIAG backend instrumentation (L1-E). built-in-agent runs
// its BuiltInAgent in-process inside this route handler. No-op pass-through
// unless CVDIAG_BACKEND_EMITTER is set truthy (default OFF).
export const POST = withCvdiagBackend(copilotkitPost, {
slug: "built-in-agent",
agentName: "default",
provider: "openai",
});
export const OPTIONS = (req: Request) =>
withForwardedHeaders(req, () => handler(req));
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
status: "ok",
integration: "built-in-agent",
timestamp: new Date().toISOString(),
});
}
@@ -0,0 +1,119 @@
import { NextResponse } from "next/server";
const INTEGRATION_SLUG = "built-in-agent";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export async function GET() {
const start = Date.now();
const baseUrl =
process.env.NEXT_PUBLIC_BASE_URL ||
`http://localhost:${process.env.PORT || 3000}`;
try {
const res = await fetch(`${baseUrl}/api/copilotkit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
method: "agent/run",
params: { agentId: "default" },
body: {
threadId: `smoke-${Date.now()}`,
runId: `smoke-run-${Date.now()}`,
state: {},
messages: [
{
id: `smoke-msg-${Date.now()}`,
role: "user",
content: "Respond with exactly: OK",
},
],
tools: [],
context: [],
forwardedProps: {},
},
}),
signal: AbortSignal.timeout(45000),
});
const latency = Date.now() - start;
if (!res.ok) {
const errBody = await res.text().catch(() => "");
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "runtime_response",
error: `Runtime returned ${res.status}: ${errBody.slice(0, 200)}`,
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
const reader = res.body?.getReader();
if (!reader) {
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "response_empty",
error: "Runtime returned no readable body",
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
const { value, done } = await reader.read();
reader.cancel();
if (done || !value || value.length === 0) {
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "response_empty",
error: "Runtime returned empty response body",
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
return NextResponse.json({
status: "ok",
integration: INTEGRATION_SLUG,
latency_ms: latency,
timestamp: new Date().toISOString(),
});
} catch (e: unknown) {
const err = e instanceof Error ? e : new Error(String(e));
const latency = Date.now() - start;
let stage = "unknown";
if (err.name === "AbortError" || err.message.includes("timeout"))
stage = "timeout";
else if (
err.message.includes("fetch") ||
err.message.includes("ECONNREFUSED")
)
stage = "agent_unreachable";
else stage = "pipeline_error";
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage,
error: err.message,
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
}
@@ -0,0 +1,9 @@
/* CopilotKit overrides — matches Dojo styling */
.copilotKitInput {
border-bottom-left-radius: 0.75rem;
border-bottom-right-radius: 0.75rem;
border-top-left-radius: 0.75rem;
border-top-right-radius: 0.75rem;
border: 1px solid var(--copilot-kit-separator-color) !important;
}
@@ -0,0 +1,17 @@
import type { ReactNode } from "react";
export function NotSupportedBanner({ children }: { children: ReactNode }) {
return (
<div
data-testid="not-supported-banner"
className="min-h-screen flex items-center justify-center p-8"
>
<div className="max-w-xl text-center border rounded p-6">
<h2 className="text-xl font-semibold mb-3">
Not supported on built-in-agent
</h2>
<p className="text-sm opacity-80">{children}</p>
</div>
</div>
);
}
@@ -0,0 +1,40 @@
# A2UI — Fixed Schema (Built-in Agent)
Declarative Generative UI with a fixed component tree. The frontend authors
the schema (a JSON tree of A2UI components); the in-process tanstack agent
only streams _data_ into the data model via a single `display_flight` tool.
## Pattern
- **Frontend** registers a custom catalog (`Title`, `Airport`, `Arrow`,
`AirlineBadge`, `PriceTag`, plus a stateful `Button` override) merged with
the basic A2UI catalog (`Card`, `Column`, `Row`, `Text`).
See `./a2ui/{catalog,definitions,renderers}.{ts,tsx}`.
- **Backend** (`src/lib/factory/a2ui-fixed-schema-factory.ts`) defines a
`display_flight` TanStack tool that returns an `a2ui_operations` container
with three ops: `createSurface`, `updateComponents` (the inlined schema),
`updateDataModel` (the user's flight values).
- **Runtime** (`src/app/api/copilotkit-a2ui-fixed-schema/route.ts`) enables
the A2UI middleware with `injectA2UITool: false` — the agent owns its own
emitter, so the runtime's auto-injected `render_a2ui` tool would only
duplicate the slot.
## How it differs from `declarative-gen-ui` (dynamic schema)
Dynamic schema lets the LLM emit _any_ component tree it likes from the
registered catalog. Fixed schema pins the tree ahead of time and lets the
LLM only fill in data — strictly typed, predictable, no second LLM call.
## Try it
```text
Find me a flight from SFO to JFK on United for $289.
```
The agent calls `display_flight(...)`, which streams the flight card with
"Book flight" stateful button.
## Reference
- Docs: https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui/fixed-schema
- Source-of-truth: `showcase/integrations/langgraph-python/src/agents/a2ui_fixed.py`
@@ -0,0 +1,71 @@
// Docs-only snippet — not imported or run. The shell-docs page at
// `/generative-ui/a2ui/fixed-schema` references the regions
// `backend-schema-json-load` and `backend-render-operations` to teach
// the *schema-inline* pattern for built-in-agent (the schema is declared
// as a typed literal in source rather than loaded from JSON at startup).
// This file exposes those regions as canonical teaching code so the docs
// render real samples instead of a missing-snippet box.
//
// Mirrors the convention from `tool-rendering/render-flight-tool.snippet.tsx`.
// @region[backend-render-operations]
// @region[backend-schema-json-load]
declare const a2ui: {
createSurface: (id: string, opts: { catalogId: string }) => unknown;
updateComponents: (id: string, schema: unknown) => unknown;
updateDataModel: (id: string, data: Record<string, unknown>) => unknown;
render: (args: { operations: unknown[] }) => unknown;
};
const SURFACE_ID = "flight-fixed-schema";
const CATALOG_ID = "flight-catalog";
// In the schema-inline pattern, the schema is declared as a typed literal
// in source rather than loaded from JSON at startup. Same shape as the
// schema-loading variant; just no file I/O.
const FLIGHT_SCHEMA = [
{
type: "Card",
children: [
{ type: "Title", text: "Flight" },
{
type: "Row",
children: [
{ type: "Label", bind: "origin" },
{ type: "Label", bind: "destination" },
],
},
{
type: "Row",
children: [
{ type: "Label", bind: "airline" },
{ type: "Label", bind: "price" },
],
},
],
},
];
// @endregion[backend-schema-json-load]
export function emitRenderOperations(args: {
origin: string;
destination: string;
airline: string;
price: number;
}) {
// The a2ui middleware detects the `a2ui_operations` container in this
// tool result and forwards the ops to the frontend renderer. The
// frontend catalog resolves component names to local React components.
return a2ui.render({
operations: [
a2ui.createSurface(SURFACE_ID, { catalogId: CATALOG_ID }),
a2ui.updateComponents(SURFACE_ID, FLIGHT_SCHEMA),
a2ui.updateDataModel(SURFACE_ID, {
origin: args.origin,
destination: args.destination,
airline: args.airline,
price: args.price,
}),
],
});
// @endregion[backend-render-operations]
}
@@ -0,0 +1,23 @@
"use client";
/**
* Fixed A2UI catalog — wires definitions to renderers.
*
* `includeBasicCatalog: true` merges CopilotKit's built-in components
* (Card, Column, Row, Text, Button, Divider, …) into this catalog, so
* the agent's fixed schema (src/agents/a2ui_schemas/flight_schema.json) can
* compose custom and basic components interchangeably.
*/
// @region[catalog-creation]
import { createCatalog } from "@copilotkit/a2ui-renderer";
import { flightDefinitions } from "./definitions";
import { flightRenderers } from "./renderers";
export const CATALOG_ID = "copilotkit://flight-fixed-catalog";
export const fixedCatalog = createCatalog(flightDefinitions, flightRenderers, {
catalogId: CATALOG_ID,
includeBasicCatalog: true,
});
// @endregion[catalog-creation]
@@ -0,0 +1,119 @@
/**
* A2UI catalog DEFINITIONS — platform-agnostic.
*
* Each entry declares a component name + its Zod props schema. The basic
* catalog (Card, Column, Row, Text, Button, …) ships with CopilotKit and
* is mixed in via `createCatalog(..., { includeBasicCatalog: true })`, so
* we only declare the project-specific additions here.
*
* IMPORTANT — path bindings: fields that can be bound to a data-model path
* (e.g. `{ path: "/origin" }` in the fixed schema JSON) must declare their
* Zod type as a union of `z.string()` and `z.object({ path: z.string() })`.
* The A2UI `GenericBinder` uses this union to detect the field as dynamic
* and resolve the path against the current data model at render time. Using
* plain `z.string()` causes the raw `{ path }` object to reach the
* renderer, which React then throws on (error #31 "object with keys {path}").
* This matches the canonical catalog's `DynString` helper:
* examples/integrations/langgraph-python/src/app/declarative-generative-ui/definitions.ts
*
* ZOD VERSION (load-bearing): these defs are authored with **Zod 3** (the
* `zod-v3` alias), NOT the package's root `zod@4`. `@a2ui/web_core@0.9.0`'s
* `GenericBinder.scrapeSchemaBehavior` classifies a field as DYNAMIC (and thus
* resolves its `{ path }` binding) by inspecting **Zod 3** schema internals
* (`_def.typeName === "ZodUnion"`, the union's `_def.options`, an option's
* `_def.shape().path`). A Zod-4 schema reports `_def.typeName === undefined`
* (Zod 4 moved to `_def.type === "union"`), so the binder MISCLASSIFIES the
* field STATIC and passes the raw `{ path: "/origin" }` object through to the
* renderer → React error #31 ("object with keys {path}") crashes the page.
* web_core bundles its own `zod@3.25.x`; authoring these defs with a matching
* Zod 3 makes the union recognizable so the binder resolves `{ path }` → "SFO".
* (`scrapeSchemaBehavior` compares `_def.typeName` by string, not `instanceof`,
* so a separate Zod-3 module instance is recognized identically.)
*/
// @region[definitions-types]
import { z } from "zod-v3";
import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";
/**
* Dynamic string: literal OR a data-model path binding. The GenericBinder
* resolves path bindings to the actual value at render time.
*/
const DynString = z.union([z.string(), z.object({ path: z.string() })]);
export const flightDefinitions = {
/**
* Card override: gives the outer flight-card container a stable
* `data-testid` for D6 e2e selectors. The basic catalog's Card ships
* its own renderer; declaring `Card` here lets us swap in a thin React
* component without otherwise altering layout.
*/
Card: {
description: "A container card with a single child.",
props: z.object({
child: z.string(),
}),
},
Title: {
description: "A prominent heading for the flight card.",
props: z.object({
text: DynString,
}),
},
Airport: {
description: "A 3-letter airport code, displayed large.",
props: z.object({
code: DynString,
}),
},
Arrow: {
description: "A right-pointing arrow used between airports.",
props: z.object({}),
},
AirlineBadge: {
description: "A pill-styled airline name tag.",
props: z.object({
name: DynString,
}),
},
PriceTag: {
description: "A stylized price display (e.g. '$289').",
props: z.object({
amount: DynString,
}),
},
/**
* Button override: swaps in an ActionButton renderer that tracks
* its own `done` state so clicking "Book flight" visually updates to
* a "Booked ✓" confirmation. The basic catalog's Button is stateless,
* so without this override the click fires the action but the button
* looks unchanged. Mirrors the pattern in beautiful-chat
* (src/app/demos/beautiful-chat/declarative-generative-ui/renderers.tsx).
*/
Button: {
description:
"An interactive button with an action event. Use 'child' with a Text component ID for the label. After click, the button shows a confirmation state.",
props: z.object({
child: z
.string()
.describe(
"The ID of the child component (e.g. a Text component for the label).",
),
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
// Union with { event } so GenericBinder resolves this as ACTION → callable () => void.
action: z
.union([
z.object({
event: z.object({
name: z.string(),
context: z.record(z.string(), z.any()).optional(),
}),
}),
z.null(),
])
.optional(),
}),
},
} satisfies CatalogDefinitions;
// @endregion[definitions-types]
export type FlightDefinitions = typeof flightDefinitions;
@@ -0,0 +1,195 @@
"use client";
/**
* A2UI catalog RENDERERS — React implementations for the custom components
* declared in `./definitions`. TypeScript enforces that the renderer map's
* keys and prop shapes match the definitions exactly.
*
* NOTE: Props in `definitions.ts` use `DynString` (a `string | { path }`
* union) so the A2UI `GenericBinder` treats them as dynamic and resolves
* path bindings before render. The binder always hands the renderer a
* resolved string, but TypeScript sees the raw union — so we cast to
* `Record<string, any>` at the renderer boundary. This matches the
* canonical beautiful-chat pattern (see FlightCard in
* `examples/integrations/langgraph-python/src/app/declarative-generative-ui/renderers.tsx`).
*/
import React, { useState } from "react";
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
import type { FlightDefinitions } from "./definitions";
/**
* Stateful action button: tracks `done` locally so clicking "Book flight"
* transitions to "Booked ✓" and disables further clicks. Ports the
* beautiful-chat pattern (see
* `src/app/demos/beautiful-chat/declarative-generative-ui/renderers.tsx`'s
* ActionButton). The basic catalog's Button is stateless, so overriding
* the `Button` entry in this custom catalog is what gives the fixed-schema
* demo a visible post-click confirmation.
*/
function ActionButton({
label,
doneLabel,
action,
children: child,
}: {
label: string;
doneLabel: string;
action: unknown;
children?: React.ReactNode;
}) {
const [done, setDone] = useState(false);
return (
<button
disabled={done}
style={{
width: "100%",
padding: "10px 16px",
borderRadius: "12px",
border: done ? "1px solid #85ECCE4D" : "1px solid transparent",
background: done ? "rgba(133, 236, 206, 0.15)" : "#010507",
color: done ? "#189370" : "#ffffff",
fontSize: "0.9rem",
fontWeight: 600,
cursor: done ? "default" : "pointer",
transition: "all 0.2s ease",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "6px",
}}
onClick={() => {
if (done) return;
if (typeof action === "function") {
(action as () => void)();
}
setDone(true);
}}
>
{done && (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="#189370"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
{done ? doneLabel : (child ?? label)}
</button>
);
}
// @region[renderers-tsx]
export const flightRenderers: CatalogRenderers<FlightDefinitions> = {
/**
* Card override: wraps the basic-catalog Card render so the flight card
* exposes a stable `data-testid` for D6 e2e tests. Behavior is otherwise
* identical to the basic catalog default (renders the resolved `child`).
*/
Card: ({ props, children }) => (
<div
data-testid="a2ui-fixed-card"
style={{
background: "#ffffff",
border: "1px solid rgba(0, 0, 0, 0.08)",
borderRadius: "16px",
padding: "16px",
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.04)",
}}
>
{props.child ? children(props.child) : null}
</div>
),
Title: ({ props: rawProps }) => {
const props = rawProps as Record<string, any>;
return (
<div
style={{
fontSize: "1.15rem",
fontWeight: 600,
color: "#010507",
}}
>
{props.text}
</div>
);
},
Airport: ({ props: rawProps }) => {
const props = rawProps as Record<string, any>;
return (
<span
style={{
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: "1.5rem",
fontWeight: 600,
letterSpacing: "0.05em",
color: "#010507",
}}
>
{props.code}
</span>
);
},
Arrow: () => <span style={{ color: "#AFAFB7", fontSize: "1.5rem" }}></span>,
AirlineBadge: ({ props: rawProps }) => {
const props = rawProps as Record<string, any>;
return (
<span
style={{
display: "inline-block",
padding: "2px 10px",
background: "rgba(190, 194, 255, 0.15)",
color: "#010507",
border: "1px solid #BEC2FF",
borderRadius: 999,
fontSize: "0.75rem",
fontWeight: 600,
letterSpacing: "0.08em",
textTransform: "uppercase",
}}
>
{props.name}
</span>
);
},
PriceTag: ({ props: rawProps }) => {
const props = rawProps as Record<string, any>;
return (
<span
style={{
fontWeight: 600,
fontSize: "1.1rem",
color: "#189370",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
}}
>
{props.amount}
</span>
);
},
/**
* Button override: the basic catalog's Button is stateless. This
* stateful version lets clicking "Book flight" transition to
* "Booked ✓" without a round-trip to the agent.
*/
Button: ({ props, children }) => {
return (
<ActionButton
label="Book flight"
doneLabel="Booked"
action={(props as Record<string, any>).action}
>
{(props as Record<string, any>).child
? children((props as Record<string, any>).child)
: null}
</ActionButton>
);
},
};
// @endregion[renderers-tsx]
@@ -0,0 +1,60 @@
"use client";
/**
* Declarative Generative UI — A2UI Fixed Schema demo.
*
* In the fixed-schema flavor of A2UI, the component tree (schema) lives on
* the frontend and the agent only streams *data* into the data model. The
* flight card is ASSEMBLED from small sub-components in the inlined schema
* (`src/lib/factory/a2ui-fixed-schema-factory.ts`), reusing the same shape
* as `langgraph-python`'s `a2ui_schemas/flight_schema.json`.
*
* - Definitions (zod schemas): `./a2ui/definitions.ts`
* - Renderers (React): `./a2ui/renderers.tsx`
* - Catalog wiring: `./a2ui/catalog.ts` (includes the basic catalog)
* - Agent: `src/lib/factory/a2ui-fixed-schema-factory.ts` (the
* `display_flight` tool emits an `a2ui_operations` container directly)
*
* Reference:
* https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui/fixed-schema
*/
import React from "react";
import {
CopilotKit,
CopilotChat,
useConfigureSuggestions,
} from "@copilotkit/react-core/v2";
import { fixedCatalog } from "./a2ui/catalog";
export default function A2UIFixedSchemaDemo() {
return (
// `a2ui.catalog` wires the fixed catalog into the A2UI activity renderer.
<CopilotKit
runtimeUrl="/api/copilotkit-a2ui-fixed-schema"
agent="default"
a2ui={{ catalog: fixedCatalog }}
>
<div className="flex justify-center items-center h-screen w-full">
<div className="h-full w-full max-w-4xl">
<Chat />
</div>
</div>
</CopilotKit>
);
}
function Chat() {
useConfigureSuggestions({
suggestions: [
{
title: "Find SFO → JFK",
message: "Find me a flight from SFO to JFK on United for $289.",
},
],
available: "always",
});
return <CopilotChat agentId="default" className="h-full rounded-2xl" />;
}
@@ -0,0 +1,13 @@
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
export function useA2UIFixedSchemaSuggestions() {
useConfigureSuggestions({
suggestions: [
{
title: "Find SFO → JFK",
message: "Find me a flight from SFO to JFK on United for $289.",
},
],
available: "always",
});
}
@@ -0,0 +1,12 @@
# Agent Config (built-in-agent)
Frontend forwards typed config (`tone`, `expertise`, `responseLength`)
through the `<CopilotKitProvider properties={...}>` prop. The runtime puts
that on `input.forwardedProps`, and the built-in-agent factory reads it to
synthesize a tuned system prompt per turn — no external graph, no extra
state plumbing.
- Dedicated route: `/api/copilotkit-agent-config`
- Single-route mode (`useSingleEndpoint`)
- Key files: `page.tsx`, `config-card.tsx`, `use-agent-config.ts`,
`config-types.ts`, `../../api/copilotkit-agent-config/route.ts`
@@ -0,0 +1,91 @@
"use client";
import type { ChangeEvent } from "react";
import {
type AgentConfig,
EXPERTISE_OPTIONS,
type Expertise,
RESPONSE_LENGTH_OPTIONS,
type ResponseLength,
TONE_OPTIONS,
type Tone,
} from "./config-types";
interface ConfigCardProps {
config: AgentConfig;
onToneChange: (tone: Tone) => void;
onExpertiseChange: (expertise: Expertise) => void;
onResponseLengthChange: (length: ResponseLength) => void;
}
export function ConfigCard({
config,
onToneChange,
onExpertiseChange,
onResponseLengthChange,
}: ConfigCardProps) {
return (
<div
data-testid="agent-config-card"
className="flex flex-col gap-2 rounded-md border border-neutral-200 bg-white p-4 text-sm"
>
<h2 className="text-sm font-semibold">Agent Config</h2>
<p className="text-xs text-neutral-600">
Change these and send a message to see the agent adapt.
</p>
<div className="flex flex-wrap gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">Tone</span>
<select
data-testid="agent-config-tone-select"
value={config.tone}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onToneChange(e.target.value as Tone)
}
className="rounded border border-neutral-200 bg-neutral-50 px-2 py-1 text-sm"
>
{TONE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">Expertise</span>
<select
data-testid="agent-config-expertise-select"
value={config.expertise}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onExpertiseChange(e.target.value as Expertise)
}
className="rounded border border-neutral-200 bg-neutral-50 px-2 py-1 text-sm"
>
{EXPERTISE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">Response length</span>
<select
data-testid="agent-config-length-select"
value={config.responseLength}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onResponseLengthChange(e.target.value as ResponseLength)
}
className="rounded border border-neutral-200 bg-neutral-50 px-2 py-1 text-sm"
>
{RESPONSE_LENGTH_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
</div>
</div>
);
}
@@ -0,0 +1,26 @@
export type Tone = "professional" | "casual" | "enthusiastic";
export type Expertise = "beginner" | "intermediate" | "expert";
export type ResponseLength = "concise" | "detailed";
export interface AgentConfig {
tone: Tone;
expertise: Expertise;
responseLength: ResponseLength;
}
export const DEFAULT_AGENT_CONFIG: AgentConfig = {
tone: "professional",
expertise: "intermediate",
responseLength: "concise",
};
export const TONE_OPTIONS: Tone[] = ["professional", "casual", "enthusiastic"];
export const EXPERTISE_OPTIONS: Expertise[] = [
"beginner",
"intermediate",
"expert",
];
export const RESPONSE_LENGTH_OPTIONS: ResponseLength[] = [
"concise",
"detailed",
];
@@ -0,0 +1,53 @@
"use client";
import { CopilotKitProvider, CopilotChat } from "@copilotkit/react-core/v2";
import { useMemo } from "react";
import { ConfigCard } from "./config-card";
import { useAgentConfig } from "./use-agent-config";
export default function AgentConfigDemoPage() {
const { config, setTone, setExpertise, setResponseLength } = useAgentConfig();
// Stable reference between renders when nothing has changed so the
// provider's `[properties]`-keyed effect only re-fires on real updates.
const providerProperties = useMemo<Record<string, unknown>>(
() => ({
tone: config.tone,
expertise: config.expertise,
responseLength: config.responseLength,
}),
[config.tone, config.expertise, config.responseLength],
);
return (
<CopilotKitProvider
runtimeUrl="/api/copilotkit-agent-config"
properties={providerProperties}
useSingleEndpoint
>
<div className="flex h-screen flex-col gap-3 p-6">
<header>
<h1 className="text-lg font-semibold">Agent Config Object</h1>
<p className="text-sm text-neutral-600">
Forwarded props let the frontend tell the agent how to behave. This
demo passes <code>tone</code>, <code>expertise</code>, and
<code> responseLength</code> through the provider; the
built-in-agent factory reads them from{" "}
<code>input.forwardedProps</code> and prepends a tuned system prompt
per turn.
</p>
</header>
<ConfigCard
config={config}
onToneChange={setTone}
onExpertiseChange={setExpertise}
onResponseLengthChange={setResponseLength}
/>
<div className="flex-1 overflow-hidden rounded-md border border-neutral-200">
<CopilotChat className="h-full rounded-md" />
</div>
</div>
</CopilotKitProvider>
);
}
@@ -0,0 +1,39 @@
"use client";
import { useCallback, useState } from "react";
import {
type AgentConfig,
DEFAULT_AGENT_CONFIG,
type Expertise,
type ResponseLength,
type Tone,
} from "./config-types";
export interface UseAgentConfigHandle {
config: AgentConfig;
setTone: (tone: Tone) => void;
setExpertise: (expertise: Expertise) => void;
setResponseLength: (length: ResponseLength) => void;
reset: () => void;
}
export function useAgentConfig(): UseAgentConfigHandle {
const [config, setConfig] = useState<AgentConfig>(DEFAULT_AGENT_CONFIG);
const setTone = useCallback(
(tone: Tone) => setConfig((prev) => ({ ...prev, tone })),
[],
);
const setExpertise = useCallback(
(expertise: Expertise) => setConfig((prev) => ({ ...prev, expertise })),
[],
);
const setResponseLength = useCallback(
(responseLength: ResponseLength) =>
setConfig((prev) => ({ ...prev, responseLength })),
[],
);
const reset = useCallback(() => setConfig(DEFAULT_AGENT_CONFIG), []);
return { config, setTone, setExpertise, setResponseLength, reset };
}
@@ -0,0 +1,31 @@
// Docs-only snippet — not imported or rendered. The actual route is served
// by page.tsx, which carries a frontend tool demo (setBackground) plus
// page chrome that aren't relevant to the prebuilt-chat docs page. This
// file gives the docs a minimal Chat definition to point at via the
// chat-component / configure-suggestions regions without disturbing the
// runtime demo.
//
// Why a sibling file: the bundler walks every file in the demo folder and
// extracts region markers from each, so a docs-targeted teaching example
// can live alongside the production demo without being wired into the
// route. See: showcase/scripts/bundle-demo-content.ts.
import {
CopilotChat,
useConfigureSuggestions,
} from "@copilotkit/react-core/v2";
// @region[chat-component]
export function Chat() {
useConfigureSuggestions({
suggestions: [
{ title: "Write a sonnet", message: "Write a short sonnet about AI." },
],
available: "always",
});
// @region[render-chat]
return <CopilotChat className="h-full rounded-2xl" />;
// @endregion[render-chat]
}
// @endregion[chat-component]
@@ -0,0 +1,54 @@
"use client";
// @region[frontend-tool]
// @region[frontend-tool-registration]
import { useState } from "react";
import {
CopilotKitProvider,
CopilotChat,
useFrontendTool,
} from "@copilotkit/react-core/v2";
import { z } from "zod";
export default function AgenticChat() {
return (
// @region[provider-setup]
<CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
<Demo />
</CopilotKitProvider>
// @endregion[provider-setup]
);
}
function Demo() {
const [bg, setBg] = useState<string>("var(--copilot-kit-background-color)");
useFrontendTool({
name: "setBackground",
description:
"Set the page background. Accepts any CSS background value (color, gradient, etc.).",
parameters: z.object({
background: z
.string()
.describe("CSS background value (color, gradient, etc.)"),
}),
// @region[frontend-tool-handler]
handler: async ({ background }) => {
setBg(background);
return { ok: true, background };
},
// @endregion[frontend-tool-handler]
});
// @endregion[frontend-tool-registration]
// @endregion[frontend-tool]
return (
<main style={{ background: bg, minHeight: "100vh" }} className="p-8">
<h1 className="text-2xl font-semibold mb-4">Agentic Chat</h1>
<p className="text-sm opacity-70 mb-6">
Try: &ldquo;Set the background to a sunset gradient.&rdquo;
</p>
<CopilotChat />
</main>
);
}
@@ -0,0 +1,22 @@
"use client";
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
// @region[configure-suggestions]
export function useAgenticChatSuggestions() {
useConfigureSuggestions({
suggestions: [
{ title: "Write a sonnet", message: "Write a short sonnet about AI." },
{
title: "Tell me a joke",
message: "Tell me a one-line joke.",
},
{
title: "Is 17 prime?",
message: "Walk me through whether 17 is prime.",
},
],
available: "always",
});
}
// @endregion[configure-suggestions]
@@ -0,0 +1,21 @@
# Authentication (built-in-agent)
Bearer-token gate on the V2 runtime via the `onRequest` hook. The hook
throws a `Response` with status 401 when the header is missing or invalid,
which the runtime maps to the HTTP response verbatim. The frontend defaults
to UNAUTHENTICATED on first paint (SignInCard); after sign-in the provider
injects `Authorization: Bearer <DEMO_TOKEN>` and the chat stays mounted
across the sign-out → sign-in cycle so the post-sign-out 401 is surfaced in
the chat (amber `auth-demo-error` banner) rather than bouncing back to the
gate. The 401 is captured via the agent-scoped `<CopilotChat onError>`
channel (and the provider-level `onError` for handshake errors).
- Dedicated route: `/api/copilotkit-auth/[[...slug]]`
- Uses `useSingleEndpoint={false}` (multi-endpoint runtime)
- Mounts `<CopilotKitProvider>` (built-in agent under the default key)
rather than `<CopilotKit agent="...">` — a forced provider divergence from
the langgraph-python gold reference; the error-handling shape, auth hook,
and testid contract match.
- Key files: `page.tsx`, `use-demo-auth.ts`, `auth-banner.tsx`,
`sign-in-card.tsx`, `demo-token.ts`,
`../../api/copilotkit-auth/[[...slug]]/route.ts`
@@ -0,0 +1,65 @@
"use client";
interface AuthBannerProps {
authenticated: boolean;
onSignOut: () => void;
onSignIn: () => void;
}
/**
* Status strip rendered above <CopilotChat /> in both authenticated and
* post-sign-out states. The post-sign-out (amber) variant exists so the demo
* actually showcases what its name promises — the runtime rejecting an
* unauthenticated request — instead of bouncing the user back to the gate
* page where the rejection never happens.
*
* Pure presentational — owns no state itself. Testids are stable contract
* for QA + Playwright specs.
*
* Note: built-in-agent is a deliberately minimal integration with no
* `@/components/ui` shadcn primitives, so this uses raw Tailwind-styled
* elements rather than the shared `Button` component that langgraph-python
* (the gold reference) uses. The prop contract, testids, and behavior match.
*/
export function AuthBanner({
authenticated,
onSignOut,
onSignIn,
}: AuthBannerProps) {
const classes = authenticated
? "border-emerald-300 bg-emerald-50 text-emerald-900"
: "border-amber-300 bg-amber-50 text-amber-900";
return (
<div
data-testid="auth-banner"
data-authenticated={authenticated ? "true" : "false"}
className={`flex items-center justify-between gap-3 rounded-md border px-4 py-2 text-sm ${classes}`}
>
<span data-testid="auth-status" className="font-medium">
{authenticated
? "✓ Signed in as demo user"
: "⚠ Signed out — the agent will reject your messages until you sign in."}
</span>
{authenticated ? (
<button
type="button"
data-testid="auth-sign-out-button"
onClick={onSignOut}
className="rounded border border-emerald-400 bg-white px-3 py-1 text-xs font-medium text-emerald-800 hover:bg-emerald-100"
>
Sign out
</button>
) : (
<button
type="button"
data-testid="auth-authenticate-button"
onClick={onSignIn}
className="rounded border border-amber-400 bg-white px-3 py-1 text-xs font-medium text-amber-800 hover:bg-amber-100"
>
Sign in
</button>
)}
</div>
);
}
@@ -0,0 +1,8 @@
/**
* Shared demo-token constant imported by both the client (use-demo-auth.ts)
* and the server runtime route. This is a DEMO token. Never use a hard-coded
* shared secret for real auth.
*/
export const DEMO_TOKEN = "demo-token-123";
export const DEMO_AUTH_HEADER = `Bearer ${DEMO_TOKEN}`;
@@ -0,0 +1,146 @@
"use client";
// Auth demo — framework-native request authentication via the V2 runtime's
// `onRequest` hook. The runtime route (/api/copilotkit-auth) rejects any
// request whose `Authorization: Bearer <demo-token>` header is missing or
// wrong.
//
// UX shape: the demo defaults to UNAUTHENTICATED on first paint so visitors
// land on a clear sign-in card. We don't mount the chat until the user has
// signed in at least once — that sidesteps the transport 401 that would
// otherwise crash `<CopilotChat>` during its initial `/info` handshake.
// After the user signs in once, the chat stays mounted across the
// sign-out → sign-in cycle so the post-sign-out state can actually
// demonstrate the runtime rejecting unauthenticated requests in the chat
// surface (the whole point of the demo).
//
// Error surfacing: the post-sign-out 401 is captured via the AGENT-SCOPED
// `<CopilotChat onError>` channel, NOT the provider-level `<CopilotKitProvider
// onError>` alone. Agent-run errors (`agent_run_failed`) are reliably
// delivered to the chat-scoped subscription, whereas the provider-level
// handler does not fire for them in this flow — so a demo that relies only
// on the provider `onError` never renders the rejection banner. We register
// the same handler on BOTH channels: `<CopilotKitProvider onError>` covers
// any provider-level errors (e.g. the initial `/info` handshake) and
// `<CopilotChat onError>` covers agent-run rejections, which is what the
// sign-out path produces.
//
// Provider note: this integration is the BUILT-IN agent, so it mounts
// `<CopilotKitProvider>` (the runtime registers the agent under the default
// key) rather than `<CopilotKit agent="auth-demo">` like the named-agent
// langgraph-python reference. That provider choice is a forced divergence;
// the error-handling shape, auth hook, and testid contract match the gold
// reference exactly.
import { useCallback, useEffect, useMemo, useState } from "react";
import { CopilotKitProvider, CopilotChat } from "@copilotkit/react-core/v2";
import type { CopilotKitCoreErrorCode } from "@copilotkit/react-core/v2";
import { AuthBanner } from "./auth-banner";
import { SignInCard } from "./sign-in-card";
import { useDemoAuth } from "./use-demo-auth";
import { DEMO_TOKEN } from "./demo-token";
interface AuthDemoErrorState {
message: string;
code: CopilotKitCoreErrorCode | string;
}
interface AuthErrorEvent {
error?: { message?: string } | null;
code: CopilotKitCoreErrorCode;
}
export default function AuthDemoPage() {
const {
isAuthenticated,
authorizationHeader,
hasEverSignedIn,
signIn,
signOut,
} = useDemoAuth();
const headers = useMemo<Record<string, string>>(
() => (authorizationHeader ? { Authorization: authorizationHeader } : {}),
[authorizationHeader],
);
const [authError, setAuthError] = useState<AuthDemoErrorState | null>(null);
// Shared error handler wired to BOTH the provider-level and chat-level
// `onError` channels (see the file header for why both are needed).
const handleAuthError = useCallback((event: AuthErrorEvent) => {
setAuthError({
message:
(event.error?.message && event.error.message.trim()) ||
(event.code
? `Request rejected (${event.code})`
: "The request was rejected."),
code: event.code,
});
}, []);
// Clear stale errors as soon as the user re-authenticates. This is the
// ONLY thing that gates the amber error surface on auth state — the render
// condition below keys off `authError` alone. Coupling the render to a
// second `!isAuthenticated` slice (the obvious-but-wrong guard) created a
// post-sign-out race: the rejection's `onError` fires and calls
// `setAuthError`, but if that commit landed in a render where the auth
// state hadn't yet settled to false, `authError && !isAuthenticated`
// evaluated false and the banner never appeared. Driving the surface off
// `authError` and clearing it here on re-auth removes the cross-slice
// ordering dependency: a rejection always renders, and signing back in
// always wipes it.
useEffect(() => {
if (isAuthenticated) setAuthError(null);
}, [isAuthenticated]);
if (!hasEverSignedIn) {
return (
<div className="flex h-screen flex-col">
<SignInCard onSignIn={signIn} />
</div>
);
}
return (
// `useSingleEndpoint={false}` opts into the V2 multi-endpoint protocol
// (separate /info, /agents/<id>/run, etc.), which is what this demo's
// runtime route is wired up for.
<CopilotKitProvider
runtimeUrl="/api/copilotkit-auth"
headers={headers}
useSingleEndpoint={false}
onError={handleAuthError}
>
<div className="flex h-screen flex-col gap-3 p-6">
<AuthBanner
authenticated={isAuthenticated}
onSignOut={signOut}
onSignIn={() => signIn(DEMO_TOKEN)}
/>
<header>
<h1 className="text-lg font-semibold">Authentication</h1>
</header>
{authError && (
<div
data-testid="auth-demo-error"
className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900"
>
<strong className="font-semibold">
Runtime rejected the request:
</strong>{" "}
<span data-testid="auth-demo-error-message">
{authError.message}
</span>{" "}
<code className="ml-1 rounded bg-amber-100 px-1 py-0.5 font-mono text-xs">
{authError.code}
</code>
</div>
)}
<div className="flex-1 overflow-hidden rounded-md border border-neutral-200">
<CopilotChat className="h-full" onError={handleAuthError} />
</div>
</div>
</CopilotKitProvider>
);
}
@@ -0,0 +1,67 @@
"use client";
import { DEMO_TOKEN } from "./demo-token";
interface SignInCardProps {
onSignIn: (token: string) => void;
}
/**
* Unauthenticated landing card for the auth demo. Surfaces the demo bearer
* token in plain text so visitors can see exactly what gets sent on the
* `Authorization` header — there's no real form because the value is fixed
* by the runtime gate. Clicking "Sign in" stores the token via
* `useDemoAuth()`, which causes the parent to mount the chat surface.
*
* Note: built-in-agent is a deliberately minimal integration with no
* `@/components/ui` shadcn primitives, so this uses raw Tailwind-styled
* elements rather than the shared `Card`/`Button` components that
* langgraph-python (the gold reference) uses. The testids and behavior match.
*/
export function SignInCard({ onSignIn }: SignInCardProps) {
return (
<div className="flex h-full items-center justify-center p-6">
<div
data-testid="auth-sign-in-card"
className="w-full max-w-md rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
>
<div className="mb-4">
<h2 className="text-lg font-semibold text-neutral-900">
Sign in to start chatting
</h2>
<p className="mt-1 text-sm text-neutral-600">
The runtime rejects requests without an{" "}
<code className="rounded bg-neutral-100 px-1 py-0.5 font-mono text-xs">
Authorization
</code>{" "}
header. Sign in below to mount the chat with a demo bearer token
attached.
</p>
</div>
<div className="mb-4 space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">
Demo token
</p>
<code
data-testid="auth-demo-token"
className="block rounded-md border border-neutral-200 bg-neutral-50 px-3 py-2 font-mono text-sm text-neutral-800"
>
{DEMO_TOKEN}
</code>
<p className="text-xs text-neutral-500">
Real apps should issue per-user tokens via your identity provider
and never hard-code shared secrets.
</p>
</div>
<button
type="button"
data-testid="auth-sign-in-button"
onClick={() => onSignIn(DEMO_TOKEN)}
className="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800"
>
Sign in with demo token
</button>
</div>
</div>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { DEMO_TOKEN } from "./demo-token";
const STORAGE_KEY = "copilotkit:auth-demo:token";
export interface DemoAuthHandle {
isAuthenticated: boolean;
/** The token string when authenticated, otherwise null. */
token: string | null;
/** The full `Bearer <token>` value when authenticated, otherwise null. */
authorizationHeader: string | null;
/**
* Whether the user has signed in at least once during the current page
* session. Used by the page to decide between the first-paint SignInCard
* (never signed in) and the persistent chat-with-amber-banner state
* (signed in, then signed out) — the latter is the only state that
* actually showcases the runtime rejecting unauthenticated requests.
* Resets on full page reload by design.
*/
hasEverSignedIn: boolean;
/** Sign in with the provided token. */
signIn: (token: string) => void;
/** Clear the stored token. */
signOut: () => void;
}
/**
* Persistent demo auth state for the /demos/auth showcase cell. Tokens are
* stored in localStorage so a page reload doesn't kick the user back out;
* first paint of a fresh visitor is unauthenticated, which lets the demo
* showcase its sign-in CTA up front.
*
* This is a DEMO. Never store real bearer tokens in localStorage in a
* production application — that exposes them to any script running on the
* page.
*/
export function useDemoAuth(): DemoAuthHandle {
const [token, setToken] = useState<string | null>(null);
const [hasEverSignedIn, setHasEverSignedIn] = useState(false);
// Hydrate from localStorage after mount. Reading on initial render would
// mismatch SSR (where window is undefined); deferring to useEffect keeps
// first paint unauthenticated and avoids hydration warnings.
useEffect(() => {
if (typeof window === "undefined") return;
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored) {
setToken(stored);
setHasEverSignedIn(true);
}
} catch {
// localStorage unavailable (privacy mode, etc.) — fall back to
// in-memory only.
}
}, []);
const signIn = useCallback((nextToken: string) => {
setToken(nextToken);
setHasEverSignedIn(true);
try {
window.localStorage.setItem(STORAGE_KEY, nextToken);
} catch {
// Ignore — in-memory state still works.
}
}, []);
const signOut = useCallback(() => {
setToken(null);
// hasEverSignedIn intentionally stays true so the page keeps showing
// the chat surface (with amber banner) after sign-out. That is the
// state that demonstrates the runtime returning 401.
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore.
}
}, []);
// The runtime gate compares against a fixed token, so anything other than
// DEMO_TOKEN won't actually authenticate against the API. We still allow
// arbitrary strings here because validation is the runtime's job — the UI
// just owns "what header are we sending".
return {
isAuthenticated: token !== null,
token,
authorizationHeader: token ? `Bearer ${token}` : null,
hasEverSignedIn,
signIn,
signOut,
};
}
@@ -0,0 +1,163 @@
import { useRef } from "react";
import {
BarChart as RechartsBarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Cell,
ResponsiveContainer,
Rectangle,
} from "recharts";
import { z } from "zod";
import { CHART_COLORS, CHART_CONFIG } from "./config";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "../../ui/card";
import { BarChart3 } from "lucide-react";
export const BarChartProps = z.object({
title: z.string().describe("Chart title"),
description: z.string().describe("Brief description or subtitle"),
data: z.array(
z.object({
label: z.string(),
value: z.number(),
}),
),
});
type BarChartProps = z.infer<typeof BarChartProps>;
/** Tracks seen indices so only NEW bars get the fade-in animation. */
function useSeenIndices() {
const seen = useRef(new Set<number>());
return {
isNew(index: number) {
if (seen.current.has(index)) return false;
seen.current.add(index);
return true;
},
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function AnimatedBar(props: any) {
const { isNew, ...rest } = props;
return (
<g
style={
isNew
? {
animation: "barSlideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both",
}
: undefined
}
>
<Rectangle {...rest} />
</g>
);
}
export function BarChart({ title, description, data }: BarChartProps) {
const { isNew } = useSeenIndices();
if (!data || !Array.isArray(data) || data.length === 0) {
return (
<Card className="max-w-2xl mx-auto my-4">
<CardHeader>
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-[var(--muted-foreground)]" />
<CardTitle>{title}</CardTitle>
</div>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
No data available
</p>
</CardContent>
</Card>
);
}
return (
<Card className="max-w-2xl mx-auto my-4 overflow-hidden">
{/* Scoped keyframe — no globals.css needed */}
<style>{`
@keyframes barSlideIn {
from { transform: translateY(40px); opacity: 0; }
20% { opacity: 1; }
to { transform: translateY(0); opacity: 1; }
}
`}</style>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center h-6 w-6 rounded-md bg-[var(--secondary)]">
<BarChart3 className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
</div>
<CardTitle>{title}</CardTitle>
</div>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="pt-2">
<ResponsiveContainer width="100%" height={280}>
<RechartsBarChart
data={data}
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_CONFIG.tooltipStyle}
cursor={{ fill: "var(--secondary)", opacity: 0.5 }}
/>
<Bar
isAnimationActive={false}
dataKey="value"
radius={[6, 6, 0, 0]}
maxBarSize={48}
shape={
((props: Record<string, unknown>) => (
<AnimatedBar
{...props}
isNew={isNew(props.index as number)}
/>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
)) as any
}
>
{data.map((_, index) => (
<Cell
key={index}
fill={CHART_COLORS[index % CHART_COLORS.length]}
/>
))}
</Bar>
</RechartsBarChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
@@ -0,0 +1,25 @@
/**
* CopilotKit brand chart palette — Plus Jakarta Sans / brand color system.
*/
export const CHART_COLORS = [
"#BEC2FF", // lilac-400
"#85ECCE", // mint-400
"#FFAC4D", // orange-400
"#FFF388", // yellow-400
"#189370", // mint-800
"#EEE6FE", // primary-100
"#FA5F67", // red-400
] as const;
export const CHART_CONFIG = {
tooltipStyle: {
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "10px",
padding: "10px 14px",
color: "var(--foreground)",
fontSize: "13px",
fontFamily: "var(--font-body)",
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
},
};
@@ -0,0 +1,155 @@
import { z } from "zod";
import { CHART_COLORS } from "./config";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "../../ui/card";
export const PieChartProps = z.object({
title: z.string().describe("Chart title"),
description: z.string().describe("Brief description or subtitle"),
data: z.array(
z.object({
label: z.string(),
value: z.number(),
}),
),
});
type PieChartProps = z.infer<typeof PieChartProps>;
/** Custom SVG donut chart built with <circle> + stroke-dasharray. */
function DonutChart({
data,
size = 240,
strokeWidth = 40,
}: {
data: { label: string; value: number }[];
size?: number;
strokeWidth?: number;
}) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const center = size / 2;
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
// Calculate each slice's arc length and starting position
let accumulated = 0;
const slices = data.map((item, index) => {
const val = Number(item.value) || 0;
const ratio = total > 0 ? val / total : 0;
const arc = ratio * circumference;
const startAt = accumulated;
accumulated += arc;
return {
...item,
arc,
gap: circumference - arc,
// Negative dashoffset shifts the dash forward (clockwise) to the correct position
dashoffset: -startAt,
color: CHART_COLORS[index % CHART_COLORS.length],
};
});
return (
<svg
width="100%"
viewBox={`0 0 ${size} ${size}`}
className="block mx-auto"
style={{ maxWidth: size, transform: "scaleX(-1)" }}
>
{/* Background ring */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="var(--secondary)"
strokeWidth={strokeWidth}
/>
{/* Data slices */}
{slices.map((slice, i) => (
<circle
key={i}
cx={center}
cy={center}
r={radius}
fill="none"
stroke={slice.color}
strokeWidth={strokeWidth}
strokeDasharray={`${slice.arc} ${slice.gap}`}
strokeDashoffset={slice.dashoffset}
strokeLinecap="butt"
transform={`rotate(-90 ${center} ${center})`}
/>
))}
</svg>
);
}
export function PieChart({ title, description, data }: PieChartProps) {
if (!data || !Array.isArray(data) || data.length === 0) {
return (
<Card className="max-w-lg mx-auto my-4">
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
No data available
</p>
</CardContent>
</Card>
);
}
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
return (
<Card className="max-w-lg mx-auto my-4 overflow-hidden">
<CardHeader className="pb-0">
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="pt-4">
<DonutChart data={data} />
{/* Legend */}
<div className="space-y-2 pt-4">
{data.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 transition-opacity duration-300 ease-out"
style={{ opacity: 1 }}
>
<span
className="inline-block h-3 w-3 rounded-full shrink-0"
style={{
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
}}
/>
<span className="flex-1 text-[var(--foreground)] truncate">
{item.label}
</span>
<span className="text-[var(--muted-foreground)] tabular-nums">
{val.toLocaleString()}
</span>
<span className="text-[var(--muted-foreground)] text-sm w-10 text-right tabular-nums">
{pct}%
</span>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,177 @@
import { useState } from "react";
import { Card, CardContent } from "../ui/card";
import { Button } from "../ui/button";
import { Badge } from "../ui/badge";
import { Spinner } from "../ui/spinner";
import { Check, X, Clock, ChevronRight } from "lucide-react";
export interface TimeSlot {
date: string;
time: string;
duration?: string;
}
export interface MeetingTimePickerProps {
status: "inProgress" | "executing" | "complete";
respond?: (response: string) => void;
reasonForScheduling?: string;
meetingDuration?: number;
title?: string;
timeSlots?: TimeSlot[];
}
export function MeetingTimePicker({
status,
respond,
reasonForScheduling,
meetingDuration,
title = "Schedule a Meeting",
timeSlots = [
{ date: "Tomorrow", time: "2:00 PM", duration: "30 min" },
{ date: "Friday", time: "10:00 AM", duration: "30 min" },
{ date: "Next Monday", time: "3:00 PM", duration: "30 min" },
],
}: MeetingTimePickerProps) {
const displayTitle = reasonForScheduling || title;
const slots = meetingDuration
? timeSlots.map((slot) => ({ ...slot, duration: `${meetingDuration} min` }))
: timeSlots;
const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(null);
const [declined, setDeclined] = useState(false);
const handleSelectSlot = (slot: TimeSlot) => {
setSelectedSlot(slot);
respond?.(
`Meeting scheduled for ${slot.date} at ${slot.time}${slot.duration ? ` (${slot.duration})` : ""}.`,
);
};
const handleDecline = () => {
setDeclined(true);
respond?.(
"The user declined all proposed meeting times. Please suggest alternative times or ask for their availability.",
);
};
// Confirmed state
if (selectedSlot) {
return (
<Card className="max-w-md w-full mx-auto mb-4 overflow-hidden">
<CardContent className="p-6">
<div className="flex flex-col items-center text-center gap-3">
<div className="flex items-center justify-center h-10 w-10 rounded-full bg-[#189370]">
<Check className="h-5 w-5 text-white" strokeWidth={3} />
</div>
<div>
<h3 className="text-lg font-bold text-[var(--foreground)]">
Meeting Scheduled
</h3>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
{selectedSlot.date} at {selectedSlot.time}
</p>
</div>
{selectedSlot.duration && (
<Badge variant="secondary">
<Clock className="h-3 w-3 mr-1" />
{selectedSlot.duration}
</Badge>
)}
</div>
</CardContent>
</Card>
);
}
// Declined state
if (declined) {
return (
<Card className="max-w-md w-full mx-auto mb-4 overflow-hidden">
<CardContent className="p-6">
<div className="flex flex-col items-center text-center gap-3">
<div className="flex items-center justify-center h-12 w-12 rounded-full bg-[var(--secondary)]">
<X className="h-6 w-6 text-[var(--muted-foreground)]" />
</div>
<div>
<h3 className="text-lg font-bold text-[var(--foreground)]">
No Time Selected
</h3>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
Looking for a better time that works for you
</p>
</div>
</div>
</CardContent>
</Card>
);
}
// Selection state
return (
<Card className="max-w-md w-full mx-auto mb-4 overflow-hidden">
<CardContent className="p-6">
<div className="flex flex-col items-center text-center mb-5">
<div className="flex items-center justify-center h-12 w-12 rounded-full bg-[var(--accent)] mb-3">
<Clock className="h-6 w-6 text-[#BEC2FF]" />
</div>
<h3 className="text-lg font-bold text-[var(--foreground)]">
{displayTitle}
</h3>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
{status === "inProgress"
? "Finding available times..."
: "Pick a time that works for you"}
</p>
</div>
{status === "inProgress" && (
<div className="flex justify-center py-6">
<Spinner size="lg" />
</div>
)}
{status === "executing" && (
<div className="space-y-3">
{slots.map((slot, index) => (
<button
key={index}
onClick={() => handleSelectSlot(slot)}
className="group w-full px-6 py-5 rounded-[var(--radius)]
border border-[var(--border)]
hover:border-[var(--ring)] hover:bg-[var(--accent)]
transition-all duration-150 cursor-pointer
flex items-center gap-4"
>
<div className="flex-1 text-left">
<div className="font-semibold text-base text-[var(--foreground)]">
{slot.date}
</div>
<div className="text-sm text-[var(--muted-foreground)] mt-0.5">
{slot.time}
</div>
</div>
{slot.duration && (
<Badge
variant="secondary"
className="shrink-0 text-sm px-3 py-1"
>
{slot.duration}
</Badge>
)}
<ChevronRight className="h-4 w-4 text-[var(--muted-foreground)] opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</button>
))}
<Button
variant="ghost"
size="sm"
className="w-full mt-1 text-xs text-[var(--muted-foreground)]"
onClick={handleDecline}
>
None of these work
</Button>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,84 @@
"use client";
import { useEffect, useRef } from "react";
import { Wrench, Check, ChevronDown } from "lucide-react";
import { Spinner } from "./ui/spinner";
interface ToolReasoningProps {
name: string;
args?: object | unknown;
status: string;
}
function formatValue(value: unknown): string {
if (Array.isArray(value)) return `[${value.length} items]`;
if (typeof value === "object" && value !== null)
return `{${Object.keys(value).length} keys}`;
if (typeof value === "string") return `"${value}"`;
return String(value);
}
export function ToolReasoning({ name, args, status }: ToolReasoningProps) {
const entries = args ? Object.entries(args) : [];
const detailsRef = useRef<HTMLDetailsElement>(null);
const isRunning = status === "executing" || status === "inProgress";
// Auto-open while executing, auto-close when complete
useEffect(() => {
if (!detailsRef.current) return;
detailsRef.current.open = isRunning;
}, [isRunning]);
const statusIcon = isRunning ? (
<Spinner size="sm" className="h-3 w-3" />
) : (
<Check className="h-3 w-3 text-emerald-500" />
);
return (
<div className="my-1.5">
{entries.length > 0 ? (
<details ref={detailsRef} open className="group">
<summary className="flex items-center gap-2 cursor-pointer list-none text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors">
{statusIcon}
<Wrench className="h-3 w-3" />
<span
className="font-medium"
style={{ fontFamily: "var(--font-code)" }}
>
{name}
</span>
<ChevronDown className="h-3 w-3 ml-auto transition-transform group-open:rotate-180" />
</summary>
<div className="ml-5 mt-1.5 rounded-md bg-[var(--secondary)] px-3 py-2 space-y-1">
{entries.map(([key, value]) => (
<div
key={key}
className="flex gap-2 min-w-0 text-xs"
style={{ fontFamily: "var(--font-code)" }}
>
<span className="text-[var(--muted-foreground)] shrink-0">
{key}:
</span>
<span className="text-[var(--foreground)] truncate">
{formatValue(value)}
</span>
</div>
))}
</div>
</details>
) : (
<div className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
{statusIcon}
<Wrench className="h-3 w-3" />
<span
className="font-medium"
style={{ fontFamily: "var(--font-code)" }}
>
{name}
</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,34 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
variant: {
default:
"border-transparent bg-[var(--primary)] text-[var(--primary-foreground)]",
secondary:
"border-transparent bg-[var(--secondary)] text-[var(--secondary-foreground)]",
outline: "border-[var(--border)] text-[var(--foreground)]",
},
},
defaultVariants: {
variant: "secondary",
},
},
);
export interface BadgeProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
@@ -0,0 +1,51 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius)] text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:pointer-events-none disabled:opacity-50 cursor-pointer",
{
variants: {
variant: {
default:
"bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90",
secondary:
"bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:opacity-80",
outline:
"border border-[var(--border)] bg-[var(--background)] hover:bg-[var(--secondary)]",
ghost:
"hover:bg-[var(--secondary)] hover:text-[var(--secondary-foreground)]",
destructive:
"bg-[var(--destructive)] text-[var(--destructive-foreground)] hover:opacity-90",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-6",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
),
);
Button.displayName = "Button";
export { Button, buttonVariants };
@@ -0,0 +1,85 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-[var(--radius)] border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-[var(--muted-foreground)]", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
};
@@ -0,0 +1,24 @@
import { cn } from "../../lib/utils";
interface SpinnerProps {
className?: string;
size?: "sm" | "md" | "lg";
}
const sizeMap = {
sm: "h-4 w-4 border-2",
md: "h-6 w-6 border-2",
lg: "h-8 w-8 border-3",
};
export function Spinner({ className, size = "md" }: SpinnerProps) {
return (
<span
className={cn(
"inline-block rounded-full border-[var(--muted)] border-t-[var(--primary)] animate-spin",
sizeMap[size],
className,
)}
/>
);
}
@@ -0,0 +1,97 @@
/**
* Beautiful-chat generative-UI tool registrations.
*
* Ported from the langgraph-python flagship
* (src/app/demos/beautiful-chat/hooks/use-generative-ui-examples.tsx).
* Registers the three controlled-gen-UI surfaces the dashboard probes
* exercise on this cell:
*
* - `pieChart` → PieChart (useComponent)
* - `barChart` → BarChart (useComponent)
* - `scheduleTime` → MeetingTimePicker (useHumanInTheLoop)
*
* plus the `toggleTheme` frontend tool (already green — kept intact).
*
* The CopilotKit runtime forwards these frontend tool definitions to the
* agent (see the Python backend's `_build_frontend_tools`), so the LLM —
* or the aimock fixture — can call them by name and the matching `render`
* callback paints the component inline.
*/
import { z } from "zod";
import { useTheme } from "./use-theme";
import {
useComponent,
useFrontendTool,
useHumanInTheLoop,
useDefaultRenderTool,
} from "@copilotkit/react-core/v2";
import {
PieChart,
PieChartProps,
} from "../components/generative-ui/charts/pie-chart";
import {
BarChart,
BarChartProps,
} from "../components/generative-ui/charts/bar-chart";
import { MeetingTimePicker } from "../components/generative-ui/meeting-time-picker";
import { ToolReasoning } from "../components/tool-rendering";
export const useGenerativeUIExamples = () => {
const { setTheme } = useTheme();
// Human-in-the-Loop (frontend tool requiring user decision)
useHumanInTheLoop({
name: "scheduleTime",
description: "Use human-in-the-loop to schedule a meeting with the user.",
parameters: z.object({
reasonForScheduling: z
.string()
.describe("Reason for scheduling, very brief - 5 words."),
meetingDuration: z
.number()
.describe("Duration of the meeting in minutes"),
}),
render: ({ respond, status, args }) => {
return <MeetingTimePicker status={status} respond={respond} {...args} />;
},
});
// Controlled Generative UI (frontend-defined chart components)
useComponent({
name: "pieChart",
description: "Controlled Generative UI that displays data as a pie chart.",
parameters: PieChartProps,
render: PieChart,
});
useComponent({
name: "barChart",
description: "Controlled Generative UI that displays data as a bar chart.",
parameters: BarChartProps,
render: BarChart,
});
// Default Tool Rendering (backend tool UI) — render any other backend
// tool call as a compact reasoning card.
useDefaultRenderTool({
render: ({ name, status, parameters }) => {
return <ToolReasoning name={name} status={status} args={parameters} />;
},
});
// Frontend Tools (direct frontend state manipulation).
// No deps array — the handler reads `document` directly and calls a
// stable setter. Including [theme, setTheme] in deps re-registers the
// hook on every theme flip, which can race an in-flight tool result.
useFrontendTool({
name: "toggleTheme",
description: "Frontend tool for toggling the theme of the app.",
parameters: z.object({}),
handler: async () => {
const isDark = document.documentElement.classList.contains("dark");
setTheme(isDark ? "light" : "dark");
},
});
};
@@ -0,0 +1,43 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system";
const ThemeContext = createContext<{
theme: Theme;
setTheme: (t: Theme) => void;
}>({
theme: "system",
setTheme: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("system");
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const apply = () => {
root.classList.remove("light", "dark");
root.classList.add(mq.matches ? "dark" : "light");
};
apply();
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}
root.classList.add(theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
@@ -0,0 +1,54 @@
/**
* Beautiful-chat brand design tokens.
*
* The ported chart / meeting-picker / ui components key on the
* `--card`, `--border`, `--foreground`, … CSS variables from the
* langgraph-python flagship's globals.css. This integration's global
* stylesheet does not define them, so we scope the same palette to the
* `.beautiful-chat-root` wrapper this demo's page renders — keeping the
* tokens out of every other cell that shares the app's globals.css.
*
* `.dark .beautiful-chat-root` mirrors the dark palette so the
* `toggleTheme` frontend tool (which flips `documentElement.classList`
* "dark") repaints the surface.
*/
.beautiful-chat-root {
--background: #ffffff;
--foreground: #1a1a18;
--card: #ffffff;
--card-foreground: #1a1a18;
--primary: #1a1a18;
--primary-foreground: #ffffff;
--secondary: #ededf5;
--secondary-foreground: #1a1a18;
--muted: #ededf5;
--muted-foreground: #57575b;
--accent: #eee6fe;
--accent-foreground: #1a1a18;
--destructive: #fa5f67;
--destructive-foreground: #ffffff;
--border: #dbdbe5;
--ring: #bec2ff;
--radius: 0.75rem;
}
.dark .beautiful-chat-root,
.beautiful-chat-root.dark {
--background: #0a0a0a;
--foreground: #fafafa;
--card: #191a1e;
--card-foreground: #fafafa;
--primary: #fafafa;
--primary-foreground: #1a1a18;
--secondary: #242529;
--secondary-foreground: #fafafa;
--muted: #242529;
--muted-foreground: #adadb2;
--accent: #303136;
--accent-foreground: #fafafa;
--destructive: #fa5f67;
--destructive-foreground: #ffffff;
--border: #303136;
--ring: #bec2ff;
--radius: 0.75rem;
}
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -0,0 +1,132 @@
"use client";
/**
* Beautiful Chat (built-in-agent flavor) — a polished two-pane chat that
* showcases CopilotChat plus the three controlled generative-UI surfaces
* the dashboard probes exercise:
* - `pieChart` / `barChart` → recharts/SVG charts (useComponent)
* - `scheduleTime` → meeting time picker (useHumanInTheLoop)
* - `toggleTheme` → frontend theme toggle (already green)
*
* The renderers are ported from the langgraph-python flagship and wired to
* the same tool names the agent emits. The in-process CopilotKit runtime
* (TanStack AI) forwards the frontend tool definitions to the LLM, so a
* tool call by name paints the matching renderer inline.
*
* The A2UI fixed-schema / dynamic-dashboard surfaces remain out of scope —
* only the controlled-gen-UI + HITL pills are ported here.
*/
import React from "react";
import {
CopilotKitProvider,
CopilotChat,
useConfigureSuggestions,
} from "@copilotkit/react-core/v2";
import { ThemeProvider } from "./hooks/use-theme";
import { useGenerativeUIExamples } from "./hooks/use-generative-ui-examples";
import "./lib/theme.css";
export default function BeautifulChatPage() {
return (
<ThemeProvider>
<CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
<ChatSurface />
</CopilotKitProvider>
</ThemeProvider>
);
}
function ChatSurface() {
// Register the controlled-gen-UI + HITL renderers (pieChart, barChart,
// scheduleTime) and the toggleTheme frontend tool. Must run inside the
// provider so the tool definitions are forwarded to the agent.
useGenerativeUIExamples();
return (
<div className="beautiful-chat-root grid grid-cols-1 md:grid-cols-[1fr_420px] h-screen w-full bg-gradient-to-br from-slate-50 to-indigo-50 dark:from-slate-950 dark:to-indigo-950">
<Canvas />
<aside className="border-l border-slate-200 dark:border-slate-800 bg-white/60 dark:bg-slate-900/60 backdrop-blur flex flex-col">
<Suggestions />
<CopilotChat
agentId="default"
className="flex-1"
input={{ disclaimer: () => null, className: "pb-6" }}
/>
</aside>
</div>
);
}
function Canvas() {
return (
<main className="hidden md:flex flex-col p-12 overflow-y-auto">
<header className="mb-8">
<span className="inline-block rounded-full bg-indigo-100 dark:bg-indigo-900 text-indigo-700 dark:text-indigo-200 px-3 py-1 text-xs font-semibold uppercase tracking-wider">
Starter
</span>
<h1 className="mt-3 text-4xl font-bold text-slate-900 dark:text-slate-50">
Beautiful Chat
</h1>
<p className="mt-2 text-slate-600 dark:text-slate-400 max-w-prose">
A polished starter layout: the built-in CopilotChat dropped onto a
two-pane canvas, with suggestions pre-wired and the disclaimer slot
customized away. Everything is theme-aware and responsive.
</p>
</header>
<div className="grid grid-cols-2 gap-4 max-w-3xl">
<Card title="In-process runner">
The CopilotKit runtime runs inside the Next.js route handler there
is no separate agent server.
</Card>
<Card title="TanStack AI">
Backend tools defined with <code>toolDefinition()</code> stream
through the agent factory.
</Card>
<Card title="Tailwind theme">
Colors come from the global CopilotKit CSS variables; flip the page to
dark mode and the chat follows.
</Card>
<Card title="Drop-in primitives">
<code>&lt;CopilotChat /&gt;</code> ships with messages, input,
suggestions, and disclaimer slots out of the box.
</Card>
</div>
</main>
);
}
function Card({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-2xl border border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-slate-900/80 p-5 shadow-sm">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-50">
{title}
</h3>
<p className="mt-1 text-xs text-slate-600 dark:text-slate-400 leading-relaxed">
{children}
</p>
</div>
);
}
function Suggestions() {
useConfigureSuggestions({
suggestions: [
{ title: "Say hi", message: "Say hi!" },
{
title: "Weather check",
message: "What's the weather in San Francisco?",
},
{ title: "Compose a haiku", message: "Write a haiku about coffee." },
],
available: "always",
});
return null;
}
@@ -0,0 +1,17 @@
# BYOC: Hashbrown (built-in-agent)
Streaming structured output via `@hashbrownai/react`. The built-in-agent
factory emits a catalog-constrained `{ ui: [...] }` JSON envelope; the
hashbrown `useJsonParser` + `useUiKit` parses it progressively and renders
MetricCard + PieChart + BarChart + DealCard + Markdown.
The agent is forced to JSON-object output via OpenAI's `response_format:
{ type: "json_object" }` (`modelOptions` on the TanStack `chat()` call) so
the parser never has to tolerate code fences or preamble.
- Dedicated route: `/api/copilotkit-byoc-hashbrown`
- Single-route mode (`useSingleEndpoint`)
- Key files: `page.tsx`, `hashbrown-renderer.tsx`, `metric-card.tsx`,
`charts/`, `suggestions.ts`, `types.ts`,
`../../api/copilotkit-byoc-hashbrown/route.ts`,
`../../../lib/factory/byoc-hashbrown-factory.ts`
@@ -0,0 +1,194 @@
"use client";
/**
* BarChart ported from showcase/starters/template/frontend/components/charts/bar-chart.tsx
* for the byoc-hashbrown demo (Wave 4a).
*
* Adds data-testid="bar-chart" on the top-level container for E2E coverage.
*/
import { useRef } from "react";
import {
BarChart as RechartsBarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Cell,
ResponsiveContainer,
Rectangle,
} from "recharts";
import { z } from "zod";
import { CHART_COLORS, CHART_CONFIG } from "./chart-config";
export const BarChartProps = z.object({
title: z.string().describe("Chart title"),
description: z.string().describe("Brief description or subtitle"),
data: z.array(
z.object({
label: z.string(),
value: z.number(),
}),
),
});
type BarChartPropsType = z.infer<typeof BarChartProps>;
/** Tracks seen indices so only NEW bars get the fade-in animation. */
function useSeenIndices() {
const seen = useRef(new Set<number>());
return {
isNew(index: number) {
if (seen.current.has(index)) return false;
seen.current.add(index);
return true;
},
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function AnimatedBar(props: any) {
const { isNew, ...rest } = props;
return (
<g
style={
isNew
? {
animation: "barSlideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both",
}
: undefined
}
>
<Rectangle {...rest} />
</g>
);
}
export function BarChart({ title, description, data }: BarChartPropsType) {
const { isNew } = useSeenIndices();
if (!data || !Array.isArray(data) || data.length === 0) {
return (
<div
data-testid="bar-chart"
className="max-w-2xl mx-auto my-4 rounded-lg border border-[var(--border)] bg-[var(--card)]"
>
<div className="p-6">
<div className="flex items-center gap-2">
<svg
className="h-4 w-4 text-[var(--muted-foreground)]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
{title}
</h3>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
{description}
</p>
</div>
<div className="p-6 pt-0">
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
No data available
</p>
</div>
</div>
);
}
return (
<div
data-testid="bar-chart"
className="max-w-2xl mx-auto my-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]"
>
{/* Scoped keyframe -- no globals.css needed */}
<style>{`
@keyframes barSlideIn {
from { transform: translateY(40px); opacity: 0; }
20% { opacity: 1; }
to { transform: translateY(0); opacity: 1; }
}
`}</style>
<div className="p-6 pb-2">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center h-6 w-6 rounded-md bg-[var(--secondary)]">
<svg
className="h-3.5 w-3.5 text-[var(--muted-foreground)]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
</div>
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
{title}
</h3>
</div>
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
</div>
<div className="p-6 pt-2">
<ResponsiveContainer width="100%" height={280}>
<RechartsBarChart
data={data}
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_CONFIG.tooltipStyle}
cursor={{ fill: "var(--secondary)", opacity: 0.5 }}
/>
<Bar
isAnimationActive={false}
dataKey="value"
radius={[6, 6, 0, 0]}
maxBarSize={48}
shape={(props: unknown) => {
const p = props as Record<string, unknown>;
return <AnimatedBar {...p} isNew={isNew(p.index as number)} />;
}}
>
{data.map((_, index) => (
<Cell
key={index}
fill={CHART_COLORS[index % CHART_COLORS.length]}
/>
))}
</Bar>
</RechartsBarChart>
</ResponsiveContainer>
</div>
</div>
);
}
@@ -0,0 +1,28 @@
/**
* CopilotKit brand chart palette -- Plus Jakarta Sans / brand color system.
*
* Ported verbatim from showcase/starters/template/frontend/components/charts/chart-config.ts
* for the byoc-hashbrown demo (Wave 4a).
*/
export const CHART_COLORS = [
"#BEC2FF", // lilac-400
"#85ECCE", // mint-400
"#FFAC4D", // orange-400
"#FFF388", // yellow-400
"#189370", // mint-800
"#EEE6FE", // primary-100
"#FA5F67", // red-400
] as const;
export const CHART_CONFIG = {
tooltipStyle: {
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "10px",
padding: "10px 14px",
color: "var(--foreground)",
fontSize: "13px",
fontFamily: "var(--font-body)",
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
},
};
@@ -0,0 +1,168 @@
"use client";
/**
* PieChart ported from showcase/starters/template/frontend/components/charts/pie-chart.tsx
* for the byoc-hashbrown demo (Wave 4a).
*
* Adds data-testid="pie-chart" on the top-level container for E2E coverage.
*/
import { z } from "zod";
import { CHART_COLORS } from "./chart-config";
export const PieChartProps = z.object({
title: z.string().describe("Chart title"),
description: z.string().describe("Brief description or subtitle"),
data: z.array(
z.object({
label: z.string(),
value: z.number(),
}),
),
});
type PieChartPropsType = z.infer<typeof PieChartProps>;
/** Custom SVG donut chart built with <circle> + stroke-dasharray. */
function DonutChart({
data,
size = 240,
strokeWidth = 40,
}: {
data: { label: string; value: number }[];
size?: number;
strokeWidth?: number;
}) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const center = size / 2;
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
// Calculate each slice's arc length and starting position
let accumulated = 0;
const slices = data.map((item, index) => {
const val = Number(item.value) || 0;
const ratio = total > 0 ? val / total : 0;
const arc = ratio * circumference;
const startAt = accumulated;
accumulated += arc;
return {
...item,
arc,
gap: circumference - arc,
// Negative dashoffset shifts the dash forward (clockwise) to the correct position
dashoffset: -startAt,
color: CHART_COLORS[index % CHART_COLORS.length],
};
});
return (
<svg
width="100%"
viewBox={`0 0 ${size} ${size}`}
className="block mx-auto"
style={{ maxWidth: size, transform: "scaleX(-1)" }}
>
{/* Background ring */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="var(--secondary)"
strokeWidth={strokeWidth}
/>
{/* Data slices */}
{slices.map((slice, i) => (
<circle
key={i}
cx={center}
cy={center}
r={radius}
fill="none"
stroke={slice.color}
strokeWidth={strokeWidth}
strokeDasharray={`${slice.arc} ${slice.gap}`}
strokeDashoffset={slice.dashoffset}
strokeLinecap="butt"
transform={`rotate(-90 ${center} ${center})`}
/>
))}
</svg>
);
}
export function PieChart({ title, description, data }: PieChartPropsType) {
if (!data || !Array.isArray(data) || data.length === 0) {
return (
<div
data-testid="pie-chart"
className="max-w-lg mx-auto my-4 rounded-lg border border-[var(--border)] bg-[var(--card)]"
>
<div className="p-6 pb-0">
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
{title}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{description}
</p>
</div>
<div className="p-6">
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
No data available
</p>
</div>
</div>
);
}
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
return (
<div
data-testid="pie-chart"
className="max-w-lg mx-auto my-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]"
>
<div className="p-6 pb-0">
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
{title}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
</div>
<div className="p-6 pt-4">
<DonutChart data={data} />
{/* Legend */}
<div className="space-y-2 pt-4">
{data.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 transition-opacity duration-300 ease-out"
style={{ opacity: 1 }}
>
<span
className="inline-block h-3 w-3 rounded-full shrink-0"
style={{
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
}}
/>
<span className="flex-1 text-[var(--foreground)] truncate">
{item.label}
</span>
<span className="text-[var(--muted-foreground)] tabular-nums">
{val.toLocaleString()}
</span>
<span className="text-[var(--muted-foreground)] text-sm w-10 text-right tabular-nums">
{pct}%
</span>
</div>
);
})}
</div>
</div>
</div>
);
}
@@ -0,0 +1,365 @@
"use client";
/**
* HashBrown message renderer for the byoc-hashbrown demo (Wave 4a).
*
* Ported from showcase/starters/template/frontend/components/renderers/hashbrown/index.tsx
* with these adjustments:
* - MetricCard lives in ./metric-card (extracted module).
* - Charts live under ./charts/ (co-located in the demo).
* - SalesStage type lives in ./types.
*
* Registers MetricCard + PieChart + BarChart + DealCard + Markdown against the
* hashbrown schema via `@hashbrownai/react`'s `useUiKit`. Renders assistant
* messages through `useJsonParser` for progressive JSON→UI streaming.
*
* Wire format
* -----------
* `useJsonParser(content, kit.schema)` parses a streaming JSON object of the
* shape produced by `createUiKit(...).schema`:
*
* {
* "ui": [
* { "metric": { "props": { "label": "...", "value": "..." } } },
* { "pieChart": { "props": { "title": "...", "data": "[{...}]" } } },
* { "Markdown": { "props": { "children": "..." } } }
* ]
* }
*
* The `useUiKit({ examples: ... })` `<ui>` JSX is hashbrown's prompt DSL used
* when hashbrown drives the LLM directly (e.g. `useUiChat`). Because this
* demo drives the LLM via langgraph, the backend agent
* (`src/agents/byoc_hashbrown_agent.py`) is responsible for emitting the JSON
* shape shown above.
*
* Consume the renderer like this in a page:
*
* <HashBrownDashboard>
* <CopilotChat RenderMessage={useHashBrownMessageRenderer()} />
* </HashBrownDashboard>
*/
import React, { memo } from "react";
import { s, prompt } from "@hashbrownai/core";
import {
exposeComponent,
exposeMarkdown,
useUiKit,
useJsonParser,
} from "@hashbrownai/react";
import { PieChart } from "./charts/pie-chart";
import { BarChart } from "./charts/bar-chart";
import { MetricCard } from "./metric-card";
import type { SalesStage } from "./types";
/**
* The underlying PieChart / BarChart components take `data` as a real
* array, but hashbrown's build-time validator (0.5.0-beta.4) rejects
* example prompts whose JSX attribute values don't match the schema
* type — i.e. `data='[{"label":"A","value":1}]'` (string) doesn't pass
* an `s.streaming.array(...)` schema. And since the LLM streams JSON as
* text anyway, modeling `data` as a string prop and parsing inside the
* component is the path the example syntax naturally supports.
*
* These wrappers accept `data: string`, JSON-parse it, and render the
* real chart. Defensive — silently render nothing if parse fails mid-
* stream (hashbrown feeds partial tokens while streaming).
*/
type ChartSlice = { label: string; value: number };
function parseChartData(data: string): ChartSlice[] | null {
try {
const parsed = JSON.parse(data);
if (!Array.isArray(parsed)) return null;
return parsed as ChartSlice[];
} catch {
return null;
}
}
function PieChartWithStringData({
title,
data,
}: {
title: string;
data: string;
}) {
const parsed = parseChartData(data);
if (!parsed) return null;
return <PieChart title={title} description="" data={parsed} />;
}
function BarChartWithStringData({
title,
data,
}: {
title: string;
data: string;
}) {
const parsed = parseChartData(data);
if (!parsed) return null;
return <BarChart title={title} description="" data={parsed} />;
}
/**
* Minimal local types for the CopilotChat RenderMessage slot + AG-UI
* assistant message shape. These mirror `RenderMessageProps` from
* `@copilotkit/react-ui` and `AssistantMessage` from `@ag-ui/core`, inlined
* to avoid adding those packages as direct dependencies of langgraph-python
* (they come in transitively via `@copilotkit/react-core`).
*
* Only the fields the renderer reads are declared.
*/
interface LocalAssistantMessage {
role: "assistant";
content?: string;
}
interface LocalChatMessage {
role: string;
content?: string;
}
interface LocalRenderMessageProps {
message: LocalChatMessage;
}
// ---------------------------------------------------------------------------
// Standalone DealCard for the kit (flat props, no SalesTodo dependency)
// ---------------------------------------------------------------------------
interface HashBrownDealCardProps {
title: string;
stage: SalesStage;
value: number;
assignee?: string;
dueDate?: string;
}
const STAGE_COLORS: Record<SalesStage, string> = {
prospect: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
qualified:
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
proposal: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200",
negotiation:
"bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
"closed-won":
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
"closed-lost": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
};
function DealCardComponent({
title,
stage,
value,
assignee,
dueDate,
}: HashBrownDealCardProps) {
const badgeClass =
STAGE_COLORS[stage] ??
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200";
return (
<div
data-testid="hashbrown-deal-card"
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4"
>
<h3 className="text-sm font-semibold leading-snug text-[var(--foreground)]">
{title}
</h3>
<div className="mt-2 flex items-center gap-2 flex-wrap">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass}`}
>
{stage}
</span>
<span className="text-sm font-semibold text-[var(--foreground)]">
${(value ?? 0).toLocaleString()}
</span>
</div>
<div className="mt-2 flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
{assignee && <span>{assignee}</span>}
{dueDate && <span>Due {dueDate}</span>}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Kit definition
// ---------------------------------------------------------------------------
export function useSalesDashboardKit() {
return useUiKit({
examples: prompt`
# Mixing components and Markdown:
<ui>
<Markdown children="## Q4 Sales Summary" />
<metric label="Total Revenue" value="$1.2M" />
<Markdown children="Revenue breakdown by segment:" />
<pieChart title="Revenue by Segment" data='[{"label":"Enterprise","value":600000},{"label":"SMB","value":400000},{"label":"Startup","value":200000}]' />
<barChart title="Monthly Trend" data='[{"label":"Oct","value":350000},{"label":"Nov","value":400000},{"label":"Dec","value":450000}]' />
<dealCard title="Acme Corp Renewal" stage="negotiation" value="250000" />
</ui>
Hint: use Markdown for explanatory text between visual components.
Hint: always include title and data for charts. Data is a JSON-encoded
array of {label, value} objects as a string.
`,
components: [
exposeMarkdown(),
exposeComponent(MetricCard, {
name: "metric",
description: "A KPI metric card with label, value, and optional trend",
// Note on "optional" props: @hashbrownai/core@0.5.0-beta.4 dropped
// the `.optional()` chain in favor of treating component prop schemas
// as Partial at the exposeComponent layer. We omit optional keys from
// the schema and surface their existence to the LLM via the
// `examples` prompt above and the natural-language `description`.
props: {
label: s.string("The metric label/name"),
value: s.string("The metric value (formatted)"),
},
}),
exposeComponent(PieChartWithStringData, {
name: "pieChart",
description:
"A donut/pie chart. `data` is a JSON-encoded string of an " +
"array of {label, value} segments, e.g. " +
'\'[{"label":"A","value":1}]\'.',
props: {
title: s.string("Chart title"),
data: s.string("JSON array of {label, value} segments"),
},
}),
exposeComponent(BarChartWithStringData, {
name: "barChart",
description:
"A vertical bar chart. `data` is a JSON-encoded string of an " +
"array of {label, value} bars, e.g. " +
'\'[{"label":"A","value":1}]\'.',
props: {
title: s.string("Chart title"),
data: s.string("JSON array of {label, value} bars"),
},
}),
exposeComponent(DealCardComponent, {
name: "dealCard",
description: "A sales deal card showing pipeline stage and value",
props: {
title: s.string("Deal title"),
stage: s.enumeration("Pipeline stage", [
"prospect",
"qualified",
"proposal",
"negotiation",
"closed-won",
"closed-lost",
]),
value: s.number("Deal value in dollars"),
},
}),
],
});
}
// ---------------------------------------------------------------------------
// Custom message renderer
// ---------------------------------------------------------------------------
const AssistantMessageRenderer = memo(function AssistantMessageRenderer({
message,
kit,
}: {
message: LocalAssistantMessage;
kit: ReturnType<typeof useSalesDashboardKit>;
}) {
const { value } = useJsonParser(message.content ?? "", kit.schema);
if (!value) return null;
// The CopilotChat default assistantMessage slot renders a wrapper with
// `data-testid="copilot-assistant-message"` — used by the harness'
// `e2e-deep` conversation runner to count settled responses. Overriding
// the slot drops that testid, so any tooling that waits for "an
// assistant message landed" never sees a count change. Re-attaching it
// here keeps the slot override behaviorally identical to the default
// for response-detection purposes.
return (
<div
data-testid="copilot-assistant-message"
data-message-role="assistant"
className="mt-2 flex w-full justify-start"
>
<div className="w-full px-1 py-1">{kit.render(value)}</div>
</div>
);
});
// ---------------------------------------------------------------------------
// Exported dashboard provider + renderer hook
// ---------------------------------------------------------------------------
export interface HashBrownDashboardProps {
/**
* Optional custom wrapper for the assistant message area.
* Defaults to rendering messages inline.
*/
children?: React.ReactNode;
}
/**
* Provider that instantiates the HashBrown kit ONCE and shares it via context.
* Both the dashboard layout and message renderer consume the same kit instance.
*
* The kit registers MetricCard, PieChart, BarChart, DealCard, and Markdown
* components. Agent context forwarding for output_schema is omitted because
* the npm-published react-core may not export useAgentContext yet.
*/
const HashBrownKitContext = React.createContext<ReturnType<
typeof useSalesDashboardKit
> | null>(null);
function useHashBrownKit() {
const kit = React.useContext(HashBrownKitContext);
if (!kit)
throw new Error("useHashBrownKit must be used within HashBrownDashboard");
return kit;
}
export function HashBrownDashboard({ children }: HashBrownDashboardProps) {
const kit = useSalesDashboardKit();
// Note: Agent context forwarding (useAgentContext) for output_schema is
// omitted because the npm-published react-core may not export it yet.
return (
<HashBrownKitContext.Provider value={kit}>
{children}
</HashBrownKitContext.Provider>
);
}
/**
* Stable message renderer component that consumes the kit from context.
* Defined at module level to avoid unstable function identity.
*/
function HashBrownRenderMessage({ message }: LocalRenderMessageProps) {
const kit = useHashBrownKit();
if (message.role === "assistant") {
return (
<AssistantMessageRenderer
message={message as LocalAssistantMessage}
kit={kit}
/>
);
}
return null;
}
/**
* Returns the stable HashBrownRenderMessage component.
* Must be used within a HashBrownDashboard provider.
*/
export function useHashBrownMessageRenderer() {
return HashBrownRenderMessage;
}
@@ -0,0 +1,46 @@
"use client";
/**
* MetricCard for the byoc-hashbrown demo (Wave 4a).
*
* Extracted from showcase/starters/template/frontend/components/renderers/hashbrown/index.tsx
* so the hashbrown-renderer can import it as its own module.
*/
import React from "react";
interface MetricCardProps {
label: string;
value: string;
trend?: string;
}
export function MetricCard({ label, value, trend }: MetricCardProps) {
const isPositive =
trend?.startsWith("+") || trend?.toLowerCase().includes("up");
const isNegative =
trend?.startsWith("-") || trend?.toLowerCase().includes("down");
const trendColor = isPositive
? "text-green-600 dark:text-green-400"
: isNegative
? "text-red-600 dark:text-red-400"
: "text-[var(--muted-foreground)]";
return (
<div
data-testid="metric-card"
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4"
>
<p className="text-xs text-[var(--muted-foreground)] uppercase tracking-wider font-medium">
{label}
</p>
<p className="text-2xl font-bold text-[var(--foreground)] mt-1">
{value}
</p>
{trend && (
<p data-testid="metric-trend" className={`text-sm mt-1 ${trendColor}`}>
{trend}
</p>
)}
</div>
);
}
@@ -0,0 +1,98 @@
"use client";
/**
* byoc-hashbrown demo page (Wave 4a).
*
* Dedicated single-mode demo that ports the starter's hashbrown renderer
* onto a langgraph-python agent. Streaming structured output from the agent
* (`byoc_hashbrown_agent`) is parsed progressively by `@hashbrownai/react`'s
* `useJsonParser` + `useUiKit` and rendered with MetricCard + PieChart +
* BarChart from `./charts/`.
*
* Layout:
* - Header with title + short description.
* - Chat composer with pre-seeded suggestion pills (via
* `useConfigureSuggestions`). Clicking a pill sends the canned prompt.
* - Assistant messages are routed through `HashBrownAssistantMessage` via
* `<CopilotChat messageView={{ assistantMessage: ... }} />`.
*
* Runtime: dedicated endpoint `/api/copilotkit-byoc-hashbrown` with its own
* agent — no bleed into the default runtime.
*/
import React from "react";
import {
CopilotKitProvider,
CopilotChat,
CopilotChatAssistantMessage,
useConfigureSuggestions,
} from "@copilotkit/react-core/v2";
import {
HashBrownDashboard,
useHashBrownMessageRenderer,
} from "./hashbrown-renderer";
import { BYOC_HASHBROWN_SUGGESTIONS } from "./suggestions";
export default function ByocHashbrownDemoPage() {
return (
<CopilotKitProvider
runtimeUrl="/api/copilotkit-byoc-hashbrown"
useSingleEndpoint
>
<HashBrownDashboard>
<div className="flex h-screen flex-col gap-3 p-6">
<header>
<h1 className="text-lg font-semibold">BYOC: Hashbrown</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Streaming structured output via <code>@hashbrownai/react</code>.
The agent emits a catalog- constrained UI envelope that renders
progressively as data streams.
</p>
</header>
<div className="flex-1 overflow-hidden rounded-md border border-[var(--border)]">
<ChatBody />
</div>
</div>
</HashBrownDashboard>
</CopilotKitProvider>
);
}
function ChatBody() {
// Pre-seed the composer with canonical prompts that steer the agent toward
// hashbrown-shaped output. `useConfigureSuggestions` renders pills inside
// the CopilotChat composer; clicking a pill sends its `message` directly.
useConfigureSuggestions({
suggestions: BYOC_HASHBROWN_SUGGESTIONS.map((s) => ({
title: s.label,
message: s.prompt,
// E2E testid-friendly class — Playwright targets visible text, but we
// keep a class hook in case we need finer-grained selectors later.
className: `byoc-hashbrown-suggestion-${s.label
.toLowerCase()
.replace(/\s+/g, "-")}`,
})),
available: "always",
});
// Resolve the memoized HashBrownRenderMessage component from the kit
// provider. It consumes the shared kit via context (see
// hashbrown-renderer.tsx) and renders assistant messages as a progressively
// assembled UI catalog.
const HashBrownMessage = useHashBrownMessageRenderer();
return (
<CopilotChat
className="h-full"
messageView={{
// `HashBrownMessage` matches the RenderMessage slot shape ({ message })
// but the v2 assistantMessage slot expects CopilotChatAssistantMessage's
// wider props. The cast is intentional — the renderer reads only
// `message`, exactly like the starter's page does with `RenderMessage`
// on CopilotSidebar.
assistantMessage:
HashBrownMessage as unknown as typeof CopilotChatAssistantMessage,
}}
/>
);
}
@@ -0,0 +1,32 @@
/**
* Pre-seeded suggestion prompts for the byoc-hashbrown demo (Wave 4a).
*
* Each prompt is tuned to steer the agent toward emitting hashbrown-shaped
* structured output that the ported renderer (MetricCard + PieChart +
* BarChart) can progressively assemble via `@hashbrownai/react`'s `useUiKit`
* + `useJsonParser`.
*/
export interface Suggestion {
/** Short label rendered on the pill + used in data-testid suffix. */
label: string;
/** Full prompt sent to the agent when the pill is clicked. */
prompt: string;
}
export const BYOC_HASHBROWN_SUGGESTIONS: Suggestion[] = [
{
label: "Sales dashboard",
prompt:
"Show me a Q4 sales dashboard. Include a total-revenue metric card, a pie chart of revenue by segment, and a bar chart of monthly revenue.",
},
{
label: "Revenue by category",
prompt:
"Break down Q4 revenue by product category as a pie chart. Include at least four segments with realistic sample values.",
},
{
label: "Expense trend",
prompt:
"Show me monthly operating expenses for the last six months as a bar chart with one bar per month.",
},
];
@@ -0,0 +1,16 @@
/**
* Local types for the byoc-hashbrown demo (Wave 4a).
*
* Ported subset from showcase/starters/template/frontend/types.ts — only the
* domain types the hashbrown renderer's DealCard needs.
*/
export const SALES_STAGES = [
"prospect",
"qualified",
"proposal",
"negotiation",
"closed-won",
"closed-lost",
] as const;
export type SalesStage = (typeof SALES_STAGES)[number];
@@ -0,0 +1,16 @@
# BYOC: json-render (built-in-agent)
Streaming structured output via `@json-render/react`. The built-in-agent
factory emits a flat `{ root, elements }` spec; the
`JsonRenderAssistantMessage` slot validates it against the Zod-typed
catalog and feeds it to `<Renderer />` against a shared registry.
The agent is forced to JSON-object output via OpenAI's `response_format:
{ type: "json_object" }` (`modelOptions` on the TanStack `chat()` call).
- Dedicated route: `/api/copilotkit-byoc-json-render`
- Single-route mode (`useSingleEndpoint`)
- Key files: `page.tsx`, `json-render-renderer.tsx`, `registry.tsx`,
`catalog.ts`, `metric-card.tsx`, `charts/`, `suggestions.ts`,
`types.ts`, `../../api/copilotkit-byoc-json-render/route.ts`,
`../../../lib/factory/byoc-json-render-factory.ts`
@@ -0,0 +1,74 @@
/**
* json-render catalog for the byoc-json-render demo.
*
* We reuse the prebuilt React schema exposed by `@json-render/react/schema`
* (flat element tree: `{ root, elements }`) rather than defining our own,
* because the React renderer we ship is `@json-render/react`'s `<Renderer />`
* which expects that exact shape.
*
* The catalog declares three components (MetricCard, BarChart, PieChart)
* matching Wave 4a's hashbrown catalog so the two BYOC demos are directly
* comparable. No actions are declared — this is a read-only rendering demo.
*/
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
/** Numeric data point used by both bar and pie charts. */
const dataPoint = z.object({
label: z.string(),
value: z.number(),
});
export const catalog = defineCatalog(schema, {
components: {
MetricCard: {
props: z.object({
label: z.string(),
value: z.string(),
trend: z.string().nullable(),
}),
description:
"A labelled metric (single number) with an optional trend subtitle",
},
BarChart: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
data: z.array(dataPoint),
}),
description:
"A vertical bar chart for comparing discrete values side by side",
},
PieChart: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
data: z.array(dataPoint),
}),
description:
"A donut-style pie chart for breaking a total down into category slices",
},
},
actions: {},
});
/**
* Human-readable catalog description. Used by the agent's system prompt
* so the LLM's available component list stays in lockstep with this file
* (single source of truth, mirrors R4 in the spec).
*/
export const CATALOG_DESCRIPTION = `
Available components (use each as the "type" field of an element):
- MetricCard
props: { label: string, value: string, trend: string | null }
Example trend strings: "+12% vs last quarter", "-3% vs last month", null.
- BarChart
props: { title: string, description: string | null, data: [{ label: string, value: number }] }
- PieChart
props: { title: string, description: string | null, data: [{ label: string, value: number }] }
`.trim();
@@ -0,0 +1,159 @@
"use client";
import { useRef } from "react";
import {
BarChart as RechartsBarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Cell,
ResponsiveContainer,
Rectangle,
} from "recharts";
import { CHART_COLORS, CHART_CONFIG } from "./chart-config";
export interface BarChartDatum {
label: string;
value: number;
}
export interface BarChartComponentProps {
title: string;
/** Optional subtitle — json-render catalog emits a nullable string. */
description?: string | null;
data: BarChartDatum[];
}
/** Tracks seen indices so only NEW bars get the fade-in animation. */
function useSeenIndices() {
const seen = useRef(new Set<number>());
return {
isNew(index: number) {
if (seen.current.has(index)) return false;
seen.current.add(index);
return true;
},
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function AnimatedBar(props: any) {
const { isNew, ...rest } = props;
return (
<g
style={
isNew
? {
animation: "barSlideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both",
}
: undefined
}
>
<Rectangle {...rest} />
</g>
);
}
export function BarChart({ title, description, data }: BarChartComponentProps) {
const { isNew } = useSeenIndices();
if (!data || !Array.isArray(data) || data.length === 0) {
return (
<div
data-testid="bar-chart"
className="max-w-2xl mx-auto my-4 rounded-lg border border-[var(--border)] bg-[var(--card)]"
>
<div className="p-6">
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
{title}
</h3>
{description ? (
<p className="text-sm text-[var(--muted-foreground)]">
{description}
</p>
) : null}
</div>
<div className="p-6 pt-0">
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
No data available
</p>
</div>
</div>
);
}
return (
<div
data-testid="bar-chart"
className="max-w-2xl mx-auto my-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]"
>
<style>{`
@keyframes barSlideIn {
from { transform: translateY(40px); opacity: 0; }
20% { opacity: 1; }
to { transform: translateY(0); opacity: 1; }
}
`}</style>
<div className="p-6 pb-2">
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
{title}
</h3>
{description ? (
<p className="text-sm text-[var(--muted-foreground)]">
{description}
</p>
) : null}
</div>
<div className="p-6 pt-2">
<ResponsiveContainer width="100%" height={280}>
<RechartsBarChart
data={data}
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_CONFIG.tooltipStyle}
cursor={{ fill: "var(--secondary)", opacity: 0.5 }}
/>
<Bar
isAnimationActive={false}
dataKey="value"
radius={[6, 6, 0, 0]}
maxBarSize={48}
shape={(props: unknown) => {
const p = props as Record<string, unknown>;
return <AnimatedBar {...p} isNew={isNew(p.index as number)} />;
}}
>
{data.map((_, index) => (
<Cell
key={index}
fill={CHART_COLORS[index % CHART_COLORS.length]}
/>
))}
</Bar>
</RechartsBarChart>
</ResponsiveContainer>
</div>
</div>
);
}
@@ -0,0 +1,28 @@
/**
* CopilotKit brand chart palette — Plus Jakarta Sans / brand color system.
* Ported from `showcase/starters/template/frontend/components/charts/chart-config.ts`
* to keep the byoc-json-render demo self-contained (per spec decision: each demo
* maintains its own catalog component copies).
*/
export const CHART_COLORS = [
"#BEC2FF", // lilac-400
"#85ECCE", // mint-400
"#FFAC4D", // orange-400
"#FFF388", // yellow-400
"#189370", // mint-800
"#EEE6FE", // primary-100
"#FA5F67", // red-400
] as const;
export const CHART_CONFIG = {
tooltipStyle: {
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "10px",
padding: "10px 14px",
color: "var(--foreground)",
fontSize: "13px",
fontFamily: "var(--font-body)",
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
},
};
@@ -0,0 +1,160 @@
"use client";
import { CHART_COLORS } from "./chart-config";
export interface PieChartDatum {
label: string;
value: number;
}
export interface PieChartComponentProps {
title: string;
/** Optional subtitle — json-render catalog emits a nullable string. */
description?: string | null;
data: PieChartDatum[];
}
/** Custom SVG donut chart built with <circle> + stroke-dasharray. */
function DonutChart({
data,
size = 240,
strokeWidth = 40,
}: {
data: PieChartDatum[];
size?: number;
strokeWidth?: number;
}) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const center = size / 2;
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
let accumulated = 0;
const slices = data.map((item, index) => {
const val = Number(item.value) || 0;
const ratio = total > 0 ? val / total : 0;
const arc = ratio * circumference;
const startAt = accumulated;
accumulated += arc;
return {
...item,
arc,
gap: circumference - arc,
dashoffset: -startAt,
color: CHART_COLORS[index % CHART_COLORS.length],
};
});
return (
<svg
width="100%"
viewBox={`0 0 ${size} ${size}`}
className="block mx-auto"
style={{ maxWidth: size, transform: "scaleX(-1)" }}
>
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="var(--secondary)"
strokeWidth={strokeWidth}
/>
{slices.map((slice, i) => (
<circle
key={i}
cx={center}
cy={center}
r={radius}
fill="none"
stroke={slice.color}
strokeWidth={strokeWidth}
strokeDasharray={`${slice.arc} ${slice.gap}`}
strokeDashoffset={slice.dashoffset}
strokeLinecap="butt"
transform={`rotate(-90 ${center} ${center})`}
/>
))}
</svg>
);
}
export function PieChart({ title, description, data }: PieChartComponentProps) {
if (!data || !Array.isArray(data) || data.length === 0) {
return (
<div
data-testid="pie-chart"
className="max-w-lg mx-auto my-4 rounded-lg border border-[var(--border)] bg-[var(--card)]"
>
<div className="p-6 pb-0">
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
{title}
</h3>
{description ? (
<p className="text-sm text-[var(--muted-foreground)]">
{description}
</p>
) : null}
</div>
<div className="p-6">
<p className="text-[var(--muted-foreground)] text-center py-8 text-sm">
No data available
</p>
</div>
</div>
);
}
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
return (
<div
data-testid="pie-chart"
className="max-w-lg mx-auto my-4 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]"
>
<div className="p-6 pb-0">
<h3 className="text-lg font-semibold leading-none tracking-tight text-[var(--foreground)]">
{title}
</h3>
{description ? (
<p className="text-sm text-[var(--muted-foreground)]">
{description}
</p>
) : null}
</div>
<div className="p-6 pt-4">
<DonutChart data={data} />
<div className="space-y-2 pt-4">
{data.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 transition-opacity duration-300 ease-out"
style={{ opacity: 1 }}
>
<span
className="inline-block h-3 w-3 rounded-full shrink-0"
style={{
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
}}
/>
<span className="flex-1 text-[var(--foreground)] truncate">
{item.label}
</span>
<span className="text-[var(--muted-foreground)] tabular-nums">
{val.toLocaleString()}
</span>
<span className="text-[var(--muted-foreground)] text-sm w-10 text-right tabular-nums">
{pct}%
</span>
</div>
);
})}
</div>
</div>
</div>
);
}
@@ -0,0 +1,184 @@
"use client";
/**
* Custom `messageView.assistantMessage` slot that renders the agent's
* structured-JSON output through `@json-render/react`.
*
* The langgraph-python `byoc_json_render_agent` emits a single JSON object
* shaped like `@json-render/react`'s flat spec format:
*
* ```json
* {
* "root": "<id>",
* "elements": {
* "<id>": { "type": "MetricCard" | "BarChart" | "PieChart", "props": { ... } }
* }
* }
* ```
*
* While the agent streams, the content is often a partial/invalid JSON
* string — we fall back to the default CopilotChatAssistantMessage which
* shows the raw streaming text. Once the content parses AND every
* referenced element type is in the catalog, we swap to the json-render
* Renderer + our catalog-backed registry (see `registry.tsx`).
*
* Streaming-model decision (R2 in the spec): `@json-render/core`'s
* SpecStream compiler consumes JSONL patches, but our agent emits a
* single JSON object, not patches. We buffer until the content is valid
* JSON, then render — losing progressive in-JSON rendering but gaining
* correct behaviour against the agent's actual output shape.
*/
import React, { useMemo } from "react";
import { CopilotChatAssistantMessage } from "@copilotkit/react-core/v2";
import type { CopilotChatAssistantMessageProps } from "@copilotkit/react-core/v2";
import { JSONUIProvider, Renderer } from "@json-render/react";
import { registry } from "./registry";
import type { JsonRenderSpec } from "./types";
// Allowed component types per the catalog (see ./catalog.ts). Kept in sync
// manually rather than derived from the catalog object, because the agent
// output may contain stray tokens while streaming and we want a defensive
// allowlist here too.
const ALLOWED_TYPES = new Set(["MetricCard", "BarChart", "PieChart"]);
export function JsonRenderAssistantMessage(
props: CopilotChatAssistantMessageProps,
) {
const content =
typeof props.message.content === "string" ? props.message.content : "";
const parseResult = useMemo(() => parseSpec(content), [content]);
// Still streaming or not valid spec yet — fall through to the default
// assistant-message chrome (renders the raw text via Streamdown). This
// keeps the bubble visually consistent during streaming, and if the
// agent replied with plain text (e.g. an unprompted free-form answer)
// we still render it sensibly.
if (!parseResult.ok) {
return <CopilotChatAssistantMessage {...props} />;
}
// Valid spec — render via json-render. `<Renderer />` alone does not
// set up the StateProvider / VisibilityProvider / ActionProvider /
// ValidationProvider contexts its `ElementRenderer` requires (it would
// crash with `useVisibility must be used within a VisibilityProvider`).
// `JSONUIProvider` wires all four in one; we don't use actions or
// state here, so defaults are fine.
return (
<div data-testid="json-render-root" className="w-full">
<JSONUIProvider registry={registry}>
<Renderer
spec={
parseResult.spec as unknown as Parameters<
typeof Renderer
>[0]["spec"]
}
registry={registry}
/>
</JSONUIProvider>
</div>
);
}
interface ParseOk {
ok: true;
spec: JsonRenderSpec;
}
interface ParseFail {
ok: false;
reason: "empty" | "invalid-json" | "wrong-shape" | "unknown-type";
}
type ParseResult = ParseOk | ParseFail;
/**
* Parse the assistant message content into a json-render spec.
*
* Tolerates:
* - code-fenced JSON (```json ... ```)
* - leading/trailing prose around the JSON object
* - partial streams (returns `invalid-json` silently)
*/
function parseSpec(raw: string): ParseResult {
if (!raw || !raw.trim()) return { ok: false, reason: "empty" };
const jsonText = extractJsonObject(raw);
if (!jsonText) return { ok: false, reason: "invalid-json" };
let parsed: unknown;
try {
parsed = JSON.parse(jsonText);
} catch {
return { ok: false, reason: "invalid-json" };
}
if (!isRecord(parsed)) return { ok: false, reason: "wrong-shape" };
const root = (parsed as { root?: unknown }).root;
const elements = (parsed as { elements?: unknown }).elements;
if (typeof root !== "string" || !isRecord(elements)) {
return { ok: false, reason: "wrong-shape" };
}
if (!(root in (elements as Record<string, unknown>))) {
return { ok: false, reason: "wrong-shape" };
}
for (const [, el] of Object.entries(elements as Record<string, unknown>)) {
if (!isRecord(el)) return { ok: false, reason: "wrong-shape" };
const type = (el as { type?: unknown }).type;
if (typeof type !== "string" || !ALLOWED_TYPES.has(type)) {
return { ok: false, reason: "unknown-type" };
}
const elProps = (el as { props?: unknown }).props;
if (elProps !== undefined && !isRecord(elProps)) {
return { ok: false, reason: "wrong-shape" };
}
}
return { ok: true, spec: parsed as unknown as JsonRenderSpec };
}
/** Strip code fences and find the first balanced JSON object. */
function extractJsonObject(raw: string): string | null {
const fenceMatch = /```(?:json)?\s*([\s\S]*?)```/i.exec(raw);
const candidate = (fenceMatch ? fenceMatch[1] : raw).trim();
if (!candidate) return null;
const start = candidate.indexOf("{");
if (start === -1) return null;
// Walk balanced braces, respecting strings and escapes.
let depth = 0;
let inString = false;
let escape = false;
for (let i = start; i < candidate.length; i++) {
const ch = candidate[i];
if (escape) {
escape = false;
continue;
}
if (inString) {
if (ch === "\\") {
escape = true;
} else if (ch === '"') {
inString = false;
}
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth === 0) return candidate.slice(start, i + 1);
}
}
return null;
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
@@ -0,0 +1,74 @@
"use client";
import type { MetricCardTrendDirection } from "./types";
export interface MetricCardComponentProps {
label: string;
value: string;
/** Optional trend copy, e.g. "+12% vs last quarter". */
trend?: string | null;
/** Optional trend direction hint, drives the colored badge. */
trendDirection?: MetricCardTrendDirection | null;
}
/**
* Presentational metric card used by the json-render catalog.
*
* The component is intentionally self-contained — the catalog shape
* (`{ label, value, trend }`) mirrors Wave 4a's hashbrown MetricCard
* so the two BYOC demos are directly comparable.
*/
export function MetricCard({
label,
value,
trend,
trendDirection,
}: MetricCardComponentProps) {
const resolvedDirection =
trendDirection ?? inferTrendDirection(trend ?? null);
return (
<div
data-testid="metric-card"
className="max-w-xs mx-auto my-3 rounded-lg border border-[var(--border)] bg-[var(--card)] p-5"
>
<div className="text-xs font-medium uppercase tracking-wider text-[var(--muted-foreground)]">
{label}
</div>
<div className="mt-1 text-3xl font-semibold tabular-nums text-[var(--foreground)]">
{value}
</div>
{trend ? (
<div
className={`mt-2 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass(
resolvedDirection,
)}`}
>
{trend}
</div>
) : null}
</div>
);
}
function inferTrendDirection(trend: string | null): MetricCardTrendDirection {
if (!trend) return "neutral";
const normalized = trend.trim();
if (normalized.startsWith("+")) return "up";
if (normalized.startsWith("-") || normalized.startsWith("")) {
return "down";
}
return "neutral";
}
function badgeClass(direction: MetricCardTrendDirection): string {
switch (direction) {
case "up":
return "bg-[color-mix(in_oklab,var(--primary)_15%,transparent)] text-[var(--primary)]";
case "down":
return "bg-[color-mix(in_oklab,var(--destructive)_15%,transparent)] text-[var(--destructive)]";
case "neutral":
default:
return "bg-[var(--secondary)] text-[var(--muted-foreground)]";
}
}
@@ -0,0 +1,62 @@
"use client";
/**
* BYOC json-render demo.
*
* Scenario: user asks for a sales-dashboard-style UI; the LangGraph agent
* emits a JSON spec shaped like `{ root, elements }`, and `@json-render/react`
* renders it against a Zod-validated catalog of three components
* (MetricCard, BarChart, PieChart).
*
* Structurally mirrors Wave 4a's hashbrown demo so the two dashboard rows
* are directly comparable — the only substantive difference is the message
* renderer (this file swaps in `JsonRenderAssistantMessage`).
*/
import React from "react";
import {
CopilotKitProvider,
CopilotChat,
CopilotChatAssistantMessage,
useConfigureSuggestions,
} from "@copilotkit/react-core/v2";
import { JsonRenderAssistantMessage } from "./json-render-renderer";
import { BYOC_JSON_RENDER_SUGGESTIONS } from "./suggestions";
export default function ByocJsonRenderDemo() {
return (
<CopilotKitProvider
runtimeUrl="/api/copilotkit-byoc-json-render"
useSingleEndpoint
>
<div className="flex justify-center items-center h-screen w-full">
<div className="h-full w-full max-w-4xl">
<Chat />
</div>
</div>
</CopilotKitProvider>
);
}
function Chat() {
useConfigureSuggestions({
suggestions: BYOC_JSON_RENDER_SUGGESTIONS.map((s) => ({
title: s.label,
message: s.prompt,
})),
available: "always",
});
// `messageView.assistantMessage` replaces CopilotChat's default assistant
// bubble. The cast mirrors the pattern used in `demos/chat-slots/page.tsx`
// — the slot's prop shape is identical to `CopilotChatAssistantMessage`'s,
// but TypeScript can't prove that through the WithSlots indirection.
const messageView = {
assistantMessage:
JsonRenderAssistantMessage as unknown as typeof CopilotChatAssistantMessage,
};
return (
<CopilotChat className="h-full rounded-2xl" messageView={messageView} />
);
}
@@ -0,0 +1,52 @@
/**
* Bridges the byoc-json-render catalog to concrete React components.
*
* `defineRegistry` returns a `registry` object that `@json-render/react`'s
* `<Renderer />` consumes. Each entry is a component function receiving
* `{ props, children }` — props are already validated + typed by the Zod
* schemas declared in `./catalog.ts`.
*
* Factored out of `json-render-renderer.tsx` so the registry is built
* exactly once at module scope (`defineRegistry` snapshots the catalog
* reference; rebuilding it per render would churn `<Renderer />`'s
* internal memoization unnecessarily).
*/
import { defineRegistry } from "@json-render/react";
import { catalog } from "./catalog";
import { MetricCard } from "./metric-card";
import { BarChart } from "./charts/bar-chart";
import { PieChart } from "./charts/pie-chart";
export const { registry } = defineRegistry(catalog, {
components: {
// The agent's system prompt includes a worked example where a MetricCard
// is the root of a sales dashboard with a BarChart nested in its
// `children` array. Forward `children` through so that multi-component
// dashboards render as one wrapped block rather than dropping the chart.
MetricCard: ({ props, children }) => (
<div className="flex w-full flex-col items-stretch gap-3">
<MetricCard
label={props.label}
value={props.value}
trend={props.trend}
/>
{children}
</div>
),
BarChart: ({ props }) => (
<BarChart
title={props.title}
description={props.description}
data={props.data}
/>
),
PieChart: ({ props }) => (
<PieChart
title={props.title}
description={props.description}
data={props.data}
/>
),
},
});
@@ -0,0 +1,28 @@
/**
* Suggestion prompts for the byoc-json-render demo.
*
* These match Wave 4a's (hashbrown) prompts verbatim so the two demos
* can be compared side-by-side in the dashboard.
*/
export interface Suggestion {
/** Short label shown on the pill. */
label: string;
/** Full prompt text sent to the agent when the pill is clicked. */
prompt: string;
}
export const BYOC_JSON_RENDER_SUGGESTIONS: Suggestion[] = [
{
label: "Sales dashboard",
prompt: "Show me the sales dashboard with metrics and a revenue chart",
},
{
label: "Revenue by category",
prompt: "Break down revenue by category as a pie chart",
},
{
label: "Expense trend",
prompt: "Show me monthly expenses as a bar chart",
},
];
@@ -0,0 +1,25 @@
/**
* Shared types for the byoc-json-render demo catalog.
*
* The catalog mirrors Wave 4a's (hashbrown) dashboard shapes so the two
* BYOC demos are directly comparable; the difference is purely in the
* rendering technology.
*/
export type MetricCardTrendDirection = "up" | "down" | "neutral";
/**
* Shape of the streaming JSON spec emitted by the agent. Matches
* `@json-render/react`'s Spec contract: a flat `elements` map keyed by
* id, with one designated `root` id.
*/
export interface JsonRenderElement {
type: string;
props: Record<string, unknown>;
children?: string[];
}
export interface JsonRenderSpec {
root: string;
elements: Record<string, JsonRenderElement>;
}
@@ -0,0 +1,25 @@
"use client";
// Chat Customization (CSS) — all theming lives in theme.css, scoped to the
// `.chat-css-demo-scope` wrapper. The page stays intentionally minimal;
// only <CopilotChat /> is visibly re-themed.
//
// https://docs.copilotkit.ai/custom-look-and-feel/customize-built-in-ui-components
import React from "react";
import { CopilotKitProvider, CopilotChat } from "@copilotkit/react-core/v2";
// @region[theme-css-import]
import "./theme.css";
// @endregion[theme-css-import]
export default function ChatCustomizationCssDemo() {
return (
<CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
<div className="flex justify-center items-center h-screen w-full">
<div className="chat-css-demo-scope h-full w-full max-w-4xl">
<CopilotChat agentId="default" className="h-full rounded-2xl" />
</div>
</div>
</CopilotKitProvider>
);
}
@@ -0,0 +1,102 @@
/* Scoped theme for the chat-customization-css demo.
* All selectors are prefixed with `.chat-css-demo-scope` so they do not
* leak out and affect the rest of the showcase app.
*
* Targets the CopilotKit built-in classes documented at
* https://docs.copilotkit.ai/custom-look-and-feel/customize-built-in-ui-components
*/
/* @region[css-variables] */
/* CopilotKit CSS variable overrides (accent colors, etc.) */
.chat-css-demo-scope {
--copilot-kit-primary-color: #ff006e;
--copilot-kit-contrast-color: #ffffff;
--copilot-kit-background-color: #fff8f0;
--copilot-kit-input-background-color: #fef3c7;
--copilot-kit-secondary-color: #fde047;
--copilot-kit-secondary-contrast-color: #2c1810;
--copilot-kit-separator-color: #ff006e;
--copilot-kit-muted-color: #c2185b;
}
/* @endregion[css-variables] */
/* Messages container swap the font for the entire chat */
.chat-css-demo-scope .copilotKitMessages {
font-family: "Georgia", "Cambria", "Times New Roman", serif;
background-color: #fff8f0;
color: #2c1810;
padding: 1.5rem 0;
}
/* User message bubble hot pink, bold white serif text, large */
.chat-css-demo-scope .copilotKitMessage.copilotKitUserMessage {
background: linear-gradient(135deg, #ff006e 0%, #c2185b 100%);
color: #ffffff;
font-family: "Georgia", "Cambria", "Times New Roman", serif;
font-size: 1.25rem;
font-weight: 700;
padding: 14px 20px;
border-radius: 22px 22px 4px 22px;
box-shadow: 0 6px 16px rgba(255, 0, 110, 0.35);
border: 2px solid #ff6fa5;
letter-spacing: 0.01em;
}
/* Assistant message bubble amber, monospace, boxy, dark text */
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage {
background: #fde047;
color: #1e1b4b;
font-family:
"JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
font-size: 1rem;
padding: 16px 20px;
border-radius: 4px 22px 22px 22px;
border: 2px solid #1e1b4b;
box-shadow: 4px 4px 0 #1e1b4b;
max-width: 80%;
margin-right: auto;
margin-bottom: 1rem;
}
/* Make markdown inside assistant bubble inherit our theme */
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
.copilotKitMarkdown,
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
.copilotKitMarkdown
p,
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage p {
color: #1e1b4b;
font-family: inherit;
font-size: inherit;
}
/* Input area themed border and font */
.chat-css-demo-scope .copilotKitInput {
font-family: "Georgia", "Cambria", "Times New Roman", serif;
background-color: #fef3c7;
border: 3px dashed #ff006e;
border-radius: 18px;
padding: 16px 18px;
min-height: 80px;
}
.chat-css-demo-scope .copilotKitInput > textarea {
font-family: "Georgia", "Cambria", "Times New Roman", serif;
font-size: 1.1rem;
color: #2c1810;
}
.chat-css-demo-scope .copilotKitInput > textarea::placeholder {
color: #c2185b;
font-style: italic;
}
.chat-css-demo-scope .copilotKitInputContainer {
background: #fff8f0;
}
.chat-css-demo-scope .copilotKitChat {
background-color: #fff8f0;
}
@@ -0,0 +1,24 @@
"use client";
import React from "react";
import { CopilotChatAssistantMessage } from "@copilotkit/react-core/v2";
import type { CopilotChatAssistantMessageProps } from "@copilotkit/react-core/v2";
// Custom assistantMessage sub-slot of messageView — wraps the default assistant
// message in a visibly tinted card with a corner "slot" badge, proving the slot
// override is active during the in-chat message flow (not just the welcome screen).
export function CustomAssistantMessage(
props: CopilotChatAssistantMessageProps,
) {
return (
<div
data-testid="custom-assistant-message"
className="relative rounded-xl border border-indigo-200 bg-indigo-50/60 dark:bg-indigo-950/40 dark:border-indigo-800 p-3 my-3"
>
<span className="absolute -top-2 -left-2 inline-block rounded-full bg-indigo-600 text-white text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 shadow">
slot
</span>
<CopilotChatAssistantMessage {...props} />
</div>
);
}
@@ -0,0 +1,20 @@
"use client";
import React from "react";
// Custom disclaimer sub-slot of the input — visibly tagged so reviewers can
// tell the slot is in use even once the welcome screen is dismissed.
export function CustomDisclaimer(props: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
{...props}
data-testid="custom-disclaimer"
className="text-xs text-center text-muted-foreground py-2"
>
<span className="inline-block rounded bg-indigo-100 text-indigo-700 px-2 py-0.5 mr-2 font-semibold">
slot
</span>
Custom disclaimer injected via <code>input.disclaimer</code>.
</div>
);
}
@@ -0,0 +1,38 @@
"use client";
import React from "react";
// Custom welcomeScreen slot — a visibly distinct gradient card wrapping the
// default input + suggestions props passed in by CopilotChatView.
export function CustomWelcomeScreen({
input,
suggestionView,
}: {
input: React.ReactElement;
suggestionView: React.ReactElement;
}) {
return (
<div
data-testid="custom-welcome-screen"
className="flex-1 flex flex-col items-center justify-center px-4"
>
<div className="w-full max-w-3xl flex flex-col items-center">
<div
data-testid="custom-welcome-message"
className="mb-6 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 p-6 text-white shadow-lg text-center"
>
<div className="inline-block rounded-full bg-white/20 px-3 py-1 text-xs font-semibold uppercase tracking-wider mb-3">
Custom Slot
</div>
<h1 className="text-2xl font-bold">Welcome to the Slots demo</h1>
<p className="mt-2 text-sm text-white/90">
This welcome card is rendered via the{" "}
<code className="font-mono">welcomeScreen</code> slot.
</p>
</div>
<div className="w-full">{input}</div>
<div className="mt-4 flex justify-center">{suggestionView}</div>
</div>
</div>
);
}
@@ -0,0 +1,62 @@
"use client";
// @region[register-assistant-message-slot]
// @region[register-disclaimer-slot]
// @region[register-welcome-slot]
import React from "react";
import {
CopilotKitProvider,
CopilotChat,
CopilotChatAssistantMessage,
useConfigureSuggestions,
} from "@copilotkit/react-core/v2";
import { CustomWelcomeScreen } from "./custom-welcome-screen";
import { CustomAssistantMessage } from "./custom-assistant-message";
import { CustomDisclaimer } from "./custom-disclaimer";
// Outer layer — provider + layout chrome.
export default function ChatSlotsDemo() {
return (
<CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
<div className="flex justify-center items-center h-screen w-full">
<div className="h-full w-full max-w-4xl">
<Chat />
</div>
</div>
</CopilotKitProvider>
);
}
// The actual view — just the chat, with two slot overrides.
function Chat() {
useConfigureSuggestions({
suggestions: [
{ title: "Write a sonnet", message: "Write a short sonnet about AI." },
{ title: "Tell me a joke", message: "Tell me a short joke." },
],
available: "always",
});
// Each slot is wired in as a prop on <CopilotChat>. Extracting the
// overrides up here keeps the JSX readable and gives the docs something
// to point at with `@region` markers for the slot system guide.
const welcomeScreen = CustomWelcomeScreen;
// @endregion[register-welcome-slot]
const input = { disclaimer: CustomDisclaimer };
// @endregion[register-disclaimer-slot]
const messageView = {
assistantMessage:
CustomAssistantMessage as unknown as typeof CopilotChatAssistantMessage,
};
// @endregion[register-assistant-message-slot]
return (
<CopilotChat
agentId="default"
className="h-full rounded-2xl"
welcomeScreen={welcomeScreen}
input={input}
messageView={messageView}
/>
);
}
@@ -0,0 +1,19 @@
"use client";
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
// The chat-slots cell is wired to the neutral `sample_agent` graph
// (plain ChatOpenAI, no Responses API, no reasoning config), so it never
// emits AG-UI REASONING_MESSAGE_* events — the `messageView.reasoningMessage`
// slot is wrapped for the slot-atlas demo but stays dormant here. A
// "Show reasoning" pill therefore can't light it up; that demo lives at
// /demos/reasoning-default and /demos/reasoning-custom instead.
export function useChatSlotsSuggestions() {
useConfigureSuggestions({
suggestions: [
{ title: "Write a sonnet", message: "Write a short sonnet about AI." },
{ title: "Tell me a joke", message: "Tell me a short joke." },
],
available: "always",
});
}
@@ -0,0 +1,43 @@
# Declarative Generative UI — A2UI Dynamic Schema (Built-in Agent)
Declarative Generative UI where the agent itself designs the component tree
at runtime. Unlike the fixed-schema variant, the LLM emits an arbitrary
A2UI v0.9 schema from the registered catalog every turn.
## Pattern
- **Frontend** registers a custom catalog (`Card`, `StatusBadge`, `Metric`,
`InfoRow`, `PrimaryButton`, `PieChart`, `BarChart`) merged with the basic
A2UI catalog. See `./a2ui/{catalog,definitions,renderers}.{ts,tsx}`.
- **Backend** (`src/lib/factory/a2ui-factory.ts`) owns a `generate_a2ui`
tool. When the primary LLM calls it, the tool fires a _secondary_ LLM
call (forced JSON-object output) that designs the surface tree and data
using the registered catalog. The tool wraps the result in an
`a2ui_operations` container.
- **Runtime** (`src/app/api/copilotkit-declarative-gen-ui/route.ts`) enables
the A2UI middleware with `injectA2UITool: false` — the agent already owns
the tool slot. The middleware still serialises the catalog into the
agent's `input.context` (which the factory pipes into the secondary
LLM's system prompt) and detects `a2ui_operations` in tool results.
## Why a secondary LLM call?
The primary LLM is small (gpt-4o-mini-class), conversational, and steered
to call `generate_a2ui` whenever a UI would help. A separate, structured
JSON call (gpt-4o, `response_format: json_object`) keeps the schema
generation deterministic and the chat reply short.
## Try it
```text
Show me a quick KPI dashboard with revenue, signups, and churn.
```
```text
Pie chart of sales by region.
```
## Reference
- Docs: https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui
- Source-of-truth: `showcase/integrations/langgraph-typescript/src/agent/a2ui-dynamic.ts`
@@ -0,0 +1,24 @@
/**
* A2UI catalog DECLARATION.
*
* Wires `myDefinitions` (component schemas) × `myRenderers` (React
* implementations) into a Catalog the provider consumes via
* `a2ui={{ catalog: myCatalog }}`. `includeBasicCatalog: true` merges
* CopilotKit's built-in A2UI primitives (Column, Row, Text, Image,
* Card, Button, List, Tabs, …) so the agent can compose custom + basic
* components interchangeably.
*
* Reference:
* https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui
*/
// @region[create-catalog]
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,
});
// @endregion[create-catalog]
@@ -0,0 +1,155 @@
/**
* A2UI catalog DEFINITIONS — platform-agnostic.
*
* Each entry declares a custom component name + Zod props schema + a short
* description. The runtime's A2UI middleware serialises this schema into
* the agent's `copilotkit.context` at request time, so the LLM knows which
* components it may emit and what each prop expects.
*
* The React implementations live next to these definitions in
* `./renderers.tsx`, where they are wired through `createCatalog(...)` with
* `includeBasicCatalog: true` so the built-in A2UI primitives (Text, Row,
* Column, Image, Card, Button, …) come along for free.
*/
// @region[definitions-zod]
// ZOD VERSION: stays on root zod@4 (NOT the `zod-v3` alias) because this catalog
// declares NO {path} dynamic bindings — only inline literals — so @a2ui/web_core's
// Zod-3 schema scraper never needs to classify a binding here. If a path-bound prop
// is ever added, switch to the `zod-v3` alias like sibling a2ui-fixed-schema/a2ui/
// definitions.ts (whose ZOD VERSION comment explains the React #31 crash otherwise).
import { z } from "zod";
import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";
export const myDefinitions = {
// Override the basic catalog's Row/Column so `gap` is honoured — the
// built-in versions ignore it, which makes composed dashboards cramped.
Row: {
description:
"Horizontal layout container. Children share the width evenly. Use `gap` (px) to space dashboard tiles.",
props: z.object({
gap: z.number().optional(),
// Enum mirrors the keys the renderer actually maps to CSS. Anything
// outside this set silently falls back at render time, so we reject
// it at schema-parse time to surface LLM typos early.
align: z
.enum(["start", "center", "end", "stretch", "baseline"])
.optional(),
justify: z.enum(["start", "center", "end", "spaceBetween"]).optional(),
children: z.array(z.string()),
}),
},
Column: {
description:
"Vertical layout container. Use `gap` (px) to space stacked sections.",
props: z.object({
gap: z.number().optional(),
align: z
.enum(["start", "center", "end", "stretch", "baseline"])
.optional(),
children: z.array(z.string()),
}),
},
// Override the basic catalog's Text so it aligns flush with sibling
// components (the built-in version carries an 8px outer margin).
Text: {
description: "A plain text line. Use for short explanations inside cards.",
props: z.object({
text: z.string(),
}),
},
Card: {
description:
"A titled card container with an optional subtitle and a single child slot. Use it to group related content (metrics, rows, buttons).",
props: z.object({
title: z.string(),
subtitle: z.string().optional(),
child: z.string().optional(),
}),
},
StatusBadge: {
description:
"A small coloured pill communicating the state of something (healthy/degraded/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 tile with an optional trend indicator and trend delta. Ideal for dashboard KPI rows (e.g. 'Revenue • $4.2M • up 12%').",
props: z.object({
label: z.string(),
value: z.string(),
trend: z.enum(["up", "down", "neutral"]).optional(),
trendValue: z.string().optional(),
}),
},
InfoRow: {
description:
"A compact two-column 'label: value' row. Good for stacks of facts inside a Card (owner, region, ARR, renewal date, etc.).",
props: z.object({
label: z.string(),
value: z.string(),
}),
},
DataTable: {
description:
"A data table with column headers and rows. Ideal for rankings and per-person/per-item breakdowns (rep performance vs quota, deal lists). Each row's keys MUST appear in `columns[].key`; unknown row keys render as blank cells and indicate model/schema drift.",
props: z.object({
columns: z.array(z.object({ key: z.string(), label: z.string() })),
// Cells may be strings or numbers — the renderer stringifies at
// render time, but accepting both lets the LLM emit raw numerics
// (e.g. attainment 124) instead of being forced to stringify.
rows: z.array(z.record(z.string(), z.union([z.string(), z.number()]))),
}),
},
PrimaryButton: {
description:
"A styled primary call-to-action button. Attach an optional `action` that will be dispatched back to the agent when the user clicks it.",
props: z.object({
label: z.string(),
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;
// @endregion[definitions-zod]
export type MyDefinitions = typeof myDefinitions;
@@ -0,0 +1,668 @@
"use client";
/**
* A2UI catalog RENDERERS.
*
* React implementations for each definition in `./definitions.ts`.
* The assembled catalog (definitions × renderers via `createCatalog`)
* lives in `./catalog.ts`.
*
* Reference:
* https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui
*/
import React, { useRef } from "react";
import {
BarChart as RechartsBarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Cell,
ResponsiveContainer,
Rectangle,
} from "recharts";
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
import type { MyDefinitions } from "./definitions";
// ─── Brand chart palette (verbatim from beautiful-chat/charts/config.ts) ────
// CopilotKit brand tokens — Plus Jakarta Sans / brand colour system.
const CHART_COLORS = [
"#BEC2FF", // lilac-400
"#85ECCE", // mint-400
"#FFAC4D", // orange-400
"#FFF388", // yellow-400
"#189370", // mint-800
"#EEE6FE", // primary-100
"#FA5F67", // red-400
] as const;
const CHART_TOOLTIP_STYLE: React.CSSProperties = {
backgroundColor: "white",
border: "1px solid #DBDBE5",
borderRadius: 10,
padding: "10px 14px",
color: "#010507",
fontSize: 13,
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
};
/** Custom SVG donut chart built with <circle> + stroke-dasharray. */
function DonutChart({
data,
size = 240,
strokeWidth = 40,
}: {
data: { label: string; value: number }[];
size?: number;
strokeWidth?: number;
}) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const center = size / 2;
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
let accumulated = 0;
const slices = data.map((item, index) => {
const val = Number(item.value) || 0;
const ratio = total > 0 ? val / total : 0;
const arc = ratio * circumference;
const startAt = accumulated;
accumulated += arc;
return {
...item,
arc,
gap: circumference - arc,
dashoffset: -startAt,
color: CHART_COLORS[index % CHART_COLORS.length],
};
});
return (
<svg
width="100%"
viewBox={`0 0 ${size} ${size}`}
style={{
display: "block",
margin: "0 auto",
maxWidth: size,
transform: "scaleX(-1)",
}}
>
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="#F4F4F7"
strokeWidth={strokeWidth}
/>
{slices.map((slice, i) => (
<circle
key={i}
cx={center}
cy={center}
r={radius}
fill="none"
stroke={slice.color}
strokeWidth={strokeWidth}
strokeDasharray={`${slice.arc} ${slice.gap}`}
strokeDashoffset={slice.dashoffset}
strokeLinecap="butt"
transform={`rotate(-90 ${center} ${center})`}
/>
))}
</svg>
);
}
/** Tracks seen indices so only NEW bars get the fade-in animation. */
function useSeenIndices() {
const seen = useRef(new Set<number>());
return {
isNew(index: number) {
if (seen.current.has(index)) return false;
seen.current.add(index);
return true;
},
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function AnimatedBar(props: any) {
const { isNew, ...rest } = props;
return (
<g
style={
isNew
? {
animation: "barSlideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both",
}
: undefined
}
>
<Rectangle {...rest} />
</g>
);
}
const badgePalette: Record<
"success" | "warning" | "error" | "info",
{ bg: string; fg: string; border: string }
> = {
success: {
bg: "rgba(133, 236, 206, 0.15)",
fg: "#189370",
border: "#85ECCE4D",
},
warning: {
bg: "rgba(255, 172, 77, 0.12)",
fg: "#010507",
border: "#FFAC4D33",
},
error: { bg: "rgba(250, 95, 103, 0.1)", fg: "#FA5F67", border: "#FA5F6733" },
info: { bg: "#BEC2FF1A", fg: "#010507", border: "#BEC2FF" },
};
// @region[renderers-react]
export const myRenderers: CatalogRenderers<MyDefinitions> = {
// Gap-honouring Row/Column overrides — the basic catalog's versions ignore
// `gap`, which makes composed dashboards cramped. Children share width
// evenly in a Row (flex: 1 1 0) and stack in a Column.
Row: ({ props, children }) => {
const justifyMap: Record<string, string> = {
start: "flex-start",
center: "center",
end: "flex-end",
spaceBetween: "space-between",
};
const items = Array.isArray(props.children) ? props.children : [];
return (
<div
style={{
display: "flex",
flexDirection: "row",
gap: `${props.gap ?? 16}px`,
alignItems: props.align ?? "stretch",
justifyContent: justifyMap[props.justify ?? "start"] ?? "flex-start",
flexWrap: "wrap",
width: "100%",
}}
>
{items.map((id, i) => (
<div key={`${id}-${i}`} style={{ flex: "1 1 0", minWidth: 0 }}>
{children(id)}
</div>
))}
</div>
);
},
Column: ({ props, children }) => {
const items = Array.isArray(props.children) ? props.children : [];
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${props.gap ?? 12}px`,
width: "100%",
}}
>
{items.map((id, i) => (
<React.Fragment key={`${id}-${i}`}>{children(id)}</React.Fragment>
))}
</div>
);
},
Text: ({ props }) => (
<span style={{ fontSize: "0.85rem", color: "#010507", lineHeight: 1.5 }}>
{props.text}
</span>
),
Card: ({ props, children }) => (
<div
data-testid="declarative-card"
style={{
border: "1px solid #DBDBE5",
borderRadius: 16,
padding: 20,
background: "white",
boxShadow: "0 1px 3px rgba(1, 5, 7, 0.04)",
display: "flex",
flexDirection: "column",
gap: 12,
minWidth: 260,
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<div style={{ fontWeight: 600, fontSize: "1rem", color: "#010507" }}>
{props.title}
</div>
{props.subtitle && (
<div style={{ color: "#57575B", fontSize: "0.85rem" }}>
{props.subtitle}
</div>
)}
</div>
{props.child && children(props.child)}
</div>
),
StatusBadge: ({ props }) => {
const variant = props.variant ?? "info";
const { bg, fg, border } = badgePalette[variant];
return (
<span
data-testid="declarative-status-badge"
style={{
display: "inline-block",
padding: "2px 10px",
background: bg,
color: fg,
border: `1px solid ${border}`,
borderRadius: 999,
fontSize: "0.7rem",
fontWeight: 600,
letterSpacing: "0.1em",
textTransform: "uppercase",
}}
>
{props.text}
</span>
);
},
Metric: ({ props }) => {
const trend = props.trend ?? "neutral";
const arrow = trend === "up" ? "↑" : trend === "down" ? "↓" : "";
const color =
trend === "up" ? "#189370" : trend === "down" ? "#FA5F67" : "#010507";
return (
<div
data-testid="declarative-metric"
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<div
style={{
fontSize: "0.7rem",
color: "#838389",
textTransform: "uppercase",
letterSpacing: "0.12em",
}}
>
{props.label}
</div>
<div
style={{
fontSize: "1.5rem",
fontWeight: 600,
color,
display: "flex",
gap: 6,
alignItems: "baseline",
}}
>
<span>{props.value}</span>
{(arrow || props.trendValue) && (
<span style={{ fontSize: "0.85rem", fontWeight: 500 }}>
{arrow}
{props.trendValue
? arrow
? ` ${props.trendValue}`
: props.trendValue
: ""}
</span>
)}
</div>
</div>
);
},
InfoRow: ({ props }) => (
<div
data-testid="declarative-info-row"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
gap: 16,
paddingTop: 6,
paddingBottom: 6,
borderBottom: "1px solid #E9E9EF",
}}
>
<span style={{ color: "#57575B", fontSize: "0.85rem" }}>
{props.label}
</span>
<span style={{ color: "#010507", fontWeight: 500, fontSize: "0.9rem" }}>
{props.value}
</span>
</div>
),
DataTable: ({ props }) => {
const cols = Array.isArray(props.columns) ? props.columns : [];
const rows = Array.isArray(props.rows) ? props.rows : [];
return (
<div
data-testid="declarative-data-table"
style={{ width: "100%", overflowX: "auto" }}
>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "0.85rem",
}}
>
<thead>
<tr>
{cols.map((col) => (
<th
key={col.key}
style={{
borderBottom: "2px solid #DBDBE5",
padding: "8px 12px",
textAlign: "left",
fontSize: "0.7rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "#838389",
}}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => {
// Stable row key: prefer the first column's value (primary-key-ish),
// suffix with index in case values repeat, fall back to a JSON
// stringify of the row when columns is empty.
const pk = cols.length > 0 ? row[cols[0].key] : undefined;
const rowKey = pk !== undefined ? `${pk}-${i}` : `row-${i}`;
return (
<tr key={rowKey} style={{ borderBottom: "1px solid #E9E9EF" }}>
{cols.map((col) => (
<td
key={col.key}
style={{
padding: "8px 12px",
color: "#010507",
fontVariantNumeric: "tabular-nums",
}}
>
{String(row[col.key] ?? "")}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
},
PrimaryButton: ({ props, dispatch }) => (
<button
onClick={() => {
if (props.action && dispatch) dispatch(props.action);
}}
style={{
padding: "10px 16px",
borderRadius: 12,
border: "none",
background: "#010507",
color: "white",
fontWeight: 500,
fontSize: "0.9rem",
cursor: "pointer",
transition: "background 0.15s ease",
}}
onMouseEnter={(e) =>
((e.currentTarget as HTMLButtonElement).style.background = "#2B2B2B")
}
onMouseLeave={(e) =>
((e.currentTarget as HTMLButtonElement).style.background = "#010507")
}
>
{props.label}
</button>
),
PieChart: ({ props }) => {
const data = props.data ?? [];
const safeData = Array.isArray(data) ? data : [];
const total = safeData.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
return (
<div
data-testid="declarative-pie-chart"
style={{
border: "1px solid #DBDBE5",
borderRadius: 16,
padding: 20,
background: "white",
boxShadow: "0 1px 3px rgba(1, 5, 7, 0.04)",
maxWidth: 520,
margin: "0 auto",
display: "flex",
flexDirection: "column",
gap: 12,
overflow: "hidden",
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<div style={{ fontWeight: 600, fontSize: "1rem", color: "#010507" }}>
{props.title}
</div>
<div style={{ color: "#57575B", fontSize: "0.85rem" }}>
{props.description}
</div>
</div>
{safeData.length === 0 ? (
<div
style={{
color: "#838389",
textAlign: "center",
padding: "32px 0",
fontSize: "0.85rem",
}}
>
No data available
</div>
) : (
<>
<DonutChart data={safeData} />
{/* Legend */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
paddingTop: 8,
}}
>
{safeData.map((item, index) => {
const val = Number(item.value) || 0;
const pct = total > 0 ? ((val / total) * 100).toFixed(0) : "0";
return (
<div
key={index}
style={{
display: "flex",
alignItems: "center",
gap: 12,
fontSize: "0.85rem",
}}
>
<span
style={{
display: "inline-block",
width: 12,
height: 12,
borderRadius: 999,
flexShrink: 0,
backgroundColor:
CHART_COLORS[index % CHART_COLORS.length],
}}
/>
<span
style={{
flex: 1,
color: "#010507",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.label}
</span>
<span
style={{
color: "#57575B",
fontVariantNumeric: "tabular-nums",
}}
>
{val.toLocaleString()}
</span>
<span
style={{
color: "#57575B",
width: 40,
textAlign: "right",
fontVariantNumeric: "tabular-nums",
}}
>
{pct}%
</span>
</div>
);
})}
</div>
</>
)}
</div>
);
},
BarChart: ({ props }) => {
const { isNew } = useSeenIndices();
const data = props.data ?? [];
const safeData = Array.isArray(data) ? data : [];
return (
<div
data-testid="declarative-bar-chart"
style={{
border: "1px solid #DBDBE5",
borderRadius: 16,
padding: 20,
background: "white",
boxShadow: "0 1px 3px rgba(1, 5, 7, 0.04)",
maxWidth: 640,
margin: "0 auto",
display: "flex",
flexDirection: "column",
gap: 12,
overflow: "hidden",
}}
>
{/* Scoped keyframe — no globals.css needed */}
<style>{`
@keyframes barSlideIn {
from { transform: translateY(40px); opacity: 0; }
20% { opacity: 1; }
to { transform: translateY(0); opacity: 1; }
}
`}</style>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<div style={{ fontWeight: 600, fontSize: "1rem", color: "#010507" }}>
{props.title}
</div>
<div style={{ color: "#57575B", fontSize: "0.85rem" }}>
{props.description}
</div>
</div>
{safeData.length === 0 ? (
<div
style={{
color: "#838389",
textAlign: "center",
padding: "32px 0",
fontSize: "0.85rem",
}}
>
No data available
</div>
) : (
<ResponsiveContainer width="100%" height={280}>
<RechartsBarChart
data={safeData}
margin={{ top: 12, right: 12, bottom: 4, left: -8 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="#E9E9EF"
vertical={false}
/>
<XAxis
dataKey="label"
tick={{ fontSize: 12, fill: "#57575B" }}
stroke="#E9E9EF"
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 12, fill: "#57575B" }}
stroke="#E9E9EF"
tickLine={false}
axisLine={false}
/>
<Tooltip
contentStyle={CHART_TOOLTIP_STYLE}
cursor={{ fill: "#F4F4F7", opacity: 0.5 }}
/>
<Bar
isAnimationActive={false}
dataKey="value"
radius={[6, 6, 0, 0]}
maxBarSize={48}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
shape={
((barProps: any) => (
<AnimatedBar
{...barProps}
isNew={isNew(barProps.index as number)}
/>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
)) as any
}
>
{safeData.map((_, index) => (
<Cell
key={index}
fill={CHART_COLORS[index % CHART_COLORS.length]}
/>
))}
</Bar>
</RechartsBarChart>
</ResponsiveContainer>
)}
</div>
);
},
};
// @endregion[renderers-react]
@@ -0,0 +1,85 @@
"use client";
/**
* Declarative Generative UI (A2UI — Dynamic Schema) demo.
*
* Pattern:
* 1. Define a small set of branded React components + Zod schemas in
* `./a2ui/definitions.ts` and `./a2ui/renderers.tsx` (the latter calls
* `createCatalog(..., { includeBasicCatalog: true })` and exports
* `myCatalog`).
* 2. Pass that catalog to the provider via
* `<CopilotKit a2ui={{ catalog: myCatalog }}>`.
* 3. The dedicated runtime at `/api/copilotkit-declarative-gen-ui` is
* configured with `injectA2UITool: false` — the backend factory
* (`src/lib/factory/a2ui-factory.ts`) owns the `generate_a2ui` tool
* explicitly. The A2UI middleware still serialises the registered
* catalog schema into the agent's `input.context` so the secondary LLM
* inside `generate_a2ui` knows which components are available.
*
* Reference:
* https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui
*/
// @region[provider-a2ui-prop]
import React from "react";
import {
CopilotKit,
CopilotChat,
useConfigureSuggestions,
} from "@copilotkit/react-core/v2";
import { myCatalog } from "./a2ui/catalog";
import { useSalesAnalystContext } from "./sales-context";
export default function DeclarativeGenUIDemo() {
return (
<CopilotKit
runtimeUrl="/api/copilotkit-declarative-gen-ui"
agent="default"
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>
// @endregion[provider-a2ui-prop]
);
}
function Chat() {
// Grounding data + composition rules for the sales-analyst persona. Flows
// to the secondary `generate_a2ui` planner LLM (the A2UI middleware
// serialises frontend context entries into the catalog context the tool
// reads), so prompts like "Show me my sales dashboard" produce grounded,
// composed surfaces instead of empty ones.
useSalesAnalystContext();
useConfigureSuggestions({
suggestions: [
{
title: "Show a KPI dashboard",
message:
"Show me a quick KPI dashboard with 3-4 metrics (revenue, signups, churn).",
},
{
title: "Pie chart — sales by region",
message: "Show a pie chart of sales by region.",
},
{
title: "Bar chart — quarterly revenue",
message: "Render a bar chart of quarterly revenue.",
},
{
title: "Status report",
message:
"Give me a status report on system health — API, database, and background workers.",
},
],
available: "always",
});
return <CopilotChat agentId="default" className="h-full rounded-2xl" />;
}
@@ -0,0 +1,44 @@
import { useAgentContext } from "@copilotkit/react-core/v2";
// Grounding data + composition rules for the sales-analyst demo persona.
// Registered as agent context so it reaches both the primary agent (App
// Context) and the secondary A2UI planner LLM, which serialises frontend
// context entries into its system instruction.
//
// DUPLICATION NOTICE: This file is intentionally byte-duplicated across
// 5 integrations — built-in-agent, google-adk, langgraph-python, strands,
// and strands-typescript — per the showcase's per-integration parity
// convention (no cross-integration imports). All 5 copies MUST be kept
// byte-for-byte identical — verify with `diff` after any edit, and update
// every copy in the same commit.
//
// TODO(OSS-136): Extract this dataset + composition rules into a shared
// showcase module so both integrations import a single source of truth
// instead of relying on manual byte-sync.
const SALES_DATASET = `Vantage Threads (fictional B2B apparel company) — Q2 sales data. Ground every visual in these numbers; invent only plausible details consistent with them.
- Quarterly revenue: $4.2M (up 12% QoQ). New customers: 186 (up 8%). Win rate: 31% (down 2pts). Avg deal size: $22.6k (up 5%).
- Revenue by region: North America $1.9M, EMEA $1.3M, APAC $720k, LATAM $280k.
- Monthly revenue: Jan $1.21M, Feb $1.34M, Mar $1.65M, Apr $1.38M, May $1.42M, Jun $1.40M.
- Reps (vs quota): Dana Whitfield 124%, Marcus Lee 108%, Priya Sharma 97%, Tom Okafor 88%, Elena Vasquez 71%.
- At-risk: total $615k ARR across 3 accounts — Northwind Retail ($340k renewal, no contact 6 weeks; severity high), Cascadia Outfitters ($180k, champion left; severity medium), Atlas Goods ($95k, stalled legal review; severity medium).
- Biggest account: Meridian Apparel Group — owner Dana Whitfield, region North America, ARR $612k, renewal Sep 30, last contact 3 days ago, health green, 4 open opportunities worth $210k.
- Meridian revenue by product line: Outerwear $260k, Footwear $180k, Accessories $112k, Custom $60k.`;
const COMPOSITION_RULES = `Pick A2UI components by the shape of the question — never ask which chart the user wants:
1. Overall snapshot / "sales dashboard" → a Column (gap 16) whose first child is a Row (gap 16) of 4 Metric tiles (each with trend + trendValue), followed by a Row with a PieChart (revenue by region) next to a BarChart (monthly revenue, all six months Jan-Jun). Do NOT wrap the dashboard in a surrounding Card — the charts carry their own card chrome. Do NOT use StatusBadge, DataTable, or InfoRow here.
2. Rep / team performance → a Column (gap 16) with a Card containing a DataTable (columns: rep, attainment, pipeline) next to or above a BarChart of quota attainment % per rep — no StatusBadge or InfoRow.
3. Risk / health checks → a Column (gap 16): first a Row (gap 16) of 3 Metric tiles (ARR at risk $615k trend down, accounts at risk 3, biggest exposure Northwind $340k), then a Row (gap 16) with one compact Card per at-risk account (title = account name, subtitle = ARR at stake) containing a StatusBadge (error for high severity, warning otherwise) above a one-line Text with the reason and the recommended next action — no DataTable or InfoRow.
4. Single account/entity details → a Row (gap 16) with a Card of InfoRow facts (owner, region, ARR, renewal date, last contact) next to a PieChart of that account's revenue by product line — no DataTable or StatusBadge.
5. Part-of-whole follow-ups → PieChart; trends or comparisons over time/categories → BarChart.
Compose generously — a dashboard should feel like a real analytics product, not a single widget.`;
export function useSalesAnalystContext() {
useAgentContext({
description: "Sales dataset for Vantage Threads (the demo company)",
value: SALES_DATASET,
});
useAgentContext({
description: "Dashboard composition rules for A2UI surfaces",
value: COMPOSITION_RULES,
});
}
@@ -0,0 +1,27 @@
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
export function useDeclarativeGenUISuggestions() {
useConfigureSuggestions({
suggestions: [
{
title: "Show a KPI dashboard",
message:
"Show me a quick KPI dashboard with 3-4 metrics (revenue, signups, churn).",
},
{
title: "Pie chart — sales by region",
message: "Show a pie chart of sales by region.",
},
{
title: "Bar chart — quarterly revenue",
message: "Render a bar chart of quarterly revenue.",
},
{
title: "Status report",
message:
"Give me a status report on system health — API, database, and background workers.",
},
],
available: "always",
});
}
@@ -0,0 +1,17 @@
# Frontend Tools (Async)
Same `useFrontendTool` pattern as `frontend-tools`, but the handler is
async and awaits a simulated client-side DB round-trip (500ms). Once it
resolves, the result is passed back to the agent and rendered through a
branded `NotesCard`.
## Files
- `page.tsx` — registers `query_notes` (async handler + render)
- `notes-card.tsx``NotesCard` component used by the per-tool render
## Backend
Reuses `createBuiltInAgent` (TanStack AI + `openaiText("gpt-4o")`) at
`src/app/api/copilotkit/route.ts`. No backend changes — frontend tools
live entirely in the browser; the agent only sees their schema.
@@ -0,0 +1,91 @@
"use client";
import React from "react";
export interface Note {
id: string;
title: string;
excerpt: string;
tags?: string[];
}
export interface NotesCardProps {
loading: boolean;
keyword: string;
notes?: Note[];
}
/**
* Branded card rendering the client-side "notes DB query" result.
* Mirrors the per-tool render path used by other tool-rendering cells —
* but the `notes` array is awaited from an async handler living entirely
* in the browser (no backend tool involved).
*/
export function NotesCard({ loading, keyword, notes }: NotesCardProps) {
return (
<div
data-testid="notes-card"
className="rounded-2xl mt-4 mb-4 max-w-md w-full bg-white border border-[#DBDBE5] shadow-sm"
>
<div className="p-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[10px] uppercase tracking-[0.14em] text-[#57575B] mb-1">
Notes DB
</div>
<h3
data-testid="notes-keyword"
className="text-base font-semibold text-[#010507]"
>
Matching &ldquo;{keyword}&rdquo;
</h3>
<p className="text-[#57575B] text-xs mt-0.5">
{loading
? "Querying local notes DB..."
: `${notes?.length ?? 0} match${(notes?.length ?? 0) === 1 ? "" : "es"}`}
</p>
</div>
<div className="text-xl" aria-hidden>
{loading ? "..." : "📓"}
</div>
</div>
{!loading && notes && notes.length > 0 && (
<ul
data-testid="notes-list"
className="mt-4 pt-4 border-t border-[#E9E9EF] space-y-2 text-sm"
>
{notes.map((n) => (
<li
key={n.id}
data-testid={`note-${n.id}`}
className="rounded-xl border border-[#E9E9EF] bg-[#FAFAFC] p-2.5"
>
<p className="font-medium text-[#010507]">{n.title}</p>
<p className="text-[#57575B] text-xs mt-0.5">{n.excerpt}</p>
{n.tags && n.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{n.tags.map((t) => (
<span
key={t}
className="text-[10px] font-medium uppercase tracking-[0.1em] bg-white border border-[#DBDBE5] text-[#57575B] rounded-full px-1.5 py-0.5"
>
{t}
</span>
))}
</div>
)}
</li>
))}
</ul>
)}
{!loading && (!notes || notes.length === 0) && (
<p className="mt-4 pt-4 border-t border-[#E9E9EF] text-sm text-[#838389] italic">
No notes matched.
</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,171 @@
"use client";
// Frontend Tools (Async) demo.
//
// Same `useFrontendTool` pattern as `frontend-tools`, but the handler is
// async and awaits a simulated client-side DB round-trip. The full async
// path is exercised end-to-end: agent invokes -> handler awaits -> result
// returned -> branded render.
import React from "react";
import {
CopilotKitProvider,
CopilotChat,
useFrontendTool,
useConfigureSuggestions,
} from "@copilotkit/react-core/v2";
import { z } from "zod";
import { NotesCard, type Note } from "./notes-card";
// Fake client-side "notes database". In a real app this could be an
// IndexedDB, a fetched local cache, or any other client-owned data store.
const NOTES_DB: Note[] = [
{
id: "n1",
title: "Q2 project planning kickoff",
excerpt:
"Discussed scope for the new onboarding flow with design. Draft spec due Friday.",
tags: ["planning", "project", "onboarding"],
},
{
id: "n2",
title: "Planning: migrate auth to passkeys",
excerpt:
"Research WebAuthn library options. Consider fallback for unsupported browsers.",
tags: ["planning", "auth", "security"],
},
{
id: "n3",
title: "Grocery list",
excerpt: "Olive oil, tomatoes, sourdough, basil, parmesan.",
tags: ["personal", "shopping"],
},
{
id: "n4",
title: "Book recommendations",
excerpt:
"Thinking Fast and Slow (Kahneman); The Design of Everyday Things (Norman).",
tags: ["reading"],
},
{
id: "n5",
title: "Project planning retrospective notes",
excerpt:
"What went well: async standups. What didn't: ambiguous ownership on shared components.",
tags: ["retro", "project", "planning"],
},
{
id: "n6",
title: "Weekend hike plan",
excerpt: "Tam West Peak → Rock Spring. 8mi loop, bring layers.",
tags: ["personal", "outdoors"],
},
{
id: "n7",
title: "1:1 prep — career planning",
excerpt: "Discuss growth areas. Ask about scope for Q3. Revisit goals doc.",
tags: ["career", "planning"],
},
];
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseJsonResult<T>(result: unknown): T {
if (!result) return {} as T;
try {
return (typeof result === "string" ? JSON.parse(result) : result) as T;
} catch {
return {} as T;
}
}
export default function FrontendToolsAsyncDemo() {
return (
<CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
<div className="flex justify-center items-center h-screen w-full">
<div className="h-full w-full max-w-4xl">
<Chat />
</div>
</div>
</CopilotKitProvider>
);
}
function Chat() {
// @region[frontend-tool-async]
// @region[frontend-tool-async-registration]
useFrontendTool({
name: "query_notes",
description:
"Search the user's local notes database for notes whose title, " +
"excerpt, or tags contain the given keyword (case-insensitive). " +
"Returns up to 5 matching notes.",
parameters: z.object({
keyword: z
.string()
.describe("Keyword or phrase to search notes for (case-insensitive)."),
}),
// @region[frontend-tool-async-handler]
// Async handler: awaits a simulated client-side DB round-trip (500ms)
// and returns the matching notes. The agent then uses the returned
// result to summarize what it found — exercising the full async
// frontend-tool path end-to-end.
handler: async ({ keyword }: { keyword: string }) => {
await sleep(500);
const q = keyword.toLowerCase();
const matches = NOTES_DB.filter((n) => {
return (
n.title.toLowerCase().includes(q) ||
n.excerpt.toLowerCase().includes(q) ||
(n.tags ?? []).some((t) => t.toLowerCase().includes(q))
);
}).slice(0, 5);
return {
keyword,
count: matches.length,
notes: matches,
};
},
// @endregion[frontend-tool-async-handler]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
render: ({ args, result, status }: any) => {
const loading = status !== "complete";
const parsed = parseJsonResult<{
keyword?: string;
count?: number;
notes?: Note[];
}>(result);
return (
<NotesCard
loading={loading}
keyword={args?.keyword ?? parsed.keyword ?? ""}
notes={parsed.notes}
/>
);
},
});
// @endregion[frontend-tool-async-registration]
// @endregion[frontend-tool-async]
useConfigureSuggestions({
suggestions: [
{
title: "Find project-planning notes",
message: "Find my notes about project planning.",
},
{
title: "Search for 'auth'",
message: "Search my notes for anything related to auth.",
},
{
title: "What do I have about reading?",
message: "Do I have any notes tagged reading?",
},
],
available: "always",
});
return <CopilotChat className="h-full rounded-2xl" />;
}
@@ -0,0 +1,18 @@
# Frontend Tools
Defines a tool entirely in the React tree via `useFrontendTool`. The
agent sees the schema (forwarded over AG-UI), invokes it, and the
handler runs in the browser to mutate page state — here, the page
background.
## Files
- `page.tsx` — registers `change_background` and reflects the result in
local state.
## Backend
Reuses `createBuiltInAgent` (TanStack AI + `openaiText("gpt-4o")`) at
`src/app/api/copilotkit/route.ts`. No backend changes — frontend tools
are entirely in-browser, advertised to the agent via the chat input
contract.
@@ -0,0 +1,83 @@
"use client";
// Frontend Tools demo.
//
// Showcases `useFrontendTool` — a tool DEFINED in the React tree,
// EXECUTED in the browser, and INVOKED by the agent. The tool's schema
// is forwarded over the AG-UI protocol so the agent knows it exists; the
// tool's handler runs locally in the page on invocation. No backend
// tool wiring required.
// @region[frontend-tool]
// @region[frontend-tool-registration]
import React, { useState } from "react";
import {
CopilotKitProvider,
CopilotChat,
useFrontendTool,
useConfigureSuggestions,
} from "@copilotkit/react-core/v2";
import { z } from "zod";
export default function FrontendToolsDemo() {
return (
<CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
<Chat />
</CopilotKitProvider>
);
}
function Chat() {
const [background, setBackground] = useState<string>(
"var(--copilot-kit-background-color)",
);
useFrontendTool({
name: "change_background",
description:
"Change the background color of the chat. Accepts any valid CSS background value — colors, linear or radial gradients, etc.",
parameters: z.object({
background: z
.string()
.describe("The CSS background value. Prefer gradients."),
}),
// @region[frontend-tool-handler]
handler: async ({ background }: { background: string }) => {
setBackground(background);
return {
status: "success",
message: `Background changed to ${background}`,
};
},
// @endregion[frontend-tool-handler]
});
// @endregion[frontend-tool-registration]
// @endregion[frontend-tool]
useConfigureSuggestions({
suggestions: [
{
title: "Change background",
message: "Change the background to a blue-to-purple gradient.",
},
{
title: "Sunset theme",
message: "Make the background a sunset-themed gradient.",
},
],
available: "always",
});
return (
<div
className="flex justify-center items-center h-screen w-full"
data-testid="frontend-tools-background"
data-background-value={background}
style={{ background }}
>
<div className="h-full w-full max-w-4xl">
<CopilotChat className="h-full rounded-2xl" />
</div>
</div>
);
}
@@ -0,0 +1,21 @@
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
export function useFrontendToolsSuggestions() {
useConfigureSuggestions({
suggestions: [
{
title: "Sunset theme",
message: "Make the background a sunset gradient.",
},
{
title: "Forest theme",
message: "Switch to a deep green forest gradient.",
},
{
title: "Cosmic theme",
message: "Make it a navy → magenta cosmic gradient.",
},
],
available: "always",
});
}
@@ -0,0 +1,87 @@
"use client";
import {
CopilotKitProvider,
CopilotChat,
useAgent,
UseAgentUpdate,
} from "@copilotkit/react-core/v2";
type Step = {
title: string;
status?: "pending" | "in_progress" | "done";
};
export default function GenUiAgent() {
return (
<CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
<Demo />
</CopilotKitProvider>
);
}
function Demo() {
const { agent } = useAgent({
agentId: "default",
updates: [UseAgentUpdate.OnStateChanged],
});
const steps = (agent.state as { steps?: Step[] }).steps ?? [];
return (
<main className="p-8 grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-8">
<div>
<h1 className="text-2xl font-semibold mb-4">Agentic Generative UI</h1>
<p className="text-sm opacity-70 mb-4">
The agent emits a live plan via <code>AGUISendStateDelta</code> (
<code>{`{ op: "replace", path: "/steps", value: [...] }`}</code>
). Each tick re-renders the panel below. Try: &ldquo;Plan a 4-step
morning routine and execute it; emit the plan to state.&rdquo;
</p>
<StepsPanel steps={steps} />
</div>
<div data-testid="copilot-message-list">
<CopilotChat />
</div>
</main>
);
}
function StepsPanel({ steps }: { steps: Step[] }) {
if (!steps.length) {
return (
<div className="border rounded p-3 text-sm opacity-40 italic">
No plan yet. The agent will fill this panel as it works.
</div>
);
}
return (
<div data-testid="agent-state-card" className="border rounded p-3">
<div className="font-medium mb-2">Plan</div>
<ol className="space-y-1 text-sm">
{steps.map((s, i) => (
<li
key={i}
data-testid="agent-step"
className="flex items-center gap-2"
>
<span className="w-4">
{s.status === "done"
? "✓"
: s.status === "in_progress"
? "•"
: "○"}
</span>
<span
className={
s.status === "done" ? "line-through opacity-60" : undefined
}
>
{s.title}
</span>
</li>
))}
</ol>
</div>
);
}
@@ -0,0 +1,24 @@
"use client";
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
export function useSuggestions() {
useConfigureSuggestions({
suggestions: [
{
title: "Plan a product launch",
message: "Plan a product launch for a new mobile app.",
},
{
title: "Organize a team offsite",
message: "Organize a three-day engineering team offsite.",
},
{
title: "Research a competitor",
message:
"Research our top competitor and summarize their strengths and weaknesses.",
},
],
available: "always",
});
}
@@ -0,0 +1,13 @@
# Gen UI Interrupt — Built-in Agent (Strategy B: Frontend Tool)
In-chat time-picker that blocks the agent until the user picks a slot or
cancels. Produces the same UX as the LangGraph `interrupt()` primitive, but
via `useFrontendTool` with an async handler.
The built-in agent (TanStack AI) auto-discovers the frontend-registered
`schedule_meeting` tool and calls it as a regular tool call. The async
handler returns a Promise that only resolves once the user picks a slot,
blocking the agent loop until the decision is made.
See the `langgraph-python` integration for the native interrupt-based
implementation.
@@ -0,0 +1,122 @@
"use client";
// Gen UI Interrupt demo (Built-in Agent port).
//
// The LangGraph version of this demo uses `useInterrupt` with LangGraph's
// native `interrupt()` primitive — the backend pauses the run and surfaces
// a payload that the frontend renders into the chat via the `useInterrupt`
// hook. The built-in agent (TanStack AI) does NOT have an equivalent
// interrupt primitive, so we adapt the demo by registering a frontend tool
// with `useFrontendTool`. The handler returns a Promise that only resolves
// once the user picks a time (or cancels), which produces the same UX: the
// picker appears inline in the chat and the agent's tool call blocks until
// the user decides.
// @region[frontend-useinterrupt-render]
import React, { useRef } from "react";
import {
CopilotKitProvider,
CopilotChat,
useConfigureSuggestions,
useFrontendTool,
} from "@copilotkit/react-core/v2";
import { z } from "zod";
import { TimePickerCard, TimeSlot } from "./time-picker-card";
const DEFAULT_SLOTS: TimeSlot[] = [
{ label: "Tomorrow 10:00 AM", iso: "2026-04-25T10:00:00-07:00" },
{ label: "Tomorrow 2:00 PM", iso: "2026-04-25T14:00:00-07:00" },
{ label: "Monday 9:00 AM", iso: "2026-04-28T09:00:00-07:00" },
{ label: "Monday 3:30 PM", iso: "2026-04-28T15:30:00-07:00" },
];
type PickerResult =
| { chosen_time: string; chosen_label: string }
| { cancelled: true };
export default function GenUiInterruptDemo() {
return (
<CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
<div className="flex justify-center items-center h-screen w-full">
<div className="h-full w-full max-w-4xl">
<Chat />
</div>
</div>
</CopilotKitProvider>
);
}
function Chat() {
// Pending-resolver ref: set by the async handler, called by the render
// prop when the user clicks a slot or cancels. This is the built-in agent
// adaptation of the LangGraph `resolve(...)` callback.
const resolverRef = useRef<((result: PickerResult) => void) | null>(null);
useConfigureSuggestions({
suggestions: [
{
title: "Book a call with sales",
message: "Book an intro call with the sales team to discuss pricing.",
},
{
title: "Schedule a 1:1 with Alice",
message: "Schedule a 1:1 with Alice next week to review Q2 goals.",
},
],
available: "always",
});
// @region[frontend-promise-handler]
useFrontendTool({
name: "schedule_meeting",
description:
"Ask the user to pick a time slot for a meeting via an in-chat " +
"picker. Blocks until the user chooses a slot or cancels.",
parameters: z.object({
topic: z
.string()
.describe("Short human-readable description of the meeting."),
attendee: z
.string()
.optional()
.describe("Who the meeting is with (optional)."),
}),
// Async handler: returns a Promise that resolves only once the user
// acts on the picker. This is the built-in agent shim for LangGraph's
// `interrupt()`/`resolve()` pair.
handler: async (): Promise<string> => {
const result = await new Promise<PickerResult>((resolve) => {
resolverRef.current = resolve;
});
if ("cancelled" in result && result.cancelled) {
return "User cancelled. Meeting NOT scheduled.";
}
if ("chosen_label" in result) {
return `Meeting scheduled for ${result.chosen_label}.`;
}
return "User did not pick a time. Meeting NOT scheduled.";
},
render: ({ args, status }) => {
if (status === "complete") return null;
const topic =
(args as { topic?: string } | undefined)?.topic ?? "a meeting";
const attendee = (args as { attendee?: string } | undefined)?.attendee;
return (
<TimePickerCard
topic={topic}
attendee={attendee}
slots={DEFAULT_SLOTS}
onSubmit={(result) => {
const fn = resolverRef.current;
resolverRef.current = null;
fn?.(result);
}}
/>
);
},
});
// @endregion[frontend-promise-handler]
// @endregion[frontend-useinterrupt-render]
return <CopilotChat className="h-full rounded-2xl" />;
}
@@ -0,0 +1,19 @@
"use client";
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
export function useGenUiInterruptSuggestions() {
useConfigureSuggestions({
suggestions: [
{
title: "Book a call with sales",
message: "Book an intro call with the sales team to discuss pricing.",
},
{
title: "Schedule a 1:1 with Alice",
message: "Schedule a 1:1 with Alice next week to review Q2 goals.",
},
],
available: "always",
});
}
@@ -0,0 +1,106 @@
"use client";
import React, { useState } from "react";
export interface TimeSlot {
label: string;
iso: string;
}
export interface TimePickerCardProps {
topic: string;
attendee?: string;
slots: TimeSlot[];
onSubmit: (
result: { chosen_time: string; chosen_label: string } | { cancelled: true },
) => void;
}
/**
* Renders a "Book a call" card with a small grid of time slots.
*
* In the LangGraph showcase this card is driven by the native
* `interrupt()` primitive and `useInterrupt`. The built-in-agent has no
* interrupt primitive, so this port wires the same card up through
* `useFrontendTool` with an async handler: when the agent calls
* `schedule_meeting`, the handler returns a Promise that only resolves
* once the user picks a slot (or cancels).
*/
export function TimePickerCard({
topic,
attendee,
slots,
onSubmit,
}: TimePickerCardProps) {
const [picked, setPicked] = useState<TimeSlot | null>(null);
const [cancelled, setCancelled] = useState(false);
const disabled = picked !== null || cancelled;
if (cancelled) {
return (
<div
className="rounded-2xl border border-[#DBDBE5] bg-[#F7F7F9] p-4 text-sm text-[#57575B] max-w-md"
data-testid="time-picker-cancelled"
>
Cancelled no time picked.
</div>
);
}
if (picked) {
return (
<div
className="rounded-2xl border border-[#85ECCE4D] bg-[#85ECCE]/10 p-4 max-w-md"
data-testid="time-picker-picked"
>
<p className="text-sm text-[#010507]">
Booked for <span className="font-semibold">{picked.label}</span>
</p>
</div>
);
}
return (
<div
className="rounded-2xl border border-[#DBDBE5] bg-white p-5 shadow-sm max-w-md"
data-testid="time-picker-card"
>
<p className="text-[10px] uppercase tracking-[0.14em] text-[#57575B] font-medium">
Book a call
</p>
<h3 className="text-base font-semibold text-[#010507] mt-1.5">{topic}</h3>
{attendee && (
<p className="text-sm text-[#57575B] mt-0.5">With {attendee}</p>
)}
<p className="text-sm text-[#57575B] mt-4 mb-2">Pick a time:</p>
<div className="grid grid-cols-2 gap-2">
{slots.map((s) => (
<button
key={s.iso}
disabled={disabled}
data-testid="time-picker-slot"
onClick={() => {
setPicked(s);
onSubmit({ chosen_time: s.iso, chosen_label: s.label });
}}
className="rounded-xl border border-[#DBDBE5] bg-white px-3 py-2 text-sm font-medium text-[#010507] hover:border-[#BEC2FF] hover:bg-[#BEC2FF1A] disabled:opacity-50 transition-colors"
>
{s.label}
</button>
))}
</div>
<button
disabled={disabled}
data-testid="time-picker-cancel"
onClick={() => {
setCancelled(true);
onSubmit({ cancelled: true });
}}
className="mt-3 w-full rounded-xl border border-[#E9E9EF] px-3 py-1.5 text-xs text-[#838389] hover:bg-[#FAFAFC] disabled:opacity-50 transition-colors"
>
None of these work
</button>
</div>
);
}
@@ -0,0 +1,32 @@
// Docs-only snippet — not imported or rendered. The dashboard demo at
// `page.tsx` for this framework runs a haiku-generator showcase via
// `useFrontendTool`, but the canonical `/generative-ui/tool-based` page
// teaches the simpler `useComponent` bar-chart pattern. This file shows
// what the bar-chart renderer would look like in the same framework's
// shape, so the docs render real teaching code rather than a missing-
// snippet box.
//
// Mirrors the convention from `tool-rendering/render-flight-tool.snippet.tsx`.
import { useComponent } from "@copilotkit/react-core/v2";
import { z } from "zod";
// Stand-ins for the locally-authored bar chart component + its prop
// schema. In a real page, these live in the demo directory (e.g.
// `./bar-chart.tsx` exporting `BarChart` and `barChartPropsSchema`).
declare const BarChart: React.ComponentType<{
title: string;
data: { label: string; value: number }[];
}>;
declare const barChartPropsSchema: z.ZodSchema;
export function BarChartRenderer() {
// @region[bar-chart-renderer]
useComponent({
name: "render_bar_chart",
description: "Display a bar chart with labeled numeric values.",
parameters: barChartPropsSchema,
render: BarChart,
});
// @endregion[bar-chart-renderer]
}
@@ -0,0 +1,92 @@
"use client";
import {
CopilotKitProvider,
CopilotChat,
useComponent,
} from "@copilotkit/react-core/v2";
export default function GenUiToolBased() {
return (
<CopilotKitProvider runtimeUrl="/api/copilotkit" useSingleEndpoint>
<Demo />
</CopilotKitProvider>
);
}
function Demo() {
useComponent({
name: "generate_haiku",
render: HaikuCard,
});
return (
<main className="p-8">
<h1 className="text-2xl font-semibold mb-4">Tool-Based Generative UI</h1>
<p className="text-sm opacity-70 mb-6">
Try: &ldquo;Write me a haiku about nature.&rdquo; The agent calls the
<code className="mx-1 px-1 bg-gray-100 rounded">generate_haiku</code>
tool and the result renders inline as a typed card.
</p>
<CopilotChat />
</main>
);
}
/**
* HaikuCard — rendered by `useComponent({ name: "generate_haiku" })`.
*
* `useComponent` passes tool-call arguments directly as React props (via
* `useFrontendTool`'s `render: ({ args }) => <Component {...args} />`).
* The D5 fixture sends: { japanese, english, image_name, gradient }.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function HaikuCard(props: any) {
const japaneseLines: string[] = props.japanese ?? [];
const englishLines: string[] = props.english ?? props.lines ?? [];
const gradient: string | undefined = props.gradient;
const topic: string | undefined = props.topic;
const hasContent = japaneseLines.length > 0 || englishLines.length > 0;
if (!hasContent) {
return (
<div
data-testid="haiku-card"
className="border rounded p-3 my-2 opacity-70 text-sm"
>
Composing haiku{topic ? ` about ${topic}` : ""}
</div>
);
}
return (
<div
data-testid="haiku-card"
className="border rounded p-4 my-2 bg-amber-50"
style={gradient ? { background: gradient } : undefined}
>
<div className="font-medium mb-2">Haiku{topic ? `${topic}` : ""}</div>
{japaneseLines.length > 0 && (
<div className="mb-2">
{japaneseLines.map((line: string, i: number) => (
<div
key={i}
data-testid="haiku-japanese-line"
className="text-lg leading-relaxed"
>
{line}
</div>
))}
</div>
)}
<div className="font-serif italic whitespace-pre-line text-lg leading-relaxed">
{englishLines.map((line: string, i: number) => (
<div key={i} data-testid="haiku-english-line">
{line}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,23 @@
"use client";
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
export function useSuggestions() {
useConfigureSuggestions({
suggestions: [
{
title: "Sales bar chart",
message: "Show me a bar chart of quarterly sales for Q1, Q2, Q3, Q4.",
},
{
title: "Traffic pie chart",
message: "Show me a pie chart of website traffic by source.",
},
{
title: "Market share",
message: "Show a pie chart of smartphone market share by brand.",
},
],
available: "always",
});
}

Some files were not shown because too many files have changed in this diff Show More