diff --git a/app/_components/agent-framework-tabs.tsx b/app/_components/agent-framework-tabs.tsx index 4c6efec2a..b1cbea94a 100644 --- a/app/_components/agent-framework-tabs.tsx +++ b/app/_components/agent-framework-tabs.tsx @@ -78,6 +78,12 @@ 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 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 + + + + + +- 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 Next.js app and add CopilotKit + +Scaffold a Next.js App Router app, then add CopilotKit's v2 runtime and React packages, `zod`, and the Arcade SDK: + + + + + +```bash +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 +``` + + + + + +```bash +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 +``` + + + + + +```bash +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 +``` + + + + + +```bash +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 +``` + + + + + +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 + +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 filename="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 filename="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; +``` + +`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" })`). + + + + **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. + +The snippet below defines small inline `LoadingCard`, `ErrorCard`, `EmailSentCard`, and `AuthorizationCard` components so it stands alone. + +```tsx filename="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; + } +} + +type ToolResult = + | { authorizationRequired: true; provider: string; authUrl: string } + | { authorizationRequired: false; provider: string; output: unknown } + | { error: string }; + +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" 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 && r.authorizationRequired === false) + return ; + return ; + }, + }); + + return ; +} +``` + +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 + +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 filename="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 filename="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 filename="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; + } +} + +type ToolResult = + | { authorizationRequired: true; provider: string; authUrl: string } + | { authorizationRequired: false; provider: string; output: unknown } + | { error: string }; + +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" 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 && r.authorizationRequired === false) + return ; + return ; + }, + }); + + return ; +} +``` + +
+ +
+**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/public/images/icons/copilotkit.svg b/public/images/icons/copilotkit.svg new file mode 100644 index 000000000..c548f75c2 --- /dev/null +++ b/public/images/icons/copilotkit.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/copilotkit-guide.test.ts b/tests/copilotkit-guide.test.ts new file mode 100644 index 000000000..dd58f3b30 --- /dev/null +++ b/tests/copilotkit-guide.test.ts @@ -0,0 +1,88 @@ +// 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*"[^"]+"/; +// 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") : ""; +} + +describe("CopilotKit agent-framework guide", () => { + test("_meta.tsx registers a copilotkit key titled CopilotKit", () => { + const content = read( + join(process.cwd(), "app/en/get-started/agent-frameworks/_meta.tsx") + ); + expect(content).toMatch(META_KEY_RE); + }); + + 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") + ); + expect(content).toContain("PlatformCard"); + expect(content).toContain('name="CopilotKit"'); + expect(content).toContain( + 'link="/en/get-started/agent-frameworks/copilotkit"' + ); + }); + + 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).toMatch(FRONTMATTER_TITLE_RE); + expect(content).toMatch(FRONTMATTER_DESCRIPTION_RE); + }); + + 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(""); + }); +}); + +// 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"); + }); +});