skills/spa-without-runtime/SKILL.md
Obtain a publicApiKey from cloud.copilotkit.ai. In a Vite SPA:
# .env.local
VITE_CPK_PUBLIC_API_KEY=ck_pub_xxxxxxxxxxxxxxxxxxxx
// src/App.tsx
import {
CopilotKitProvider,
CopilotChat,
useFrontendTool,
} from "@copilotkit/react-core/v2";
import "@copilotkit/react-core/v2/styles.css";
import { z } from "zod";
function RegisterTools() {
useFrontendTool({
name: "readClipboard",
description: "Read the clipboard contents.",
parameters: z.object({}),
handler: async () => {
try {
const text = await navigator.clipboard.readText();
return { text };
} catch (error) {
// navigator.clipboard.readText() rejects on denied permission,
// non-HTTPS origins, and lost document focus. Return a structured
// error so the agent sees it as a regular tool result and can
// respond ("I couldn't read your clipboard — please paste here")
// instead of surfacing a raw DOMException.
//
// Note: returning an error object does NOT trigger `onError` with
// `tool_handler_failed` — that fires only when the handler throws.
// If you want `onError` to fire, `throw` instead of returning.
return {
error: "clipboard_read_failed",
message:
error instanceof Error ? error.message : "Clipboard unavailable",
};
}
},
});
return null;
}
export default function App() {
return (
<CopilotKitProvider publicApiKey={import.meta.env.VITE_CPK_PUBLIC_API_KEY}>
<RegisterTools />
<div className="h-screen">
<CopilotChat agentId="default" className="h-full" />
</div>
</CopilotKitProvider>
);
}
No runtimeUrl. No server. Auth header
(X-CopilotCloud-Public-Api-Key: ck_pub_...) is injected by the Cloud
client. Frontend tools still work — they execute in the browser; the Cloud
runtime dispatches tool calls to the SPA over SSE.
Frontend tools work identically to the full-runtime case — register them inside the provider subtree.
import { useFrontendTool, useHumanInTheLoop } from "@copilotkit/react-core/v2";
import { z } from "zod";
function AppTools() {
useFrontendTool({
name: "navigate",
description: "Navigate to a route",
parameters: z.object({ path: z.string() }),
handler: async ({ path }) => {
// Use your router's navigate function; the exact API depends on
// your router (react-router's `useNavigate`, TanStack Router's
// `router.navigate`, etc.). Do NOT use `window.location.pathname = ...`
// — it triggers a full page reload and kills the agent stream,
// destroying chat state mid-run.
navigate(path);
return { ok: true };
},
});
useHumanInTheLoop({
name: "confirmDelete",
description: "Ask the user to confirm a destructive action",
parameters: z.object({ label: z.string() }),
render: ({ args, status, respond }) =>
status === "executing" ? (
<div>
Delete {args.label}?
<button onClick={() => respond({ ok: true })}>Yes</button>
<button onClick={() => respond({ ok: false })}>No</button>
</div>
) : null,
});
return null;
}
import { useAgent, useAgentContext } from "@copilotkit/react-core/v2";
function Panel() {
useAgentContext({
description: "Current page URL",
value: window.location.href,
});
const { agent } = useAgent({ agentId: "default" });
return (
<div>
State: {JSON.stringify(agent?.state)} —{" "}
{agent?.isRunning ? "running" : "idle"}
</div>
);
}
useAgent returns { agent } only — agent may be undefined while the
runtime is still loading, so guard with optional chaining. Access
isRunning via the agent instance itself.
Source: packages/react-core/src/v2/hooks/use-agent.tsx:333-335
Wrong:
// SPA with no backend, trying to avoid CopilotKit Cloud:
<CopilotKitProvider
selfManagedAgents={{
default: new BuiltInAgent({ apiKey: "sk-live-..." }),
}}
/>
Correct:
// For a SPA without a runtime, the ONLY production path is CopilotKit Cloud:
<CopilotKitProvider publicApiKey="ck_pub_..." />
// If you control a backend, use a runtime instead:
<CopilotKitProvider runtimeUrl="/api/copilotkit" />
selfManagedAgents and agents__unsafe_dev_only are aliases — merged at
CopilotKitProvider.tsx:393 with no auth gating. Any agent constructed in
the browser has its API key visible in the bundle. The benign-sounding name
"selfManagedAgents" is the trap: it is NOT production-safe. Both props
exist purely for local-dev demos.
Source: packages/react-core/src/v2/providers/CopilotKitProvider.tsx:136-138,393
Wrong:
<CopilotKitProvider
agents__unsafe_dev_only={{
default: new BuiltInAgent({ apiKey: process.env.OPENAI_API_KEY }),
}}
/>
Correct:
<CopilotKitProvider publicApiKey="ck_pub_..." />
The __unsafe_dev_only suffix is a warning, not a safety belt — nothing
prevents the prop from shipping. Any API key referenced from browser code
ends up in the bundle.
Source: packages/react-core/src/v2/providers/CopilotKitProvider.tsx:136-138,393
Wrong:
<CopilotKitProvider runtimeUrl="/api/x" publicApiKey="ck_pub_..." />
Correct:
// Cloud-only SPA:
<CopilotKitProvider publicApiKey="ck_pub_..." />
// Or own runtime, not both:
<CopilotKitProvider runtimeUrl="/api/copilotkit" />
runtimeUrl wins when both are present — the Cloud call is never issued,
and the hardcoded public key sits in the bundle for no reason.
Source: docs/snippets/shared/troubleshooting/common-issues.mdx:30-42
publicApiKey is canonical; publicLicenseKey is an accepted alias.
Resolution order:
// packages/react-core/src/v2/providers/CopilotKitProvider.tsx:391
const resolvedPublicKey = publicApiKey ?? publicLicenseKey;
publicApiKey wins when both are set. Prefer publicApiKey in new code
for consistency with the HTTP header name (X-CopilotCloud-Public-Api-Key)
and the Cloud dashboard label.
Source: packages/react-core/src/v2/providers/CopilotKitProvider.tsx:122-128,391
Wrong:
// Dynamic-import gymnastics in a pure Vite SPA:
const CopilotChat = React.lazy(() =>
import("@copilotkit/react-core/v2").then((m) => ({ default: m.CopilotChat })),
);
Correct:
import { CopilotKitProvider, CopilotChat } from "@copilotkit/react-core/v2";
// Just render it at the top level; no SSR concerns in a Vite SPA.
@copilotkit/react-core/v2 is marked "use client" — SSR is not in scope.
A pure SPA has no server render path; the normal import is correct.
Source: packages/react-core/src/v2/index.ts:1