skills/0-to-working-chat/SKILL.md
One agent, one tool, one chat. The React Router v7 framework-mode branch is the canonical example — pick it first unless you're on a different stack.
npx copilotkit create -f react-router my-app
cd my-app
pnpm install
Create a catch-all resource route app/routes/api.copilotkit.$.tsx. React
Router v7 framework mode runs its own server — mounting the runtime as a
loader+action in a resource route is the canonical pattern. Do NOT spin up
a sidecar Express or Hono server.
import type { Route } from "./+types/api.copilotkit.$";
import {
CopilotRuntime,
createCopilotRuntimeHandler,
InMemoryAgentRunner,
BuiltInAgent,
convertInputToTanStackAI,
} from "@copilotkit/runtime/v2";
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const tanstackAgent = new BuiltInAgent({
type: "tanstack",
factory: ({ input, abortController }) => {
const { messages, systemPrompts } = convertInputToTanStackAI(input);
return chat({
adapter: openaiText("gpt-4o"),
messages,
systemPrompts,
abortController,
});
},
});
const runtime = new CopilotRuntime({
agents: { default: tanstackAgent },
runner: new InMemoryAgentRunner(),
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export async function loader({ request }: Route.LoaderArgs) {
return handler(request);
}
export async function action({ request }: Route.ActionArgs) {
return handler(request);
}
app/routes/_index.tsx)import { useState } from "react";
import {
CopilotKitProvider,
CopilotChat,
useFrontendTool,
} from "@copilotkit/react-core/v2";
import "@copilotkit/react-core/v2/styles.css";
import { z } from "zod";
function RegisterTools() {
useFrontendTool({
name: "getCurrentLocation",
description: "Return the user's current location name.",
parameters: z.object({}),
handler: async () => ({ city: "San Francisco", country: "US" }),
});
return null;
}
export default function Index() {
return (
<CopilotKitProvider runtimeUrl="/api/copilotkit" showDevConsole="auto">
<RegisterTools />
<div className="h-screen">
<CopilotChat
agentId="default"
className="h-full"
attachments={{ enabled: true }}
/>
</div>
</CopilotKitProvider>
);
}
That's the quickstart. Run pnpm dev; visit the app; the chat connects to
/api/copilotkit/info, the agent runs, the tool fires.
No dedicated helper — mount createCopilotRuntimeHandler in a Start server
route's Request handler.
// app/routes/api/copilotkit.$.ts
import { createAPIFileRoute } from "@tanstack/react-start/api";
import {
CopilotRuntime,
createCopilotRuntimeHandler,
BuiltInAgent,
convertInputToTanStackAI,
} from "@copilotkit/runtime/v2";
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const runtime = new CopilotRuntime({
agents: {
default: new BuiltInAgent({
type: "tanstack",
factory: ({ input, abortController }) => {
const { messages, systemPrompts } = convertInputToTanStackAI(input);
return chat({
adapter: openaiText("gpt-4o"),
messages,
systemPrompts,
abortController,
});
},
}),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export const APIRoute = createAPIFileRoute("/api/copilotkit/$")({
GET: ({ request }) => handler(request),
POST: ({ request }) => handler(request),
});
// app/api/copilotkit/[[...slug]]/route.ts
//
// Optional catch-all ([[...slug]]) so the bare /api/copilotkit basePath
// and every sub-path (/info, /agent/*/run, /threads, etc.) all route to
// this handler. A non-optional [...slug] 404s the bare basePath.
import {
CopilotRuntime,
createCopilotRuntimeHandler,
BuiltInAgent,
convertInputToTanStackAI,
} from "@copilotkit/runtime/v2";
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const runtime = new CopilotRuntime({
agents: {
default: new BuiltInAgent({
type: "tanstack",
factory: ({ input, abortController }) => {
const { messages, systemPrompts } = convertInputToTanStackAI(input);
return chat({
adapter: openaiText("gpt-4o"),
messages,
systemPrompts,
abortController,
});
},
}),
},
});
const handler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
});
export const GET = handler;
export const POST = handler;
Hoist the runtime + handler to module scope and construct them lazily on
first request. Workers isolates reuse module globals across requests, so
a let-cached instance persists in-memory runner state within the isolate
(this does NOT span isolates — for durable cross-isolate state, pair with
SqliteAgentRunner or Intelligence). Constructing new CopilotRuntime(...)
inside fetch(request, env) on every call wastes CPU and throws away the
in-memory thread state.
import {
CopilotRuntime,
createCopilotRuntimeHandler,
BuiltInAgent,
} from "@copilotkit/runtime/v2";
interface Env {
OPENAI_API_KEY: string;
}
// Module-scoped cache. `env` arrives per-request, so we initialize lazily
// the first time we see it. Subsequent requests in the same isolate reuse.
let cachedHandler: ((request: Request) => Response | Promise<Response>) | null =
null;
function getHandler(env: Env) {
if (cachedHandler) return cachedHandler;
const runtime = new CopilotRuntime({
agents: {
// Simple Mode: thread the API key through the `apiKey` option — on
// Workers `process.env` is undefined, so BuiltInAgent's env-var
// fallback never fires. Wire env.OPENAI_API_KEY explicitly.
default: new BuiltInAgent({
model: "openai/gpt-4o",
apiKey: env.OPENAI_API_KEY,
}),
},
});
cachedHandler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
cors: true,
});
return cachedHandler;
}
export default {
fetch(request: Request, env: Env) {
return getHandler(env)(request);
},
};
Point the provider at CopilotKit Cloud via publicApiKey — no backend,
no runtimeUrl. This is the ONLY production-safe SPA path. See
copilotkit/spa-without-runtime for the full treatment.
import { CopilotKitProvider, CopilotChat } from "@copilotkit/react-core/v2";
import "@copilotkit/react-core/v2/styles.css";
export default function App() {
return (
<CopilotKitProvider publicApiKey={import.meta.env.VITE_CPK_PUBLIC_API_KEY}>
<CopilotChat agentId="default" className="h-full" />
</CopilotKitProvider>
);
}
Wrong:
// server.js — spun up alongside the RR v7 app
import express from "express";
import { createCopilotExpressHandler } from "@copilotkit/runtime/v2/express";
const app = express();
app.use(
"/api/copilotkit",
createCopilotExpressHandler({ runtime, basePath: "/api/copilotkit" }),
);
app.listen(3001);
Correct:
// app/routes/api.copilotkit.$.tsx
export async function loader({ request }: Route.LoaderArgs) {
return handler(request);
}
export async function action({ request }: Route.ActionArgs) {
return handler(request);
}
RR v7 framework mode already runs its own server; a sidecar Express/Hono app
duplicates servers and breaks unified routing/SSR. Same principle applies to
Next.js (use route.ts) and TanStack Start (use an APIRoute). Maintainer
guidance: avoid the Express/Hono adapters.
Source: examples/v2/react-router/app/routes/api.copilotkit.$.tsx
Wrong:
import { CopilotKitProvider } from "@copilotkitnext/react-core";
import { CopilotRuntime } from "@copilotkitnext/runtime";
Correct:
import { CopilotKitProvider } from "@copilotkit/react-core/v2";
import { CopilotRuntime } from "@copilotkit/runtime/v2";
// Only Angular uses the @copilotkitnext/ scope:
// import { ... } from "@copilotkitnext/angular";
Every CopilotKit package except Angular uses @copilotkit/. Agents
over-generalize from the Angular example and hallucinate the scope for
react-core / runtime / etc.
Source: packages/angular/package.json; all other packages/*/package.json
Wrong:
<CopilotKitProvider runtimeUrl="api/copilotkit" />
Correct:
<CopilotKitProvider runtimeUrl="/api/copilotkit" />
Without the leading slash the URL resolves relative to the current page — breaks on any nested route.
Source: docs/snippets/shared/troubleshooting/common-issues.mdx:38-42
Wrong:
import { CopilotChat } from "@copilotkit/react-core/v2";
Correct:
import { CopilotChat } from "@copilotkit/react-core/v2";
import "@copilotkit/react-core/v2/styles.css";
Without the stylesheet, the chat renders unstyled/broken — no layout, no spacing, no theme.
Source: examples/v2/react-router/app/routes/_index.tsx:3
Wrong:
// server
new CopilotRuntime({ agents: { default: agent } });
// client
<CopilotChat agentId="main" />
Correct:
<CopilotChat agentId="default" />
// or rename the server key to "main" so both sides match
Mismatched IDs surface as agent_not_found on first run. Keep the string
identical on both sides.
Source: packages/core/src/core/core.ts:80
Wrong:
// Module-scoped — `process.env` is undefined on Workers:
const agent = new BuiltInAgent({
type: "tanstack",
factory: ({ input, abortController }) =>
chat({
adapter: openaiText("gpt-4o"), // no access to process.env.OPENAI_API_KEY
messages: convertInputToTanStackAI(input).messages,
abortController,
}),
});
Correct: use Simple Mode and let the runtime read OPENAI_API_KEY from
the env binding (see the Cloudflare Workers branch above), or thread
env.OPENAI_API_KEY in through a closure if you genuinely need Factory
Mode.
Workers don't expose process.env. Secrets arrive via the env binding
argument to fetch(request, env).
Source: examples/v2/runtime/cf-workers/src/index.ts:7-17
Wrong:
const h = createCopilotRuntimeHandler({ runtime });
server.on("request", h);
Correct:
import { createCopilotNodeHandler } from "@copilotkit/runtime/v2/node";
const node = createCopilotNodeHandler(
createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
cors: true,
}),
);
server.on("request", node);
createCopilotRuntimeHandler takes a Web Request; Node's
IncomingMessage shape is different. createCopilotNodeHandler adapts the
fetch handler for http.Server — for frameworks (RR v7 / Start / Next.js)
use the fetch handler directly.
Source: examples/v2/runtime/node/src/index.ts:1-21