Most AI tutorials in 2026 build backend agents — Claude, LangGraph, Mastra, Pydantic AI. Useful, but they all stop at a chat endpoint. The next problem is harder: how do you embed an AI assistant inside your product so it can read the same state your user sees and call the same actions your buttons call?
That is exactly what CopilotKit is for. It is an open-source React framework that puts a copilot next to your UI, hands it a typed view of your app state, and lets it trigger functions you already wrote. By the end of this tutorial you will have a working Next.js 15 app where a sidebar copilot can read a task list, add tasks, mark them done, and render rich UI cards inside the chat — all without leaving your codebase.
Why CopilotKit instead of a raw chat widget? A chat widget answers questions. A copilot operates the app. CopilotKit gives you typed React hooks (useCopilotReadable, useCopilotAction) so the LLM has structured context and a structured tool surface — which is the difference between "looks like AI" and "feels like a teammate".
What You Will Learn
By the end of this tutorial, you will be able to:
- Install CopilotKit packages and wire up the
<CopilotKit>provider in a Next.js 15 App Router project - Build the
/api/copilotkitruntime route with the OpenAI adapter - Share React state with the LLM using
useCopilotReadable - Define typed frontend actions with
useCopilotActionso the copilot can mutate UI state - Render generative UI inside the chat (custom cards, not just text)
- Ship the
CopilotSidebarUI and theme it to match your brand
Prerequisites
Before starting, ensure you have:
- Node.js 20 or later (Node 18 will not work — see Troubleshooting)
- Next.js 15 familiarity (App Router, Server Components vs Client Components)
- An OpenAI API key (or an Anthropic / Groq / Azure key — CopilotKit supports several adapters)
- Basic knowledge of React hooks and TypeScript
What You Will Build
We are building a Smart Task Manager — a Next.js 15 page with a tasks list and a CopilotKit sidebar. The user can talk to the copilot to add tasks, mark tasks as done, filter by status, and ask for a summary of what is left for the day. The copilot will render task cards directly in the chat instead of plain text.
Step 1: Project Setup
Spin up a fresh Next.js 15 project with TypeScript and Tailwind:
npx create-next-app@latest copilot-tasks \
--typescript --tailwind --app --eslint --src-dir
cd copilot-tasksNow install the three CopilotKit packages we need. The frontend needs react-core and react-ui, and the API route needs runtime:
npm install @copilotkit/react-core @copilotkit/react-ui @copilotkit/runtime
npm install openaiAdd your OpenAI key to .env.local:
OPENAI_API_KEY=sk-proj-...Tip: Never commit .env.local. Confirm .gitignore already excludes it (Next.js scaffolds this by default).
Step 2: Build the Runtime API Route
CopilotKit ships a runtime that bridges your React app and the LLM provider. In the App Router, that is one route handler at app/api/copilotkit/route.ts:
// src/app/api/copilotkit/route.ts
import {
CopilotRuntime,
OpenAIAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import OpenAI from "openai";
import { NextRequest } from "next/server";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const serviceAdapter = new OpenAIAdapter({ openai });
const runtime = new CopilotRuntime();
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
endpoint: "/api/copilotkit",
});
return handleRequest(req);
};Three things to notice:
- The
OpenAIAdapteris one of several — swap it forAnthropicAdapter,GroqAdapter, orLangChainAdapterwithout touching any other file. CopilotRuntimeis the brain. We will pass server-side actions to it later.copilotRuntimeNextJSAppRouterEndpointreturns ahandleRequestfunction that already speaks the App Router contract — no manual streaming wiring.
Step 3: Mount the CopilotKit Provider
The <CopilotKit> provider needs to wrap any tree that uses copilot hooks. Put it in your root layout, but mark the wrapper as a client component:
// src/components/CopilotProvider.tsx
"use client";
import { CopilotKit } from "@copilotkit/react-core";
import "@copilotkit/react-ui/styles.css";
export function CopilotProvider({ children }: { children: React.ReactNode }) {
return (
<CopilotKit runtimeUrl="/api/copilotkit">
{children}
</CopilotKit>
);
}Then plug it into the root layout (which stays a Server Component):
// src/app/layout.tsx
import { CopilotProvider } from "@/components/CopilotProvider";
import "./globals.css";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<CopilotProvider>{children}</CopilotProvider>
</body>
</html>
);
}The runtimeUrl is the public path of the route we built in Step 2. Anything inside <CopilotProvider> can now use useCopilotReadable and useCopilotAction.
Step 4: Build the Task List UI
Before we add any AI, we build a normal React state-driven task list. The copilot will plug into this exact state — no rewrite required.
// src/app/page.tsx
"use client";
import { useState } from "react";
import { CopilotSidebar } from "@copilotkit/react-ui";
type Task = {
id: string;
title: string;
status: "todo" | "done";
};
export default function HomePage() {
const [tasks, setTasks] = useState<Task[]>([
{ id: "1", title: "Write tutorial draft", status: "todo" },
{ id: "2", title: "Review PR #42", status: "todo" },
]);
return (
<main className="mx-auto max-w-3xl p-8">
<h1 className="mb-6 text-3xl font-semibold">Smart Tasks</h1>
<ul className="space-y-2">
{tasks.map((task) => (
<li
key={task.id}
className="flex items-center justify-between rounded border p-3"
>
<span
className={
task.status === "done"
? "text-gray-400 line-through"
: "text-gray-900"
}
>
{task.title}
</span>
<span className="text-xs uppercase text-gray-500">
{task.status}
</span>
</li>
))}
</ul>
<CopilotSidebar
labels={{
title: "Task Copilot",
initial: "Hi! I can add, complete, or summarize your tasks.",
}}
/>
</main>
);
}Run npm run dev, open http://localhost:3000, and you should see the list and a chat sidebar on the right. The copilot will respond — but it does not know about your tasks yet. We fix that next.
Step 5: Share State with useCopilotReadable
useCopilotReadable injects any React value into the copilot's context window every turn. Add it next to your useState:
import { useCopilotReadable } from "@copilotkit/react-core";
// inside HomePage()
useCopilotReadable({
description: "The user's current task list, including title and status",
value: tasks,
});That is all the AI plumbing required to give the copilot read access. Reload the page and ask: "How many tasks do I have left?" — it will answer correctly because tasks is now part of every prompt.
Why describe the value? The description field is what the LLM sees in its system prompt. Vague descriptions like "data" produce vague answers. Concrete descriptions like "the user's current task list, including title and status" produce precise tool calls.
Step 6: Define Frontend Actions with useCopilotAction
Reading state is half the job. Now we let the copilot mutate state. useCopilotAction is a typed function the LLM can call directly from the chat. The framework handles the JSON schema, the tool routing, and the streaming response.
Add an addTask action:
import { useCopilotAction } from "@copilotkit/react-core";
useCopilotAction({
name: "addTask",
description: "Add a new task to the user's task list",
parameters: [
{
name: "title",
type: "string",
description: "The title of the new task",
required: true,
},
],
handler: async ({ title }) => {
const newTask: Task = {
id: crypto.randomUUID(),
title,
status: "todo",
};
setTasks((prev) => [...prev, newTask]);
return `Added task: ${title}`;
},
});Now ask the copilot: "Add a task to deploy the staging branch by Friday." You will see the chat acknowledge the action, and the new task appears in the list instantly. No page reload, no API roundtrip — the action runs inside the React tree.
Add a second action to mark tasks as done:
useCopilotAction({
name: "markTaskDone",
description: "Mark a task as completed by its title",
parameters: [
{
name: "title",
type: "string",
description: "The title (or partial title) of the task to complete",
required: true,
},
],
handler: async ({ title }) => {
let matched: Task | undefined;
setTasks((prev) =>
prev.map((t) => {
if (
t.status === "todo" &&
t.title.toLowerCase().includes(title.toLowerCase())
) {
matched = t;
return { ...t, status: "done" };
}
return t;
}),
);
return matched
? `Marked done: ${matched.title}`
: `No matching task found for "${title}"`;
},
});Try: "Mark the PR review as done." The copilot will pick the right task even when you do not give the exact title — the useCopilotReadable from Step 5 is what makes that fuzzy matching possible.
Step 7: Generative UI — Render Cards in the Chat
Plain text responses are fine for confirmations, but the copilot can also render arbitrary React inside the chat. Pass a render function to your action and the chat bubble becomes whatever you return.
useCopilotAction({
name: "showTaskSummary",
description: "Show a visual summary of pending vs completed tasks",
parameters: [],
handler: async () => "summary rendered",
render: () => {
const todo = tasks.filter((t) => t.status === "todo").length;
const done = tasks.filter((t) => t.status === "done").length;
return (
<div className="rounded-lg border bg-white p-4 shadow-sm">
<div className="mb-2 text-sm font-semibold text-gray-700">
Task Summary
</div>
<div className="flex gap-4 text-sm">
<span className="text-blue-600">Pending: {todo}</span>
<span className="text-green-600">Done: {done}</span>
</div>
</div>
);
},
});Ask: "Show me a summary." Instead of a paragraph, you get a styled card embedded in the chat. This pattern scales — you can render charts with Recharts, maps, file diffs, or even interactive forms.
Step 8: Polish and Theme the Sidebar
CopilotKit sidebar styles are CSS variables, so you can match your brand without forking the component. Drop these into globals.css:
:root {
--copilot-kit-primary-color: #0ea5e9;
--copilot-kit-contrast-color: #ffffff;
--copilot-kit-background-color: #ffffff;
--copilot-kit-secondary-color: #f4f4f5;
--copilot-kit-secondary-contrast-color: #18181b;
}You can also pass instructions to the sidebar so the copilot has a system-level personality:
<CopilotSidebar
instructions={`You are a productivity copilot. Keep replies under 3 sentences. Always confirm destructive actions before performing them.`}
labels={{ title: "Task Copilot" }}
/>Testing Your Implementation
Walk through these prompts against your dev server. All four should now work end to end:
- "What is on my list?" — answered from
useCopilotReadable - "Add a task to renew the SSL cert." — calls
addTask, the list grows - "Mark the SSL one as done." — calls
markTaskDonewith fuzzy matching - "Show me a summary." — renders the generative UI card
If any of these fail, open the Network tab. Every copilot turn hits POST /api/copilotkit. The response is a streamed JSON body — if it 500s, your OPENAI_API_KEY is missing or the model name is wrong.
Troubleshooting
The sidebar shows but messages do nothing. Your runtimeUrl does not match the route. Confirm runtimeUrl="/api/copilotkit" matches the file path app/api/copilotkit/route.ts.
Module not found: @copilotkit/react-ui/styles.css. You forgot to import the stylesheet in CopilotProvider.tsx. Without it, the sidebar renders unstyled.
The action fires but state never updates. You probably defined the action outside the component that owns the state. useCopilotAction must run inside the same component tree as the useState it mutates.
Build error: Unexpected identifier 'assert'. You are on Node 18. Switch to Node 20 with nvm use 20.
Next Steps
You now have a real copilot that reads and writes app state. Natural extensions:
- Server-side actions: Pass
actions: [...]toCopilotRuntimeto expose database mutations the LLM can call without going through the browser. - Multi-agent flows: Combine CopilotKit with LangGraph for stateful, branching agents that hold the sidebar over multiple turns.
- Streaming markdown: Replace plain handlers with streamed responses so long answers appear word-by-word.
- Auth + per-user context: Read the session in your route handler and inject user-scoped readables (workspace ID, role) into the runtime.
For broader AI patterns, the Claude Agent SDK tutorial covers the backend agent angle, and the Vercel AI SDK agentic RAG tutorial covers retrieval-augmented copilots.
Conclusion
CopilotKit closes the gap between "chatbot stapled to a website" and "AI that operates the product". Three primitives carry the whole experience: the provider, useCopilotReadable for shared state, and useCopilotAction for typed tools. The rest — the sidebar, generative UI, model adapters — is polish.
Ship one copilot per product surface, keep readables specific, keep actions narrow, and your users will start treating the chat as a faster keyboard rather than a help desk.