showcase/shell-docs/src/content/docs/channels/persistence-and-scaling.mdx
Intelligence durably owns provider ingress and canonical Thread history. Redis-backed claims and ordered packets coordinate active provider delivery. Your Channels process still owns SDK workflow state. Those are separate persistence boundaries.
Without createChannel({ store: { adapter } }), the SDK uses MemoryStore.
These values disappear when the process restarts:
thread.state()Managed conversation history is different: Intelligence reconstructs recent provider turns even when the SDK store is in memory.
<Callout type="warn" title="Online does not mean restart-safe"> The Intelligence **Online** state proves that the listener is connected. It does not prove that a pending approval or application workflow survives a deployment. </Callout>Use a Standard Schema to type reads and validate complete replacement values:
npm install zod
import { createChannel } from "@copilotkit/channels";
import { z } from "zod";
import { durableStateStore } from "./state-store.js";
const workflowState = z.object({
incidentId: z.string(),
stage: z.enum(["triage", "awaiting-approval", "resolved"]),
});
const channel = createChannel({
name: required("CHANNEL_CODE"),
provider: "slack",
agent: makeAgent,
store: {
adapter: durableStateStore,
state: workflowState,
},
});
channel.onMessage(async ({ thread, message }) => {
const current = await thread.state();
await thread.setState({
incidentId: current?.incidentId ?? "INC-421",
stage: "triage",
});
await thread.runAgent({ prompt: message.text });
});
import { createChannel } from "@copilotkit/channels";
import { z } from "zod";
import { durableStateStore } from "./state-store.js";
const workflowState = z.object({
incidentId: z.string(),
stage: z.enum(["triage", "awaiting-approval", "resolved"]),
});
const channel = createChannel({
name: required("CHANNEL_CODE"),
provider: "teams",
agent: makeAgent,
store: {
adapter: durableStateStore,
state: workflowState,
},
});
channel.onMessage(async ({ thread, message }) => {
const current = await thread.state();
await thread.setState({
incidentId: current?.incidentId ?? "INC-421",
stage: "triage",
});
await thread.runAgent({ prompt: message.text });
});
setState(value) replaces the whole value. It does not merge a partial patch.
Store JSON-serializable values; remote backends do not preserve Date, Map,
class instances, or functions.
A complete production store implements:
| Facet | Required behavior |
|---|---|
kv | Get, set with optional TTL, and delete |
list | Oldest-first append/range, cap, trim, delete, and list TTL |
lock | Atomic acquire plus token-checked release |
dedup | Atomic first-seen check with TTL |
queue | FIFO enqueue/dequeue/depth with bounded overflow policy |
Locks, deduplication, and queues must be atomic across every process sharing the backend. Namespacing must prevent one project or Channel from reading another one's keys.
Run the SDK's Vitest conformance suite against your adapter:
npm install -D vitest
import { runStateStoreConformance } from "@copilotkit/channels/testing";
import { createRedisStateStore, closeRedisStateStore } from "./redis-store.js";
runStateStoreConformance(
"redis",
() => createRedisStateStore(),
(store) => closeRedisStateStore(store),
);
Run it against the real backend:
npx vitest run state-store.test.ts
The SDK ships MemoryStore and the conformance suite; it does not ship a fully
durable Redis or Postgres implementation in the umbrella package.
A callback survives only when both conditions are true:
createChannel({ components }).StateStore.const channel = createChannel({
name: required("CHANNEL_CODE"),
provider: "slack",
agent: makeAgent,
components: [ApprovalCard],
store: { adapter: durableStateStore },
});
const channel = createChannel({
name: required("CHANNEL_CODE"),
provider: "teams",
agent: makeAgent,
components: [ApprovalCard],
store: { adapter: durableStateStore },
});
Keep registered component props JSON-serializable and derive the handler from those props. Test by posting a card, restarting the process, then clicking the old card.
Each prepared delivery has one claim winner. Every connected Runtime that declares the matching Channel can receive the invitation, check local capacity, and attempt the claim. Different replicas can serve different conversations at the same time; there is no Channel-wide leader.
Keep each replica's Channel declarations and agent code aligned. Set
maxConcurrentDeliveries to a local bound each process can sustain. A Runtime
that has reached that bound declines new invitations before claiming them.
Provider effects use one ordered packet at a time. The SDK retries that exact
packet after a known retry wait or reconnect. If a provider call becomes
uncertain, Gateway records an uncertain terminal result instead of sending it
again. Application tools still need their own idempotency keys when the
application may call them more than once; use message.eventId,
message.turnId, or a durable operation ID.
Managed admission keeps one canonical Thread exclusive on a Runtime. A second
invitation for that Thread is not claimed while the first delivery is active.
Unrelated Threads continue in parallel up to maxConcurrentDeliveries.
This delivery guard is separate from the SDK store.concurrency setting used
by other adapter paths. Choose that setting for your application-state rules;
do not use it to raise the managed same-Thread delivery limit. See
StoreConfig.
store.concurrency deliberately if you need serial workflow state.SIGINT and SIGTERM.Continue with history and transcripts, or
use the StateStore reference.