skills/scale-to-multi-agent/SKILL.md
Register multiple agents on the runtime, pass agentId to each
<CopilotChat>, and scope tools/context to the agent that should see them.
// app/routes/api.copilotkit.$.tsx
import type { Route } from "./+types/api.copilotkit.$";
import {
CopilotRuntime,
createCopilotRuntimeHandler,
BuiltInAgent,
convertInputToTanStackAI,
} from "@copilotkit/runtime/v2";
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const makeAgent = (system: string) =>
new BuiltInAgent({
type: "tanstack",
factory: ({ input, abortController }) => {
const { messages, systemPrompts: fromInput } =
convertInputToTanStackAI(input);
return chat({
adapter: openaiText("gpt-4o"),
// Merge the runtime's system prompts (e.g. from useAgentContext)
// with the agent's own persona — dropping `fromInput` silently
// discards any client-supplied context.
systemPrompts: [system, ...fromInput],
messages,
abortController,
});
},
});
const runtime = new CopilotRuntime({
agents: {
research: makeAgent("You are a research assistant."),
coding: makeAgent("You are a coding assistant."),
},
});
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);
}
import {
CopilotKitProvider,
CopilotChat,
useFrontendTool,
} from "@copilotkit/react-core/v2";
import "@copilotkit/react-core/v2/styles.css";
import { z } from "zod";
function Tools() {
// Scoped to coding agent only:
useFrontendTool({
agentId: "coding",
name: "applyEdit",
description: "Apply a text edit to the active buffer.",
parameters: z.object({ path: z.string(), content: z.string() }),
handler: async ({ path, content }) => {
await editor.writeFile(path, content);
return { ok: true };
},
});
// Scoped to research agent only:
useFrontendTool({
agentId: "research",
name: "searchDocs",
description: "Search internal docs.",
parameters: z.object({ query: z.string() }),
handler: async ({ query }) => ({ results: await search(query) }),
});
return null;
}
export default function App() {
return (
<CopilotKitProvider runtimeUrl="/api/copilotkit">
<Tools />
<div className="grid grid-cols-2 h-screen">
<CopilotChat agentId="research" className="h-full" />
<CopilotChat agentId="coding" className="h-full" />
</div>
</CopilotKitProvider>
);
}
import { useState } from "react";
import { CopilotChat } from "@copilotkit/react-core/v2";
function SwitcherChat() {
const [agentId, setAgentId] = useState<"research" | "coding">("research");
return (
<>
<button onClick={() => setAgentId("research")}>Research</button>
<button onClick={() => setAgentId("coding")}>Coding</button>
<CopilotChat key={agentId} agentId={agentId} className="h-full" />
</>
);
}
Without the key={agentId}, WeakMap per-thread clones cache by threadId
and the prior agent's messages leak into the new panel.
import { useEffect, useState } from "react";
import { useCopilotKit } from "@copilotkit/react-core/v2";
import type { AbstractAgent } from "@copilotkit/react-core/v2";
function AgentSwitcher() {
const { copilotkit } = useCopilotKit();
const [agents, setAgents] = useState<Record<string, AbstractAgent>>({});
useEffect(() => {
const sub = copilotkit.subscribe({
onAgentsChanged: ({ agents }) => setAgents(agents),
});
return () => sub.unsubscribe();
}, [copilotkit]);
return (
<select>
{Object.keys(agents).map((id) => (
<option key={id}>{id}</option>
))}
</select>
);
}
import { useThreads } from "@copilotkit/react-core/v2";
function ThreadSidebar({ agentId }: { agentId: string }) {
const { threads, isLoading, hasMoreThreads, fetchMoreThreads } = useThreads({
agentId,
});
if (isLoading) return <div>Loading…</div>;
return (
<ul>
{threads.map((t) => (
<li key={t.id}>{t.title ?? t.id}</li>
))}
{hasMoreThreads ? <button onClick={fetchMoreThreads}>More</button> : null}
</ul>
);
}
useThreads requires Intelligence mode on the runtime. In SSE mode it
errors with "Runtime URL is not configured". See copilotkit/react-core → references/threads.md.
import { useAgentContext } from "@copilotkit/react-core/v2";
function ResearchContext({ value }: { value: string }) {
// Context is global: every agent sees it.
useAgentContext({ description: "Active project name", value });
return null;
}
There is no supported way to scope context to a single agent in v2. The
ContextStore.addContext API accepts only { description, value } —
passing an agentId is silently dropped (see
packages/core/src/core/context-store.ts:26-31). If you need agent-specific
information, gate it at the agent boundary instead: register distinct
tools per agentId, embed the per-agent detail in the agent's system
prompt, or swap the value passed to a single useAgentContext based on
which agent is active.
Wrong:
class MyAgent {
clone() {
return this;
}
}
Correct:
class MyAgent {
clone() {
return new MyAgent({ ...this.config, state: { ...this.state } });
}
}
useAgent clones per-thread; returning this shares state across
threads and throws a cloning assertion at runtime.
Source: packages/react-core/src/v2/hooks/use-agent.tsx:58-69
Wrong:
function AgentSwitcher() {
const agents = useAgents(); // does not exist
return (
<select>
{agents.map((a) => (
<option key={a.id}>{a.name}</option>
))}
</select>
);
}
Correct:
function AgentSwitcher() {
const { copilotkit } = useCopilotKit();
const [agents, setAgents] = useState<Record<string, AbstractAgent>>({});
useEffect(() => {
const sub = copilotkit.subscribe({
onAgentsChanged: ({ agents }) => setAgents(agents),
});
return () => sub.unsubscribe();
}, [copilotkit]);
return (
<select>
{Object.keys(agents).map((id) => (
<option key={id}>{id}</option>
))}
</select>
);
}
There is no useAgents hook in v2 — the client discovers agents via the
core subscriber only. Agents trained on generic-SDK patterns hallucinate
this hook.
Source: packages/core/src/core/agent-registry.ts:502; packages/core/src/core/core.ts:127,360
Wrong:
<CopilotChat agentId={activeAgent} />
Correct:
<CopilotChat key={activeAgent} agentId={activeAgent} />
Without the key, per-thread clone WeakMaps remain cached by threadId and
prior agent state leaks into the new panel.
Source: examples/v2/react-router/app/routes/_index.tsx:38-39
Wrong:
useFrontendTool({
name: "applyEdit",
parameters: z.object({ path: z.string(), content: z.string() }),
handler,
});
Correct:
useFrontendTool({
agentId: "coding",
name: "applyEdit",
parameters: z.object({ path: z.string(), content: z.string() }),
handler,
});
Omitting agentId attaches the tool to every agent — the research agent
sees applyEdit and may call it in scenarios it shouldn't.
Source: packages/react-core/src/v2/hooks/use-frontend-tool.tsx
Wrong:
const { state, setState, running } = useCoAgent({ name: "research" });
Correct:
const { agent } = useAgent({ agentId: "research" });
const state = agent?.state;
agent?.setState({ ...agent.state, foo: "bar" });
if (agent) await copilotkit.runAgent({ agent });
v2 useAgent returns { agent } only — agent can be undefined while
the runtime is still loading, so guard with optional chaining. Access
state, isRunning, and mutation via the agent instance itself
(agent.state, agent.isRunning, agent.setState(...)).
copilotkit.runAgent({ agent }) triggers a run. No state-returning
callback.
Source: packages/react-core/src/v2/hooks/use-agent.tsx:11-51