8.6 KiB
CopilotKit Agent Runners
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; requiresbetter-sqlite3peer.IntelligenceAgentRunner— auto-wired byCopilotIntelligenceRuntime. You do NOT construct this directly and you cannot passrunneralongsideintelligence.- Custom — subclass
AgentRunnerfor Redis / Postgres / any backend.
Setup
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
Core Patterns
The AgentRunner contract
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;
}
}
Handle double-submit on the client
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:
- Intelligence mode — the Intelligence platform returns HTTP
409when a lock is held. The client core maps this toCopilotKitCoreErrorCode.AGENT_THREAD_LOCKEDand firesonError({ code: "agent_thread_locked", ... }). Handle this in<CopilotKit onError>(theCopilotKitprovider from@copilotkit/react-core/v2). - SSE mode (default, in-memory / SQLite runners) — the runner throws
synchronously and the handler returns a plain
500JSON body like{ "error": "Failed to run agent", "message": "Thread already running" }. There is no typedagent_thread_lockedcode — match on the message text or just guard on the client with a busy flag.
// client — Intelligence mode (typed code)
import { CopilotKit } from "@copilotkit/react-core/v2";
<CopilotKit
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;
}
Common Mistakes
HIGH Shipping InMemoryAgentRunner to production
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.
HIGH Setting runner alongside intelligence option
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 Intelligence service 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.
HIGH Forgetting the better-sqlite3 peer
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.
HIGH Default SqliteAgentRunner with :memory: dbPath
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.
MEDIUM Concurrent run() on the same threadId
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
<CopilotKit 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.
HIGH In-memory runner + horizontal scaling
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.
References
- InMemoryAgentRunner — internals and hot-reload note
- SqliteAgentRunner — schema, retention, ops
- Custom runner — Redis/Postgres skeleton
See also
copilotkit/intelligence-mode— managed durability alternative (CopilotKit Intelligence managed service, not self-hostable)copilotkit/setup-endpoint— runner is passed via the CopilotRuntime constructorcopilotkit/scale-to-multi-agent— horizontal scaling considerations