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,51 @@
// Dedicated runtime for the A2UI — Fixed Schema cell. Splitting into its own
// endpoint (mirroring the LangGraph reference) lets us set
// `a2ui.injectA2UITool: false` — the backend .NET agent owns the
// `search_flights` tool which emits its own `a2ui_operations` container.
//
// The .NET backend exposes this agent at `AGENT_URL/a2ui-fixed-schema`
// (see agent/Program.cs).
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const a2uiFixedSchemaAgent = new HttpAgent({
url: `${AGENT_URL}/a2ui-fixed-schema`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: { "a2ui-fixed-schema": a2uiFixedSchemaAgent },
a2ui: {
// The backend emits its own `a2ui_operations` container via the
// `search_flights` tool. We still run the A2UI middleware so it detects
// the container in tool results and forwards surfaces to the frontend —
// but we do NOT inject a runtime `render_a2ui` tool on top of the
// agent's existing tools.
injectA2UITool: false,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-a2ui-fixed-schema",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,53 @@
// Dedicated runtime for the /demos/agent-config cell.
//
// Proxies to the .NET backend's `/agent-config` endpoint, where a dedicated
// agent reads the forwarded tone / expertise / responseLength triple from
// shared state and rebuilds its system prompt per turn.
//
// References:
// - showcase/integrations/ms-agent-dotnet/agent/AgentConfigAgent.cs
// - showcase/integrations/ms-agent-dotnet/src/app/demos/agent-config/page.tsx
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/agent-config` });
}
// The page mounts <CopilotKit agent="agent-config-demo" />; resolve that to
// the dedicated agent. `default` aliased so any internal default-agent
// lookups resolve against the same agent.
const agents: Record<string, AbstractAgent> = {
"agent-config-demo": createAgent(),
default: createAgent(),
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-agent-config",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records;
// fixed in source, pending release.
agents,
}),
});
return await handleRequest(req);
} catch (error: unknown) {
const err = error as Error;
return NextResponse.json(
{ error: err.message, stack: err.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,79 @@
// Dedicated runtime for the /demos/auth cell.
//
// Demonstrates framework-native request authentication via the V2 runtime's
// `onRequest` hook, which runs before routing and can short-circuit the
// request by throwing a Response. We validate a static `Authorization: Bearer
// <DEMO_TOKEN>` header; mismatch throws 401 before the request reaches the
// agent.
//
// Implementation note: the V1 Next.js adapter
// (`copilotRuntimeNextJSAppRouterEndpoint`) does NOT forward the `hooks`
// option to the V2 fetch handler. To get `onRequest` wired, this route uses
// `createCopilotRuntimeHandler` from `@copilotkit/runtime/v2` directly — the
// framework-agnostic fetch handler that returns a plain
// `(Request) => Promise<Response>`, which composes cleanly with a Next.js
// App Router route export.
//
// References:
// - packages/runtime/src/v2/runtime/core/hooks.ts (onRequest semantics)
// - packages/runtime/src/v2/runtime/__tests__/hooks.test.ts (throw Response pattern)
import type { NextRequest } from "next/server";
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
import { DEMO_AUTH_HEADER } from "@/app/demos/auth/demo-token";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/` });
}
// Reuse the neutral SalesAgent for the authenticated path. The point of this
// demo is the gate mechanism, not per-user agent branching — authenticated
// users get the same behavior as any other neutral demo.
const runtime = new CopilotRuntime({
agents: {
"auth-demo": createAgent(),
// Fallback: useAgent() with no args resolves "default" — alias to the
// same agent so hooks in the demo page resolve cleanly.
default: createAgent(),
},
});
const BASE_PATH = "/api/copilotkit-auth";
// Framework-agnostic fetch handler with the auth gate wired up.
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
// thrown Responses to the HTTP response verbatim (status + body).
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" },
},
);
}
},
},
});
// Next.js App Router bindings. The handler is framework-agnostic — it takes
// a web Request and returns a web Response — so it drops straight into the
// POST/GET exports without any adapter shim.
export const POST = (req: NextRequest) => handler(req);
export const GET = (req: NextRequest) => handler(req);
@@ -0,0 +1,77 @@
// Dedicated runtime for the Beautiful Chat flagship cell.
//
// Beautiful Chat simultaneously exercises A2UI (dynamic + fixed schema),
// Open Generative UI, and MCP Apps. Those three flags are set on the
// CopilotRuntime itself (not on the backing agent), so we scope this cell
// to its own route instead of bleeding flags into the shared
// `/api/copilotkit` endpoint used by every other cell.
//
// References:
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-beautiful-chat/route.ts
// - ../copilotkit-multimodal/route.ts (dedicated-route pattern)
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import type { AbstractAgent } from "@ag-ui/client";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
// Points at the `/beautiful-chat` mount on the .NET backend
// (Program.cs: `app.MapAGUI("/beautiful-chat", ...)`).
return new HttpAgent({ url: `${AGENT_URL}/beautiful-chat` });
}
const agents: Record<string, AbstractAgent> = {
// The page's <CopilotKit agent="beautiful-chat"> resolves here.
"beautiful-chat": createAgent(),
// Alias for internal components that call `useAgent()` without args.
default: createAgent(),
};
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents,
openGenerativeUI: true,
a2ui: {
// The .NET agent has its own `generate_a2ui` tool; don't inject the
// runtime's default A2UI tool on top.
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: "copilotkit://app-dashboard-catalog",
},
mcpApps: {
servers: [
{
type: "http",
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
serverId: "beautiful_chat_mcp",
},
],
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-beautiful-chat",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,59 @@
// Dedicated runtime for the Declarative Generative UI (A2UI — Dynamic Schema)
// cell. Splitting into its own endpoint (mirroring the LangGraph reference)
// lets us set `a2ui.injectA2UITool: false` — the backend .NET agent owns the
// `generate_a2ui` tool itself, so double-binding from the runtime would
// duplicate the tool slot and confuse the LLM.
//
// The .NET backend exposes this agent at `AGENT_URL/declarative-gen-ui`
// (see agent/Program.cs).
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const declarativeGenUiAgent = new HttpAgent({
url: `${AGENT_URL}/declarative-gen-ui`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: { "declarative-gen-ui": declarativeGenUiAgent },
a2ui: {
// The backend agent owns the `generate_a2ui` tool explicitly, so the
// runtime MUST NOT auto-inject its own A2UI tool on top. The A2UI
// middleware still runs — it serialises the registered client catalog
// into the agent's `copilotkit.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.
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",
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-gen-ui",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,52 @@
// Dedicated runtime for the declarative-hashbrown demo.
//
// The demo page (`src/app/demos/declarative-hashbrown/page.tsx`) wraps
// CopilotChat in the HashBrownDashboard provider and overrides the
// assistant message slot with a renderer that consumes hashbrown-shaped
// structured output via `@hashbrownai/react`'s `useUiKit` + `useJsonParser`.
// The MS Agent behind this endpoint (see `src/agents/byoc_hashbrown_agent.py`,
// mounted at `/byoc-hashbrown` in `agent_server.py`) has a system prompt
// tuned to emit that shape. The legacy `byoc_hashbrown` Python module name
// is retained (mirrors LangGraph's convention — see
// `langgraph-python/src/app/api/copilotkit-declarative-hashbrown/route.ts`);
// only the user-facing slug, route, and frontend folder use the
// `declarative-` prefix so the manifest is one-to-one with LGP.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const declarativeHashbrownAgent = new HttpAgent({
url: `${AGENT_URL}/byoc-hashbrown`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in
// source, pending release.
agents: { "declarative-hashbrown-demo": declarativeHashbrownAgent },
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-hashbrown",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,48 @@
// Dedicated runtime for the declarative-json-render demo.
//
// The demo page (`src/app/demos/declarative-json-render/page.tsx`) swaps
// in `JsonRenderAssistantMessage` and renders an agent-emitted JSON spec
// via `@json-render/react` against a Zod-validated catalog (MetricCard,
// BarChart, PieChart). The MS Agent behind this endpoint (see
// `src/agents/byoc_json_render_agent.py`, mounted at `/byoc-json-render`
// in `agent_server.py`) emits that JSON envelope. The legacy
// `byoc_json_render` Python module name is retained (matches LGP's
// convention); only the slug, route, and frontend folder use the
// `declarative-` prefix.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const declarativeJsonRenderAgent = new HttpAgent({
url: `${AGENT_URL}/byoc-json-render`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see hashbrown route
agents: { byoc_json_render: declarativeJsonRenderAgent },
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-json-render",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,79 @@
// CopilotKit runtime for the MCP Apps cell (MS Agent Framework backend).
//
// The runtime's `mcpApps` config auto-applies the MCP Apps middleware to the
// agent: when the agent calls a tool backed by an MCP UI resource, the
// middleware fetches the resource and emits the activity event that the
// built-in `MCPAppsActivityRenderer` (registered by CopilotKit internally)
// renders in the chat as a sandboxed iframe.
//
// The optional catch-all route mirrors the langgraph-python north star. MCP
// Apps resource proxy requests are addressed below `/api/copilotkit-mcp-apps`,
// so a plain `route.ts` at the parent segment handles the chat POST but misses
// those subpath requests.
//
// Reference:
// https://docs.copilotkit.ai/integrations/microsoft-agent-framework/generative-ui/mcp-apps
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const mcpAppsAgent = new HttpAgent({ url: `${AGENT_URL}/mcp-apps/` });
// headless-complete shares this runtime because its cell also exercises
// MCP Apps activity rendering (the "Sketch a diagram" pill exercises the
// Excalidraw MCP server via the same middleware). The catch-all `/` agent
// on the Python backend backs it -- no dedicated headless endpoint.
const headlessCompleteAgent = new HttpAgent({
url: `${AGENT_URL}/headless-complete`,
});
// @region[runtime-mcpapps-config]
// The `mcpApps.servers` config is all you need server-side. The runtime
// auto-applies the MCP Apps middleware to every registered agent: on each
// MCP tool call it fetches the associated UI resource and emits an
// `activity` event that the built-in `MCPAppsActivityRenderer` renders
// inline in the chat.
const runtime = new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents: {
"mcp-apps": mcpAppsAgent,
"headless-complete": headlessCompleteAgent,
},
mcpApps: {
servers: [
{
type: "http",
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
// Keep the server id 1:1 with langgraph-python so persisted MCP Apps
// and fixture-backed resource calls use the same identity.
serverId: "excalidraw",
},
],
},
});
// @endregion[runtime-mcpapps-config]
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-mcp-apps",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,63 @@
// Dedicated runtime for the Multimodal Attachments demo.
//
// Why its own route? The backing agent (MultimodalAgent, mounted at
// `/multimodal` by the .NET backend) is the only one in this showcase that
// expects vision content parts. Registering it under the shared
// `/api/copilotkit` runtime would silently route every other cell through the
// vision endpoint too. A dedicated route keeps the vision capability scoped
// to exactly the cell that exercises it, matching the LangGraph reference's
// pattern for `/api/copilotkit-multimodal`.
//
// The page at src/app/demos/multimodal/page.tsx points its `runtimeUrl` at
// this endpoint and sets `agent="multimodal-demo"`.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { AbstractAgent, HttpAgent, type RunAgentInput } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
class DotnetMultimodalHttpAgent extends HttpAgent {
run(input: RunAgentInput): ReturnType<HttpAgent["run"]> {
return super.run(input);
}
protected requestInit(input: RunAgentInput): RequestInit {
return super.requestInit(input);
}
}
function createAgent() {
// Points at the `/multimodal` mount on the .NET backend (Program.cs:
// `app.MapAGUI("/multimodal", ...)`).
return new DotnetMultimodalHttpAgent({ url: `${AGENT_URL}/multimodal` });
}
const agents: Record<string, AbstractAgent> = {
"multimodal-demo": createAgent(),
default: createAgent(),
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-multimodal",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents,
}),
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,157 @@
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import {
AbstractAgent,
HttpAgent,
type BaseEvent,
type RunAgentInput,
} from "@ag-ui/client";
import { map, type Observable } from "rxjs";
// Dedicated runtime for the Open Generative UI demos, mirroring the
// LangGraph-Python `copilotkit-ogui` route.
//
// Isolated here because the `openGenerativeUI` runtime flag sets
// `openGenerativeUIEnabled: true` globally on the probe response, which
// causes the CopilotKit provider's setTools effect to wipe per-demo
// `useFrontendTool`/`useComponent` registrations in the default runtime.
//
// Each agent name proxies to a separate `MapAGUI` endpoint on the .NET
// backend (see `agent/Program.cs`).
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const OGUI_TOOL_CALL_ID_SUFFIX = /__ogui_run_[0-9a-f-]+$/i;
console.log("[copilotkit-ogui/route] Initializing OGUI CopilotKit runtime");
console.log(`[copilotkit-ogui/route] AGENT_URL: ${AGENT_URL}`);
function stripOguiToolCallId(id: string): string {
return id.replace(OGUI_TOOL_CALL_ID_SUFFIX, "");
}
function makeOguiToolCallId(id: string, runId: string): string {
return `${stripOguiToolCallId(id)}__ogui_run_${runId}`;
}
function stripOguiToolCallIdsFromMessage(message: unknown): unknown {
if (!message || typeof message !== "object") return message;
const next = { ...(message as Record<string, unknown>) };
let changed = false;
if (typeof next.toolCallId === "string") {
next.toolCallId = stripOguiToolCallId(next.toolCallId);
changed = true;
}
if (typeof next.tool_call_id === "string") {
next.tool_call_id = stripOguiToolCallId(next.tool_call_id);
changed = true;
}
if (Array.isArray(next.toolCalls)) {
next.toolCalls = next.toolCalls.map((toolCall) => {
if (!toolCall || typeof toolCall !== "object") return toolCall;
const call = { ...(toolCall as Record<string, unknown>) };
if (typeof call.id === "string") {
call.id = stripOguiToolCallId(call.id);
}
return call;
});
changed = true;
}
return changed ? next : message;
}
class OpenGenUiHttpAgent extends HttpAgent {
run(input: RunAgentInput): Observable<BaseEvent> {
const toolCallIds = new Map<string, string>();
const runId = input.runId;
const sanitizedInput = {
...input,
messages: input.messages.map(stripOguiToolCallIdsFromMessage),
} as RunAgentInput;
return super.run(sanitizedInput).pipe(
map((event: BaseEvent) => {
const e = event as BaseEvent & {
toolCallId?: string;
toolCallName?: string;
};
if (
(e.type === "TOOL_CALL_START" || e.type === "TOOL_CALL_CHUNK") &&
e.toolCallName === "generateSandboxedUi" &&
e.toolCallId
) {
const originalId = stripOguiToolCallId(e.toolCallId);
const rewrittenId = makeOguiToolCallId(originalId, runId);
toolCallIds.set(originalId, rewrittenId);
return { ...event, toolCallId: rewrittenId };
}
if (e.toolCallId) {
const originalId = stripOguiToolCallId(e.toolCallId);
const rewrittenId = toolCallIds.get(originalId);
if (rewrittenId) {
return { ...event, toolCallId: rewrittenId };
}
}
return event;
}),
);
}
}
const openGenUiAgent = new OpenGenUiHttpAgent({
url: `${AGENT_URL}/open-gen-ui`,
});
const openGenUiAdvancedAgent = new OpenGenUiHttpAgent({
url: `${AGENT_URL}/open-gen-ui-advanced`,
});
const agents: Record<string, AbstractAgent> = {
"open-gen-ui": openGenUiAgent,
"open-gen-ui-advanced": openGenUiAdvancedAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-ogui",
serviceAdapter: new ExperimentalEmptyAdapter(),
// 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); the runtime middleware
// converts each agent's streamed `generateSandboxedUi` tool call into
// `open-generative-ui` activity events.
// @region[minimal-runtime-flag]
// @region[advanced-runtime-config]
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
openGenerativeUI: {
agents: ["open-gen-ui", "open-gen-ui-advanced"],
},
}),
// @endregion[advanced-runtime-config]
// @endregion[minimal-runtime-flag]
});
return await handleRequest(req);
} catch (error: unknown) {
const err = error as Error;
console.error(`[copilotkit-ogui/route] ERROR: ${err.message}`);
return NextResponse.json(
{ error: err.message, stack: err.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,107 @@
// Dedicated runtime for the /demos/voice cell (MS Agent Python).
//
// Goals
// -----
// 1. Advertise `audioFileTranscriptionEnabled: true` on `/info` so the chat
// composer renders the mic button.
// 2. Handle `POST /transcribe` by invoking an OpenAI-backed
// `TranscriptionServiceOpenAI` (from `@copilotkit/voice`).
// 3. Return a deterministic 4xx when `OPENAI_API_KEY` is not configured.
//
// Wires the V2 `CopilotRuntime` directly because the V1 wrapper drops the
// `transcriptionService` option. V2 URL-routes on `/info`, `/agent/:id/run`,
// `/transcribe`, etc., so the route lives at `[[...slug]]/route.ts`.
// @region[voice-runtime]
// @region[transcription-service-guard]
import type { NextRequest } from "next/server";
import {
CopilotRuntime,
TranscriptionService,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import type { TranscribeFileOptions } from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
import { TranscriptionServiceOpenAI } from "@copilotkit/voice";
import OpenAI from "openai";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
// Point at the tool-free /voice endpoint so aimock returns a direct text
// response instead of a tool call that the agent can't summarize.
const voiceDemoAgent = new HttpAgent({ url: `${AGENT_URL}/voice/` });
/**
* Transcription service wrapper that reports a clean, typed auth error when
* OPENAI_API_KEY is not configured. When the key is present we delegate to
* the real OpenAI-backed service; any upstream Whisper error keeps its
* natural categorization.
*
* Note: We pin `baseURL` to real OpenAI (or `OPENAI_TRANSCRIPTION_BASE_URL`
* when explicitly set) instead of falling through to `OPENAI_BASE_URL`. In
* local docker / Railway preview environments `OPENAI_BASE_URL` points at
* aimock so LLM completions stay deterministic, but aimock has a catchall
* `endpoint: "transcription"` fixture that would otherwise intercept every
* real mic recording and return the canned "What is the weather in Tokyo?"
* phrase regardless of what the user actually said — and on production
* aimock's transcription proxy returns a 502 "Invalid file format" before
* any phrase reaches the user. The sample-audio button is the deterministic
* affordance (synchronous text injection); the mic is the only path that
* should exercise real Whisper.
*
* Mirrors langgraph-python's voice route exactly.
*/
class GuardedOpenAITranscriptionService extends TranscriptionService {
private delegate: TranscriptionServiceOpenAI | null;
constructor() {
super();
const apiKey = process.env.OPENAI_API_KEY;
const baseURL =
process.env.OPENAI_TRANSCRIPTION_BASE_URL ?? "https://api.openai.com/v1";
this.delegate = apiKey
? new TranscriptionServiceOpenAI({
openai: new OpenAI({ apiKey, baseURL }),
})
: null;
}
async transcribeFile(options: TranscribeFileOptions): Promise<string> {
if (!this.delegate) {
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({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in
// source, pending release.
agents: {
"voice-demo": voiceDemoAgent,
default: voiceDemoAgent,
},
transcriptionService: new GuardedOpenAITranscriptionService(),
});
cachedHandler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-voice",
});
return cachedHandler;
}
export const POST = (req: NextRequest) => getHandler()(req);
export const GET = (req: NextRequest) => getHandler()(req);
export const PUT = (req: NextRequest) => getHandler()(req);
export const DELETE = (req: NextRequest) => getHandler()(req);
// @endregion[voice-runtime]
@@ -0,0 +1,830 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import type { AbstractAgent, BaseEvent } from "@ag-ui/client";
import { EventType, FunctionMiddleware, HttpAgent } from "@ag-ui/client";
import { Observable } from "rxjs";
// The agent backend runs as a separate process on port 8000.
// This runtime proxies CopilotKit requests to it via AG-UI protocol.
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const REPLAY_SAFE_TOOL_CALL_ID_SUFFIX = /__ck_run_[0-9a-f-]+$/i;
// Per-request request/response logging is gated behind this flag (default off).
// Under d6 probe fan-out, unconditional per-request logs flooded Railway's
// 500-logs/sec cap and killed the replica ("Messages dropped" → container stop).
// Set SHOWCASE_ROUTE_DEBUG=1 to re-enable verbose per-request tracing locally.
const ROUTE_DEBUG =
process.env.SHOWCASE_ROUTE_DEBUG === "1" ||
process.env.SHOWCASE_ROUTE_DEBUG === "true";
console.log("[copilotkit/route] Initializing CopilotKit runtime");
console.log(`[copilotkit/route] AGENT_URL: ${AGENT_URL}`);
function createAgent(path = "/") {
const agent = new HttpAgent({ url: `${AGENT_URL}${path}` });
// Universal strip middleware (no decision-suffix). Runs as the first
// registered middleware so EVERY outbound request carries clean
// canonical toolCallIds (the replay-safe `__ck_run_<uuid>` suffix is
// removed) before any agent-specific middleware sees the input.
// Decision suffixing (`__approved` / `__rejected` / `__cancelled`)
// is intentionally NOT applied here — only `createReplaySafeAgent`
// does that, because the suffix is non-idempotent and the inner
// replay-safe middleware re-runs the same logic after this one.
agent.use(
new FunctionMiddleware((input, next) => {
return next.run({
...input,
messages: (input.messages ?? []).map(
stripReplaySafeToolCallIdsFromMessage,
),
});
}),
);
return agent;
}
function stripReplaySafeToolCallId(id: string): string {
return id.replace(REPLAY_SAFE_TOOL_CALL_ID_SUFFIX, "");
}
function makeReplaySafeToolCallId(id: string, runId: string): string {
return `${stripReplaySafeToolCallId(id)}__ck_run_${runId}`;
}
function stripReplaySafeToolCallIdsFromMessage(message: unknown): unknown {
if (!message || typeof message !== "object") return message;
const next = { ...(message as Record<string, unknown>) };
let changed = false;
if (typeof next.toolCallId === "string") {
next.toolCallId = stripReplaySafeToolCallId(next.toolCallId);
changed = true;
}
if (typeof next.tool_call_id === "string") {
next.tool_call_id = stripReplaySafeToolCallId(next.tool_call_id);
changed = true;
}
// Strip on BOTH the camelCase (AG-UI canonical) and snake_case (OpenAI
// wire format) tool-call arrays. Some runtimes / message converters
// pass the OpenAI shape through unchanged; without this branch the
// replay-safe `__ck_run_<uuid>` suffix slips through to aimock and
// toolCallId-keyed fixtures fail to match on follow-up turns.
const stripToolCallArrayEntry = (toolCall: unknown) => {
if (!toolCall || typeof toolCall !== "object") return toolCall;
const call = { ...(toolCall as Record<string, unknown>) };
if (typeof call.id === "string") {
call.id = stripReplaySafeToolCallId(call.id);
}
// OpenAI nests the tool_call_id under `function` in some shapes; strip
// there too just to keep the surface clean before aimock sees it.
if (call.function && typeof call.function === "object") {
const fn = { ...(call.function as Record<string, unknown>) };
if (typeof fn.tool_call_id === "string") {
fn.tool_call_id = stripReplaySafeToolCallId(fn.tool_call_id);
call.function = fn;
}
}
return call;
};
if (Array.isArray(next.toolCalls)) {
next.toolCalls = next.toolCalls.map(stripToolCallArrayEntry);
changed = true;
}
if (Array.isArray(next.tool_calls)) {
next.tool_calls = next.tool_calls.map(stripToolCallArrayEntry);
changed = true;
}
return changed ? next : message;
}
function textFromMessageContent(content: unknown): string | undefined {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return undefined;
const text = content
.map((part) => {
if (!part || typeof part !== "object") return "";
const text = (part as { text?: unknown }).text;
return typeof text === "string" ? text : "";
})
.join("");
return text || undefined;
}
function textFromContextValue(value: unknown): string | undefined {
if (typeof value === "string") return value;
if (value === undefined || value === null) return undefined;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function buildContextSystemMessage(context: unknown): string | undefined {
if (!Array.isArray(context) || context.length === 0) return undefined;
const lines = ["## Context from the application"];
for (const entry of context) {
if (!entry || typeof entry !== "object") continue;
const record = entry as Record<string, unknown>;
const description =
typeof record.description === "string" ? record.description : undefined;
const value = textFromContextValue(record.value);
if (!description || !value) continue;
lines.push("", description, value);
}
return lines.length > 1 ? lines.join("\n") : undefined;
}
function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object"
? (value as Record<string, unknown>)
: undefined;
}
function buildSharedStateReadWriteSystemMessage(state: unknown): string {
const stateRecord = readRecord(state);
const prefs = readRecord(stateRecord?.preferences) ?? {};
const name = typeof prefs.name === "string" ? prefs.name : "";
const tone = typeof prefs.tone === "string" ? prefs.tone : "casual";
const language =
typeof prefs.language === "string" ? prefs.language : "English";
const interests = Array.isArray(prefs.interests)
? prefs.interests.filter(
(interest): interest is string => typeof interest === "string",
)
: [];
return [
"You are a helpful, concise assistant. The user's preferences are supplied via shared state and added as a system message at the start of every turn - always respect them. When the user asks you to remember something, or you observe something worth surfacing in the UI's notes panel, call `set_notes` with the FULL updated list of short notes (existing notes + new). Keep each note short.",
"",
"[shared-state-read-write] preferences:",
"{",
` "name": ${JSON.stringify(name)},`,
` "tone": ${JSON.stringify(tone)},`,
` "language": ${JSON.stringify(language)},`,
` "interests": ${JSON.stringify(interests)}`,
"}",
"Tailor every response to these preferences. Address the user by name when appropriate.",
].join("\n");
}
type ToolResultDecision = "approved" | "rejected" | "cancelled";
function toolDecisionFromContent(
content: unknown,
): ToolResultDecision | undefined {
const text = textFromMessageContent(content);
if (!text) return undefined;
try {
const parsed = JSON.parse(text);
if (parsed?.approved === true || parsed?.accepted === true) {
return "approved";
}
if (parsed?.approved === false || parsed?.accepted === false) {
return "rejected";
}
if (parsed?.cancelled === true || parsed?.canceled === true) {
return "cancelled";
}
} catch {
const normalized = text.toLowerCase();
if (
(normalized.includes("cancelled") || normalized.includes("canceled")) &&
(normalized.includes("not scheduled") ||
normalized.includes("not booked") ||
normalized.includes("no time"))
) {
return "cancelled";
}
}
return undefined;
}
function makeDecisionToolCallId(id: string, decision: ToolResultDecision) {
return `${id}__${decision}`;
}
function applyToolResultDecisionSuffix(
message: unknown,
decisionsByToolCallId: Map<string, ToolResultDecision>,
): unknown {
if (!message || typeof message !== "object") return message;
const next = { ...(message as Record<string, unknown>) };
let changed = false;
if (typeof next.toolCallId === "string") {
const decision = decisionsByToolCallId.get(next.toolCallId);
if (decision) {
next.toolCallId = makeDecisionToolCallId(next.toolCallId, decision);
changed = true;
}
}
if (typeof next.tool_call_id === "string") {
const decision = decisionsByToolCallId.get(next.tool_call_id);
if (decision) {
next.tool_call_id = makeDecisionToolCallId(next.tool_call_id, decision);
changed = true;
}
}
const suffixArrayEntry = (toolCall: unknown) => {
if (!toolCall || typeof toolCall !== "object") return toolCall;
const call = { ...(toolCall as Record<string, unknown>) };
if (typeof call.id === "string") {
const decision = decisionsByToolCallId.get(call.id);
if (decision) {
call.id = makeDecisionToolCallId(call.id, decision);
}
}
return call;
};
if (Array.isArray(next.toolCalls)) {
next.toolCalls = next.toolCalls.map(suffixArrayEntry);
changed = true;
}
if (Array.isArray(next.tool_calls)) {
next.tool_calls = next.tool_calls.map(suffixArrayEntry);
changed = true;
}
return changed ? next : message;
}
function messageRole(message: unknown): string | undefined {
if (!message || typeof message !== "object") return undefined;
const role = (message as Record<string, unknown>).role;
return typeof role === "string" ? role : undefined;
}
function hasTextContent(message: Record<string, unknown>): boolean {
const content = textFromMessageContent(message.content);
return Boolean(content?.trim());
}
function dropStaleToolInteractionsBeforeLatestUser(messages: unknown[]) {
const latestUserIndex = messages.findLastIndex(
(message) => messageRole(message) === "user",
);
if (latestUserIndex < 0 || latestUserIndex !== messages.length - 1) {
return messages;
}
let changed = false;
const nextMessages: unknown[] = [];
messages.forEach((message, index) => {
if (index >= latestUserIndex || !message || typeof message !== "object") {
nextMessages.push(message);
return;
}
const record = message as Record<string, unknown>;
if (record.role === "tool") {
changed = true;
return;
}
if (
record.role === "assistant" &&
(Array.isArray(record.toolCalls) || Array.isArray(record.tool_calls))
) {
const next = { ...record };
delete next.toolCalls;
delete next.tool_calls;
changed = true;
if (hasTextContent(next)) {
nextMessages.push(next);
}
return;
}
nextMessages.push(message);
});
return changed ? nextMessages : messages;
}
function prepareReplaySafeMessages(messages: unknown[] = []) {
const stripped = messages.map(stripReplaySafeToolCallIdsFromMessage);
const decisionsByToolCallId = new Map<string, ToolResultDecision>();
for (const message of stripped) {
if (!message || typeof message !== "object") continue;
const record = message as Record<string, unknown>;
const toolCallId =
typeof record.tool_call_id === "string"
? record.tool_call_id
: typeof record.toolCallId === "string"
? record.toolCallId
: undefined;
if (record.role !== "tool" || !toolCallId) {
continue;
}
const decision = toolDecisionFromContent(record.content);
if (decision) {
decisionsByToolCallId.set(toolCallId, decision);
}
}
const decisionAwareMessages =
decisionsByToolCallId.size === 0
? stripped
: stripped.map((message) =>
applyToolResultDecisionSuffix(message, decisionsByToolCallId),
);
return dropStaleToolInteractionsBeforeLatestUser(decisionAwareMessages);
}
function createReplaySafeAgent(path: string, replaySafeToolNames: string[]) {
const agent = createAgent(path);
const replaySafeTools = new Set(replaySafeToolNames);
agent.use(
new FunctionMiddleware((input, next) => {
return new Observable<BaseEvent>((subscriber) => {
const toolCallIds = new Map<string, string>();
const sanitizedInput = {
...input,
messages: prepareReplaySafeMessages(input.messages),
};
const subscription = next.run(sanitizedInput).subscribe({
next(event) {
const e = event as BaseEvent & {
toolCallId?: string;
toolCallName?: string;
};
if (
(e.type === EventType.TOOL_CALL_START ||
e.type === EventType.TOOL_CALL_CHUNK) &&
e.toolCallName &&
replaySafeTools.has(e.toolCallName) &&
e.toolCallId
) {
const originalId = stripReplaySafeToolCallId(e.toolCallId);
const rewrittenId = makeReplaySafeToolCallId(
originalId,
input.runId,
);
toolCallIds.set(originalId, rewrittenId);
subscriber.next({ ...event, toolCallId: rewrittenId });
return;
}
if (e.toolCallId) {
const originalId = stripReplaySafeToolCallId(e.toolCallId);
const rewrittenId = toolCallIds.get(originalId);
if (rewrittenId) {
subscriber.next({ ...event, toolCallId: rewrittenId });
return;
}
}
subscriber.next(event);
},
error(error) {
subscriber.error(error);
},
complete() {
subscriber.complete();
},
});
return () => subscription.unsubscribe();
});
}),
);
return agent;
}
function createGenUiAgent() {
const agent = createAgent("/gen-ui-agent");
agent.use(
new FunctionMiddleware((input, next) => {
// The outer createAgent middleware already stripped replay-safe ids
// on inbound messages, so this middleware only needs to wire the
// gen-ui-agent's STATE_SNAPSHOT bridging from set_steps tool args.
return new Observable<BaseEvent>((subscriber) => {
const setStepsToolCallIds = new Set<string>();
const argsByToolCallId = new Map<string, string>();
const emitStateSnapshotFromArgs = (toolCallId: string) => {
const args = argsByToolCallId.get(toolCallId);
if (!args) return;
try {
const parsed = JSON.parse(args);
if (!Array.isArray(parsed?.steps)) return;
subscriber.next({
type: EventType.STATE_SNAPSHOT,
snapshot: { steps: parsed.steps },
} as BaseEvent);
} catch {
// Args may arrive in chunks; wait until the buffered JSON is whole.
}
};
const subscription = next.run(input).subscribe({
next(event) {
if (
event.type === EventType.TOOL_CALL_START &&
(event as { toolCallName?: string }).toolCallName === "set_steps"
) {
const toolCallId = (event as { toolCallId?: string }).toolCallId;
if (toolCallId) setStepsToolCallIds.add(toolCallId);
return;
}
if (event.type === EventType.TOOL_CALL_ARGS) {
const toolCallId = (event as { toolCallId?: string }).toolCallId;
if (toolCallId && setStepsToolCallIds.has(toolCallId)) {
const delta = String((event as { delta?: string }).delta ?? "");
argsByToolCallId.set(
toolCallId,
`${argsByToolCallId.get(toolCallId) ?? ""}${delta}`,
);
emitStateSnapshotFromArgs(toolCallId);
return;
}
}
if (
event.type === EventType.TOOL_CALL_END ||
event.type === EventType.TOOL_CALL_RESULT
) {
const toolCallId = (event as { toolCallId?: string }).toolCallId;
if (toolCallId && setStepsToolCallIds.has(toolCallId)) {
if (event.type === EventType.TOOL_CALL_RESULT) {
setStepsToolCallIds.delete(toolCallId);
argsByToolCallId.delete(toolCallId);
}
return;
}
}
subscriber.next(event);
},
error(error) {
subscriber.error(error);
},
complete() {
subscriber.complete();
},
});
return () => subscription.unsubscribe();
});
}),
);
return agent;
}
function createReadonlyContextAgent() {
const agent = createAgent("/readonly-state-agent-context");
agent.use(
new FunctionMiddleware((input, next) => {
// The outer createAgent middleware already stripped replay-safe ids
// on `input.messages`, so the injected system message rides along
// with already-canonicalised toolCallIds.
const contextMessage = buildContextSystemMessage(
(input as { context?: unknown }).context,
);
if (!contextMessage) {
return next.run(input);
}
return next.run({
...input,
messages: [
{
id: `${input.runId ?? crypto.randomUUID()}-app-context`,
role: "system",
content: contextMessage,
},
...(input.messages ?? []),
],
});
}),
);
return agent;
}
function createSharedStateReadWriteAgent() {
const agent = createAgent("/shared-state-read-write");
agent.use(
new FunctionMiddleware((input, next) => {
// The outer createAgent middleware already stripped replay-safe ids
// on `input.messages`; the injected shared-state system message
// rides along with already-canonicalised toolCallIds.
return next.run({
...input,
messages: [
{
id: `${input.runId ?? crypto.randomUUID()}-shared-state`,
role: "system",
content: buildSharedStateReadWriteSystemMessage(
(input as { state?: unknown }).state,
),
},
...(input.messages ?? []),
],
});
}),
);
return agent;
}
function createReasoningAgent(path = "/reasoning") {
const agent = createAgent(path);
// Microsoft.Agents.AI.Hosting.AGUI.AspNetCore@1.0.0-preview.251110.1
// does not emit AG-UI REASONING_MESSAGE_* events yet. Keep the backend
// behavior intact, but add the reasoning-role stream shape CopilotKit's
// v2 chat slots expect. Also strip replayed reasoning messages before
// sending follow-up turns back to the .NET AG-UI host, whose input mapper
// only accepts user/assistant/system/tool roles.
agent.use(
new FunctionMiddleware((input, next) => {
// The outer createAgent middleware already stripped replay-safe ids
// on inbound messages. Here we additionally drop reasoning-role
// messages because the .NET AG-UI host's input mapper rejects
// them (it only accepts user/assistant/system/tool roles).
const sanitizedInput = {
...input,
messages: input.messages?.filter(
(message) => message.role !== "reasoning",
),
};
const reasoningMessageId = `${input.runId ?? crypto.randomUUID()}-reasoning`;
const reasoningDelta =
"I am checking the request, choosing the relevant tool or answer path, and then summarizing the result.";
return new Observable<BaseEvent>((subscriber) => {
let injected = false;
const injectReasoning = () => {
if (injected) return;
injected = true;
subscriber.next({
type: EventType.REASONING_START,
messageId: reasoningMessageId,
} as BaseEvent);
subscriber.next({
type: EventType.REASONING_MESSAGE_START,
messageId: reasoningMessageId,
role: "reasoning",
} as BaseEvent);
subscriber.next({
type: EventType.REASONING_MESSAGE_CONTENT,
messageId: reasoningMessageId,
delta: reasoningDelta,
} as BaseEvent);
subscriber.next({
type: EventType.REASONING_MESSAGE_END,
messageId: reasoningMessageId,
} as BaseEvent);
subscriber.next({
type: EventType.REASONING_END,
messageId: reasoningMessageId,
} as BaseEvent);
};
const subscription = next.run(sanitizedInput).subscribe({
next(event) {
subscriber.next(event);
if (event.type === EventType.RUN_STARTED) {
injectReasoning();
}
},
error(error) {
subscriber.error(error);
},
complete() {
injectReasoning();
subscriber.complete();
},
});
return () => subscription.unsubscribe();
});
}),
);
return agent;
}
// Register the same agent under all names used by demo pages.
const agentNames = [
"agentic_chat",
"human_in_the_loop",
"tool-rendering",
"shared-state-read",
"prebuilt-sidebar",
"prebuilt-popup",
"chat-slots",
"chat-customization-css",
"headless-simple",
"frontend-tools",
"frontend-tools-async",
// Aliases for ADK/LGP-style underscore names (frontend pages use these).
"frontend_tools",
"frontend_tools_async",
];
// Agent names routed to the interrupt-adapted scheduling backend. Both
// gen-ui-interrupt and interrupt-headless share the same MS Agent Framework
// scheduling agent; only the frontend UX differs (inline in chat vs. external
// popup driven from a button grid).
const interruptAgentNames = ["gen-ui-interrupt", "interrupt-headless"];
const agents: Record<string, AbstractAgent> = {};
for (const name of agentNames) {
agents[name] = createAgent();
}
agents["human_in_the_loop"] = createReplaySafeAgent("/", [
"generate_task_steps",
]);
agents["headless-complete"] = createAgent("/headless-complete");
// Interrupt-adapted demos — frontend-tool shim for LangGraph `interrupt()`.
// Both gen-ui-interrupt and interrupt-headless share the same scheduling agent;
// only the frontend UX differs (inline time-picker vs. external popup).
for (const name of interruptAgentNames) {
agents[name] = createReplaySafeAgent("/interrupt-adapted", [
"schedule_meeting",
]);
}
// In-App HITL -- async frontend-tool + app-level modal (outside chat).
// Dedicated hitl-in-app agent mounted at /hitl-in-app on the FastAPI
// backend; agent has tools=[] and relies on the frontend-provided
// `request_user_approval` tool injected by CopilotKit at request time.
agents["hitl-in-app"] = createReplaySafeAgent("/hitl-in-app", [
"request_user_approval",
]);
// In-Chat HITL -- frontend-defined `book_call` tool rendered inline in the
// chat via `useHumanInTheLoop`. Backend agent has tools=[] and routes to
// /hitl-in-chat on the FastAPI backend.
agents["hitl-in-chat"] = createReplaySafeAgent("/hitl-in-chat", ["book_call"]);
// Generative UI Agent — backend with `set_steps` tool + `steps` state
// schema mirrored from LGP's gen_ui_agent. The frontend renders a live
// progress card subscribed to `agent.state.steps`.
agents["gen-ui-agent"] = createGenUiAgent();
// Tool-Based Generative UI -- frontend registers `render_bar_chart` and
// `render_pie_chart` via `useComponent`; backend agent has tools=[] and a
// system prompt that picks the right chart type for the user's request.
agents["gen-ui-tool-based"] = createAgent("/gen-ui-tool-based");
// Shared State (Streaming) — `write_document` tool with `predict_state_config`
// that streams the tool's `document` arg into `state.document` per-token.
// See `src/agents/shared_state_streaming.py`.
agents["shared-state-streaming"] = createAgent("/shared-state-streaming");
// Readonly state via `useAgentContext` — minimal agent, no tools, reads
// frontend-provided context entries on every turn.
agents["readonly-state-agent-context"] = createReadonlyContextAgent();
// Shared State (Read + Write) — bidirectional state via state_schema +
// state_update. Backend exposes a dedicated agent at /shared-state-read-write
// with `preferences` + `notes` slots; UI writes preferences via setState,
// agent writes notes via the `set_notes` tool.
agents["shared-state-read-write"] = createSharedStateReadWriteAgent();
// Sub-Agents — supervisor agent at /subagents that delegates to research /
// writing / critique sub-agents and surfaces a live `delegations` log to the
// UI via shared state.
agents["subagents"] = createAgent("/subagents");
agents["default"] = createAgent();
// Tool-rendering demos — share the dedicated reasoning-chain agent
// mounted at /tool-rendering-reasoning-chain on the Python backend. All
// three cells call the same agent; they differ only in how the frontend
// renders tool calls.
// Reasoning cells (`reasoning-default` + `reasoning-custom`) share a
// dedicated backend mounted at `/reasoning` that uses the OpenAI Responses
// API (gpt-5/o-series) — the only chat client that emits AG-UI
// `REASONING_MESSAGE_*` events. See `src/agents/reasoning_agent.py`.
agents["reasoning-default"] = createReasoningAgent("/reasoning");
agents["reasoning-custom"] = createReasoningAgent("/reasoning");
// Tool-rendering demos — the plain `tool-rendering` cell and the two
// catchall variants share a non-reasoning backend (mounted at
// `/tool-rendering`). The reasoning-chain cell has its own dedicated
// backend (mounted at `/tool-rendering-reasoning-chain`) that routes
// through OpenAI's Responses API for reasoning streaming; mixing
// reasoning blocks into the catchall renderers breaks the
// default-catchall cell's spec.
agents["tool-rendering"] = createAgent("/tool-rendering");
agents["tool-rendering-default-catchall"] = createAgent("/tool-rendering");
agents["tool-rendering-custom-catchall"] = createAgent("/tool-rendering");
agents["tool-rendering-reasoning-chain"] = createReasoningAgent(
"/tool-rendering-reasoning-chain",
);
console.log(
`[copilotkit/route] Registered ${Object.keys(agents).length} agent names: ${Object.keys(agents).join(", ")}`,
);
export const POST = async (req: NextRequest) => {
const url = req.url;
const contentType = req.headers.get("content-type");
if (ROUTE_DEBUG) {
console.log(
`[copilotkit/route] POST ${url} (content-type: ${contentType})`,
);
}
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
}),
});
const response = await handleRequest(req);
if (!response.ok) {
console.log(`[copilotkit/route] Response status: ${response.status}`);
} else if (ROUTE_DEBUG) {
console.log(`[copilotkit/route] Response status: ${response.status}`);
}
return response;
} catch (error: unknown) {
const err = error as Error;
console.error(`[copilotkit/route] ERROR: ${err.message}`);
console.error(`[copilotkit/route] Stack: ${err.stack}`);
return NextResponse.json(
{ error: err.message, stack: err.stack },
{ status: 500 },
);
}
};
export const GET = async () => {
if (ROUTE_DEBUG) {
console.log("[copilotkit/route] GET /api/copilotkit (health probe)");
}
let agentStatus = "unknown";
try {
const res = await fetch(`${AGENT_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
agentStatus = res.ok ? "reachable" : `error (${res.status})`;
} catch (e: unknown) {
agentStatus = `unreachable (${(e as Error).message})`;
}
return NextResponse.json({
status: "ok",
agent_url: AGENT_URL,
agent_status: agentStatus,
env: {
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
NODE_ENV: process.env.NODE_ENV,
},
});
};
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
// Token-gated: SHOWCASE_DEBUG_TOKEN must be set in env and matched
const token =
req.headers.get("x-debug-token") || req.nextUrl.searchParams.get("token");
const expectedToken = process.env.SHOWCASE_DEBUG_TOKEN;
if (!expectedToken || !token || token !== expectedToken) {
return NextResponse.json({ error: "unauthorized" }, { status: 403 });
}
const AGENT_URL = process.env.AGENT_URL || "unknown";
// Agent connectivity
let agentStatus = "unknown";
let agentDetail = "";
try {
const res = await fetch(`${AGENT_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
agentStatus = res.ok ? "ok" : "error";
agentDetail = `HTTP ${res.status}`;
} catch (e: unknown) {
agentStatus = "down";
agentDetail = (e as Error).message;
}
const uptime = process.uptime();
const mem = process.memoryUsage();
return NextResponse.json({
integration: "ms-agent-dotnet",
uptime: `${Math.floor(uptime / 60)}m ${Math.floor(uptime % 60)}s`,
agent: { url: AGENT_URL, status: agentStatus, detail: agentDetail },
memory: {
rss: `${Math.round(mem.rss / 1024 / 1024)}MB`,
heapUsed: `${Math.round(mem.heapUsed / 1024 / 1024)}MB`,
},
env: {
NODE_ENV: process.env.NODE_ENV,
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ? "set" : "NOT SET",
LANGSMITH_API_KEY: process.env.LANGSMITH_API_KEY ? "set" : "NOT SET",
},
nodeVersion: process.version,
});
}
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
status: "ok",
integration: "ms-agent-dotnet",
timestamp: new Date().toISOString(),
});
}
@@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
const INTEGRATION_SLUG = "ms-agent-dotnet";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export async function GET() {
const start = Date.now();
// Hit our own /api/copilotkit endpoint — tests the full deployed stack
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: "agentic_chat" },
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 },
);
}
// TTFB: read first chunk only to confirm SSE stream started, then cancel
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,55 @@
// Shared fallback time-slot generator for the interrupt demos
// (`gen-ui-interrupt`, `interrupt-headless`). The interrupt backend
// (`src/agents/interrupt_agent.py`) supplies its own candidate slots
// inside the interrupt payload — these fallbacks only run if the
// payload arrives without them. Generating relative to `Date.now()`
// keeps the fallback from rotting, which previously had hardcoded
// dates that decayed within a week of being authored.
export interface TimeSlot {
label: string;
iso: string;
}
function atLocal(date: Date, hour: number, minute = 0): Date {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
hour,
minute,
0,
0,
);
}
function nextMonday(from: Date): Date {
// `getDay()` is 0=Sun, 1=Mon, ..., 6=Sat. We want the next Monday
// that's at LEAST 2 days away — otherwise "Monday" would collide
// with "Tomorrow" on Sunday (offset would be 1) or with itself on
// Monday (offset would be 0). Mirrors interrupt_agent.py.
const day = from.getDay();
let offset = (1 - day + 7) % 7;
if (offset <= 1) offset += 7;
const next = new Date(from);
next.setDate(from.getDate() + offset);
return next;
}
export function generateFallbackSlots(now: Date = new Date()): TimeSlot[] {
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
const monday = nextMonday(now);
const candidates: Array<[string, Date]> = [
["Tomorrow 10:00 AM", atLocal(tomorrow, 10)],
["Tomorrow 2:00 PM", atLocal(tomorrow, 14)],
["Monday 9:00 AM", atLocal(monday, 9)],
["Monday 3:30 PM", atLocal(monday, 15, 30)],
];
return candidates.map(([label, date]) => ({
label,
iso: date.toISOString(),
}));
}
@@ -0,0 +1,21 @@
// Coerces a tool-call result into a typed object. The .NET AG-UI adapter can
// wrap JSON string tool results in another JSON string, so parse a few layers.
export function parseJsonResult<T>(result: unknown): T {
let parsed = result;
for (let depth = 0; depth < 3 && typeof parsed === "string"; depth += 1) {
if (!parsed.trim()) return {} as T;
try {
parsed = JSON.parse(parsed);
} catch {
return {} as T;
}
}
if (!parsed || typeof parsed !== "object") {
return {} as T;
}
return parsed as T;
}
@@ -0,0 +1,21 @@
// Helper for the CopilotChat slot overrides. The slot prop types in
// `@copilotkit/react-core` are nominally typed against the *exact*
// default component identity, but a custom wrapper that returns a
// structurally compatible ReactElement is functionally a drop-in. This
// helper names that fact and centralizes the type assertion in one
// place — readers see `makeSlotOverride` and know it's about the slot
// contract, not arbitrary type-system gymnastics. Once the slot prop
// types accept structural compatibility, this helper can be deleted
// and the casts will resolve automatically.
import type { ComponentType } from "react";
// `any` on the input is intentional: the helper's purpose is to accept
// any component shape and assert it as the slot's expected type. A
// stricter constraint would defeat the whole point.
export function makeSlotOverride<TDefault>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: ComponentType<any>,
): TDefault {
return component as unknown as TDefault;
}
@@ -0,0 +1,31 @@
import * as React from "react";
/**
* ShadCN-style Badge primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
type Variant = "default" | "secondary" | "outline" | "success";
const variantClasses: Record<Variant, string> = {
default: "border-transparent bg-neutral-900 text-neutral-50",
secondary: "border-transparent bg-neutral-100 text-neutral-900",
outline: "border-neutral-200 text-neutral-700 bg-white",
success: "border-transparent bg-emerald-100 text-emerald-700",
};
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: Variant;
}
export function Badge({
className = "",
variant = "default",
...props
}: BadgeProps) {
return (
<div
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium tracking-wide ${variantClasses[variant]} ${className}`}
{...props}
/>
);
}
@@ -0,0 +1,48 @@
import * as React from "react";
/**
* ShadCN-style Button primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
type Variant = "default" | "outline" | "secondary" | "ghost" | "success";
type Size = "default" | "sm" | "lg";
const baseClasses =
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60";
const variantClasses: Record<Variant, string> = {
default: "bg-neutral-900 text-neutral-50 shadow-sm hover:bg-neutral-800",
outline:
"border border-neutral-200 bg-white text-neutral-900 shadow-sm hover:bg-neutral-100",
secondary: "bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-200",
ghost: "hover:bg-neutral-100 hover:text-neutral-900",
success:
"bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm hover:bg-emerald-50",
};
const sizeClasses: Record<Size, string> = {
default: "h-10 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-11 rounded-md px-6",
};
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className = "", variant = "default", size = "default", ...props },
ref,
) => {
return (
<button
ref={ref}
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
{...props}
/>
);
},
);
Button.displayName = "Button";
@@ -0,0 +1,61 @@
import * as React from "react";
/**
* ShadCN-style Card primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
export const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`rounded-xl border border-neutral-200 bg-white text-neutral-950 shadow-sm ${className}`}
{...props}
/>
));
Card.displayName = "Card";
export const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`flex flex-col space-y-1.5 p-5 pb-3 ${className}`}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
export const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className = "", ...props }, ref) => (
<h3
ref={ref}
className={`text-base font-semibold leading-none tracking-tight text-neutral-900 ${className}`}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
export const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div ref={ref} className={`p-5 pt-0 ${className}`} {...props} />
));
CardContent.displayName = "CardContent";
export const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`flex items-center p-5 pt-0 ${className}`}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
@@ -0,0 +1,26 @@
import * as React from "react";
/**
* ShadCN-style Separator primitive (inline-cloned for this demo).
* Plain Tailwind classes; uses a div instead of Radix to keep dependencies minimal.
*/
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: "horizontal" | "vertical";
}
export function Separator({
className = "",
orientation = "horizontal",
...props
}: SeparatorProps) {
const orientationClasses =
orientation === "horizontal" ? "h-px w-full" : "h-full w-px";
return (
<div
role="separator"
aria-orientation={orientation}
className={`shrink-0 bg-neutral-200 ${orientationClasses} ${className}`}
{...props}
/>
);
}
@@ -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 { definitions } from "./definitions";
import { renderers } from "./renderers";
export const CATALOG_ID = "copilotkit://flight-fixed-catalog";
export const catalog = createCatalog(definitions, renderers, {
catalogId: CATALOG_ID,
includeBasicCatalog: true,
});
// @endregion[catalog-creation]
@@ -0,0 +1,107 @@
/**
* 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 and the visual overrides
* here. (Custom entries with the same name as a basic component override
* the basic one — Catalog dedupes by `comp.name`, last-write-wins.)
*
* 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
*/
// @region[definitions-types]
import { z } from "zod";
import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";
/**
* Dynamic string: literal OR a data-model path binding. The GenericBinder
* resolves path bindings to the actual value at render time.
*/
const DynString = z.union([z.string(), z.object({ path: z.string() })]);
export const definitions = {
/**
* Card override: gives the outer flight-card container a ShadCN look
* (rounded-xl, neutral-200 border, soft shadow). The basic catalog's
* Card uses inline styles; overriding here lets the demo's renderer
* adopt the demo's Tailwind aesthetic without touching the schema JSON.
*/
Card: {
description: "A container card with a single child.",
props: z.object({
child: z.string(),
}),
},
Title: {
description: "A prominent heading for the flight card.",
props: z.object({
text: DynString,
}),
},
Airport: {
description: "A 3-letter airport code, displayed large.",
props: z.object({
code: DynString,
}),
},
Arrow: {
description: "A right-pointing arrow used between airports.",
props: z.object({}),
},
AirlineBadge: {
description: "A pill-styled airline name tag.",
props: z.object({
name: DynString,
}),
},
PriceTag: {
description: "A stylized price display (e.g. '$289').",
props: z.object({
amount: DynString,
}),
},
/**
* Button override: swaps in an ActionButton renderer that tracks
* its own `done` state so clicking "Book flight" visually updates to
* a "Booked ✓" confirmation. The basic catalog's Button is stateless,
* so without this override the click fires the action but the button
* looks unchanged. Mirrors the pattern in beautiful-chat
* (src/app/demos/beautiful-chat/declarative-generative-ui/renderers.tsx).
*/
Button: {
description:
"An interactive button with an action event. Use 'child' with a Text component ID for the label. After click, the button shows a confirmation state.",
props: z.object({
child: z
.string()
.describe(
"The ID of the child component (e.g. a Text component for the label).",
),
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
// Union with { event } so GenericBinder resolves this as ACTION → callable () => void.
action: z
.union([
z.object({
event: z.object({
name: z.string(),
context: z.record(z.any()).optional(),
}),
}),
z.null(),
])
.optional(),
}),
},
} satisfies CatalogDefinitions;
// @endregion[definitions-types]
export type Definitions = typeof definitions;
@@ -0,0 +1,110 @@
"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.
*
* Visual style: ShadCN aesthetic (neutral palette, rounded-xl, subtle
* borders, clean typography). Tailwind utility classes only — no `cn()` /
* `cva` helpers, no shadcn CLI install. Inline-cloned primitives live in
* `../_components/`.
*/
import React from "react";
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
import type { Definitions } from "./definitions";
import { Card } from "../_components/card";
import { Badge } from "../_components/badge";
import { Button as UIButton } from "../_components/button";
import { Separator } from "../_components/separator";
// `DynString` props are typed as `string | { path }` (see definitions.ts), but
// the A2UI binder resolves path bindings before render — renderers only ever
// see resolved strings. One shared helper keeps that narrowing in one place.
const s = (v: unknown): string => (typeof v === "string" ? v : "");
// @region[renderers-tsx]
export const renderers: CatalogRenderers<Definitions> = {
/**
* Card override: ShadCN-style outer container. The basic catalog's Card
* uses inline styles; overriding here keeps the demo's tailwind aesthetic.
* The flight schema renders Card > Column > [Title, Row, …]; the inner
* Column adds the vertical spacing.
*/
Card: ({ props, children }) => (
<Card className="w-full max-w-md p-5" data-testid="a2ui-fixed-card">
{props.child ? children(props.child) : null}
</Card>
),
Title: ({ props }) => (
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
Itinerary
</p>
<h3 className="text-base font-semibold leading-none tracking-tight text-neutral-900">
{s(props.text)}
</h3>
</div>
<Badge variant="outline" className="font-mono">
1-stop · economy
</Badge>
</div>
),
Airport: ({ props }) => (
<div className="flex flex-col items-center">
<span className="font-mono text-2xl font-semibold tracking-wider text-neutral-900">
{s(props.code)}
</span>
</div>
),
Arrow: () => (
<div className="flex flex-1 items-center px-3">
<Separator className="flex-1 bg-neutral-200" />
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mx-1 text-neutral-400"
aria-hidden
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
<Separator className="flex-1 bg-neutral-200" />
</div>
),
AirlineBadge: ({ props }) => (
<Badge variant="secondary" className="uppercase tracking-[0.08em]">
{s(props.name)}
</Badge>
),
PriceTag: ({ props }) => (
<div className="flex items-baseline gap-1">
<span className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
Total
</span>
<span className="font-mono text-base font-semibold text-neutral-900">
{s(props.amount)}
</span>
</div>
),
/**
* Button override: this is a pure-presentation demo, so the button just
* renders its label. The schema declares an `action` for visual fidelity,
* but the click handler is inert until the Python SDK exposes
* `action_handlers=` on `a2ui.render` (see `src/agents/a2ui_fixed.py`).
*/
Button: ({ props, children }) => (
<UIButton className="w-full">
{props.child ? children(props.child) : null}
</UIButton>
),
};
// @endregion[renderers-tsx]
@@ -0,0 +1,11 @@
"use client";
import { CopilotChat } from "@copilotkit/react-core/v2";
import { useA2UIFixedSchemaSuggestions } from "./suggestions";
export function Chat() {
useA2UIFixedSchemaSuggestions();
return (
<CopilotChat agentId="a2ui-fixed-schema" className="h-full rounded-2xl" />
);
}
@@ -0,0 +1,41 @@
"use client";
/**
* Declarative Generative UI — A2UI Fixed Schema demo.
*
* In the fixed-schema flavor of A2UI, the component tree (schema) lives on
* the frontend and the agent only streams *data* into the data model. The
* flight card is ASSEMBLED from small sub-components in
* `src/agents/a2ui_schemas/flight_schema.json` (Card > Column > [Title, Row, …]).
*
* - Definitions (zod schemas): `./a2ui/definitions.ts`
* - Renderers (React): `./a2ui/renderers.tsx`
* - Catalog wiring: `./a2ui/catalog.ts` (includes the basic catalog)
* - Agent: `src/agents/a2ui_fixed.py` (emits an `a2ui_operations` container)
*
* Reference:
* https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui/fixed-schema
*/
import React from "react";
import { CopilotKit } from "@copilotkit/react-core/v2";
import { catalog } from "./a2ui/catalog";
import { Chat } from "./chat";
export default function A2UIFixedSchemaDemo() {
return (
// `a2ui.catalog` wires the fixed catalog into the A2UI activity renderer.
<CopilotKit
runtimeUrl="/api/copilotkit-a2ui-fixed-schema"
agent="a2ui-fixed-schema"
a2ui={{ catalog: catalog }}
>
<div className="flex justify-center items-center h-screen w-full bg-neutral-50">
<div className="h-full w-full max-w-4xl border-x border-neutral-200 bg-white">
<Chat />
</div>
</div>
</CopilotKit>
);
}
@@ -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,93 @@
"use client";
import type { ChangeEvent } from "react";
import {
EXPERTISE_OPTIONS,
RESPONSE_LENGTH_OPTIONS,
TONE_OPTIONS,
} from "./config-types";
import type {
AgentConfig,
Expertise,
ResponseLength,
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-[var(--border)] bg-[var(--bg-surface)] p-4 text-sm"
>
<h2 className="text-sm font-semibold">Agent Config</h2>
<p className="text-xs text-[var(--text-muted)]">
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-[var(--border)] bg-[var(--bg-muted)] 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-[var(--border)] bg-[var(--bg-muted)] 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-[var(--border)] bg-[var(--bg-muted)] 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,25 @@
"use client";
/**
* Publishes the current agent-config toggles to the agent runtime via
* `useAgentContext`. Lives inside the `<CopilotKit>` provider so the
* context store is reachable. The middleware on the Python side reads
* this entry off the agent's runtime context on every turn and routes
* it into the model's prompt.
*/
import { useAgentContext } from "@copilotkit/react-core/v2";
import type { AgentConfig } from "./config-types";
export function ConfigContextRelay({ config }: { config: AgentConfig }) {
useAgentContext({
description:
"Agent response preferences. Apply tone, expertise level, and response length to every reply.",
value: {
tone: config.tone,
expertise: config.expertise,
responseLength: config.responseLength,
},
});
return null;
}
@@ -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,38 @@
"use client";
import React from "react";
import { CopilotChat } from "@copilotkit/react-core/v2";
import { ConfigCard } from "./config-card";
import type { AgentConfig } from "./config-types";
interface DemoLayoutProps {
config: AgentConfig;
onToneChange: (tone: AgentConfig["tone"]) => void;
onExpertiseChange: (expertise: AgentConfig["expertise"]) => void;
onResponseLengthChange: (length: AgentConfig["responseLength"]) => void;
}
export function DemoLayout({
config,
onToneChange,
onExpertiseChange,
onResponseLengthChange,
}: DemoLayoutProps) {
return (
<div className="flex h-screen flex-col gap-3 p-6">
<ConfigCard
config={config}
onToneChange={onToneChange}
onExpertiseChange={onExpertiseChange}
onResponseLengthChange={onResponseLengthChange}
/>
<div className="flex-1 overflow-hidden rounded-md border border-[var(--border)]">
<CopilotChat
agentId="agent-config-demo"
className="h-full rounded-md"
/>
</div>
</div>
);
}
@@ -0,0 +1,40 @@
"use client";
/**
* Agent Config Object - typed config knobs (tone / expertise / responseLength)
* forwarded from the provider into the agent so its behavior changes per turn.
*
* Wiring: the toggles live in `useAgentConfig`. Each render publishes the
* resolved config through both CopilotKit `properties` and `useAgentContext`.
* The Microsoft Agent Framework route reads `properties` as AG-UI
* `forwardedProps`; the context relay keeps the demo aligned with the
* LangGraph Python v2 pattern.
*/
import { CopilotKit } from "@copilotkit/react-core/v2";
import { DemoLayout } from "./demo-layout";
import { ConfigContextRelay } from "./config-context-relay";
import { useAgentConfig } from "./use-agent-config";
export default function AgentConfigDemoPage() {
const { config, setTone, setExpertise, setResponseLength } = useAgentConfig();
return (
// @region[provider-setup]
<CopilotKit
runtimeUrl="/api/copilotkit-agent-config"
agent="agent-config-demo"
properties={config}
>
<ConfigContextRelay config={config} />
<DemoLayout
config={config}
onToneChange={setTone}
onExpertiseChange={setExpertise}
onResponseLengthChange={setResponseLength}
/>
</CopilotKit>
// @endregion[provider-setup]
);
}
@@ -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,28 @@
# Agentic Chat
## What This Demo Shows
The simplest CopilotKit surface: a plain agentic chat backed by a LangGraph (Python) agent.
- **Natural Conversation**: Chat with your Copilot in a familiar chat interface
- **Streaming Responses**: Assistant messages stream in token-by-token via AG-UI
- **Suggestion Chips**: A starter suggestion is rendered as a quick-action chip
## How to Interact
Click the suggestion chip, or type your own prompt. For example:
- "Write a short sonnet about AI"
- "Explain the difference between an LLM and an agent"
- "Give me three ideas for a weekend project"
## Technical Details
**Provider**`CopilotKit` wires the page to the runtime:
- `runtimeUrl="/api/copilotkit"` points at the Next.js route that proxies to the agent
- `agent="agentic_chat"` selects the LangGraph agent defined in `langgraph.json`
**Chat surface**`CopilotChat` renders the full chat UI with input, message list, and streaming.
**Suggestions**`useConfigureSuggestions` registers a static suggestion that appears as a clickable chip below the chat input.
@@ -0,0 +1,28 @@
// Docs-only snippet — not imported or rendered. The actual route is served
// by page.tsx, which carries QA hooks (frontend tools, render tools, agent
// context) 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 region 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",
});
return <CopilotChat agentId="agentic_chat" className="h-full rounded-2xl" />;
}
// @endregion[chat-component]
@@ -0,0 +1,22 @@
"use client";
import React from "react";
import { CopilotKit, CopilotChat } from "@copilotkit/react-core/v2";
import { useAgenticChatSuggestions } from "./suggestions";
export default function AgenticChatDemo() {
return (
// @region[provider-setup]
<CopilotKit runtimeUrl="/api/copilotkit" agent="agentic_chat">
<Chat />
</CopilotKit>
// @endregion[provider-setup]
);
}
function Chat() {
useAgenticChatSuggestions();
// @region[render-chat]
return <CopilotChat agentId="agentic_chat" />;
// @endregion[render-chat]
}
@@ -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,63 @@
"use client";
import { Button } from "@/components/ui/button";
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.
*/
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"
size="sm"
variant="outline"
onClick={onSignOut}
>
Sign out
</Button>
) : (
<Button
type="button"
data-testid="auth-authenticate-button"
size="sm"
onClick={onSignIn}
>
Sign in
</Button>
)}
</div>
);
}
@@ -0,0 +1,11 @@
/**
* Shared demo-token constant imported by both the client
* (use-demo-auth.ts) and the server runtime route
* (api/copilotkit-auth/route.ts). Keeping the constant in one file
* prevents drift: changing the token in one place changes it everywhere.
*
* 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,149 @@
"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 render `<CopilotKit>` 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, `<CopilotKit>` 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 `<CopilotKit
// 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 `<CopilotKit onError>` never renders the rejection banner. We register
// the same handler on BOTH channels: `<CopilotKit 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.
import { useCallback, useEffect, useMemo, useState } from "react";
import { CopilotKit, 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.
<CopilotKit
runtimeUrl="/api/copilotkit-auth"
agent="auth-demo"
headers={headers}
useSingleEndpoint={false}
// The dev-only inspector overlay is auto-enabled on localhost after
// <CopilotKit> mounts. In this demo that happens only after the first
// sign-in, so the post-sign-out Sign in button can sit underneath the
// overlay in local/D5 runs even though production never shows it.
enableInspector={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
agentId="auth-demo"
className="h-full"
onError={handleAuthError}
/>
</div>
</div>
</CopilotKit>
);
}
@@ -0,0 +1,70 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
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 `<CopilotKit>`.
*/
export function SignInCard({ onSignIn }: SignInCardProps) {
return (
<div className="flex h-full items-center justify-center p-6">
<Card data-testid="auth-sign-in-card" className="w-full max-w-md">
<CardHeader>
<CardTitle>Sign in to start chatting</CardTitle>
<CardDescription>
The runtime rejects requests without an{" "}
<code className="rounded bg-muted 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.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Demo token
</p>
<code
data-testid="auth-demo-token"
className="mt-1 block rounded-md border bg-muted px-3 py-2 font-mono text-sm"
>
{DEMO_TOKEN}
</code>
</div>
<p className="text-xs text-muted-foreground">
Real apps should issue per-user tokens via your identity provider
and never hard-code shared secrets.
</p>
</CardContent>
<CardFooter>
<Button
type="button"
data-testid="auth-sign-in-button"
className="w-full"
onClick={() => onSignIn(DEMO_TOKEN)}
>
Sign in with demo token
</Button>
</CardFooter>
</Card>
</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,20 @@
"use client";
import { useAgent } from "@copilotkit/react-core/v2";
import { TodoList } from "./todo-list";
export function ExampleCanvas() {
const { agent } = useAgent({ agentId: "beautiful-chat" });
return (
<div className="h-full overflow-y-auto bg-[--background]">
<div className="max-w-4xl mx-auto px-8 py-10 h-full">
<TodoList
todos={agent.state?.todos || []}
onUpdate={(updatedTodos) => agent.setState({ todos: updatedTodos })}
isAgentRunning={agent.isRunning}
/>
</div>
</div>
);
}
@@ -0,0 +1,197 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { Card } from "../ui/card";
import { Checkbox } from "../ui/checkbox";
import { Button } from "../ui/button";
import { X } from "lucide-react";
import { cn } from "../../lib/utils";
interface Todo {
id: string;
title: string;
description: string;
emoji: string;
status: "pending" | "completed";
}
interface TodoCardProps {
todo: Todo;
onToggleStatus: (todo: Todo) => void;
onDelete: (todo: Todo) => void;
onUpdateTitle: (todoId: string, title: string) => void;
onUpdateDescription: (todoId: string, description: string) => void;
onUpdateEmoji: (todoId: string, emoji: string) => void;
}
const EMOJI_OPTIONS = ["✅", "🔥", "🎯", "💡", "🚀"];
export function TodoCard({
todo,
onToggleStatus,
onDelete,
onUpdateTitle,
onUpdateDescription,
onUpdateEmoji,
}: TodoCardProps) {
const [editingField, setEditingField] = useState<
"title" | "description" | null
>(null);
const [editValue, setEditValue] = useState("");
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isCompleted = todo.status === "completed";
const truncatedDescription =
todo.description.length > 120
? todo.description.slice(0, 120) + "..."
: todo.description;
const startEdit = (field: "title" | "description") => {
setEditingField(field);
setEditValue(field === "title" ? todo.title : todo.description);
};
const saveEdit = (field: "title" | "description") => {
if (editValue.trim()) {
if (field === "title") {
onUpdateTitle(todo.id, editValue.trim());
} else {
onUpdateDescription(todo.id, editValue.trim());
}
}
setEditingField(null);
setEditValue("");
};
const cancelEdit = () => {
setEditingField(null);
setEditValue("");
};
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height =
textareaRef.current.scrollHeight + "px";
}
}, [editValue]);
return (
<Card
className={cn(
"group relative p-5 transition-all duration-150",
isCompleted && "opacity-60",
)}
>
{/* Delete — top right on hover */}
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(todo)}
className="absolute top-3 right-3 h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Delete todo"
>
<X className="h-3.5 w-3.5" />
</Button>
{/* Emoji avatar */}
<div className="relative inline-block mb-3">
<button
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
className={cn(
"block text-3xl leading-none cursor-pointer rounded-xl p-2 transition-colors",
isCompleted ? "bg-[var(--muted)]" : "bg-[var(--secondary)]",
)}
aria-label="Change emoji"
>
{todo.emoji}
</button>
{showEmojiPicker && (
<div className="absolute top-0 left-full ml-2 z-10 flex gap-1 p-1.5 rounded-full bg-[var(--card)] border border-[var(--border)] shadow-lg">
{EMOJI_OPTIONS.map((emoji) => (
<button
key={emoji}
onClick={() => {
onUpdateEmoji(todo.id, emoji);
setShowEmojiPicker(false);
}}
className="text-lg w-8 h-8 flex items-center justify-center rounded-full cursor-pointer transition-colors hover:bg-[var(--secondary)]"
>
{emoji}
</button>
))}
</div>
)}
</div>
{/* Title */}
<div className="flex items-start gap-3">
<Checkbox
checked={isCompleted}
onCheckedChange={() => onToggleStatus(todo)}
className="mt-[2px]"
/>
<div className="flex-1 min-w-0">
{editingField === "title" ? (
<input
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={() => saveEdit("title")}
onKeyDown={(e) => {
if (e.key === "Enter") saveEdit("title");
if (e.key === "Escape") cancelEdit();
}}
className="w-full text-base font-semibold focus:outline-none bg-transparent text-[var(--foreground)] border-b-2 border-[var(--primary)] pb-[2px]"
autoFocus
aria-label="Edit todo title"
/>
) : (
<div
onClick={() => startEdit("title")}
className={cn(
"text-base font-semibold cursor-text break-words leading-snug",
isCompleted
? "text-[var(--muted-foreground)] line-through"
: "text-[var(--foreground)]",
)}
>
{todo.title}
</div>
)}
{editingField === "description" ? (
<textarea
ref={textareaRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={() => saveEdit("description")}
onKeyDown={(e) => {
if (e.key === "Escape") cancelEdit();
}}
className="w-full mt-1.5 text-sm leading-relaxed focus:outline-none resize-none bg-transparent text-[var(--muted-foreground)] border-b-2 border-[var(--primary)] pb-[2px]"
rows={1}
autoFocus
aria-label="Edit todo description"
/>
) : (
<p
onClick={() => startEdit("description")}
className={cn(
"mt-1.5 text-sm leading-relaxed cursor-text",
isCompleted
? "text-[var(--muted-foreground)] line-through"
: "text-[var(--muted-foreground)]",
)}
>
{truncatedDescription}
</p>
)}
</div>
</div>
</Card>
);
}
@@ -0,0 +1,88 @@
"use client";
import { TodoCard } from "./todo-card";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Plus } from "lucide-react";
interface Todo {
id: string;
title: string;
description: string;
emoji: string;
status: "pending" | "completed";
}
interface TodoColumnProps {
title: string;
todos: Todo[];
emptyMessage: string;
showAddButton?: boolean;
onAddTodo?: () => void;
onToggleStatus: (todo: Todo) => void;
onDelete: (todo: Todo) => void;
onUpdateTitle: (todoId: string, title: string) => void;
onUpdateDescription: (todoId: string, description: string) => void;
onUpdateEmoji: (todoId: string, emoji: string) => void;
isAgentRunning: boolean;
}
export function TodoColumn({
title,
todos,
emptyMessage,
showAddButton = false,
onAddTodo,
onToggleStatus,
onDelete,
onUpdateTitle,
onUpdateDescription,
onUpdateEmoji,
isAgentRunning,
}: TodoColumnProps) {
return (
<section aria-label={`${title} column`} className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<h2 className="text-lg font-bold tracking-tight text-[var(--foreground)]">
{title}
</h2>
<Badge variant="secondary">{todos.length}</Badge>
</div>
{showAddButton && onAddTodo && (
<Button
variant="ghost"
size="icon"
onClick={onAddTodo}
disabled={isAgentRunning}
aria-label="Add new todo"
>
<Plus className="h-4 w-4" />
</Button>
)}
</div>
{/* Cards */}
<div className="space-y-3">
{todos.length === 0 ? (
<div className="text-center text-sm rounded-[var(--radius)] border-2 border-dashed border-[var(--border)] p-5 min-h-[151px] flex items-center justify-center text-[var(--muted-foreground)]">
{emptyMessage}
</div>
) : (
todos.map((todo) => (
<TodoCard
key={todo.id}
todo={todo}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
onUpdateTitle={onUpdateTitle}
onUpdateDescription={onUpdateDescription}
onUpdateEmoji={onUpdateEmoji}
/>
))
)}
</div>
</section>
);
}
@@ -0,0 +1,115 @@
"use client";
import { TodoColumn } from "./todo-column";
import { Button } from "../ui/button";
interface Todo {
id: string;
title: string;
description: string;
emoji: string;
status: "pending" | "completed";
}
interface TodoListProps {
todos: Todo[];
onUpdate: (todos: Todo[]) => void;
isAgentRunning: boolean;
}
export function TodoList({ todos, onUpdate, isAgentRunning }: TodoListProps) {
const pendingTodos = todos.filter((t) => t.status === "pending");
const completedTodos = todos.filter((t) => t.status === "completed");
const toggleStatus = (todo: Todo) => {
const updated = todos.map((t) =>
t.id === todo.id
? {
...t,
status: (t.status === "completed" ? "pending" : "completed") as
| "pending"
| "completed",
}
: t,
);
onUpdate(updated);
};
const deleteTodo = (todo: Todo) => {
onUpdate(todos.filter((t) => t.id !== todo.id));
};
const updateTitle = (todoId: string, title: string) => {
const updated = todos.map((t) => (t.id === todoId ? { ...t, title } : t));
onUpdate(updated);
};
const updateDescription = (todoId: string, description: string) => {
const updated = todos.map((t) =>
t.id === todoId ? { ...t, description } : t,
);
onUpdate(updated);
};
const updateEmoji = (todoId: string, emoji: string) => {
const updated = todos.map((t) => (t.id === todoId ? { ...t, emoji } : t));
onUpdate(updated);
};
const addTodo = () => {
const newTodo: Todo = {
id: crypto.randomUUID(),
title: "New Todo",
description: "Add a description",
emoji: "🎯",
status: "pending",
};
onUpdate([...todos, newTodo]);
};
if (!todos || todos.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4">
<div className="text-5xl"></div>
<p className="text-base font-semibold text-[--foreground]">
No todos yet
</p>
<p className="text-sm text-[--muted-foreground]">
Create your first task to get started
</p>
<Button onClick={addTodo} disabled={isAgentRunning} className="mt-2">
Add a task
</Button>
</div>
);
}
return (
<div className="flex gap-8 h-full">
<TodoColumn
title="To Do"
todos={pendingTodos}
emptyMessage="No pending todos"
showAddButton
onAddTodo={addTodo}
onToggleStatus={toggleStatus}
onDelete={deleteTodo}
onUpdateTitle={updateTitle}
onUpdateDescription={updateDescription}
onUpdateEmoji={updateEmoji}
isAgentRunning={isAgentRunning}
/>
<TodoColumn
title="Done"
todos={completedTodos}
emptyMessage="No completed todos yet"
onToggleStatus={toggleStatus}
onDelete={deleteTodo}
onUpdateTitle={updateTitle}
onUpdateDescription={updateDescription}
onUpdateEmoji={updateEmoji}
isAgentRunning={isAgentRunning}
/>
</div>
);
}
@@ -0,0 +1,68 @@
"use client";
import type { ReactNode } from "react";
import { useState } from "react";
import { ModeToggle } from "./mode-toggle";
import { useFrontendTool } from "@copilotkit/react-core/v2";
interface ExampleLayoutProps {
chatContent: ReactNode;
appContent: ReactNode;
}
export function ExampleLayout({ chatContent, appContent }: ExampleLayoutProps) {
const [mode, setMode] = useState<"chat" | "app">("chat");
useFrontendTool({
name: "enableAppMode",
description:
"Enable app mode, make sure its open when interacting with todos.",
handler: async () => {
setMode("app");
},
});
useFrontendTool({
name: "enableChatMode",
description: "Enable chat mode",
handler: async () => {
setMode("chat");
},
});
return (
<div className="h-full flex flex-row pb-6">
<ModeToggle mode={mode} onModeChange={setMode} />
{/* Chat Content */}
<div
className={`max-h-full flex flex-col dark:bg-stone-950 ${
mode === "app"
? "w-1/3 px-6 max-lg:hidden" // Hide on mobile in app mode
: "flex-1 max-lg:px-4"
}`}
>
<div className="shrink-0 pt-6 pl-6 pb-2 max-lg:pl-4 max-lg:pt-4 flex gap-1.5 items-center align-center">
<span className="font-extrabold text-2xl pb-1.5">CopilotKit</span>
<img
src="/copilotkit-logo-mark.svg"
alt="CopilotKit"
className="h-7"
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">{chatContent}</div>
</div>
{/* State Panel */}
<div
className={`h-full overflow-hidden ${
mode === "app"
? "w-2/3 max-lg:w-full border-l border-[var(--border)] max-lg:border-l-0" // Full width on mobile
: "w-0 border-l-0"
}`}
>
<div className="w-full lg:w-[66.666vw] h-full">{appContent}</div>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
interface ModeToggleProps {
mode: "chat" | "app";
onModeChange: (mode: "chat" | "app") => void;
}
export function ModeToggle({ mode, onModeChange }: ModeToggleProps) {
return (
<div className="fixed top-4 right-4 z-50 flex rounded-full border border-[var(--border)] bg-[var(--secondary)] p-0.5 max-lg:top-2 max-lg:right-2 max-lg:scale-90">
<button
onClick={() => onModeChange("chat")}
className={`px-4 py-1.5 rounded-full text-[13px] font-medium transition-all cursor-pointer ${
mode === "chat"
? "bg-[var(--card)] text-[var(--card-foreground)] shadow-sm"
: "text-[var(--muted-foreground)]"
}`}
>
Chat
</button>
<button
onClick={() => onModeChange("app")}
className={`px-4 py-1.5 rounded-full text-[13px] font-medium transition-all cursor-pointer ${
mode === "app"
? "bg-[var(--card)] text-[var(--card-foreground)] shadow-sm"
: "text-[var(--muted-foreground)]"
}`}
>
App
</button>
</div>
);
}
@@ -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,27 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "../../lib/utils";
const Checkbox = React.forwardRef<
React.ComponentRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-5 w-5 shrink-0 rounded-md border border-[var(--border)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[var(--primary)] data-[state=checked]:text-[var(--primary-foreground)] data-[state=checked]:border-transparent cursor-pointer transition-colors",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
<Check className="h-3.5 w-3.5" strokeWidth={3} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-[var(--radius)] border border-[var(--input)] bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-[var(--muted-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
),
);
Input.displayName = "Input";
export { Input };
@@ -0,0 +1,30 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "../../lib/utils";
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-[var(--border)]",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
@@ -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,186 @@
/**
* Demonstration Catalog — Component Definitions
*
* Platform-agnostic definitions: component names, props (Zod), descriptions.
* This is the contract between the app and the AI agent. Agents receive these
* definitions as context so they know what components are available.
*
* Renderers (React, React Native, etc.) import these definitions and provide
* platform-specific implementations, type-checked against the Zod schemas.
*/
import { z } from "zod";
/**
* Dynamic string: accepts either a literal string or a data-model path binding
* like `{ path: "airline" }`. 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 demonstrationCatalogDefinitions = {
Title: {
description: "A heading. Use for section titles and page headers.",
props: z.object({
text: z.string(),
level: z.string().optional(),
}),
},
// Custom Row/Column: override the basic catalog's versions so we can
// honour `gap` (basic Row/Column from web_core ignores it). Children may
// be a literal-string array (flat trees) OR a structural template form
// `{ componentId, path }` so the GenericBinder expands per-row templates
// from the data model — required for fixed-schema flows like
// flight_schema.json (Row.children = { componentId, path: "/flights" }).
Row: {
description: "Horizontal layout container.",
props: z.object({
gap: z.number().optional(),
align: z.string().optional(),
justify: z.string().optional(),
// Union with { componentId, path } so GenericBinder treats this as
// STRUCTURAL and resolves template children from the data model.
children: z.union([
z.array(z.string()),
z.object({ componentId: z.string(), path: z.string() }),
]),
}),
},
Column: {
description: "Vertical layout container.",
props: z.object({
gap: z.number().optional(),
align: z.string().optional(),
// Same union as Row — required for template children support.
children: z.union([
z.array(z.string()),
z.object({ componentId: z.string(), path: z.string() }),
]),
}),
},
DashboardCard: {
description:
"A card container with title and optional subtitle. Has a 'child' slot for content (chart, metrics, etc). Use 'child' with a single component ID.",
props: z.object({
title: z.string(),
subtitle: z.string().optional(),
child: z.string().optional(),
}),
},
Metric: {
description:
"A key metric display with label, value, and optional trend indicator. Great for KPIs and stats.",
props: z.object({
label: z.string(),
value: z.string(),
trend: z.enum(["up", "down", "neutral"]).optional(),
trendValue: z.string().optional(),
}),
},
PieChart: {
description:
"A pie/donut chart. Provide data as array of {label, value, color} objects.",
props: z.object({
data: z.array(
z.object({
label: z.string(),
value: z.number(),
color: z.string().optional(),
}),
),
innerRadius: z.number().optional(),
}),
},
BarChart: {
description:
"A bar chart. Provide data as array of {label, value} objects.",
props: z.object({
data: z.array(z.object({ label: z.string(), value: z.number() })),
color: z.string().optional(),
}),
},
Badge: {
description:
"A small status badge/tag. Use for labels, statuses, categories.",
props: z.object({
text: z.string(),
variant: z
.enum(["success", "warning", "error", "info", "neutral"])
.optional(),
}),
},
DataTable: {
description: "A data table with columns and rows.",
props: z.object({
columns: z.array(z.object({ key: z.string(), label: z.string() })),
rows: z.array(z.record(z.any())),
}),
},
Button: {
description:
"An interactive button with an action event. Use 'child' with a Text component ID for the label. 'action' is dispatched on click.",
props: z.object({
child: z
.string()
.describe(
"The ID of the child component (e.g. a Text component for the label).",
),
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
// Union with { event } so GenericBinder resolves this as ACTION → callable () => void.
action: z
.union([
z.object({
event: z.object({
name: z.string(),
context: z.record(z.any()).optional(),
}),
}),
z.null(),
])
.optional(),
}),
},
FlightCard: {
description:
"A rich flight result card. Displays airline, flight number, route, times, duration, status, and price. Use inside a Row for side-by-side layout.",
props: z.object({
airline: DynString,
airlineLogo: DynString,
flightNumber: DynString,
origin: DynString,
destination: DynString,
date: DynString,
departureTime: DynString,
arrivalTime: DynString,
duration: DynString,
status: DynString,
statusColor: DynString.optional(),
price: DynString,
action: z
.union([
z.object({
event: z.object({
name: z.string(),
context: z.record(z.any()).optional(),
}),
}),
z.null(),
])
.optional(),
}),
},
};
/** Type helper for renderers */
export type DemonstrationCatalogDefinitions =
typeof demonstrationCatalogDefinitions;
@@ -0,0 +1,606 @@
/**
* A2UI Catalog — React Renderers
*
* Each renderer maps a component name from definitions.ts to a React
* implementation. Props are type-checked against the Zod schemas.
*
* To add a component: define its schema in definitions.ts, then add a
* renderer here. See README.md "Adding a custom component" for details.
*
* The assembled catalog is registered in layout.tsx via
* <CopilotKit a2ui={{ catalog: demonstrationCatalog }}>.
*/
"use client";
import React, { useState } from "react";
import type { JSX } from "react";
import {
PieChart as RechartsPie,
Pie,
Cell,
ResponsiveContainer,
BarChart as RechartsBar,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
} from "recharts";
import { createCatalog } from "@copilotkit/a2ui-renderer";
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
import { demonstrationCatalogDefinitions } from "./definitions";
import type { DemonstrationCatalogDefinitions } from "./definitions";
// ─── Theme-aware colors ─────────────────────────────────────────────
const c = {
card: "var(--card)",
cardFg: "var(--card-foreground)",
border: "var(--border)",
muted: "var(--muted-foreground)",
divider: "color-mix(in srgb, var(--border) 50%, var(--card))",
shadow: "0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04)",
btnBg: "color-mix(in srgb, var(--muted) 40%, var(--card))",
btnDoneBg: "color-mix(in srgb, #22c55e 10%, var(--card))",
};
function ActionButton({
label,
doneLabel,
action,
children: child,
}: {
label: string;
doneLabel: string;
action: any;
children?: React.ReactNode;
}) {
const [done, setDone] = useState(false);
return (
<button
disabled={done}
style={{
width: "100%",
padding: "10px 16px",
borderRadius: "10px",
border: done ? "1px solid #bbf7d0" : `1px solid ${c.border}`,
background: done ? c.btnDoneBg : c.btnBg,
color: done ? "#059669" : c.cardFg,
fontSize: "0.85rem",
fontWeight: 500,
cursor: done ? "default" : "pointer",
transition: "all 0.2s ease",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "6px",
}}
onClick={() => {
if (!done) {
action?.();
setDone(true);
}
}}
>
{done && (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="#059669"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
{done ? doneLabel : (child ?? label)}
</button>
);
}
// ─── Renderers (type-checked against schema definitions) ────────────
const demonstrationCatalogRenderers: CatalogRenderers<DemonstrationCatalogDefinitions> =
{
Title: ({ props }) => {
const Tag = (
props.level === "h1" ? "h1" : props.level === "h3" ? "h3" : "h2"
) as keyof JSX.IntrinsicElements;
const sizes: Record<string, string> = {
h1: "1.75rem",
h2: "1.25rem",
h3: "1rem",
};
return (
<Tag
style={{
margin: 0,
fontWeight: 600,
fontSize: sizes[props.level ?? "h2"],
color: c.cardFg,
letterSpacing: "-0.01em",
}}
>
{props.text}
</Tag>
);
},
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((item: any, i: number) => {
if (typeof item === "string")
return (
<div
key={`${item}-${i}`}
style={{ flex: "1 1 0", minWidth: 0 }}
>
{children(item)}
</div>
);
if (item && typeof item === "object" && "id" in item)
return (
<div
key={`${item.id}-${i}`}
style={{ flex: "1 1 0", minWidth: 0 }}
>
{(children as any)(item.id, item.basePath)}
</div>
);
return null;
})}
</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((item: any, i: number) => {
if (typeof item === "string")
return (
<React.Fragment key={`${item}-${i}`}>
{children(item)}
</React.Fragment>
);
if (item && typeof item === "object" && "id" in item)
return (
<React.Fragment key={`${item.id}-${i}`}>
{(children as any)(item.id, item.basePath)}
</React.Fragment>
);
return null;
})}
</div>
);
},
DashboardCard: ({ props, children }) => (
<div
style={{
background: c.card,
borderRadius: "12px",
border: `1px solid ${c.border}`,
padding: "20px",
boxShadow: c.shadow,
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<div>
<div style={{ fontWeight: 600, fontSize: "0.9rem", color: c.cardFg }}>
{props.title}
</div>
{props.subtitle && (
<div
style={{
fontSize: "0.75rem",
color: c.muted,
marginTop: "2px",
}}
>
{props.subtitle}
</div>
)}
</div>
{props.child && children(props.child)}
</div>
),
Metric: ({ props }) => {
const trendColors: Record<string, string> = {
up: "#059669",
down: "#dc2626",
neutral: c.muted,
};
const trendIcons: Record<string, string> = {
up: "↑",
down: "↓",
neutral: "→",
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<span
style={{
fontSize: "0.75rem",
color: c.muted,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{props.label}
</span>
<div style={{ display: "flex", alignItems: "baseline", gap: "8px" }}>
<span
style={{
fontSize: "1.5rem",
fontWeight: 700,
color: c.cardFg,
letterSpacing: "-0.02em",
}}
>
{props.value}
</span>
{props.trend && props.trendValue && (
<span
style={{
fontSize: "0.8rem",
fontWeight: 500,
color: trendColors[props.trend] ?? c.muted,
}}
>
{trendIcons[props.trend]} {props.trendValue}
</span>
)}
</div>
</div>
);
},
PieChart: ({ props }) => {
const COLORS = [
"#3b82f6",
"#8b5cf6",
"#ec4899",
"#f59e0b",
"#10b981",
"#6366f1",
];
const data = props.data ?? [];
return (
<div style={{ width: "100%", height: 200 }}>
<ResponsiveContainer>
<RechartsPie>
<Pie
data={data}
dataKey="value"
nameKey="label"
cx="50%"
cy="50%"
innerRadius={props.innerRadius ?? 40}
outerRadius={80}
paddingAngle={2}
>
{data.map((entry: any, i: number) => (
<Cell
key={i}
fill={entry.color ?? COLORS[i % COLORS.length]}
/>
))}
</Pie>
<Tooltip />
</RechartsPie>
</ResponsiveContainer>
</div>
);
},
BarChart: ({ props }) => {
const data = props.data ?? [];
return (
<div style={{ width: "100%", height: 200 }}>
<ResponsiveContainer>
<RechartsBar data={data}>
<CartesianGrid strokeDasharray="3 3" stroke={c.divider} />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: c.muted }} />
<YAxis tick={{ fontSize: 11, fill: c.muted }} />
<Tooltip />
<Bar
dataKey="value"
fill={props.color ?? "#3b82f6"}
radius={[4, 4, 0, 0]}
/>
</RechartsBar>
</ResponsiveContainer>
</div>
);
},
Badge: ({ props }) => {
const variants: Record<string, { bg: string; color: string }> = {
success: { bg: "#dcfce7", color: "#166534" },
warning: { bg: "#fef3c7", color: "#92400e" },
error: { bg: "#fee2e2", color: "#991b1b" },
info: { bg: "#dbeafe", color: "#1e40af" },
neutral: { bg: "var(--muted)", color: c.cardFg },
};
const v = variants[props.variant ?? "neutral"] ?? variants.neutral;
return (
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: "9999px",
fontSize: "0.7rem",
fontWeight: 500,
background: v.bg,
color: v.color,
}}
>
{props.text}
</span>
);
},
DataTable: ({ props }) => {
const cols = props.columns ?? [];
const rows = props.rows ?? [];
return (
<div style={{ overflowX: "auto", width: "100%" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "0.8rem",
}}
>
<thead>
<tr>
{cols.map((col: any) => (
<th
key={col.key}
style={{
textAlign: "left",
padding: "8px 12px",
borderBottom: `2px solid ${c.border}`,
color: c.muted,
fontWeight: 600,
fontSize: "0.7rem",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row: any, i: number) => (
<tr key={i} style={{ borderBottom: `1px solid ${c.divider}` }}>
{cols.map((col: any) => (
<td
key={col.key}
style={{ padding: "8px 12px", color: c.cardFg }}
>
{String(row[col.key] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
},
Button: ({ props, children }) => {
return (
<ActionButton label="Click" doneLabel="Done" action={props.action}>
{props.child ? children(props.child) : null}
</ActionButton>
);
},
FlightCard: ({ props: rawProps }) => {
// The binder resolves path bindings to strings at runtime.
const props = rawProps as Record<string, any>;
const statusColors: Record<string, string> = {
"On Time": "#22c55e",
Delayed: "#eab308",
Cancelled: "#ef4444",
};
const dotColor =
props.statusColor ?? statusColors[props.status] ?? "#22c55e";
return (
<div
style={{
border: `1px solid ${c.border}`,
borderRadius: "16px",
padding: "20px",
background: c.card,
color: c.cardFg,
minWidth: 260,
maxWidth: 340,
flex: "1 1 260px",
display: "flex",
flexDirection: "column",
gap: "12px",
boxShadow: c.shadow,
}}
>
{/* Header: airline + price */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<img
src={props.airlineLogo}
alt={props.airline}
style={{
width: 28,
height: 28,
borderRadius: "50%",
objectFit: "contain",
}}
/>
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>
{props.airline}
</span>
</div>
<span style={{ fontWeight: 700, fontSize: "1.15rem" }}>
{props.price}
</span>
</div>
{/* Meta */}
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "0.8rem",
color: c.muted,
}}
>
<span>{props.flightNumber}</span>
<span>{props.date}</span>
</div>
<hr
style={{
border: "none",
borderTop: `1px solid ${c.divider}`,
margin: 0,
}}
/>
{/* Times */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span style={{ fontWeight: 700, fontSize: "1.1rem" }}>
{props.departureTime}
</span>
<span style={{ fontSize: "0.75rem", color: c.muted }}>
{props.duration}
</span>
<span style={{ fontWeight: 700, fontSize: "1.1rem" }}>
{props.arrivalTime}
</span>
</div>
{/* Route */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "0.95rem",
fontWeight: 600,
}}
>
<span>{props.origin}</span>
<span style={{ color: c.muted }}></span>
<span>{props.destination}</span>
</div>
<div
style={{
marginTop: "auto",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<hr
style={{
border: "none",
borderTop: `1px solid ${c.divider}`,
margin: 0,
}}
/>
{/* Status */}
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: dotColor,
display: "inline-block",
}}
/>
<span style={{ fontSize: "0.8rem", color: c.muted }}>
{props.status}
</span>
</div>
<ActionButton
label="Select"
doneLabel="Selected"
action={props.action}
/>
</div>
</div>
);
},
};
// ─── Assembled Catalog ───────────────────────────────────────────────
export const demonstrationCatalog = createCatalog(
demonstrationCatalogDefinitions,
demonstrationCatalogRenderers,
{
catalogId: "copilotkit://app-dashboard-catalog",
// Required: merges the basic A2UI primitives (Row, Column, Text, Card,
// Button, …) into this catalog so structural-children expansion works
// for templates like flight_schema.json's
// `Row { children: { componentId: "flight-card", path: "/flights" } }`.
// Both sibling working demos (a2ui-fixed-schema, declarative-gen-ui)
// already set this — beautiful-chat was the outlier.
includeBasicCatalog: true,
},
);
@@ -0,0 +1,23 @@
"use client";
import { CopilotChat } from "@copilotkit/react-core/v2";
import { ExampleLayout } from "./components/example-layout";
import { ExampleCanvas } from "./components/example-canvas";
import { useGenerativeUIExamples, useExampleSuggestions } from "./hooks";
export function HomePage() {
useGenerativeUIExamples();
useExampleSuggestions();
return (
<ExampleLayout
chatContent={
<CopilotChat
attachments={{ enabled: true }}
input={{ disclaimer: () => null, className: "pb-6" }}
/>
}
appContent={<ExampleCanvas />}
/>
);
}
@@ -0,0 +1,3 @@
export * from "./use-example-suggestions";
export * from "./use-generative-ui-examples";
export * from "./use-theme";
@@ -0,0 +1,69 @@
/**
* Suggestion pills shown in the chat UI. Each suggestion triggers a specific
* demo feature when clicked.
*
* Ordered from most constrained (fixed UI) to most open (freeform UI).
*
* Showcase mode (showcase.json) controls which pills are visually highlighted.
* Highlight styling: globals.css (.a2ui-highlight, .opengenui-highlight)
* A2UI agent tools: agent/src/a2ui_fixed_schema.py, a2ui_dynamic_schema.py
* A2UI catalog: src/app/declarative-generative-ui/
*/
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
import showcaseConfig from "../showcase.json";
const showcase = showcaseConfig.showcase;
export const useExampleSuggestions = () => {
useConfigureSuggestions({
suggestions: [
{
title: "Pie Chart (Controlled Generative UI)",
message:
"Show me a pie chart of our revenue distribution by category. Use the query_data tool to fetch the data first, then render it with the pieChart component.",
},
{
title: "Bar Chart (Controlled Generative UI)",
message:
"Show me a bar chart of our expenses by category. Use the query_data tool to fetch the data first, then render it with the barChart component.",
},
{
title: "Schedule Meeting (Human In The Loop)",
message:
"I'd like to schedule a 30-minute meeting to learn about CopilotKit. Please use the scheduleTime tool to let me pick a time.",
},
{
title: "Search Flights (A2UI Fixed Schema)",
message: "Find flights from SFO to JFK for next Tuesday.",
className: showcase === "a2ui" ? "a2ui-highlight" : undefined,
},
{
title: "Sales Dashboard (A2UI Dynamic)",
message:
"First use the query_data tool to fetch the financial sales data, then using A2UI, show me a sales dashboard with total revenue, new customers, and conversion rate metrics. Include a pie chart of revenue by category and a bar chart of monthly sales.",
className: showcase === "a2ui" ? "a2ui-highlight" : undefined,
},
{
title: "Excalidraw Diagram (MCP App)",
message:
"Use Excalidraw to create a simple network diagram showing a router connected to two switches, each connected to two computers.",
},
{
title: "Calculator App (Open Generative UI)",
message:
"Using the generateSandboxedUi tool, build a modern calculator with standard buttons plus labeled metric shortcut buttons that insert their values into the display when clicked. Use sample company data.",
className: showcase === "opengenui" ? "opengenui-highlight" : undefined,
},
{
title: "Toggle Theme (Frontend Tools)",
message: "Toggle the app theme using the toggleTheme tool.",
},
{
title: "Task Manager (Shared State)",
message:
"Enable app mode and add three todos about learning CopilotKit: one about reading the docs, one about building a prototype, and one about exploring agent state.",
},
],
available: "always",
});
};
@@ -0,0 +1,85 @@
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)
const ignoredTools = [
"render_a2ui", // Rendered by A2UI streaming, not as a tool card
"generate_a2ui", // Legacy: rendered by A2UI, not as a tool card
"log_a2ui_event", // Internal A2UI event tracker
];
useDefaultRenderTool({
render: ({ name, status, parameters }) => {
if (ignoredTools.includes(name)) return <></>;
return <ToolReasoning name={name} status={status} args={parameters} />;
},
});
// Frontend Tools (direct frontend state manipulation).
// No deps array needed — the handler reads `document` directly and
// calls a stable setter. Including [theme, setTheme] in deps caused
// the hook to re-register every time the theme flipped, which could
// race with an in-flight tool result from the runtime and surface
// as a renderer-level error during multi-turn beautiful-chat probes.
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,162 @@
:root {
--n-100: #ffffff;
--n-99: #fcfcfc;
--n-98: #f9f9f9;
--n-95: #f1f1f1;
--n-90: #e2e2e2;
--n-80: #c6c6c6;
--n-70: #ababab;
--n-60: #919191;
--n-50: #777777;
--n-40: #5e5e5e;
--n-35: #525252;
--n-30: #474747;
--n-25: #3b3b3b;
--n-20: #303030;
--n-15: #262626;
--n-10: #1b1b1b;
--n-5: #111111;
--n-0: #000000;
--p-100: #ffffff;
--p-99: #fffbff;
--p-98: #fcf8ff;
--p-95: #f2efff;
--p-90: #e1e0ff;
--p-80: #c0c1ff;
--p-70: #a0a3ff;
--p-60: #8487ea;
--p-50: #6a6dcd;
--p-40: #5154b3;
--p-35: #4447a6;
--p-30: #383b99;
--p-25: #2c2e8d;
--p-20: #202182;
--p-15: #131178;
--p-10: #06006c;
--p-5: #03004d;
--p-0: #000000;
--s-100: #ffffff;
--s-99: #fffbff;
--s-98: #fcf8ff;
--s-95: #f2efff;
--s-90: #e2e0f9;
--s-80: #c6c4dd;
--s-70: #aaa9c1;
--s-60: #8f8fa5;
--s-50: #75758b;
--s-40: #5d5c72;
--s-35: #515165;
--s-30: #454559;
--s-25: #393a4d;
--s-20: #2e2f42;
--s-15: #242437;
--s-10: #191a2c;
--s-5: #0f0f21;
--s-0: #000000;
--t-100: #ffffff;
--t-99: #fffbff;
--t-98: #fff8f9;
--t-95: #ffecf4;
--t-90: #ffd8ec;
--t-80: #e9b9d3;
--t-70: #cc9eb8;
--t-60: #af849d;
--t-50: #946b83;
--t-40: #79536a;
--t-35: #6c475d;
--t-30: #5f3c51;
--t-25: #523146;
--t-20: #46263a;
--t-15: #3a1b2f;
--t-10: #2e1125;
--t-5: #22071a;
--t-0: #000000;
--nv-100: #ffffff;
--nv-99: #fffbff;
--nv-98: #fcf8ff;
--nv-95: #f2effa;
--nv-90: #e4e1ec;
--nv-80: #c8c5d0;
--nv-70: #acaab4;
--nv-60: #918f9a;
--nv-50: #777680;
--nv-40: #5e5d67;
--nv-35: #52515b;
--nv-30: #46464f;
--nv-25: #3b3b43;
--nv-20: #303038;
--nv-15: #25252d;
--nv-10: #1b1b23;
--nv-5: #101018;
--nv-0: #000000;
--e-100: #ffffff;
--e-99: #fffbff;
--e-98: #fff8f7;
--e-95: #ffedea;
--e-90: #ffdad6;
--e-80: #ffb4ab;
--e-70: #ff897d;
--e-60: #ff5449;
--e-50: #de3730;
--e-40: #ba1a1a;
--e-35: #a80710;
--e-30: #93000a;
--e-25: #7e0007;
--e-20: #690005;
--e-15: #540003;
--e-10: #410002;
--e-5: #2d0001;
--e-0: #000000;
--primary: #137fec;
--text-color: #fff;
--background-light: #f6f7f8;
--background-dark: #101922;
--border-color: oklch(
from var(--background-light) l c h / calc(alpha * 0.15)
);
--elevated-background-light: oklch(
from var(--background-light) l c h / calc(alpha * 0.05)
);
--bb-grid-size: 4px;
--bb-grid-size-2: calc(var(--bb-grid-size) * 2);
--bb-grid-size-3: calc(var(--bb-grid-size) * 3);
--bb-grid-size-4: calc(var(--bb-grid-size) * 4);
--bb-grid-size-5: calc(var(--bb-grid-size) * 5);
--bb-grid-size-6: calc(var(--bb-grid-size) * 6);
--bb-grid-size-7: calc(var(--bb-grid-size) * 7);
--bb-grid-size-8: calc(var(--bb-grid-size) * 8);
--bb-grid-size-9: calc(var(--bb-grid-size) * 9);
--bb-grid-size-10: calc(var(--bb-grid-size) * 10);
--bb-grid-size-11: calc(var(--bb-grid-size) * 11);
--bb-grid-size-12: calc(var(--bb-grid-size) * 12);
--bb-grid-size-13: calc(var(--bb-grid-size) * 13);
--bb-grid-size-14: calc(var(--bb-grid-size) * 14);
--bb-grid-size-15: calc(var(--bb-grid-size) * 15);
--bb-grid-size-16: calc(var(--bb-grid-size) * 16);
}
* {
box-sizing: border-box;
}
html,
body {
--font-family: "Google Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-family-flex:
"Google Sans Flex", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-family-mono:
"Google Sans Code", "Helvetica Neue", Helvetica, Arial, sans-serif;
background: var(--background-light);
font-family: var(--font-family);
margin: 0;
padding: 0;
width: 100svw;
height: 100svh;
}
@@ -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,51 @@
"use client";
/**
* Beautiful Chat — the flagship CopilotKit showcase cell, ported verbatim
* from the 4084 reference clone. The 4084 version lived as its own Next.js
* frontend at `demos/beautiful-chat/frontend/` with a full `src/components`
* tree + A2UI catalog. Here the same tree is colocated under the cell and
* re-wired with relative imports.
*
* Providers: layout-level `CopilotKit` + `ThemeProvider` wrappers from the
* original 4084 root layout are applied here instead, because the unified
* 4085 shell does not give each cell its own layout.tsx.
*
* Runtime: this cell uses its own dedicated runtime endpoint
* (`/api/copilotkit-beautiful-chat`) so it can enable `openGenerativeUI`,
* `a2ui` with `injectA2UITool: false`, and `mcpApps` simultaneously — the
* same combined-runtime shape the canonical starter uses — without bleeding
* those global flags into other cells sharing the main `/api/copilotkit`
* endpoint. The backend graph is `beautiful_chat` (src/agents/beautiful_chat.py).
*/
import React from "react";
import { CopilotKit } from "@copilotkit/react-core/v2";
import { ThemeProvider } from "./hooks/use-theme";
import { demonstrationCatalog } from "./declarative-generative-ui/renderers";
import { HomePage } from "./home-page";
export default function BeautifulChatPage() {
return (
<ThemeProvider>
<CopilotKit
runtimeUrl="/api/copilotkit-beautiful-chat"
agent="beautiful-chat"
a2ui={{ catalog: demonstrationCatalog }}
openGenerativeUI={{}}
/*
* `useSingleEndpoint` defaults to true (the single-POST-endpoint
* protocol). The canonical reference sets it to false to use the
* v2 multi-endpoint protocol (GET /info + POST /agent/{name}/connect),
* which requires a Hono-based endpoint via `createCopilotEndpoint`.
* The 4085 showcase uses `copilotRuntimeNextJSAppRouterEndpoint`
* (single-endpoint), which matches the other 4085 cells — so we
* use its default behavior here. Functionally equivalent for this demo.
*/
>
<HomePage />
</CopilotKit>
</ThemeProvider>
);
}
@@ -0,0 +1,3 @@
{
"showcase": "default"
}
@@ -0,0 +1,81 @@
# Chat Customization (CSS)
## What This Demo Shows
How far you can push `CopilotChat` with CSS alone — no slot overrides, no
component swaps, no React. The default look is rounded, system-sans, and
minimal-light. This demo replaces it with **HALCYON**, a warm-paper
editorial brand: cream parchment surface, sharp 90° corners, copper-ember
accents, an italic display serif for big headings, a Fraunces serif voice
for the assistant, and JetBrains Mono dispatch lines for the user.
The point: a team can take CopilotChat off the shelf and skin it to match
their own brand without ever opening a component file.
## How it works
Two layers do the work:
1. **v2 token overrides on `[data-copilotkit]`**`--background`,
`--foreground`, `--primary`, `--muted`, `--border`, `--ring`, `--radius`,
etc. Recolors every Tailwind utility (`cpk:bg-muted`,
`cpk:text-foreground`, …) the runtime renders.
2. **Class-targeted styling**`.copilotKitChat`, `.copilotKitMessages`,
`.copilotKitMessage.copilotKitUserMessage`,
`.copilotKitMessage.copilotKitAssistantMessage`, `.copilotKitInput`, the
welcome screen, suggestions, scrollbar.
Every selector is namespaced under `.chat-css-demo-scope`, so the theme
cannot leak into the rest of the showcase.
## How to Interact
Type any prompt and watch the conversation render in the HALCYON voice:
- `"Say hi"`
- `"Write a one-paragraph product memo about quarterly OKRs"`
- `"Show me a Python snippet for retry with exponential backoff"`
- `"Quote a famous business strategist on focus"`
You'll see:
- The user line render as a mono CLI dispatch with an ember `→` marker
- The assistant respond in serif body type with editorial spacing, an
ember left rule, and a dark code-card for code blocks
- The composer pill flatten to a sharp card with an ember focus ring and
a square copper send button
## Aesthetic Notes
- **Surface** — warm parchment (`#F4EFE6`) with a single ambient ember glow
in the top-left and a barely-perceptible paper-grain noise via inline
SVG
- **Masthead** — a centered mono label pinned just under the top edge of
the chat surface (`CopilotChat · Customized with CSS`)
- **Typography** — Instrument Serif (display, italic), Fraunces (assistant
body), Inter Tight (UI), JetBrains Mono (user dispatch + metadata +
suggestions)
- **Accent** — deep copper ember (`#C44A1F`), used only on the user prompt
marker, the assistant left rule, the send button, and focus rings —
sparingly, so it actually reads as signal
- **Geometry** — sharp 90° corners everywhere (radius is overridden to
`0px`), opposite of the default rounded pills
## Technical Details
- `<CopilotKit>` wires `runtimeUrl="/api/copilotkit"` and
`agent="chat-customization-css"` (backed by `graph` in
`src/agents/main.py`)
- `<CopilotChat>` is wrapped in `<div className="chat-css-demo-scope">`;
the theme is applied by `import "./theme.css"` at the top of the page
- `theme.css` first overrides the v2 token variables on `[data-copilotkit]`
(so Tailwind utilities recolor automatically), then layers
class-targeted rules on top for the editorial details that CSS
variables alone can't express
- Fonts load from Google Fonts via `@import` at the top of `theme.css`
so the demo is self-contained — copy the file into another project and
the theme works end-to-end
- Reach for slots (see `chat-slots`) when you need to change _what_ a
piece renders, not just how it looks; reach for CSS — like this demo —
when the default structure is fine and you only need a different
visual identity
@@ -0,0 +1,30 @@
"use client";
// Chat Customization (CSS) — every visual choice in this demo lives in
// theme.css and is scoped to the `.chat-css-demo-scope` wrapper. The page
// intentionally stays minimal so the contrast against the default look
// comes entirely from the stylesheet.
//
// https://docs.copilotkit.ai/custom-look-and-feel/customize-built-in-ui-components
import React from "react";
import { CopilotKit, CopilotChat } from "@copilotkit/react-core/v2";
// @region[theme-css-import]
import "./theme.css";
// @endregion[theme-css-import]
export default function ChatCustomizationCssDemo() {
return (
<CopilotKit runtimeUrl="/api/copilotkit" agent="chat-customization-css">
<div className="flex justify-center items-center h-screen w-full bg-white p-6">
<div className="chat-css-demo-scope h-full w-full max-w-4xl">
<CopilotChat
agentId="chat-customization-css"
className="h-full"
attachments={{ enabled: true }}
/>
</div>
</div>
</CopilotKit>
);
}
@@ -0,0 +1,628 @@
/* HALCYON — a warm-paper editorial theme for CopilotChat.
*
* The point of this demo is to show how far a single stylesheet can take
* CopilotChat away from the default look without touching components or
* slots. Every selector is namespaced under `.chat-css-demo-scope` so this
* theme cannot leak into the rest of the showcase.
*
* Two layers do the work:
* 1. v2 token overrides on `[data-copilotkit]` recolor every Tailwind
* utility (cpk:bg-muted, cpk:text-foreground, cpk:border, …) the
* runtime relies on — see @copilotkit/react-core/v2/styles.css.
* 2. Targeted class rules on `.copilotKitChat`, `.copilotKitMessage*`,
* and `.copilotKitInput` add the editorial details: parchment grain,
* corner brackets, serif voice, mono dispatch, ember accents.
*
* Class-name reference:
* https://docs.copilotkit.ai/custom-look-and-feel/customize-built-in-ui-components
*/
/* @region[google-fonts] */
@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=Instrument+Serif:ital@0;1&family=Inter+Tight:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap");
/* @endregion[google-fonts] */
/* @region[design-tokens] */
/* HALCYON palette — a private library at golden hour. The whole theme is
* one warm parchment hue, one warm ink, and a deep copper ember used
* sparingly so it actually reads as a signal. */
.chat-css-demo-scope {
--halcyon-paper: #f4efe6;
--halcyon-paper-soft: #ece6d9;
--halcyon-paper-elevated: #fbf8f2;
--halcyon-card: #ffffff;
--halcyon-rule: #d6cfbe;
--halcyon-rule-strong: #aea48a;
--halcyon-ink: #1a1714;
--halcyon-ink-soft: #3d362e;
--halcyon-ink-mute: #7a7468;
--halcyon-ember: #c44a1f;
--halcyon-ember-bright: #e45f2b;
--halcyon-ember-soft: #f3d7c5;
--halcyon-champagne: #98794a;
--halcyon-display:
"Instrument Serif", ui-serif, "Iowan Old Style", Georgia, serif;
--halcyon-serif:
"Fraunces", "Source Serif Pro", ui-serif, Georgia, "Times New Roman", serif;
--halcyon-sans:
"Inter Tight", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
--halcyon-mono:
"JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--halcyon-shadow-soft:
0 1px 0 rgba(26, 23, 20, 0.04), 0 12px 32px -18px rgba(26, 23, 20, 0.18);
--halcyon-shadow-ember:
0 1px 0 rgba(196, 74, 31, 0.18), 0 14px 36px -16px rgba(196, 74, 31, 0.42);
}
/* @endregion[design-tokens] */
/* @region[v2-token-overrides] */
/* CopilotKit v2 reads these on the [data-copilotkit] root inside the chat.
* Re-pointing them under our scope retints every Tailwind utility the
* runtime renders (user message bubble, prose, borders, focus rings, …)
* without us having to touch any individual class. */
.chat-css-demo-scope [data-copilotkit] {
--background: var(--halcyon-paper);
--foreground: var(--halcyon-ink);
--card: var(--halcyon-card);
--card-foreground: var(--halcyon-ink);
--popover: var(--halcyon-paper-elevated);
--popover-foreground: var(--halcyon-ink);
--primary: var(--halcyon-ember);
--primary-foreground: var(--halcyon-paper-elevated);
--secondary: var(--halcyon-paper-soft);
--secondary-foreground: var(--halcyon-ink);
--muted: var(--halcyon-paper-soft);
--muted-foreground: var(--halcyon-ink-mute);
--accent: var(--halcyon-ember-soft);
--accent-foreground: var(--halcyon-ember);
--destructive: #b3361b;
--destructive-foreground: var(--halcyon-paper-elevated);
--border: var(--halcyon-rule);
--input: var(--halcyon-rule);
--ring: var(--halcyon-ember);
--radius: 0px;
}
/* @endregion[v2-token-overrides] */
/* @region[chat-shell] */
/* The chat surface — warm parchment with a single ambient ember glow,
* a barely-perceptible paper grain via inline SVG noise, and architectural
* corner brackets. Sharp 90° corners are deliberate; the default look is
* rounded, so squaring everything off is the fastest visual signal that
* "this is a different brand". */
.chat-css-demo-scope .copilotKitChat {
font-family: var(--halcyon-sans);
color: var(--halcyon-ink);
background-color: var(--halcyon-paper);
background-image:
radial-gradient(
900px 460px at 0% -10%,
rgba(228, 95, 43, 0.14),
transparent 62%
),
radial-gradient(
720px 380px at 100% 110%,
rgba(152, 121, 74, 0.08),
transparent 65%
),
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.09 0 0 0 0 0.07 0 0 0 0.045 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
border: 1px solid var(--halcyon-rule);
border-radius: 0;
box-shadow: var(--halcyon-shadow-soft);
position: relative;
overflow: hidden;
}
/* The masthead label — a small mono bar pinned to the top of the surface,
* playing against the editorial serif voice. Lives on ::before so it
* tracks the chat root and shows in every state (welcome, mid-thread,
* empty after clear). */
.chat-css-demo-scope .copilotKitChat::before {
content: "CopilotChat · Customized with CSS";
position: absolute;
top: 18px;
left: 0;
right: 0;
text-align: center;
font-family: var(--halcyon-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--halcyon-ink-mute);
white-space: nowrap;
pointer-events: none;
z-index: 3;
}
/* @endregion[chat-shell] */
/* @region[welcome] */
/* The welcome screen — the page-one impression. The default heading is
* sans-serif and tidy; we replace it with a large italic display serif
* that wraps the question like a magazine cover line. */
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] {
padding-top: 4rem;
}
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1 {
font-family: var(--halcyon-display);
font-size: clamp(2.4rem, 5vw, 4rem);
font-weight: 400;
font-style: italic;
color: var(--halcyon-ink);
letter-spacing: -0.02em;
line-height: 1.05;
text-align: center;
margin: 0 auto 0.6rem;
max-width: 22ch;
position: relative;
}
/* A small mono eyebrow above the heading. */
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1::before {
content: "CopilotKit";
display: block;
font-family: var(--halcyon-mono);
font-size: 11px;
font-style: normal;
font-weight: 500;
letter-spacing: 0.06em;
color: var(--halcyon-ember);
margin-bottom: 1.2rem;
}
/* A short rule under the heading as a visual settle point. */
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1::after {
content: "";
display: block;
width: 36px;
height: 1px;
background: var(--halcyon-rule-strong);
margin: 1.4rem auto 0;
}
/* @endregion[welcome] */
/* @region[messages-container] */
.chat-css-demo-scope .copilotKitMessages {
font-family: var(--halcyon-sans);
background: transparent;
color: var(--halcyon-ink);
padding: 5rem 0 2rem;
line-height: 1.6;
}
/* @endregion[messages-container] */
/* @region[user-message] */
/* User message — a "transmission" in JetBrains Mono on a paper card. The
* outer wrapper is the right-aligning flex column; we leave it transparent
* and style the inner bubble (which uses cpk:bg-muted, hence we also
* target the substring class as a stable hook). */
.chat-css-demo-scope .copilotKitMessage.copilotKitUserMessage {
background: transparent;
padding: 0;
border: none;
box-shadow: none;
}
.chat-css-demo-scope
.copilotKitMessage.copilotKitUserMessage
> [class*="bg-muted"] {
font-family: var(--halcyon-mono);
font-size: 0.875rem;
font-weight: 400;
color: var(--halcyon-ink);
background: var(--halcyon-paper-elevated);
border: 1px solid var(--halcyon-rule);
border-left: 2px solid var(--halcyon-ember);
border-radius: 0;
padding: 12px 16px 12px 18px;
letter-spacing: -0.005em;
line-height: 1.55;
box-shadow: 0 1px 0 rgba(26, 23, 20, 0.03);
position: relative;
}
/* A mono "→" marker before the user's text to read like a CLI prompt. */
.chat-css-demo-scope
.copilotKitMessage.copilotKitUserMessage
> [class*="bg-muted"]::before {
content: "→";
display: inline-block;
margin-right: 10px;
color: var(--halcyon-ember);
font-weight: 500;
}
/* @endregion[user-message] */
/* @region[assistant-message] */
/* Assistant message — editorial Fraunces serif, no bubble, just generous
* paragraphs offset by a thin ember rule on the left. Reads like the
* voice of a publication, not a chatbot. */
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage {
background: transparent;
color: var(--halcyon-ink);
font-family: var(--halcyon-serif);
font-size: 1.0625rem;
font-weight: 400;
padding: 4px 0 4px 22px;
border: none;
border-radius: 0;
margin-right: auto;
margin-bottom: 1.25rem;
max-width: 78ch;
position: relative;
}
/* The editorial left rule. */
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage::before {
content: "";
position: absolute;
top: 0.45em;
bottom: 0.45em;
left: 0;
width: 1px;
background: var(--halcyon-ember);
}
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose,
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose p {
font-family: var(--halcyon-serif);
font-size: inherit;
color: inherit;
line-height: 1.7;
font-feature-settings: "ss01", "ss02", "ss03", "kern";
margin: 0 0 0.85em;
}
/* Headings inside assistant content swap to the display serif so a long
* answer reads like a structured article. */
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
.prose
:is(h1, h2, h3, h4) {
font-family: var(--halcyon-display);
font-style: italic;
font-weight: 400;
letter-spacing: -0.015em;
color: var(--halcyon-ink);
margin: 1em 0 0.4em;
line-height: 1.15;
}
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose h1 {
font-size: 2rem;
}
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose h2 {
font-size: 1.55rem;
}
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose h3 {
font-size: 1.25rem;
}
/* Lists — looser, with serif numerals. */
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
.prose
:is(ul, ol) {
margin: 0.5em 0 1em;
padding-left: 1.4em;
}
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose li {
margin: 0.25em 0;
}
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
.prose
ol
> li::marker {
color: var(--halcyon-ember);
font-feature-settings: "tnum";
font-weight: 500;
}
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
.prose
ul
> li::marker {
color: var(--halcyon-ember);
}
/* Blockquote — pull-quote treatment in italic display serif. */
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
.prose
blockquote {
border-left: 0;
margin: 1.2em 0;
padding: 0 0 0 1em;
font-family: var(--halcyon-display);
font-style: italic;
font-size: 1.25em;
color: var(--halcyon-ink-soft);
position: relative;
}
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
.prose
blockquote::before {
content: "“";
position: absolute;
left: -0.05em;
top: -0.4em;
font-size: 2.4em;
color: var(--halcyon-ember);
line-height: 1;
}
/* Inline code — small ember chip on a tinted card. */
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose code {
font-family: var(--halcyon-mono);
font-size: 0.86em;
font-weight: 500;
color: var(--halcyon-ember);
background: var(--halcyon-ember-soft);
border: 1px solid color-mix(in srgb, var(--halcyon-ember) 22%, transparent);
border-radius: 0;
padding: 1px 6px;
}
/* Code block — dark ink card flipped against the parchment. The contrast
* is deliberate; it reads like a code excerpt set in a printed book. */
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose pre,
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
div[data-streamdown="code-block"]
> pre {
background: var(--halcyon-ink) !important;
color: #e8e2d5;
border: 1px solid var(--halcyon-ink);
border-radius: 0;
padding: 14px 16px;
margin: 1em 0;
font-family: var(--halcyon-mono);
font-size: 0.86em;
line-height: 1.55;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
var(--halcyon-shadow-soft);
}
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
.prose
pre
code {
background: transparent;
color: inherit;
border: none;
padding: 0;
}
/* Anchor links — ember underline in classic editorial style. */
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose a {
color: var(--halcyon-ember);
text-decoration-line: underline;
text-decoration-color: color-mix(
in srgb,
var(--halcyon-ember) 35%,
transparent
);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
transition: text-decoration-color 160ms ease;
}
.chat-css-demo-scope
.copilotKitMessage.copilotKitAssistantMessage
.prose
a:hover {
text-decoration-color: var(--halcyon-ember);
}
/* Horizontal rule — short, centered, ornament-like. */
.chat-css-demo-scope .copilotKitMessage.copilotKitAssistantMessage .prose hr {
border: none;
height: 1px;
background: var(--halcyon-rule);
width: 64px;
margin: 1.6em auto;
}
/* @endregion[assistant-message] */
/* @region[input-composer] */
/* Composer — a sharp paper card with an ember focus rule. The default
* pill is rounded; squaring it off is again the visual cue that this is
* a different brand. The wrapper around .copilotKitInput uses a fixed
* white background in v2, so we override it directly. */
.chat-css-demo-scope .copilotKitInput {
font-family: var(--halcyon-sans) !important;
background: var(--halcyon-card) !important;
border: 1px solid var(--halcyon-rule);
border-radius: 0 !important;
padding: 14px 16px;
min-height: 56px;
box-shadow:
0 1px 0 rgba(26, 23, 20, 0.03),
0 8px 24px -16px rgba(26, 23, 20, 0.18);
transition:
border-color 200ms ease,
box-shadow 200ms ease,
transform 120ms ease;
}
.chat-css-demo-scope .copilotKitInput:focus-within {
border-color: var(--halcyon-ember);
box-shadow:
0 0 0 3px rgba(196, 74, 31, 0.12),
0 1px 0 rgba(196, 74, 31, 0.18),
0 14px 36px -16px rgba(196, 74, 31, 0.22);
transform: translateY(-1px);
}
.chat-css-demo-scope .copilotKitInput textarea {
font-family: var(--halcyon-sans) !important;
font-size: 1rem;
font-weight: 400;
color: var(--halcyon-ink);
line-height: 1.55;
letter-spacing: -0.005em;
}
.chat-css-demo-scope .copilotKitInput textarea::placeholder {
color: var(--halcyon-ink-mute);
font-style: italic;
opacity: 1;
}
/* @endregion[input-composer] */
/* @region[input-buttons] */
/* The send button — a square ember chit, not the default circular pill.
* v2 ships this as `<Button variant="chatInputToolbarPrimary"
* size="chatInputToolbarIcon" data-testid="copilot-send-button">`, which
* compiles to `cpk:bg-black cpk:text-white cpk:rounded-full cpk:h-9
* cpk:w-9`. We override every one of those tokens so the brand wins. */
.chat-css-demo-scope button[data-testid="copilot-send-button"] {
background-color: var(--halcyon-ember) !important;
color: var(--halcyon-paper-elevated) !important;
border: 1px solid var(--halcyon-ember) !important;
border-radius: 2px !important;
height: 36px !important;
width: 36px !important;
box-shadow: var(--halcyon-shadow-ember);
transition:
transform 150ms ease,
box-shadow 150ms ease,
background-color 150ms ease;
}
.chat-css-demo-scope button[data-testid="copilot-send-button"]:hover {
background-color: var(--halcyon-ember-bright) !important;
transform: translateY(-1px);
}
.chat-css-demo-scope button[data-testid="copilot-send-button"]:disabled {
background-color: var(--halcyon-paper-soft) !important;
color: var(--halcyon-ink-mute) !important;
border-color: var(--halcyon-rule) !important;
box-shadow: none;
transform: none;
opacity: 1 !important;
}
.chat-css-demo-scope button[data-testid="copilot-send-button"] svg {
color: inherit;
}
/* Secondary input chrome (add-menu plus, mic, transcribe) — ghost squares
* with an ember tint on hover. v2's `chatInputToolbarSecondary` variant
* uses transparent bg + #444 text, which we re-tint to match the brand. */
.chat-css-demo-scope
.copilotKitInput
button:not([data-testid="copilot-send-button"]) {
border-radius: 2px !important;
color: var(--halcyon-ink-soft) !important;
transition:
color 150ms ease,
background-color 150ms ease;
}
.chat-css-demo-scope
.copilotKitInput
button:not([data-testid="copilot-send-button"]):hover {
color: var(--halcyon-ember) !important;
background-color: var(--halcyon-ember-soft) !important;
}
/* @endregion[input-buttons] */
/* @region[suggestions] */
/* Suggestion pills — sharp outlined chips, not rounded balloons. The
* inner suggestion text uses the editorial mono so it reads like a
* curated set of dispatch options. */
.chat-css-demo-scope [class*="copilotKitSuggestion"] {
background: transparent;
color: var(--halcyon-ink-soft);
border: 1px solid var(--halcyon-rule);
border-radius: 0;
padding: 8px 14px;
font-family: var(--halcyon-mono);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.02em;
text-transform: uppercase;
transition:
color 150ms ease,
border-color 150ms ease,
background 150ms ease,
transform 150ms ease;
}
.chat-css-demo-scope [class*="copilotKitSuggestion"]:hover {
color: var(--halcyon-ember);
border-color: var(--halcyon-ember);
background: var(--halcyon-ember-soft);
transform: translateY(-1px);
}
/* @endregion[suggestions] */
/* @region[scrollbar] */
.chat-css-demo-scope [data-copilotkit] ::-webkit-scrollbar {
width: 4px;
}
.chat-css-demo-scope [data-copilotkit] ::-webkit-scrollbar-thumb {
background: var(--halcyon-rule-strong);
border-radius: 0;
}
.chat-css-demo-scope [data-copilotkit] ::-webkit-scrollbar-thumb:hover {
background: var(--halcyon-ember);
}
.chat-css-demo-scope [data-copilotkit] * {
scrollbar-width: thin;
scrollbar-color: var(--halcyon-rule-strong) transparent;
}
/* @endregion[scrollbar] */
/* @region[selection] */
.chat-css-demo-scope ::selection {
background: var(--halcyon-ember-soft);
color: var(--halcyon-ember);
}
/* @endregion[selection] */
/* @region[motion] */
/* A single, restrained entrance for the welcome screen — staggered fade-up
* on the eyebrow / heading / rule. No infinite loops, no bouncy easing. */
@keyframes halcyon-rise {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1 {
animation: halcyon-rise 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1::before {
animation: halcyon-rise 600ms cubic-bezier(0.2, 0.7, 0.2, 1) 80ms both;
}
@media (prefers-reduced-motion: reduce) {
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1,
.chat-css-demo-scope [data-testid="copilot-welcome-screen"] h1::before {
animation: none;
}
}
/* @endregion[motion] */
@@ -0,0 +1,20 @@
# Chat Slots
## What This Demo Shows
How to override built-in chat UI regions using the `CopilotChat` **slot** system. Three slots are overridden:
- `welcomeScreen` — the empty-state card shown before the first message
- `input.disclaimer` — the small disclaimer line under the input box
- `messageView.assistantMessage` — the card rendered for each assistant reply
## How to Interact
- On page load you see the gradient welcome card (custom `welcomeScreen`) with a "slot"-tagged disclaimer under the input
- Ask anything (for example "Tell me a joke") and watch the assistant reply render inside a custom indigo card (custom `assistantMessage`)
## Technical Details
- `<CopilotChat>` accepts `welcomeScreen`, `input`, and `messageView` props; each can receive either a single component or an object mapping sub-slot names to components.
- The custom `assistantMessage` wraps the default `CopilotChatAssistantMessage` rather than reimplementing it, so markdown rendering, streaming, and tool UI all still work.
- The agent name `chat-slots` is registered in `src/app/api/copilotkit/route.ts` and forwards to the shared .NET `ProverbsAgent`.
@@ -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,35 @@
"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 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,112 @@
"use client";
import React from "react";
import {
CopilotKit,
CopilotChat,
CopilotChatAssistantMessage,
CopilotChatUserMessage,
CopilotChatReasoningMessage,
CopilotChatView,
CopilotChatInput,
} from "@copilotkit/react-core/v2";
import {
CustomWelcomeScreen,
CustomAssistantMessage,
CustomUserMessage,
CustomReasoningMessage,
CustomCursor,
CustomTextArea,
CustomSendButton,
CustomDisclaimer,
CustomAddMenuButton,
CustomSuggestionContainer,
CustomSuggestion,
CustomScrollToBottomButton,
CustomFeather,
} from "./slot-wrappers";
import { makeSlotOverride } from "../_shared/slot-override";
import { useChatSlotsSuggestions } from "./suggestions";
// "Slot Atlas" — every overrideable slot on CopilotChat is wrapped in a
// dashed, color-coded marker so a developer can see at a glance what is
// customizable and where it lives. Hover any region to reveal its slot path.
export default function ChatSlotsDemo() {
return (
<CopilotKit runtimeUrl="/api/copilotkit" agent="chat-slots">
<div className="flex flex-col h-screen w-full bg-background">
<div className="flex-1 flex justify-center items-stretch p-4 min-h-0">
<div className="h-full w-full max-w-5xl">
<Chat />
</div>
</div>
</div>
</CopilotKit>
);
}
function Chat() {
useChatSlotsSuggestions();
// Slot overrides go through `makeSlotOverride<TDefault>(component)` so
// the cast is centralized in one named helper instead of sprinkled
// through this file. See `../_shared/slot-override.ts` for the why.
const welcomeScreen =
makeSlotOverride<typeof CopilotChatView.WelcomeScreen>(CustomWelcomeScreen);
// The input prop accepts both slot overrides AND CopilotChatInput's rest
// props (toolsMenu, mode, etc.) merged together. We seed `toolsMenu` so the
// addMenuButton slot has a reason to render.
const input = {
textArea:
makeSlotOverride<typeof CopilotChatInput.TextArea>(CustomTextArea),
sendButton:
makeSlotOverride<typeof CopilotChatInput.SendButton>(CustomSendButton),
disclaimer:
makeSlotOverride<typeof CopilotChatInput.Disclaimer>(CustomDisclaimer),
addMenuButton:
makeSlotOverride<typeof CopilotChatInput.AddMenuButton>(
CustomAddMenuButton,
),
toolsMenu: [
{
label: "Demo tool (no-op)",
action: () => {},
},
],
};
const messageView = {
assistantMessage: makeSlotOverride<typeof CopilotChatAssistantMessage>(
CustomAssistantMessage,
),
userMessage:
makeSlotOverride<typeof CopilotChatUserMessage>(CustomUserMessage),
reasoningMessage: makeSlotOverride<typeof CopilotChatReasoningMessage>(
CustomReasoningMessage,
),
cursor: CustomCursor,
};
const suggestionView = {
container: CustomSuggestionContainer,
suggestion: CustomSuggestion,
};
const scrollView = {
scrollToBottomButton: CustomScrollToBottomButton,
feather: CustomFeather,
};
return (
<CopilotChat
agentId="chat-slots"
className="h-full rounded-2xl border border-border/60 bg-card overflow-hidden"
welcomeScreen={welcomeScreen}
input={input}
messageView={messageView}
suggestionView={suggestionView}
scrollView={scrollView}
/>
);
}
@@ -0,0 +1,161 @@
"use client";
import React, { useCallback, useState } from "react";
export type SlotColor =
| "indigo"
| "violet"
| "emerald"
| "sky"
| "amber"
| "rose"
| "orange"
| "red"
| "yellow"
| "pink"
| "cyan"
| "teal"
| "lime"
| "fuchsia";
// Static lookups so Tailwind v4's source scanner finds every class string at
// build time. Dynamic concatenation like `border-${color}-400` would not work.
export const SLOT_COLORS: Record<
SlotColor,
{ border: string; label: string; ring: string }
> = {
indigo: {
border: "border-indigo-400",
label: "bg-indigo-500",
ring: "ring-indigo-400/40",
},
violet: {
border: "border-violet-400",
label: "bg-violet-500",
ring: "ring-violet-400/40",
},
emerald: {
border: "border-emerald-400",
label: "bg-emerald-500",
ring: "ring-emerald-400/40",
},
sky: {
border: "border-sky-400",
label: "bg-sky-500",
ring: "ring-sky-400/40",
},
amber: {
border: "border-amber-400",
label: "bg-amber-500",
ring: "ring-amber-400/40",
},
rose: {
border: "border-rose-400",
label: "bg-rose-500",
ring: "ring-rose-400/40",
},
orange: {
border: "border-orange-400",
label: "bg-orange-500",
ring: "ring-orange-400/40",
},
red: {
border: "border-red-400",
label: "bg-red-500",
ring: "ring-red-400/40",
},
yellow: {
border: "border-yellow-400",
label: "bg-yellow-500",
ring: "ring-yellow-400/40",
},
pink: {
border: "border-pink-400",
label: "bg-pink-500",
ring: "ring-pink-400/40",
},
cyan: {
border: "border-cyan-400",
label: "bg-cyan-500",
ring: "ring-cyan-400/40",
},
teal: {
border: "border-teal-400",
label: "bg-teal-500",
ring: "ring-teal-400/40",
},
lime: {
border: "border-lime-400",
label: "bg-lime-500",
ring: "ring-lime-400/40",
},
fuchsia: {
border: "border-fuchsia-400",
label: "bg-fuchsia-500",
ring: "ring-fuchsia-400/40",
},
};
/**
* Wraps a slot region with a dashed outline plus a small clickable badge
* that copies the slot's component path to the clipboard.
*
* The label is opacity-0 by default and turns visible only when this marker
* is hovered AND no descendant marker is also hovered. Markers nest
* (welcomeScreen wraps welcomeMessage / input / suggestionView), so a plain
* `:hover .slot-label { opacity: 1 }` would light up every nested label.
* The `:not(:has(.slot-marker:hover))` predicate isolates each marker.
*/
export function SlotMarker({
color,
label,
children,
inline,
className,
}: {
color: SlotColor;
label: string;
children: React.ReactNode;
inline?: boolean;
className?: string;
}) {
const c = SLOT_COLORS[color];
const [copied, setCopied] = useState(false);
const onCopy = useCallback(
async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
try {
await navigator.clipboard.writeText(label);
setCopied(true);
setTimeout(() => setCopied(false), 1100);
} catch {
// clipboard may be unavailable (e.g. insecure context); silently no-op
}
},
[label],
);
return (
<span
data-slot-label={label}
className={`slot-marker relative ${inline ? "inline-flex" : "flex"} border border-dashed ${c.border} rounded-lg p-1 ${className ?? ""}`}
style={{ flexDirection: inline ? "row" : "column" }}
>
<button
type="button"
onClick={onCopy}
title={copied ? "Copied!" : `Copy slot path: ${label}`}
aria-label={`Copy slot path ${label}`}
className={`slot-label absolute -top-2 left-2 inline-flex items-center gap-1 rounded ${c.label} text-white text-[9px] font-bold uppercase tracking-wider px-1.5 py-px shadow-sm z-10 whitespace-nowrap opacity-0 transition-opacity hover:brightness-110 cursor-pointer pointer-events-auto font-mono normal-case tracking-normal`}
>
<span>{copied ? "Copied" : label}</span>
<span aria-hidden="true" className="text-white/70 text-[8px]">
{copied ? "✓" : "⧉"}
</span>
</button>
<span style={{ display: "contents" }}>{children}</span>
</span>
);
}
@@ -0,0 +1,45 @@
// Docs-only snippet — not imported or rendered. The langgraph-python
// chat-slots production demo registers a dozen slot overrides at once
// (see page.tsx) with `as unknown as typeof X` casts that exist to
// satisfy the WithSlots types when the wrappers are structurally
// compatible but not nominally identical. That's necessary in the
// running app but obscures the teaching shape.
//
// This file gives the slots docs page (custom-look-and-feel/slots.mdx)
// three minimal teaching examples — the welcome screen, assistant
// message, and disclaimer slot patterns — without changing the
// production demo's runtime behavior. See agentic-chat /
// chat-component.snippet.tsx for the same sibling-file pattern.
// @region[register-disclaimer-slot]
// @region[register-assistant-message-slot]
// @region[register-welcome-slot]
import type {
CopilotChatAssistantMessage,
CopilotChatInput,
CopilotChatView,
} from "@copilotkit/react-core/v2";
declare const CustomWelcomeScreen: React.ComponentType;
declare const CustomAssistantMessage: React.ComponentType;
declare const CustomDisclaimer: React.ComponentType;
export function ChatSlotsTeachingExtracts() {
const welcomeScreen =
CustomWelcomeScreen as unknown as typeof CopilotChatView.WelcomeScreen;
// @endregion[register-welcome-slot]
const messageView = {
assistantMessage:
CustomAssistantMessage as unknown as typeof CopilotChatAssistantMessage,
};
// @endregion[register-assistant-message-slot]
const input = {
disclaimer:
CustomDisclaimer as unknown as typeof CopilotChatInput.Disclaimer,
};
// @endregion[register-disclaimer-slot]
return { welcomeScreen, messageView, input };
}
@@ -0,0 +1,287 @@
"use client";
import React from "react";
import {
CopilotChatAssistantMessage,
CopilotChatUserMessage,
CopilotChatReasoningMessage,
CopilotChatMessageView,
CopilotChatView,
CopilotChatInput,
CopilotChatSuggestionPill,
type CopilotChatAssistantMessageProps,
type CopilotChatUserMessageProps,
type CopilotChatReasoningMessageProps,
type CopilotChatSuggestionPillProps,
} from "@copilotkit/react-core/v2";
import { SlotMarker } from "./slot-marker";
// =====================================================================
// welcomeScreen + welcomeScreen.welcomeMessage
// The welcomeScreen receives `input` and `suggestionView` as elements; we
// also expose the `welcomeMessage` sub-slot to show that slots can nest.
// =====================================================================
export function CustomWelcomeMessage(
props: React.HTMLAttributes<HTMLDivElement>,
) {
return (
<SlotMarker color="violet" label="WelcomeScreen.WelcomeMessage">
<div
{...props}
className="text-center px-4 py-3 text-sm text-muted-foreground"
data-testid="custom-welcome-message"
>
Hover any region to see its slot path · click the badge to copy
</div>
</SlotMarker>
);
}
export function CustomWelcomeScreen({
input,
suggestionView,
}: {
input: React.ReactElement;
suggestionView: React.ReactElement;
welcomeMessage?: React.ReactElement;
}) {
return (
<SlotMarker color="indigo" label="WelcomeScreen" className="flex-1 m-3">
<div
data-testid="custom-welcome-screen"
className="flex-1 flex flex-col items-center justify-center px-4 py-6 gap-4 w-full"
>
<CustomWelcomeMessage />
<div className="w-full max-w-2xl">{input}</div>
<div className="flex justify-center">{suggestionView}</div>
</div>
</SlotMarker>
);
}
// =====================================================================
// messageView.assistantMessage
// =====================================================================
export function CustomAssistantMessage(
props: CopilotChatAssistantMessageProps,
) {
return (
<SlotMarker
color="emerald"
label="MessageView.AssistantMessage"
className="my-3"
>
<CopilotChatAssistantMessage {...props} />
</SlotMarker>
);
}
// =====================================================================
// messageView.userMessage
// =====================================================================
export function CustomUserMessage(props: CopilotChatUserMessageProps) {
return (
<SlotMarker
color="sky"
label="MessageView.UserMessage"
className="my-3 ml-auto"
>
<CopilotChatUserMessage {...props} />
</SlotMarker>
);
}
// =====================================================================
// messageView.reasoningMessage
// Only renders when the message stream contains reasoning content.
// =====================================================================
export function CustomReasoningMessage(
props: CopilotChatReasoningMessageProps,
) {
return (
<SlotMarker
color="rose"
label="MessageView.ReasoningMessage"
className="my-2"
>
<CopilotChatReasoningMessage {...props} />
</SlotMarker>
);
}
// =====================================================================
// messageView.cursor
// Renders while a message is streaming. Tiny — wrap inline.
// =====================================================================
export function CustomCursor(props: React.HTMLAttributes<HTMLDivElement>) {
return (
<SlotMarker color="amber" label="MessageView.Cursor" inline>
<CopilotChatMessageView.Cursor {...props} />
</SlotMarker>
);
}
// =====================================================================
// input.textArea
// We wrap the default in a SlotMarker. The marker is `display: contents`-ish
// inside; the dashed border is on the marker's outer span.
// =====================================================================
export function CustomTextArea(
props: React.ComponentProps<typeof CopilotChatInput.TextArea>,
) {
return (
<SlotMarker
color="orange"
label="Input.TextArea"
className="flex-1 min-w-0"
>
<CopilotChatInput.TextArea {...props} />
</SlotMarker>
);
}
// =====================================================================
// input.sendButton
// =====================================================================
export function CustomSendButton(
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
) {
return (
<SlotMarker color="red" label="Input.SendButton" inline>
<CopilotChatInput.SendButton {...props} />
</SlotMarker>
);
}
// =====================================================================
// input.disclaimer
// =====================================================================
export function CustomDisclaimer(props: React.HTMLAttributes<HTMLDivElement>) {
return (
<SlotMarker
color="yellow"
label="Input.Disclaimer"
className="mx-auto my-1.5"
>
<div
{...props}
data-testid="custom-disclaimer"
className="text-xs text-center text-muted-foreground px-2 py-1"
>
Custom disclaimer slot · stays visible in every input variant
</div>
</SlotMarker>
);
}
// =====================================================================
// input.addMenuButton
// Only renders if `onAddFile` or `toolsMenu` is set on CopilotChatInput.
// =====================================================================
export function CustomAddMenuButton(
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
) {
return (
<SlotMarker color="pink" label="Input.AddMenuButton" inline>
<CopilotChatInput.AddMenuButton {...props} />
</SlotMarker>
);
}
// =====================================================================
// suggestionView.container
// =====================================================================
export const CustomSuggestionContainer = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function CustomSuggestionContainer(props, ref) {
return (
<SlotMarker color="cyan" label="SuggestionView.Container" className="my-2">
<div ref={ref} {...props} />
</SlotMarker>
);
});
// =====================================================================
// suggestionView.suggestion
// =====================================================================
export const CustomSuggestion = React.forwardRef<
HTMLButtonElement,
CopilotChatSuggestionPillProps
>(function CustomSuggestion(props, ref) {
return (
<SlotMarker color="teal" label="SuggestionView.Suggestion" inline>
<CopilotChatSuggestionPill ref={ref} {...props} />
</SlotMarker>
);
});
// =====================================================================
// scrollView.scrollToBottomButton
// =====================================================================
export function CustomScrollToBottomButton(
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
) {
return (
<SlotMarker
color="lime"
label="ScrollView.ScrollToBottomButton"
inline
className="absolute bottom-20 right-6"
>
<CopilotChatView.ScrollToBottomButton {...props} />
</SlotMarker>
);
}
// =====================================================================
// scrollView.feather
// The default Feather is the gradient fade above the input. The default
// implementation is an empty div with absolute positioning, so we render
// our own visible gradient + a clickable copy badge so the slot is
// unambiguously visible.
// =====================================================================
export function CustomFeather(props: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
{...props}
data-testid="custom-feather"
className="slot-marker pointer-events-none absolute left-0 right-0 bottom-0 h-12 bg-gradient-to-t from-fuchsia-100/90 to-transparent dark:from-fuchsia-950/40"
>
<FeatherCopyLabel />
</div>
);
}
function FeatherCopyLabel() {
const label = "ScrollView.Feather";
const [copied, setCopied] = React.useState(false);
const onCopy = React.useCallback(
async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
try {
await navigator.clipboard.writeText(label);
setCopied(true);
setTimeout(() => setCopied(false), 1100);
} catch {
// clipboard may be unavailable in non-secure contexts
}
},
[],
);
return (
<button
type="button"
onClick={onCopy}
title={copied ? "Copied!" : `Copy slot path: ${label}`}
aria-label={`Copy slot path ${label}`}
className="slot-label absolute -top-2 left-2 inline-flex items-center gap-1 rounded bg-fuchsia-500 text-white text-[9px] font-bold px-1.5 py-px shadow-sm z-10 whitespace-nowrap opacity-0 transition-opacity hover:brightness-110 cursor-pointer pointer-events-auto font-mono"
>
<span>{copied ? "Copied" : label}</span>
<span aria-hidden="true" className="text-white/70 text-[8px]">
{copied ? "✓" : "⧉"}
</span>
</button>
);
}
@@ -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,37 @@
"use client";
/**
* ShadCN-flavoured Badge primitive (inline-cloned, no `cn()`/`cva`).
* Variant palette mirrors ShadCN's default/secondary/destructive/outline,
* extended with `success` / `warning` / `info` for status reporting.
*/
import React from "react";
export type BadgeVariant = "success" | "warning" | "error" | "info";
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
success: "border-transparent bg-emerald-100 text-emerald-800",
warning: "border-transparent bg-amber-100 text-amber-800",
error: "border-transparent bg-rose-100 text-rose-800",
info: "border-[var(--border)] bg-[var(--muted)] text-[var(--foreground)]",
};
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
variant?: BadgeVariant;
}
export function Badge({
variant = "info",
className = "",
children,
...props
}: BadgeProps) {
return (
<span
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium tracking-wide ${VARIANT_CLASSES[variant]} ${className}`}
{...props}
>
{children}
</span>
);
}
@@ -0,0 +1,54 @@
"use client";
/**
* ShadCN-flavoured Button primitive (inline-cloned, no `cn()`/`cva`).
*/
import React from "react";
export type ButtonVariant =
| "default"
| "secondary"
| "outline"
| "ghost"
| "destructive";
export type ButtonSize = "sm" | "default" | "lg";
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
default:
"bg-[var(--primary)] text-[var(--primary-foreground)] hover:bg-[var(--primary)]/90",
secondary:
"bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:bg-[var(--secondary)]/80",
outline:
"border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]",
ghost: "bg-transparent text-[var(--foreground)] hover:bg-[var(--muted)]",
destructive:
"bg-[var(--destructive)] text-[var(--destructive-foreground)] hover:bg-[var(--destructive)]/90",
};
const SIZE_CLASSES: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs rounded-md",
default: "h-9 px-4 text-sm rounded-md",
lg: "h-10 px-6 text-sm rounded-md",
};
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
}
export function Button({
variant = "default",
size = "default",
className = "",
children,
...props
}: ButtonProps) {
return (
<button
className={`inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:pointer-events-none disabled:opacity-50 ${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className}`}
{...props}
>
{children}
</button>
);
}
@@ -0,0 +1,80 @@
"use client";
/**
* ShadCN-flavoured Card primitive (inline-cloned, no `cn()`/`cva`).
* Uses the showcase's `--card` / `--border` / `--foreground` /
* `--muted-foreground` CSS variables so the demo respects light/dark.
*/
import React from "react";
export function Card({
children,
className = "",
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={`rounded-xl border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-sm ${className}`}
{...props}
>
{children}
</div>
);
}
export function CardHeader({
children,
className = "",
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={`flex flex-col gap-1.5 px-6 pt-5 pb-3 ${className}`}
{...props}
>
{children}
</div>
);
}
export function CardTitle({
children,
className = "",
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={`text-base font-semibold leading-none tracking-tight ${className}`}
{...props}
>
{children}
</h3>
);
}
export function CardDescription({
children,
className = "",
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={`text-sm text-[var(--muted-foreground)] ${className}`}
{...props}
>
{children}
</p>
);
}
export function CardContent({
children,
className = "",
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={`px-6 pb-5 ${className}`} {...props}>
{children}
</div>
);
}
@@ -0,0 +1,26 @@
"use client";
/**
* ShadCN-flavoured Separator primitive — uses the (already-installed)
* `@radix-ui/react-separator` accessibility primitive.
*/
import React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
export function Separator({
className = "",
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
decorative={decorative}
orientation={orientation}
className={`shrink-0 bg-[var(--border)] ${
orientation === "horizontal" ? "h-px w-full" : "h-full w-px"
} ${className}`}
{...props}
/>
);
}
@@ -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,98 @@
/**
* 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]
import { z } from "zod";
import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";
export const myDefinitions = {
Card: {
description:
"A titled card container with an optional subtitle and a single child slot. Use it to group related content (metrics, rows, buttons).",
props: z.object({
title: z.string(),
subtitle: z.string().optional(),
child: z.string().optional(),
}),
},
StatusBadge: {
description:
"A small coloured pill communicating the state of something (healthy/degraded/down, online/offline, open/closed). Choose `variant` to match the intent.",
props: z.object({
text: z.string(),
variant: z.enum(["success", "warning", "error", "info"]).optional(),
}),
},
Metric: {
description:
"A key/value KPI display with an optional trend indicator. Ideal for dashboards (e.g. 'Revenue • $12.4k • up').",
props: z.object({
label: z.string(),
value: z.string(),
trend: z.enum(["up", "down", "neutral"]).optional(),
}),
},
InfoRow: {
description:
"A compact two-column 'label: value' row. Good for stacks of facts inside a Card (owner, region, last updated, etc.).",
props: z.object({
label: z.string(),
value: z.string(),
}),
},
PrimaryButton: {
description:
"A styled primary call-to-action button. Attach an optional `action` that will be dispatched back to the agent when the user clicks it.",
props: z.object({
label: z.string(),
action: z.any().optional(),
}),
},
PieChart: {
description:
"A pie/donut chart with a brand-coloured legend. Provide `title`, `description`, and `data` as an array of `{ label, value }` objects. Great for part-of-whole breakdowns (sales by region, traffic sources, portfolio allocation).",
props: z.object({
title: z.string(),
description: z.string(),
data: z.array(
z.object({
label: z.string(),
value: z.number(),
}),
),
}),
},
BarChart: {
description:
"A vertical bar chart built on Recharts. Provide `title`, `description`, and `data` as an array of `{ label, value }` objects. Great for comparing series across categories (quarterly revenue, headcount by team, signups per month).",
props: z.object({
title: z.string(),
description: z.string(),
data: z.array(
z.object({
label: z.string(),
value: z.number(),
}),
),
}),
},
} satisfies CatalogDefinitions;
// @endregion[definitions-zod]
export type MyDefinitions = typeof myDefinitions;
@@ -0,0 +1,392 @@
"use client";
/**
* A2UI catalog RENDERERS.
*
* React implementations for each definition in `./definitions.ts`,
* styled with the demo's local ShadCN-flavoured primitives in
* `../_components/`. 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";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "../_components/card";
import { Badge } from "../_components/badge";
import { Button } from "../_components/button";
// ─── ShadCN-friendly chart palette ─────────────────────────────────────────
// Neutral, slightly muted hues that pair with `bg-card` / `--border`
// (zinc/slate-leaning, akin to ShadCN's chart-{1..5} palette).
const CHART_COLORS = [
"#3F3F46", // zinc-700
"#71717A", // zinc-500
"#A1A1AA", // zinc-400
"#18181B", // zinc-900
"#52525B", // zinc-600
"#D4D4D8", // zinc-300
"#27272A", // zinc-800
] as const;
const CHART_TOOLTIP_STYLE: React.CSSProperties = {
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "8px 12px",
color: "var(--foreground)",
fontSize: 12,
boxShadow: "0 4px 12px rgba(0,0,0,0.06)",
};
/** Custom SVG donut chart built with <circle> + stroke-dasharray. */
function DonutChart({
data,
size = 220,
strokeWidth = 36,
}: {
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="var(--muted)"
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>
);
}
// @region[renderers-react]
export const myRenderers: CatalogRenderers<MyDefinitions> = {
Card: ({ props, children }) => (
<Card
className="w-full min-w-0 overflow-hidden"
data-testid="declarative-card"
>
<CardHeader>
<CardTitle>{props.title}</CardTitle>
{props.subtitle && <CardDescription>{props.subtitle}</CardDescription>}
</CardHeader>
{props.child && (
<CardContent className="flex flex-col gap-4">
{children(props.child)}
</CardContent>
)}
</Card>
),
StatusBadge: ({ props }) => (
<Badge
variant={props.variant ?? "info"}
data-testid="declarative-status-badge"
>
{props.text}
</Badge>
),
Metric: ({ props }) => {
const trend = props.trend ?? "neutral";
const arrow = trend === "up" ? "↑" : trend === "down" ? "↓" : "";
const trendClass =
trend === "up"
? "text-emerald-600"
: trend === "down"
? "text-rose-600"
: "text-[var(--foreground)]";
return (
// `flex-1 min-w-[120px]` lets a row of Metrics distribute evenly
// inside the basic catalog's gap-less Row — 3 metrics in a 600px
// card column get ~200px each instead of squishing to content width.
<div
data-testid="declarative-metric"
className="flex flex-1 min-w-[120px] flex-col gap-1"
>
<div className="text-xs font-medium uppercase tracking-wider text-[var(--muted-foreground)]">
{props.label}
</div>
<div
className={`flex items-baseline gap-1.5 text-2xl font-semibold tabular-nums ${trendClass}`}
>
<span>{props.value}</span>
{arrow && <span className="text-base">{arrow}</span>}
</div>
</div>
);
},
InfoRow: ({ props }) => (
// Divider via `border-b last:border-b-0` so the final row doesn't dangle
// a trailing line, regardless of whether the agent wraps these in a
// Column or drops them directly into a Card's child slot.
<div className="flex items-baseline justify-between gap-4 py-2 border-b border-[var(--border)] last:border-b-0 last:pb-0 first:pt-0">
<span className="text-sm text-[var(--muted-foreground)]">
{props.label}
</span>
<span className="text-sm font-medium text-[var(--foreground)] text-right tabular-nums">
{props.value}
</span>
</div>
),
PrimaryButton: ({ props, dispatch }) => (
<Button
onClick={() => {
if (props.action && dispatch) dispatch(props.action);
}}
>
{props.label}
</Button>
),
PieChart: ({ props }) => {
const data = props.data ?? [];
const safeData = Array.isArray(data) ? data : [];
const total = safeData.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
return (
// `flex-1 min-w-0` so multiple charts in a basic-catalog Row
// distribute the available width evenly instead of each insisting
// on its content size and overflowing.
<Card
className="w-full flex-1 min-w-0 overflow-hidden"
data-testid="declarative-pie-chart"
>
<CardHeader>
<CardTitle>{props.title}</CardTitle>
<CardDescription>{props.description}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{safeData.length === 0 ? (
<div className="py-8 text-center text-sm text-[var(--muted-foreground)]">
No data available
</div>
) : (
<>
<DonutChart data={safeData} />
<div className="flex flex-col gap-2 pt-2">
{safeData.map((item, index) => {
const val = Number(item.value) || 0;
const pct =
total > 0 ? ((val / total) * 100).toFixed(0) : "0";
return (
<div
key={index}
className="flex items-center gap-3 text-sm"
>
<span
className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
style={{
backgroundColor:
CHART_COLORS[index % CHART_COLORS.length],
}}
/>
<span className="flex-1 truncate text-[var(--foreground)]">
{item.label}
</span>
<span className="tabular-nums text-[var(--muted-foreground)]">
{val.toLocaleString()}
</span>
<span className="w-10 text-right tabular-nums text-[var(--muted-foreground)]">
{pct}%
</span>
</div>
);
})}
</div>
</>
)}
</CardContent>
</Card>
);
},
BarChart: ({ props }) => {
const { isNew } = useSeenIndices();
const data = props.data ?? [];
const safeData = Array.isArray(data) ? data : [];
return (
<Card
className="w-full flex-1 min-w-0 overflow-hidden"
data-testid="declarative-bar-chart"
>
{/* Scoped keyframe — no globals.css needed */}
<style>{`
@keyframes barSlideIn {
from { transform: translateY(40px); opacity: 0; }
20% { opacity: 1; }
to { transform: translateY(0); opacity: 1; }
}
`}</style>
<CardHeader>
<CardTitle>{props.title}</CardTitle>
<CardDescription>{props.description}</CardDescription>
</CardHeader>
<CardContent>
{safeData.length === 0 ? (
<div className="py-8 text-center text-sm text-[var(--muted-foreground)]">
No data available
</div>
) : (
<ResponsiveContainer width="100%" height={260}>
<RechartsBarChart
data={safeData}
margin={{ top: 12, right: 12, bottom: 4, left: -8 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
/>
<XAxis
dataKey="label"
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 12, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
tickLine={false}
axisLine={false}
/>
<Tooltip
contentStyle={CHART_TOOLTIP_STYLE}
cursor={{ fill: "var(--muted)", opacity: 0.5 }}
/>
<Bar
isAnimationActive={false}
dataKey="value"
radius={[6, 6, 0, 0]}
maxBarSize={48}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
shape={
((barProps: any) => (
<AnimatedBar
{...barProps}
isNew={isNew(barProps.index as number)}
/>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
)) as any
}
>
{safeData.map((_, index) => (
<Cell
key={index}
fill={CHART_COLORS[index % CHART_COLORS.length]}
/>
))}
</Bar>
</RechartsBarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
},
};
// @endregion[renderers-react]
@@ -0,0 +1,11 @@
"use client";
import { CopilotChat } from "@copilotkit/react-core/v2";
import { useDeclarativeGenUISuggestions } from "./suggestions";
export function Chat() {
useDeclarativeGenUISuggestions();
return (
<CopilotChat agentId="declarative-gen-ui" className="h-full rounded-2xl" />
);
}
@@ -0,0 +1,48 @@
"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 agent
* (`src/agents/a2ui_dynamic.py`) owns the `generate_a2ui` tool
* explicitly, mirroring the working pattern from beautiful-chat and the
* canonical `examples/integrations/langgraph-python` reference. The
* A2UI middleware still serialises the registered catalog schema into
* `copilotkit.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 } from "@copilotkit/react-core/v2";
import { myCatalog } from "./a2ui/catalog";
import { Chat } from "./chat";
export default function DeclarativeGenUIDemo() {
return (
<CopilotKit
runtimeUrl="/api/copilotkit-declarative-gen-ui"
agent="declarative-gen-ui"
a2ui={{ catalog: myCatalog }}
>
<div className="flex justify-center items-center h-screen w-full">
<div className="h-full w-full max-w-4xl">
<Chat />
</div>
</div>
</CopilotKit>
// @endregion[provider-a2ui-prop]
);
}
@@ -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,194 @@
"use client";
/**
* BarChart ported from showcase/shared/starter-template/components/charts/bar-chart.tsx
* for the declarative-hashbrown demo.
*
* 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/shared/starter-template/components/charts/chart-config.ts
* for the declarative-hashbrown demo.
*/
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/shared/starter-template/components/charts/pie-chart.tsx
* for the declarative-hashbrown demo.
*
* 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,22 @@
"use client";
import type { CopilotChatAssistantMessage } from "@copilotkit/react-core/v2";
import { CopilotChat } from "@copilotkit/react-core/v2";
import { HashBrownRenderMessage } from "./hashbrown-renderer";
import { useByocHashbrownSuggestions } from "./suggestions";
export function Chat() {
useByocHashbrownSuggestions();
return (
<CopilotChat
className="h-full"
messageView={{
// The renderer reads only `message` from the slot props; cast to the
// wider CopilotChatAssistantMessage signature to satisfy the slot type.
assistantMessage:
HashBrownRenderMessage as unknown as typeof CopilotChatAssistantMessage,
}}
/>
);
}
@@ -0,0 +1,239 @@
"use client";
/**
* HashBrown message renderer for the declarative-hashbrown demo.
*
* 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.
*
* The agent (`src/agents/byoc_hashbrown_agent.py`) emits JSON of the shape:
* { "ui": [ { "metric": { "props": {...} } }, ... ] }
*/
import React, { createContext, memo, useContext } 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";
// Charts take `data` as a real array, but hashbrown's schema validator rejects
// example prompts whose JSX attribute values don't match the schema type. The
// LLM streams JSON as text anyway, so we model `data` as a string and parse
// inside the wrapper. Render nothing if parse fails mid-stream.
type ChartSlice = { label: string; value: number };
function parseChartData(data: string): ChartSlice[] | null {
try {
const parsed = JSON.parse(data);
return Array.isArray(parsed) ? (parsed as ChartSlice[]) : null;
} catch {
return null;
}
}
function PieChartWithStringData({
title,
data,
}: {
title: string;
data: string;
}) {
const parsed = parseChartData(data);
return parsed ? (
<PieChart title={title} description="" data={parsed} />
) : null;
}
function BarChartWithStringData({
title,
data,
}: {
title: string;
data: string;
}) {
const parsed = parseChartData(data);
return parsed ? (
<BarChart title={title} description="" data={parsed} />
) : null;
}
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 DealCard({
title,
stage,
value,
assignee,
dueDate,
}: {
title: string;
stage: SalesStage;
value: number;
assignee?: string;
dueDate?: string;
}) {
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>
);
}
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",
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(DealCard, {
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"),
},
}),
],
});
}
type Kit = ReturnType<typeof useSalesDashboardKit>;
const HashBrownKitContext = createContext<Kit | null>(null);
export function HashBrownDashboard({
children,
}: {
children?: React.ReactNode;
}) {
const kit = useSalesDashboardKit();
return (
<HashBrownKitContext.Provider value={kit}>
{children}
</HashBrownKitContext.Provider>
);
}
const AssistantMessage = memo(function AssistantMessage({
content,
kit,
}: {
content: string;
kit: Kit;
}) {
const { value } = useJsonParser(content, kit.schema);
if (!value) return null;
// Re-attach `data-testid="copilot-assistant-message"` so the e2e harness'
// assistant-message-landed detection still works when this slot overrides
// the default CopilotChatAssistantMessage.
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>
);
});
export function HashBrownRenderMessage({
message,
}: {
message: { role: string; content?: string };
}) {
const kit = useContext(HashBrownKitContext);
if (!kit)
throw new Error(
"HashBrownRenderMessage must be used within HashBrownDashboard",
);
if (message.role !== "assistant") return null;
return <AssistantMessage content={message.content ?? ""} kit={kit} />;
}
@@ -0,0 +1,38 @@
"use client";
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>
);
}

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