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; process-global in-memory 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;
}
}
By default, both InMemoryAgentRunner and SqliteAgentRunner throw
"Thread already running" on concurrent run() calls for the same threadId.
"throw" is the default, but it is not the only option: constructing
InMemoryAgentRunner with onConcurrentRun: "supersede" makes it abort the
in-flight run (the same path stop() takes) and start the new one instead of
throwing — the superseded run's partial output is discarded rather than persisted
to history. SqliteAgentRunner has no such option and always throws. When the
throw does happen, how it 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
<CopilotKit onError> (the CopilotKit provider from @copilotkit/react-core/v2).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 { 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;
}
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 process-global,
bounded store — threads are lost on restart, evicted past the memory limits, and
horizontally-scaled instances see divergent state. See agent-runners-in-memory.md
for the bounds and how to tune them.
Source: packages/runtime/src/v2/runtime/runner/in-memory.ts.
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.
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>;
By default both runners throw "Thread already running" on concurrent runs, so
debouncing on the client is still the right baseline. In Intelligence mode you can
additionally handle code === "agent_thread_locked" in <CopilotKit onError>; SSE
mode surfaces only a generic 500 with that message.
Throwing is the default (onConcurrentRun: "throw"), not the only behavior:
constructing InMemoryAgentRunner with onConcurrentRun: "supersede" aborts the
in-flight run (the stop() path) and starts the new one instead of throwing,
discarding the superseded run's partial output rather than persisting it. That
suits a UX where a fast follow-up should displace a still-running (or wedged) turn.
Unlike the process-global memory limits, onConcurrentRun is per-runner-instance —
it affects only the runner you pass it to. SqliteAgentRunner has no such option
and always throws.
Source: the throw new Error("Thread already running") in InMemoryAgentRunner.run(),
packages/runtime/src/v2/runtime/runner/in-memory.ts;
packages/core/src/intelligence-agent.ts:368-369.
Wrong:
// 3 Fly.io / Cloud Run instances, each with its own InMemoryAgentRunner
new CopilotRuntime({ agents });
Correct:
// Sticky-session one instance per thread (so every run for a thread lands on the
// same process), OR move to Intelligence mode for managed multi-instance durability.
new CopilotRuntime({ agents }); // + route by threadId at the load balancer
InMemoryAgentRunner's store is a process-global singleton — multi-instance deploys see
totally different thread state per worker, making reconnects and GET /connect non-deterministic.
Source: the exported ɵGLOBAL_STORE singleton in packages/runtime/src/v2/runtime/runner/in-memory.ts.
A shared dbPath on SqliteAgentRunner is not a horizontal-scaling fix on its own.
Sharing the file gives you durable, persisted history: runs survive process restarts, and
completed runs are readable from any instance pointed at the same file. But the live-run
bookkeeping used by the connect-bridge and by stop() lives in a process-local
ACTIVE_CONNECTIONS map. A second instance has no entry for a run started elsewhere, so
it can replay stored history but cannot reconnect to — or stop — an in-flight run on
another instance. Use SqliteAgentRunner for restart-resilient single-instance durability;
for managed multi-instance durability, use Intelligence mode.
Source: packages/sqlite-runner/src/sqlite-runner.ts:46 (module-level ACTIVE_CONNECTIONS).
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