StateStore
The pluggable persistence contract for Channel state, callbacks, transcripts, locks, deduplication, and queues.
StateStore is the asynchronous persistence boundary used by the Channels
runtime.
Interface
interface StateStore {
kv: {
get<T>(key: string): Promise<T | undefined>;
set<T>(key: string, value: T, ttlMs?: number): Promise<void>;
consume<T>(key: string): Promise<T | undefined>;
delete(key: string): Promise<void>;
};
list: {
append<T>(
key: string,
value: T,
options?: { maxLen?: number; ttlMs?: number },
): Promise<number>;
range<T>(key: string, start?: number, stop?: number): Promise<T[]>;
trim(key: string, maxLen: number): Promise<void>;
delete(key: string): Promise<void>;
};
lock: {
acquire(
key: string,
options?: { ttlMs?: number },
): Promise<{ token: string } | null>;
release(key: string, token: string): Promise<void>;
};
dedup: {
seen(key: string, ttlMs: number): Promise<boolean>;
};
queue: {
enqueue<T>(
key: string,
value: T,
options?: {
maxSize?: number;
onFull?: "drop-oldest" | "drop-newest";
},
): Promise<number>;
dequeue<T>(key: string): Promise<T | undefined>;
depth(key: string): Promise<number>;
};
}Semantics
list.rangeis oldest-first and uses non-negative, inclusive indices.kv.consumeatomically returns and deletes one value. Concurrent callers must observe at most one non-undefinedresult.list.append({ ttlMs })sets the expiry for the whole list. An append withoutttlMspreserves an existing expiry.lock.acquirereturnsnullwhile another token holds an unexpired lock.lock.releasemust not release a lock owned by another or newer token.dedup.seenreturnsfalsefor the first observation andtruefor a repeat inside the TTL.- Queues are FIFO. The configured overflow policy must be deterministic.
Remote stores must support JSON-serializable values. MemoryStore preserves
objects by reference, but that behavior is not portable.
Conformance test
import { runStateStoreConformance } from "@copilotkit/channels/testing";
runStateStoreConformance("postgres", () => createPostgresStateStore());KV consumption, locks, deduplication, and queues must be atomic across processes. Use project/Channel namespacing to prevent key collisions.
Pass the adapter through createChannel({ store: { adapter } }). See
Persistence and scaling.