skills/runtime/references/agent-runners.md
AgentRunner is the abstraction that owns thread run state — active runs, the event stream
replay, and stop semantics. Pick one per CopilotRuntime instance.
InMemoryAgentRunner — default; globalThis-keyed Map; lost on restart.SqliteAgentRunner — file-backed; requires better-sqlite3 peer.IntelligenceAgentRunner — auto-wired by CopilotIntelligenceRuntime. You do NOT
construct this directly and you cannot pass runner alongside intelligence.AgentRunner for Redis / Postgres / any backend.Default (in-memory, dev only):
import { CopilotRuntime } from "@copilotkit/runtime/v2";
// Equivalent to passing `runner: new InMemoryAgentRunner()`
const runtime = new CopilotRuntime({
agents: {
/* ... */
} as any,
});
Production (file-backed SQLite):
import { CopilotRuntime } from "@copilotkit/runtime/v2";
import { SqliteAgentRunner } from "@copilotkit/sqlite-runner";
const runtime = new CopilotRuntime({
agents: {
/* ... */
} as any,
runner: new SqliteAgentRunner({ dbPath: "./data/threads.db" }),
});
Installation for the SQLite runner (the better-sqlite3 peer is required):
pnpm add @copilotkit/sqlite-runner better-sqlite3
import { AgentRunner } from "@copilotkit/runtime/v2";
import type {
AgentRunnerRunRequest,
AgentRunnerConnectRequest,
AgentRunnerIsRunningRequest,
AgentRunnerStopRequest,
} from "@copilotkit/runtime/v2";
import { Observable } from "rxjs";
import type { BaseEvent } from "@ag-ui/client";
class MyRunner extends AgentRunner {
run(request: AgentRunnerRunRequest): Observable<BaseEvent> {
// Start a new run for request.threadId. Throw `new Error("Thread already running")`
// if a run is in flight. Stream events from agent.run(request.input).
return new Observable<BaseEvent>();
}
connect(request: AgentRunnerConnectRequest): Observable<BaseEvent> {
// Replay events for an active run, or historic runs for request.threadId.
return new Observable<BaseEvent>();
}
async isRunning(request: AgentRunnerIsRunningRequest): Promise<boolean> {
return false;
}
async stop(request: AgentRunnerStopRequest): Promise<boolean | undefined> {
return true;
}
}
Both InMemoryAgentRunner and SqliteAgentRunner throw
"Thread already running" on concurrent run() calls for the same threadId. How
that surfaces to the client depends on the runtime mode:
409 when a lock is
held. The client core maps this to CopilotKitCoreErrorCode.AGENT_THREAD_LOCKED
and fires onError({ code: "agent_thread_locked", ... }). Handle this in
<CopilotKitProvider onError>.500 JSON body like
{ "error": "Failed to run agent", "message": "Thread already running" }.
There is no typed agent_thread_locked code — match on the message text or
just guard on the client with a busy flag.// client — Intelligence mode (typed code)
import { CopilotKitProvider } from "@copilotkit/react-core/v2";
<CopilotKitProvider
onError={({ code }) => {
if (code === "agent_thread_locked") {
alert("Agent is busy — wait for the current response to finish.");
}
}}
/>;
// client — any mode: guard with a busy flag so double-submit is impossible
import { useAgent } from "@copilotkit/react-core/v2";
import { useState } from "react";
function Composer() {
const agent = useAgent({ agentId: "default" });
const [busy, setBusy] = useState(false);
async function send(text: string) {
if (busy) return;
setBusy(true);
try {
await agent?.addMessage({ role: "user", content: text });
} finally {
setBusy(false);
}
}
return null;
}
Wrong:
// production:
new CopilotRuntime({ agents: { default: agent } });
Correct:
import { SqliteAgentRunner } from "@copilotkit/sqlite-runner";
new CopilotRuntime({
agents: { default: agent },
runner: new SqliteAgentRunner({ dbPath: "./data/threads.db" }),
});
// Or upgrade to Intelligence mode for managed durability.
The default runner is new InMemoryAgentRunner(). It keeps state in a globalThis-keyed
Map — threads are lost on restart, and horizontally-scaled instances see divergent state.
Source: packages/runtime/src/v2/runtime/runner/in-memory.ts:63-96.
Wrong:
new CopilotRuntime({
agents,
intelligence,
runner: new SqliteAgentRunner({ dbPath: "./data/threads.db" }),
});
Correct:
new CopilotRuntime({
agents,
intelligence,
identifyUser: (req) => ({ id: req.headers.get("x-user-id")! }),
});
CopilotIntelligenceRuntimeOptions does not declare a runner field — Intelligence mode
auto-wires IntelligenceAgentRunner pointed at the Cloud socket. Excess-property checks will
flag a runner: key on an Intelligence-shaped options object as a type error, and at runtime
the auto-wired Intelligence runner wins regardless of what you pass.
Source: packages/runtime/src/v2/runtime/core/runtime.ts:149-173,285-294.
Wrong:
pnpm add @copilotkit/sqlite-runner
Correct:
pnpm add @copilotkit/sqlite-runner better-sqlite3
@copilotkit/sqlite-runner imports better-sqlite3 at the top of its module, so if the peer
is missing, import { SqliteAgentRunner } from "@copilotkit/sqlite-runner" itself fails at
module load with Cannot find module 'better-sqlite3' — long before the constructor runs.
(The constructor has a friendlier multi-line install hint as a belt-and-suspenders fallback,
but in practice you will see the bare module-resolution error first.) It is a peer dependency,
not a direct dep.
Source: packages/sqlite-runner/src/sqlite-runner.ts:18, :55-66.
Wrong:
new SqliteAgentRunner();
Correct:
new SqliteAgentRunner({ dbPath: "./data/threads.db" });
The default dbPath is ":memory:" — SQLite's in-memory mode. Data is lost at restart,
defeating the reason to use the file-backed runner.
Source: packages/sqlite-runner/src/sqlite-runner.ts:48-54.
Wrong:
// Double-click send button → two POST /agent/:id/run to the same thread
<button onClick={() => agent.addMessage({ role: "user", content })}>
Send
</button>
Correct:
const [busy, setBusy] = useState(false);
<button
disabled={busy}
onClick={async () => {
setBusy(true);
try {
await agent.addMessage({ role: "user", content });
} finally {
setBusy(false);
}
}}
>
Send
</button>;
Both runners throw "Thread already running" on concurrent runs. Debounce on the client.
In Intelligence mode you can additionally handle code === "agent_thread_locked" in
<CopilotKitProvider onError>; SSE mode surfaces only a generic 500 with that message.
Source: packages/runtime/src/v2/runtime/runner/in-memory.ts:110;
packages/core/src/intelligence-agent.ts:368-369.
Wrong:
// 3 Fly.io / Cloud Run instances, each with its own InMemoryAgentRunner
new CopilotRuntime({ agents });
Correct:
// Either sticky-session a single instance per thread, or use shared state:
new CopilotRuntime({
agents,
runner: new SqliteAgentRunner({ dbPath: process.env.THREADS_DB! }),
});
// Best: Intelligence mode for managed multi-instance durability.
InMemoryAgentRunner's globalThis store is per-process — multi-instance deploys see
totally different thread state per worker, making reconnects and GET /connect non-deterministic.
Source: packages/runtime/src/v2/runtime/runner/in-memory.ts:63-96.
copilotkit/intelligence-mode — managed durability alternative (Cloud-only)copilotkit/setup-endpoint — runner is passed via the CopilotRuntime constructorcopilotkit/scale-to-multi-agent — horizontal scaling considerations