From ae3965b044e609e21c9e72f71e585199fcbd53e7 Mon Sep 17 00:00:00 2001 From: GeneralJerel <85066839+GeneralJerel@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:47:56 -0700 Subject: [PATCH 1/6] Add CopilotKit agent framework guide Add a guide for using Arcade tools with CopilotKit, mirroring the existing Mastra and Vercel AI framework pages. It covers wrapping Arcade tools with defineTool, the authorize-then-execute pattern, a single-route CopilotRuntime, and rendering the authorization step as generative UI with useRenderTool. - New page: app/en/get-started/agent-frameworks/copilotkit/page.mdx - Register the page in agent-frameworks/_meta.tsx - Add a CopilotKit card to the JavaScript tab in agent-framework-tabs.tsx - Add public/images/icons/copilotkit.svg - Add tests/copilotkit-guide.test.ts (contract test, written test-first) Co-Authored-By: Claude Opus 4.8 --- app/_components/agent-framework-tabs.tsx | 7 + app/en/get-started/agent-frameworks/_meta.tsx | 3 + .../agent-frameworks/copilotkit/page.mdx | 702 ++++++++++++++++++ public/images/icons/copilotkit.svg | 1 + tests/copilotkit-guide.test.ts | 50 ++ 5 files changed, 763 insertions(+) create mode 100644 app/en/get-started/agent-frameworks/copilotkit/page.mdx create mode 100644 public/images/icons/copilotkit.svg create mode 100644 tests/copilotkit-guide.test.ts diff --git a/app/_components/agent-framework-tabs.tsx b/app/_components/agent-framework-tabs.tsx index 4c6efec2a..9614f6ced 100644 --- a/app/_components/agent-framework-tabs.tsx +++ b/app/_components/agent-framework-tabs.tsx @@ -78,6 +78,13 @@ export function AgentFrameworkTabs() { name="Vercel AI" type="Agent Framework" /> + + + +A CopilotKit Built-in Agent app that uses Arcade tools for Gmail and Google News, with OAuth rendered as a Connect card in the chat. + + + + + +- +- [Node.js 18+](https://nodejs.org/) +- An [OpenAI API key](https://platform.openai.com/api-keys) +- A running [CopilotKit](https://docs.copilotkit.ai) Built-in Agent app + + + + + +- How to wrap Arcade tools as CopilotKit tools with `defineTool` +- How the authorize-then-execute pattern returns a connect URL instead of blocking +- How to mount the agent on a single-route `CopilotRuntime` +- How to render the authorization step as generative UI with `useRenderTool` +- How to scope every tool call to a per-user Arcade identity + + + + +## CopilotKit concepts + +Before diving into the code, here are the key [CopilotKit](https://docs.copilotkit.ai) concepts you'll use: + +- [Built-in Agent](https://docs.copilotkit.ai): A ready-made agent that handles the model loop, tool calling, and streaming so you bring tools and a prompt. +- [CopilotRuntime and `createCopilotRuntimeHandler`](https://docs.copilotkit.ai): The server runtime that hosts your agent. The client defaults to a single endpoint, so you mount a single-route handler to match. +- [`defineTool`](https://docs.copilotkit.ai): Defines a tool with a name, description, and a Zod parameter schema that the agent can call. +- [`useRenderTool` and generative UI](https://docs.copilotkit.ai): A client hook that subscribes to each tool call and renders a React component for its state and result. + +## Build + + + +### Scaffold a CopilotKit Built-in Agent app + +Start from a running [CopilotKit](https://docs.copilotkit.ai) Built-in Agent app. If you don't have one yet, follow the [CopilotKit quickstart](https://docs.copilotkit.ai) first. + +Then install the Arcade SDK: + + + + + +```bash +npm install @arcadeai/arcadejs +``` + + + + + +```bash +pnpm add @arcadeai/arcadejs +``` + + + + + +```bash +yarn add @arcadeai/arcadejs +``` + + + + + +```bash +bun add @arcadeai/arcadejs +``` + + + + + +### Set up environment variables + +Add your keys to **.env.local**: + +```env filename=".env.local" +ARCADE_API_KEY={arcade_api_key} +ARCADE_USER_ID={arcade_user_id} +OPENAI_API_KEY=your_openai_api_key +``` + +The `ARCADE_USER_ID` is your app's internal identifier for the user (often the email you signed up with, a UUID, etc.). Arcade uses this to track authorizations per user. + +### Add an authorize-then-execute helper + +This helper is the heart of the integration. `runArcadeTool` authorizes the user for a tool and, **only if** the user hasn't connected yet, hands the auth URL back to the chat instead of blocking. Otherwise it executes the tool and returns its structured output. + + + **Failures come back as data, not exceptions.** Arcade reports a tool's *runtime* failures as data (`success === false` / `output.error`), not as a thrown exception. If you only read `output.value`, a failed send renders a green "success" card, so the helper checks for errors explicitly and returns an `{ error }` shape the UI can show. + + +```ts title="lib/arcade.ts" +import Arcade from "@arcadeai/arcadejs"; + +// Created lazily so the module can be imported during `next build` without the +// key set (the SDK throws on construction when ARCADE_API_KEY is missing). +let arcadeClient: Arcade | undefined; +function getArcade() { + if (!arcadeClient) arcadeClient = new Arcade({ apiKey: process.env.ARCADE_API_KEY }); + return arcadeClient; +} + +export function getArcadeUserId(): string { + const userId = process.env.ARCADE_USER_ID; + if (userId) return userId; + // Fail CLOSED in production: a shared fallback id would put every user on ONE + // Arcade token vault (cross-account access). In dev, fall back for convenience. + if (process.env.NODE_ENV === "production") { + throw new Error("ARCADE_USER_ID is not set. Derive it per-request from your session."); + } + return "demo-user@example.com"; +} + +export type ArcadeToolResult = + | { authorizationRequired: true; toolName: string; provider: string; authUrl: string } + | { authorizationRequired: false; toolName: string; provider: string; output: unknown } + | { error: string; toolName: string }; + +export async function runArcadeTool({ + toolName, + input, + userId, +}: { + toolName: string; + input: Record; + userId: string; +}): Promise { + const provider = toolName.split(".")[0] ?? toolName; + try { + const arcade = getArcade(); + + // 1. Does this user already have the scopes this tool needs? Status is one of + // not_started | pending | completed | failed. + const auth = await arcade.tools.authorize({ tool_name: toolName, user_id: userId }); + + // 2. Not connected yet → return the URL so CopilotKit renders a "Connect" card. + // A `failed` status (or a missing URL) is an error, not a dead card. + if (auth.status !== "completed") { + if (auth.status === "failed" || !auth.url) { + return { error: `Couldn't start authorization for ${provider}.`, toolName }; + } + return { authorizationRequired: true, toolName, provider, authUrl: auth.url }; + } + + // 3. Otherwise run the tool with the user's vaulted credentials. + const response = await arcade.tools.execute({ tool_name: toolName, input, user_id: userId }); + + // 4. Fail closed: runtime failures come back as data, not as a thrown error. + if (response.success === false || response.output?.error) { + return { error: response.output?.error?.message ?? "The tool call failed.", toolName }; + } + return { authorizationRequired: false, toolName, provider, output: response.output?.value ?? null }; + } catch (err) { + // Unexpected/transport error. Return an error shape instead of throwing, since a + // thrown error kills the run. Don't surface raw internals in production. + console.error(`[arcade] ${toolName} failed:`, err); + const detail = err instanceof Error ? err.message : String(err); + return { + error: process.env.NODE_ENV === "production" ? "The tool call failed unexpectedly." : detail, + toolName, + }; + } +} +``` + +### Define the Arcade tools and mount the agent + +Each CopilotKit tool is a thin `defineTool` wrapper that calls `runArcadeTool` with an Arcade tool name. `searchNews` needs no auth; Gmail does. Keep tool descriptions about *what the tool does*. The agent learns the Connect-then-retry protocol from the system prompt and the tool's result, not from prose in the description it can misread. Match each tool's parameter names to the Arcade tool's own schema, or unknown params are silently dropped. + +```ts title="app/api/copilotkit/route.ts" +import { + BuiltInAgent, + CopilotRuntime, + createCopilotRuntimeHandler, + defineTool, +} from "@copilotkit/runtime/v2"; +import { z } from "zod"; +import { getArcadeUserId, runArcadeTool } from "@/lib/arcade"; + +// Tools are built per request so each runs against the *current* user's id. +function buildTools(userId: string) { + const searchNews = defineTool({ + name: "searchNews", + description: "Search recent news stories by keyword using Google News.", + parameters: z.object({ keywords: z.string().describe("Search keywords") }), + execute: async ({ keywords }) => + runArcadeTool({ toolName: "GoogleNews.SearchNewsStories", input: { keywords }, userId }), + }); + + const sendEmail = defineTool({ + name: "sendEmail", + description: "Send an email from the user's connected Gmail account.", + parameters: z.object({ + recipient: z.string().describe("Recipient email address"), + subject: z.string().describe("Subject line"), + body: z.string().describe("Plain-text body"), + }), + execute: async ({ recipient, subject, body }) => + runArcadeTool({ toolName: "Gmail.SendEmail", input: { recipient, subject, body }, userId }), + }); + + const listEmails = defineTool({ + name: "listEmails", + description: "List recent emails from the user's connected Gmail inbox.", + // Param name mirrors the Arcade tool's schema (Gmail.ListEmails takes `n_emails`). + parameters: z.object({ + n_emails: z.number().int().min(1).max(50).default(10).describe("How many to return"), + }), + execute: async ({ n_emails }) => + runArcadeTool({ toolName: "Gmail.ListEmails", input: { n_emails }, userId }), + }); + + return [searchNews, sendEmail, listEmails]; +} + +const SYSTEM_PROMPT = `You can act for the user through Arcade tools: searching Google News, and reading and sending Gmail. + +When a tool result is { "authorizationRequired": true, ... }, the chat shows a "Connect" card. +Do NOT retry or invent a result. In one sentence, tell the user to click Connect, then come +back and say continue. When they confirm, call the SAME tool again with the SAME arguments. + +Before sending email, confirm the recipient and subject in one line. Keep replies short. The +tool cards show the details.`; + +function buildAgent(userId: string) { + return new BuiltInAgent({ + model: process.env.OPENAI_MODEL || "openai/gpt-4o", + apiKey: process.env.OPENAI_API_KEY, + prompt: SYSTEM_PROMPT, + tools: buildTools(userId), + maxSteps: 6, // > 1 so the agent can call a tool and then respond + }); +} + +// Resolve the Arcade user id per request from your SERVER-VERIFIED session. A +// single shared id would put every visitor on ONE Arcade vault (cross-account +// access). getArcadeUserId() is the demo fallback and throws in production. +function resolveArcadeUserId(request: Request): string { + // const { userId } = await verifySession(request); return userId; + return getArcadeUserId(); +} + +// The runtime can read and send email on your keys, so never leave it open in prod. +// Replace this with your real session check; it fails CLOSED until you do. +function authorizeRuntimeRequest(request: Request): void { + // const session = await verifySession(request); + // if (!session) throw new Response("Unauthorized", { status: 401 }); + if (process.env.NODE_ENV === "production") { + throw new Response("Runtime auth is not configured.", { status: 503 }); + } +} + +const runtime = new CopilotRuntime({ + // Per request → a fresh agent scoped to THIS user's id. + agents: ({ request }) => ({ default: buildAgent(resolveArcadeUserId(request)) }), +}); + +const handler = createCopilotRuntimeHandler({ + runtime, + basePath: "/api/copilotkit", + mode: "single-route", + hooks: { + // Reject unauthorized calls before the agent runs. + onRequest: ({ request }) => authorizeRuntimeRequest(request), + }, +}); + +export const GET = handler; +export const POST = handler; +export const OPTIONS = handler; +``` + + + **Single-route transport.** CopilotKit's `` provider defaults to `useSingleEndpoint`, so the client POSTs every call as a `{ method, params, body }` envelope to the base path. Mount a single-route handler to match (`createCopilotRuntimeHandler({ mode: "single-route" })`). + + +### Render the authorization step as generative UI + +On the client, `useRenderTool` subscribes to each tool call. When the result is `authorizationRequired`, render a Connect card with the `authUrl`; otherwise render the result. This is the generative-UI moment: the OAuth handshake becomes a card in the chat. + +The snippet below defines small inline `LoadingCard`, `ErrorCard`, `EmailSentCard`, and `AuthorizationCard` components so it stands alone. + +```tsx title="app/page.tsx" +"use client"; +import { CopilotChat, useRenderTool } from "@copilotkit/react-core/v2"; +import { z } from "zod"; + +// useRenderTool usually hands `result` back as a JSON string, but tolerate an +// already-parsed object or an empty/missing value so a partial result never throws. +function parse(result: unknown): T | undefined { + if (result == null) return undefined; + if (typeof result === "object") return result as T; + if (typeof result !== "string" || result.length === 0) return undefined; + try { + return JSON.parse(result) as T; + } catch { + return undefined; + } +} + +function LoadingCard({ label }: { label: string }) { + return

{label}

; +} + +function ErrorCard({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} + +function EmailSentCard({ recipient, subject }: { recipient: string; subject: string }) { + return ( +
+

Email sent

+

+ To {recipient} — {subject} +

+
+ ); +} + +// Tool output flows into an href, so only ever link to a real http(s) URL, +// never a javascript:/data: scheme. +function safeHttpUrl(url: string): string | undefined { + try { + const u = new URL(url); + return u.protocol === "https:" || u.protocol === "http:" ? url : undefined; + } catch { + return undefined; + } +} + +function AuthorizationCard({ provider, authUrl }: { provider: string; authUrl: string }) { + const href = safeHttpUrl(authUrl); + return ( +
+

Connect {provider}

+

+ Arcade needs you to authorize {provider} once. Your credentials are vaulted by Arcade + and never shared with the model. +

+ {href && ( + + Connect {provider} + + )} +
+ ); +} + +export default function Page() { + useRenderTool({ + name: "sendEmail", + parameters: z.object({ recipient: z.string(), subject: z.string(), body: z.string() }), + render: ({ status, parameters, result }) => { + if (status !== "complete") return ; + const r = parse(result); + if (r?.error) return ; + if (r?.authorizationRequired) + return ; + return ; + }, + }); + + return ; +} +``` + +Wrap your app in the v2 `` provider (`runtimeUrl="/api/copilotkit"` plus `useSingleEndpoint` to match the single-route handler) and import `@copilotkit/react-core/v2/styles.css`, exactly as in the [CopilotKit](https://docs.copilotkit.ai) Built-in Agent quickstart. + +### Run it + +Start your app and ask the agent to do something that needs a connection: + +```text +Send an email to me@example.com with the subject "Hello from my agent" and a friendly one-liner. +``` + +The first time, the agent calls `sendEmail`, Arcade reports authorization is required, and the chat shows a **Connect Gmail** card. Click it, approve in the new tab, come back, and say: + +```text +Done, go ahead. +``` + +The agent re-calls `sendEmail`; this time Arcade returns `completed` and the email sends. Now chain a no-auth tool into an authed one: + +```text +Find the latest news on open-source AI agents and email me a 3-bullet summary. +``` + +`searchNews` runs without auth; `sendEmail` reuses the Gmail connection you already granted. + +
+ +## Key takeaways + +- **Arcade tools become CopilotKit tools through `defineTool` wrappers**: Each wrapper calls `runArcadeTool` with an Arcade tool name and a matching Zod schema. +- **Authorization is evaluated at runtime, per user**: `tools.authorize` returns `completed` only when this `user_id` already holds the required scopes, so the same code works for one user locally and thousands in production. +- **The model never sees a token**: Credentials are vaulted by Arcade; `execute` runs the tool server-side and returns only the structured result to the agent. +- **The auth step is non-blocking generative UI**: Returning the `authUrl` instead of waiting on the server is what lets CopilotKit render it as a card and keeps the chat responsive. + +## Next steps + +- **Add more tools**: Browse the [tool catalog](/resources/integrations) and add tools for GitHub, Notion, Linear, and more. +- **Scale from three tools to thousands**: Instead of hand-writing a `defineTool` per tool, pull formatted tool definitions from Arcade and generate the wrappers. + + + **Building a multi-user app?** This guide uses a single `ARCADE_USER_ID` for local testing. For production apps where each user needs their own OAuth tokens, see [Secure auth for production](/guides/user-facing-agents/secure-auth-production) to learn how to resolve the Arcade `user_id` per request from a server-verified session and authenticate the runtime. + + +## Complete code + +
+**lib/arcade.ts** (full file) + +```ts title="lib/arcade.ts" +import Arcade from "@arcadeai/arcadejs"; + +let arcadeClient: Arcade | undefined; +function getArcade() { + if (!arcadeClient) arcadeClient = new Arcade({ apiKey: process.env.ARCADE_API_KEY }); + return arcadeClient; +} + +export function getArcadeUserId(): string { + const userId = process.env.ARCADE_USER_ID; + if (userId) return userId; + // Fail CLOSED in production: a shared fallback id would put every user on ONE + // Arcade token vault (cross-account access). In dev, fall back for convenience. + if (process.env.NODE_ENV === "production") { + throw new Error("ARCADE_USER_ID is not set. Derive it per-request from your session."); + } + return "demo-user@example.com"; +} + +export type ArcadeToolResult = + | { authorizationRequired: true; toolName: string; provider: string; authUrl: string } + | { authorizationRequired: false; toolName: string; provider: string; output: unknown } + | { error: string; toolName: string }; + +export async function runArcadeTool({ + toolName, + input, + userId, +}: { + toolName: string; + input: Record; + userId: string; +}): Promise { + const provider = toolName.split(".")[0] ?? toolName; + try { + const arcade = getArcade(); + + const auth = await arcade.tools.authorize({ tool_name: toolName, user_id: userId }); + + if (auth.status !== "completed") { + if (auth.status === "failed" || !auth.url) { + return { error: `Couldn't start authorization for ${provider}.`, toolName }; + } + return { authorizationRequired: true, toolName, provider, authUrl: auth.url }; + } + + const response = await arcade.tools.execute({ tool_name: toolName, input, user_id: userId }); + + if (response.success === false || response.output?.error) { + return { error: response.output?.error?.message ?? "The tool call failed.", toolName }; + } + return { authorizationRequired: false, toolName, provider, output: response.output?.value ?? null }; + } catch (err) { + console.error(`[arcade] ${toolName} failed:`, err); + const detail = err instanceof Error ? err.message : String(err); + return { + error: process.env.NODE_ENV === "production" ? "The tool call failed unexpectedly." : detail, + toolName, + }; + } +} +``` + +
+ +
+**app/api/copilotkit/route.ts** (full file) + +```ts title="app/api/copilotkit/route.ts" +import { + BuiltInAgent, + CopilotRuntime, + createCopilotRuntimeHandler, + defineTool, +} from "@copilotkit/runtime/v2"; +import { z } from "zod"; +import { getArcadeUserId, runArcadeTool } from "@/lib/arcade"; + +function buildTools(userId: string) { + const searchNews = defineTool({ + name: "searchNews", + description: "Search recent news stories by keyword using Google News.", + parameters: z.object({ keywords: z.string().describe("Search keywords") }), + execute: async ({ keywords }) => + runArcadeTool({ toolName: "GoogleNews.SearchNewsStories", input: { keywords }, userId }), + }); + + const sendEmail = defineTool({ + name: "sendEmail", + description: "Send an email from the user's connected Gmail account.", + parameters: z.object({ + recipient: z.string().describe("Recipient email address"), + subject: z.string().describe("Subject line"), + body: z.string().describe("Plain-text body"), + }), + execute: async ({ recipient, subject, body }) => + runArcadeTool({ toolName: "Gmail.SendEmail", input: { recipient, subject, body }, userId }), + }); + + const listEmails = defineTool({ + name: "listEmails", + description: "List recent emails from the user's connected Gmail inbox.", + parameters: z.object({ + n_emails: z.number().int().min(1).max(50).default(10).describe("How many to return"), + }), + execute: async ({ n_emails }) => + runArcadeTool({ toolName: "Gmail.ListEmails", input: { n_emails }, userId }), + }); + + return [searchNews, sendEmail, listEmails]; +} + +const SYSTEM_PROMPT = `You can act for the user through Arcade tools: searching Google News, and reading and sending Gmail. + +When a tool result is { "authorizationRequired": true, ... }, the chat shows a "Connect" card. +Do NOT retry or invent a result. In one sentence, tell the user to click Connect, then come +back and say continue. When they confirm, call the SAME tool again with the SAME arguments. + +Before sending email, confirm the recipient and subject in one line. Keep replies short. The +tool cards show the details.`; + +function buildAgent(userId: string) { + return new BuiltInAgent({ + model: process.env.OPENAI_MODEL || "openai/gpt-4o", + apiKey: process.env.OPENAI_API_KEY, + prompt: SYSTEM_PROMPT, + tools: buildTools(userId), + maxSteps: 6, + }); +} + +function resolveArcadeUserId(request: Request): string { + // const { userId } = await verifySession(request); return userId; + return getArcadeUserId(); +} + +function authorizeRuntimeRequest(request: Request): void { + // const session = await verifySession(request); + // if (!session) throw new Response("Unauthorized", { status: 401 }); + if (process.env.NODE_ENV === "production") { + throw new Response("Runtime auth is not configured.", { status: 503 }); + } +} + +const runtime = new CopilotRuntime({ + agents: ({ request }) => ({ default: buildAgent(resolveArcadeUserId(request)) }), +}); + +const handler = createCopilotRuntimeHandler({ + runtime, + basePath: "/api/copilotkit", + mode: "single-route", + hooks: { + onRequest: ({ request }) => authorizeRuntimeRequest(request), + }, +}); + +export const GET = handler; +export const POST = handler; +export const OPTIONS = handler; +``` + +
+ +
+**app/page.tsx** (full file) + +```tsx title="app/page.tsx" +"use client"; +import { CopilotChat, useRenderTool } from "@copilotkit/react-core/v2"; +import { z } from "zod"; + +function parse(result: unknown): T | undefined { + if (result == null) return undefined; + if (typeof result === "object") return result as T; + if (typeof result !== "string" || result.length === 0) return undefined; + try { + return JSON.parse(result) as T; + } catch { + return undefined; + } +} + +function LoadingCard({ label }: { label: string }) { + return

{label}

; +} + +function ErrorCard({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} + +function EmailSentCard({ recipient, subject }: { recipient: string; subject: string }) { + return ( +
+

Email sent

+

+ To {recipient} — {subject} +

+
+ ); +} + +function safeHttpUrl(url: string): string | undefined { + try { + const u = new URL(url); + return u.protocol === "https:" || u.protocol === "http:" ? url : undefined; + } catch { + return undefined; + } +} + +function AuthorizationCard({ provider, authUrl }: { provider: string; authUrl: string }) { + const href = safeHttpUrl(authUrl); + return ( +
+

Connect {provider}

+

+ Arcade needs you to authorize {provider} once. Your credentials are vaulted by Arcade + and never shared with the model. +

+ {href && ( + + Connect {provider} + + )} +
+ ); +} + +export default function Page() { + useRenderTool({ + name: "sendEmail", + parameters: z.object({ recipient: z.string(), subject: z.string(), body: z.string() }), + render: ({ status, parameters, result }) => { + if (status !== "complete") return ; + const r = parse(result); + if (r?.error) return ; + if (r?.authorizationRequired) + return ; + return ; + }, + }); + + return ; +} +``` + +
diff --git a/public/images/icons/copilotkit.svg b/public/images/icons/copilotkit.svg new file mode 100644 index 000000000..d748f047e --- /dev/null +++ b/public/images/icons/copilotkit.svg @@ -0,0 +1 @@ + diff --git a/tests/copilotkit-guide.test.ts b/tests/copilotkit-guide.test.ts new file mode 100644 index 000000000..053ca163f --- /dev/null +++ b/tests/copilotkit-guide.test.ts @@ -0,0 +1,50 @@ +// These tests MUST fail until the implementation (Wave 2) lands. +// They assert the end state of four deliverables that do not exist yet. + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; + +function read(p: string): string { + return existsSync(p) ? readFileSync(p, "utf-8") : ""; +} + +describe("CopilotKit agent-framework guide", () => { + test("_meta.tsx registers a copilotkit key titled CopilotKit", () => { + const filePath = join( + process.cwd(), + "app/en/get-started/agent-frameworks/_meta.tsx" + ); + const content = read(filePath); + expect(content).toContain("copilotkit"); + expect(content).toContain('"CopilotKit"'); + }); + + test("agent-framework-tabs.tsx contains PlatformCard link and name for CopilotKit", () => { + const filePath = join( + process.cwd(), + "app/_components/agent-framework-tabs.tsx" + ); + const content = read(filePath); + expect(content).toContain("/en/get-started/agent-frameworks/copilotkit"); + expect(content).toContain('name="CopilotKit"'); + }); + + test("copilotkit page.mdx exists and has frontmatter with title and description", () => { + const filePath = join( + process.cwd(), + "app/en/get-started/agent-frameworks/copilotkit/page.mdx" + ); + expect(existsSync(filePath)).toBe(true); + const content = read(filePath); + expect(content).toContain("title:"); + expect(content).toContain("description:"); + }); + + test("copilotkit SVG icon exists and contains { + const filePath = join(process.cwd(), "public/images/icons/copilotkit.svg"); + expect(existsSync(filePath)).toBe(true); + const content = read(filePath); + expect(content).toContain(" Date: Wed, 24 Jun 2026 07:55:34 -0700 Subject: [PATCH 2/6] Use the official CopilotKit logo mark Replace the placeholder icon with CopilotKit's authoritative brand mark and drop invertInDark on the card, since the mark is full-color (matching the CrewAI / Google ADK full-color icon pattern rather than the monochrome invert pattern). Co-Authored-By: Claude Opus 4.8 --- app/_components/agent-framework-tabs.tsx | 1 - public/images/icons/copilotkit.svg | 31 +++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/app/_components/agent-framework-tabs.tsx b/app/_components/agent-framework-tabs.tsx index 9614f6ced..b1cbea94a 100644 --- a/app/_components/agent-framework-tabs.tsx +++ b/app/_components/agent-framework-tabs.tsx @@ -80,7 +80,6 @@ export function AgentFrameworkTabs() { /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 261d97735fbd56b1ce57aa44f0e4dfeee32b18ad Mon Sep 17 00:00:00 2001 From: GeneralJerel <85066839+GeneralJerel@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:08:55 -0700 Subject: [PATCH 3/6] Harden the CopilotKit guide after code review A 7-reviewer pass surfaced a false-success render path and a dropped security note; fix both and tighten the contract test. - Render: show the "email sent" card only on the positive success discriminant (authorizationRequired === false). An unparseable or missing tool result now renders an error card instead of a false green "sent" card, in both the inline snippet and the full-file listing. This matches the guide's own "failures come back as data" Callout, which the render layer previously contradicted. - Restore a "Before you expose this publicly" warning callout (authenticate the runtime, scope every call to a server-verified user, disposable keys + rate limiting + security headers) that the adaptation had dropped from the upstream recipe. - Bump the Node prerequisite to 20+ (the CopilotKit v2 / Next.js stack needs it; Node 18 is end-of-life). - Tighten the contract test: assert the _meta key and title together, require non-empty frontmatter values, check the SVG closing tag, and hoist regexes to module scope. Update the stale header comment. Deferred as matching the authoritative Arcade-authored recipe / SDK contract: the success===false failure check and the runtime auth gate's dev-open default (the restored callout documents the hardening steps). Co-Authored-By: Claude Opus 4.8 --- .../agent-frameworks/copilotkit/page.mdx | 22 ++++++++-- tests/copilotkit-guide.test.ts | 42 +++++++++++-------- 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/app/en/get-started/agent-frameworks/copilotkit/page.mdx b/app/en/get-started/agent-frameworks/copilotkit/page.mdx index 5ac8d08fb..fd3d94340 100644 --- a/app/en/get-started/agent-frameworks/copilotkit/page.mdx +++ b/app/en/get-started/agent-frameworks/copilotkit/page.mdx @@ -19,7 +19,7 @@ A CopilotKit Built-in Agent app that uses Arcade tools for Gmail and Google News - -- [Node.js 18+](https://nodejs.org/) +- [Node.js 20+](https://nodejs.org/) - An [OpenAI API key](https://platform.openai.com/api-keys) - A running [CopilotKit](https://docs.copilotkit.ai) Built-in Agent app @@ -296,6 +296,14 @@ export const OPTIONS = handler; **Single-route transport.** CopilotKit's `` provider defaults to `useSingleEndpoint`, so the client POSTs every call as a `{ method, params, body }` envelope to the base path. Mount a single-route handler to match (`createCopilotRuntimeHandler({ mode: "single-route" })`). + + **Before you expose this publicly.** The runtime can read and send email on your keys, so treat `/api/copilotkit` like any privileged endpoint: + + - **Authenticate the runtime.** Replace the `onRequest` hook with a real session check that throws a `Response` for unauthorized calls. The example fails closed in production and is open in development until you wire one. If your session check is async, make the hook `async` and `await` it, or the request runs before the check resolves. + - **Scope every call to a real user.** Resolve the Arcade `user_id` per request from a server-verified session, not a spoofable client header and not one shared env value. A single `ARCADE_USER_ID` for all visitors puts everyone on one token vault. + - **Use disposable keys** and a throwaway Google account for any public demo, add rate limiting, and set CSP, HSTS, and `X-Frame-Options`. + + ### Render the authorization step as generative UI On the client, `useRenderTool` subscribes to each tool call. When the result is `authorizationRequired`, render a Connect card with the `authUrl`; otherwise render the result. This is the generative-UI moment: the OAuth handshake becomes a card in the chat. @@ -387,7 +395,11 @@ export default function Page() { if (r?.error) return ; if (r?.authorizationRequired) return ; - return ; + // Only render success on the positive discriminant. A missing or unparseable + // result (r === undefined) must not fall through to a green "sent" card. + if (r?.authorizationRequired === false) + return ; + return ; }, }); @@ -691,7 +703,11 @@ export default function Page() { if (r?.error) return ; if (r?.authorizationRequired) return ; - return ; + // Only render success on the positive discriminant. A missing or unparseable + // result (r === undefined) must not fall through to a green "sent" card. + if (r?.authorizationRequired === false) + return ; + return ; }, }); diff --git a/tests/copilotkit-guide.test.ts b/tests/copilotkit-guide.test.ts index 053ca163f..eebe78e95 100644 --- a/tests/copilotkit-guide.test.ts +++ b/tests/copilotkit-guide.test.ts @@ -1,50 +1,56 @@ -// These tests MUST fail until the implementation (Wave 2) lands. -// They assert the end state of four deliverables that do not exist yet. +// Contract test for the CopilotKit agent-framework guide: verifies the guide +// page, its nav registration, the framework-tab card, and the icon are wired up +// and remain consistent with each other. import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; +// Key and title asserted together, not as two unrelated substrings. +const META_KEY_RE = /copilotkit:\s*\{\s*title:\s*"CopilotKit"/; +// Frontmatter keys must carry real values, not just be present. +const FRONTMATTER_TITLE_RE = /title:\s*"[^"]+"/; +const FRONTMATTER_DESCRIPTION_RE = /description:\s*"[^"]+"/; + function read(p: string): string { return existsSync(p) ? readFileSync(p, "utf-8") : ""; } describe("CopilotKit agent-framework guide", () => { test("_meta.tsx registers a copilotkit key titled CopilotKit", () => { - const filePath = join( - process.cwd(), - "app/en/get-started/agent-frameworks/_meta.tsx" + const content = read( + join(process.cwd(), "app/en/get-started/agent-frameworks/_meta.tsx") ); - const content = read(filePath); - expect(content).toContain("copilotkit"); - expect(content).toContain('"CopilotKit"'); + expect(content).toMatch(META_KEY_RE); }); - test("agent-framework-tabs.tsx contains PlatformCard link and name for CopilotKit", () => { - const filePath = join( - process.cwd(), - "app/_components/agent-framework-tabs.tsx" + test("agent-framework-tabs.tsx has a CopilotKit PlatformCard linking to the guide", () => { + const content = read( + join(process.cwd(), "app/_components/agent-framework-tabs.tsx") ); - const content = read(filePath); - expect(content).toContain("/en/get-started/agent-frameworks/copilotkit"); + expect(content).toContain("PlatformCard"); expect(content).toContain('name="CopilotKit"'); + expect(content).toContain( + 'link="/en/get-started/agent-frameworks/copilotkit"' + ); }); - test("copilotkit page.mdx exists and has frontmatter with title and description", () => { + test("the copilotkit page exists with a non-empty title and description", () => { const filePath = join( process.cwd(), "app/en/get-started/agent-frameworks/copilotkit/page.mdx" ); expect(existsSync(filePath)).toBe(true); const content = read(filePath); - expect(content).toContain("title:"); - expect(content).toContain("description:"); + expect(content).toMatch(FRONTMATTER_TITLE_RE); + expect(content).toMatch(FRONTMATTER_DESCRIPTION_RE); }); - test("copilotkit SVG icon exists and contains { + test("the CopilotKit icon exists and is a well-formed SVG", () => { const filePath = join(process.cwd(), "public/images/icons/copilotkit.svg"); expect(existsSync(filePath)).toBe(true); const content = read(filePath); expect(content).toContain(""); }); }); From 94385eb6d9861641fb78d43119515db5b1b29fbd Mon Sep 17 00:00:00 2001 From: GeneralJerel <85066839+GeneralJerel@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:20:46 -0700 Subject: [PATCH 4/6] Fix Nextra compile error: use filename= on code fences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `title=` on code fences (carried over from the upstream Starlight-based recipe) broke Nextra's rehype pipeline with "Cannot convert undefined or null to object", returning a 500 for the page. The Arcade docs use `filename=` for code-block labels (150 existing usages); switch all six fences to match. Caught by rendering the page in the local dev server — the unit tests and an mdx-js compile check don't exercise Nextra's loader. Co-Authored-By: Claude Opus 4.8 --- .../get-started/agent-frameworks/copilotkit/page.mdx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/en/get-started/agent-frameworks/copilotkit/page.mdx b/app/en/get-started/agent-frameworks/copilotkit/page.mdx index fd3d94340..6b33374b0 100644 --- a/app/en/get-started/agent-frameworks/copilotkit/page.mdx +++ b/app/en/get-started/agent-frameworks/copilotkit/page.mdx @@ -111,7 +111,7 @@ This helper is the heart of the integration. `runArcadeTool` authorizes the user **Failures come back as data, not exceptions.** Arcade reports a tool's *runtime* failures as data (`success === false` / `output.error`), not as a thrown exception. If you only read `output.value`, a failed send renders a green "success" card, so the helper checks for errors explicitly and returns an `{ error }` shape the UI can show. -```ts title="lib/arcade.ts" +```ts filename="lib/arcade.ts" import Arcade from "@arcadeai/arcadejs"; // Created lazily so the module can be imported during `next build` without the @@ -189,7 +189,7 @@ export async function runArcadeTool({ Each CopilotKit tool is a thin `defineTool` wrapper that calls `runArcadeTool` with an Arcade tool name. `searchNews` needs no auth; Gmail does. Keep tool descriptions about *what the tool does*. The agent learns the Connect-then-retry protocol from the system prompt and the tool's result, not from prose in the description it can misread. Match each tool's parameter names to the Arcade tool's own schema, or unknown params are silently dropped. -```ts title="app/api/copilotkit/route.ts" +```ts filename="app/api/copilotkit/route.ts" import { BuiltInAgent, CopilotRuntime, @@ -310,7 +310,7 @@ On the client, `useRenderTool` subscribes to each tool call. When the result is The snippet below defines small inline `LoadingCard`, `ErrorCard`, `EmailSentCard`, and `AuthorizationCard` components so it stands alone. -```tsx title="app/page.tsx" +```tsx filename="app/page.tsx" "use client"; import { CopilotChat, useRenderTool } from "@copilotkit/react-core/v2"; import { z } from "zod"; @@ -454,7 +454,7 @@ Find the latest news on open-source AI agents and email me a 3-bullet summary.
**lib/arcade.ts** (full file) -```ts title="lib/arcade.ts" +```ts filename="lib/arcade.ts" import Arcade from "@arcadeai/arcadejs"; let arcadeClient: Arcade | undefined; @@ -523,7 +523,7 @@ export async function runArcadeTool({
**app/api/copilotkit/route.ts** (full file) -```ts title="app/api/copilotkit/route.ts" +```ts filename="app/api/copilotkit/route.ts" import { BuiltInAgent, CopilotRuntime, @@ -622,7 +622,7 @@ export const OPTIONS = handler;
**app/page.tsx** (full file) -```tsx title="app/page.tsx" +```tsx filename="app/page.tsx" "use client"; import { CopilotChat, useRenderTool } from "@copilotkit/react-core/v2"; import { z } from "zod"; From efb0015840c9a028888b2201a70c4a594acf75f1 Mon Sep 17 00:00:00 2001 From: GeneralJerel <85066839+GeneralJerel@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:34:11 -0700 Subject: [PATCH 5/6] Add page title and a CopilotKit scaffold command Local preview review surfaced two gaps: - The page rendered no title. Nextra does not promote the frontmatter `title` to an on-page H1 (the Vercel AI guide includes an explicit one); add `# Build an AI agent with Arcade and CopilotKit`. - Step 1 linked to the quickstart but showed no create command, unlike the Mastra/Vercel guides. Add `npx copilotkit@latest init` (CopilotKit CLI) in the package-manager tabs before the Arcade SDK install. Co-Authored-By: Claude Opus 4.8 --- .../agent-frameworks/copilotkit/page.mdx | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/app/en/get-started/agent-frameworks/copilotkit/page.mdx b/app/en/get-started/agent-frameworks/copilotkit/page.mdx index 6b33374b0..ab37e16cc 100644 --- a/app/en/get-started/agent-frameworks/copilotkit/page.mdx +++ b/app/en/get-started/agent-frameworks/copilotkit/page.mdx @@ -5,6 +5,8 @@ description: "Give a CopilotKit agent OAuth-backed Arcade tools and render the a import { Steps, Tabs, Callout } from "nextra/components"; +# Build an AI agent with Arcade and CopilotKit + Arcade is an MCP runtime for production agents: it brokers per-user OAuth, vaults and refreshes tokens, and runs a catalog of agent-optimized tools, all without the credentials ever reaching the LLM. [CopilotKit](https://docs.copilotkit.ai) is the React framework that provides the agent runtime and the chat UI. In this guide, you'll give a CopilotKit Built-in Agent a set of Arcade-backed tools (send Gmail, read the inbox, search Google News) and render Arcade's one-time authorization step as a generative-UI Connect card right in the chat. The user approves access once, and the agent completes the action. @@ -51,7 +53,45 @@ Before diving into the code, here are the key [CopilotKit](https://docs.copilotk ### Scaffold a CopilotKit Built-in Agent app -Start from a running [CopilotKit](https://docs.copilotkit.ai) Built-in Agent app. If you don't have one yet, follow the [CopilotKit quickstart](https://docs.copilotkit.ai) first. +Scaffold a CopilotKit app with the CopilotKit CLI: + + + + + +```bash +npx copilotkit@latest init +``` + + + + + +```bash +pnpm dlx copilotkit@latest init +``` + + + + + +```bash +yarn dlx copilotkit@latest init +``` + + + + + +```bash +bunx copilotkit@latest init +``` + + + + + +Follow the prompts to set up the Built-in Agent. For the full walkthrough, see the [CopilotKit quickstart](https://docs.copilotkit.ai). Then install the Arcade SDK: From c43db05be30db363fb8a36657518aa15016d68f8 Mon Sep 17 00:00:00 2001 From: GeneralJerel <85066839+GeneralJerel@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:25:57 -0700 Subject: [PATCH 6/6] docs(copilotkit): fix scaffold path, typed tool result, and provider wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated the guide by building it as a runnable app — a Next.js demo that reproduces the three "Complete code" files verbatim. The build surfaced fixes: - Step 1: replace `copilotkit init` (interactive-only, account-gated, and with no Built-in Agent template) with `create-next-app` plus explicit installs, the path that actually reproduces the app. - Install `zod` (both files import it; it otherwise resolves only as a peer dependency of the CopilotKit packages). - `app/page.tsx`: parse into a discriminated-union `ToolResult` instead of `any`, so the sample passes `eslint` (no-explicit-any) as well as `tsc`. - Show the `` provider wiring (`app/providers.tsx` and `app/layout.tsx`); the provider is a client component the prose only described. - Note that `BuiltInAgent` model ids are `provider/model` strings. Extend the contract test to lock in these invariants. Co-Authored-By: Claude Opus 4.8 --- .../agent-frameworks/copilotkit/page.mdx | 158 ++++++++++++------ tests/copilotkit-guide.test.ts | 32 ++++ 2 files changed, 136 insertions(+), 54 deletions(-) diff --git a/app/en/get-started/agent-frameworks/copilotkit/page.mdx b/app/en/get-started/agent-frameworks/copilotkit/page.mdx index ab37e16cc..89cc36cf9 100644 --- a/app/en/get-started/agent-frameworks/copilotkit/page.mdx +++ b/app/en/get-started/agent-frameworks/copilotkit/page.mdx @@ -51,16 +51,18 @@ Before diving into the code, here are the key [CopilotKit](https://docs.copilotk -### Scaffold a CopilotKit Built-in Agent app +### Scaffold a Next.js app and add CopilotKit -Scaffold a CopilotKit app with the CopilotKit CLI: +Scaffold a Next.js App Router app, then add CopilotKit's v2 runtime and React packages, `zod`, and the Arcade SDK: ```bash -npx copilotkit@latest init +npx create-next-app@latest my-arcade-agent --ts --app --tailwind --eslint --import-alias "@/*" +cd my-arcade-agent +npm install @copilotkit/runtime @copilotkit/react-core zod @arcadeai/arcadejs ``` @@ -68,7 +70,9 @@ npx copilotkit@latest init ```bash -pnpm dlx copilotkit@latest init +pnpm create next-app my-arcade-agent --ts --app --tailwind --eslint --import-alias "@/*" +cd my-arcade-agent +pnpm add @copilotkit/runtime @copilotkit/react-core zod @arcadeai/arcadejs ``` @@ -76,7 +80,9 @@ pnpm dlx copilotkit@latest init ```bash -yarn dlx copilotkit@latest init +yarn create next-app my-arcade-agent --ts --app --tailwind --eslint --import-alias "@/*" +cd my-arcade-agent +yarn add @copilotkit/runtime @copilotkit/react-core zod @arcadeai/arcadejs ``` @@ -84,52 +90,16 @@ yarn dlx copilotkit@latest init ```bash -bunx copilotkit@latest init +bunx create-next-app@latest my-arcade-agent --ts --app --tailwind --eslint --import-alias "@/*" +cd my-arcade-agent +bun add @copilotkit/runtime @copilotkit/react-core zod @arcadeai/arcadejs ``` -Follow the prompts to set up the Built-in Agent. For the full walkthrough, see the [CopilotKit quickstart](https://docs.copilotkit.ai). - -Then install the Arcade SDK: - - - - - -```bash -npm install @arcadeai/arcadejs -``` - - - - - -```bash -pnpm add @arcadeai/arcadejs -``` - - - - - -```bash -yarn add @arcadeai/arcadejs -``` - - - - - -```bash -bun add @arcadeai/arcadejs -``` - - - - +This guide uses CopilotKit's v2 Built-in Agent APIs (the `@copilotkit/runtime/v2` and `@copilotkit/react-core/v2` subpaths) and targets `@copilotkit/runtime@1.61.1`, `@copilotkit/react-core@1.61.1`, and `@arcadeai/arcadejs@2.4.1`. For the full CopilotKit walkthrough, see the [CopilotKit quickstart](https://docs.copilotkit.ai). ### Set up environment variables @@ -332,6 +302,8 @@ export const POST = handler; export const OPTIONS = handler; ``` +`BuiltInAgent` model ids are `provider/model` strings — use `openai/gpt-4o`, not a bare `gpt-4o`. + **Single-route transport.** CopilotKit's `` provider defaults to `useSingleEndpoint`, so the client POSTs every call as a `{ method, params, body }` envelope to the base path. Mount a single-route handler to match (`createCopilotRuntimeHandler({ mode: "single-route" })`). @@ -368,6 +340,11 @@ function parse(result: unknown): T | undefined { } } +type ToolResult = + | { authorizationRequired: true; provider: string; authUrl: string } + | { authorizationRequired: false; provider: string; output: unknown } + | { error: string }; + function LoadingCard({ label }: { label: string }) { return

{label}

; } @@ -431,13 +408,13 @@ export default function Page() { parameters: z.object({ recipient: z.string(), subject: z.string(), body: z.string() }), render: ({ status, parameters, result }) => { if (status !== "complete") return ; - const r = parse(result); - if (r?.error) return ; - if (r?.authorizationRequired) + const r = parse(result); + if (r && "error" in r) return ; + if (r && r.authorizationRequired) return ; // Only render success on the positive discriminant. A missing or unparseable // result (r === undefined) must not fall through to a green "sent" card. - if (r?.authorizationRequired === false) + if (r && r.authorizationRequired === false) return ; return ; }, @@ -447,7 +424,37 @@ export default function Page() { } ``` -Wrap your app in the v2 `` provider (`runtimeUrl="/api/copilotkit"` plus `useSingleEndpoint` to match the single-route handler) and import `@copilotkit/react-core/v2/styles.css`, exactly as in the [CopilotKit](https://docs.copilotkit.ai) Built-in Agent quickstart. +The `` provider is a client component, so wrap it in a small `"use client"` file, then use that wrapper from your server `app/layout.tsx` (which also imports the v2 stylesheet): + +```tsx filename="app/providers.tsx" +"use client"; +import { CopilotKit } from "@copilotkit/react-core/v2"; + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +``` + +```tsx filename="app/layout.tsx" +import "@copilotkit/react-core/v2/styles.css"; +import { Providers } from "./providers"; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} +``` + +`useSingleEndpoint` matches the single-route handler you mounted, and `runtimeUrl` points the client at `/api/copilotkit`. For the full set of provider options, see the [CopilotKit](https://docs.copilotkit.ai) Built-in Agent quickstart. ### Run it @@ -678,6 +685,11 @@ function parse(result: unknown): T | undefined { } } +type ToolResult = + | { authorizationRequired: true; provider: string; authUrl: string } + | { authorizationRequired: false; provider: string; output: unknown } + | { error: string }; + function LoadingCard({ label }: { label: string }) { return

{label}

; } @@ -739,13 +751,13 @@ export default function Page() { parameters: z.object({ recipient: z.string(), subject: z.string(), body: z.string() }), render: ({ status, parameters, result }) => { if (status !== "complete") return ; - const r = parse(result); - if (r?.error) return ; - if (r?.authorizationRequired) + const r = parse(result); + if (r && "error" in r) return ; + if (r && r.authorizationRequired) return ; // Only render success on the positive discriminant. A missing or unparseable // result (r === undefined) must not fall through to a green "sent" card. - if (r?.authorizationRequired === false) + if (r && r.authorizationRequired === false) return ; return ; }, @@ -756,3 +768,41 @@ export default function Page() { ```
+ +
+**app/providers.tsx** (full file) + +```tsx filename="app/providers.tsx" +"use client"; +import { CopilotKit } from "@copilotkit/react-core/v2"; + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +``` + +
+ +
+**app/layout.tsx** (full file) + +```tsx filename="app/layout.tsx" +import "@copilotkit/react-core/v2/styles.css"; +import { Providers } from "./providers"; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} +``` + +
diff --git a/tests/copilotkit-guide.test.ts b/tests/copilotkit-guide.test.ts index eebe78e95..dd58f3b30 100644 --- a/tests/copilotkit-guide.test.ts +++ b/tests/copilotkit-guide.test.ts @@ -11,6 +11,12 @@ const META_KEY_RE = /copilotkit:\s*\{\s*title:\s*"CopilotKit"/; // Frontmatter keys must carry real values, not just be present. const FRONTMATTER_TITLE_RE = /title:\s*"[^"]+"/; const FRONTMATTER_DESCRIPTION_RE = /description:\s*"[^"]+"/; +// The install step must pull in zod — the code samples import it, and it +// otherwise resolves only as a transitive/peer dep. Matches `npm install … zod` +// or `pnpm add … zod`. +const INSTALL_ZOD_RE = /(?:install|add)\b[^\n]*\bzod\b/; + +const GUIDE_PAGE = "app/en/get-started/agent-frameworks/copilotkit/page.mdx"; function read(p: string): string { return existsSync(p) ? readFileSync(p, "utf-8") : ""; @@ -54,3 +60,29 @@ describe("CopilotKit agent-framework guide", () => { expect(content).toContain(""); }); }); + +// These lock in the fixes surfaced by building the guide as a real app: the +// scaffold path must be one that actually exists, the code samples must be the +// versions verified to typecheck/lint, and the prose-only steps must ship code. +describe("CopilotKit guide content reflects the validated fixes", () => { + const content = read(join(process.cwd(), GUIDE_PAGE)); + + test("scaffolds with create-next-app, not the interactive copilotkit CLI", () => { + expect(content).toContain("create-next-app"); + expect(content).not.toContain("copilotkit@latest init"); + }); + + test("installs zod alongside the SDKs", () => { + expect(content).toMatch(INSTALL_ZOD_RE); + }); + + test("renders a typed tool result instead of any", () => { + expect(content).not.toContain("parse"); + expect(content).toContain("parse"); + }); + + test("shows the client-side CopilotKit provider wiring", () => { + expect(content).toContain('filename="app/providers.tsx"'); + expect(content).toContain("useSingleEndpoint"); + }); +});