Persistence and scaling
Make Slack and Teams workflow state restart-safe, implement the StateStore contract, and deploy within the managed listener's concurrency model.
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.
Know what is process-local by default#
Without createChannel({ store: { adapter } }), the SDK uses MemoryStore.
These values disappear when the process restarts:
thread.state()- registered component snapshots used to restore callbacks
- SDK transcript lists
- SDK locks, deduplication windows, and queues
Managed conversation history is different: Intelligence reconstructs recent provider turns even when the SDK store is in memory.
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.
Configure typed workflow state#
Use a Standard Schema to type reads and validate complete replacement values:
npm install zodimport { 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"),
identifyUser: "platform",
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.
Implement every StateStore facet#
A complete production store implements:
| Facet | Required behavior |
|---|---|
kv | Get, set with optional TTL, atomic consume, 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 vitestimport { 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.tsThe SDK ships MemoryStore and the conformance suite; it does not ship a fully
durable Redis or Postgres implementation in the umbrella package.
Preserve interactive callbacks across restarts#
A callback survives only when both conditions are true:
- The JSX comes from a named component registered in
createChannel({ components }). - The action snapshot is stored in a durable
StateStore.
const channel = createChannel({
name: required("CHANNEL_CODE"),
identifyUser: "platform",
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.
Match the managed concurrency model#
Delivery claims (multi-replica)#
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. The managed listener bounds active and pending delivery work internally. A Runtime that has reached its internal capacity declines new invitations before claiming them; that capacity is not currently a public Channel configuration option.
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.
Turn concurrency (same conversation)#
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 within the managed listener's bounded capacity.
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.
Production checklist#
- Prefer an agent factory; singletons are cloned per run automatically.
- Choose
store.concurrencydeliberately if you need serial workflow state. - Provide a durable store before promising restart-safe state or callbacks.
- Run the StateStore conformance suite against the real backend.
- Test a restart while a card is awaiting a click.
- Make external writes idempotent.
- Keep Channel declarations aligned across replicas and scale horizontally when the managed listener's bounded local capacity is insufficient.
- Alert on Intelligence Conflict, Offline, and Delivery failing.
- Stop the listener on
SIGINTandSIGTERM.
Continue with history and transcripts, or
use the StateStore reference.