Implement the MessageStore, RunStore, InterruptStore, MetadataStore contracts for @tanstack/ai-persistence against any database. defineAIPersistence, composePersistence overrides, critical invariants (full-replace saveThread, insert-if-absent createOrResume and interrupt create), authorize thread access, runPersistenceConformance testkit. Use whenever you need server persistence — the package ships contracts, not a backend for your database.
Builds on ai-persistence and ai-persistence/server.
@tanstack/ai-persistence ships contracts, not a backend for your
database. An adapter is an object with a stores map; implement the stores you
need against whatever you already run and hand the result to
withPersistence. The core never inspects your tables, so the schema is yours.
Use memoryPersistence() for dev and tests. Everything durable is an adapter
you write. This skill is the contract reference; the per-stack recipes that
write a chat-persistence.ts into an app are
ai-persistence/build-{drizzle,prisma,cloudflare,custom}-adapter, and
a complete node:sqlite implementation lives in
examples/ts-react-chat/src/lib/sqlite-persistence.ts.
import { defineAIPersistence } from '@tanstack/ai-persistence'
import type { ChatWithInterruptsPersistence } from '@tanstack/ai-persistence'
// Sparse is fine — only implement what you need.
export const persistence: ChatWithInterruptsPersistence = defineAIPersistence({
stores: {
messages, // required for withPersistence / reconstructChat
runs, // required if you have interrupts
interrupts,
// metadata optional
},
})| Shape | Contents |
|---|---|
ChatTranscriptPersistence | messages (+ optional runs/interrupts/metadata) |
ChatWithInterruptsPersistence | messages + runs + interrupts |
ChatPersistence | all four chat stores |
defineAIPersistence preserves exact keys and rejects unknown keys at runtime.
Annotate your factory with a named shape. Bare AIPersistence is the
all-optional sparse bag, so withPersistence and reconstructChat reject it
(stores.messages is possibly undefined). This is the single most common
mistake when writing an adapter.
stores accepts exactly four keys — messages, runs, interrupts,
metadata. Anything else (notably locks or sandbox instance maps) throws
Unknown AIPersistence store key at runtime and fails to type-check. Locks:
ai-core/locks / @tanstack/ai/locks. Sandbox instance resume:
@tanstack/ai-sandbox.
MessageStoreinterface MessageStore {
loadThread(threadId: string): Promise<Array<ModelMessage>>
saveThread(threadId: string, messages: Array<ModelMessage>): Promise<void>
}loadThread → [] for unknown threads (never null).saveThread is a full overwrite, not append. A one-message payload wipes history.RunStoreRunStatus, TerminalRunStatus, RunRecord, RunStore, defineRunStore, and
isTerminalRunStatus are defined in @tanstack/ai and re-exported from
@tanstack/ai-persistence. Import those from either; the recipes in this skill
import from @tanstack/ai-persistence so an adapter author needs only one
package name.
RunError is the exception — it is NOT re-exported. Import it from
@tanstack/ai directly (import type { RunError } from '@tanstack/ai'); the
@tanstack/ai-persistence barrel has no such export and the import fails to
resolve.
Four methods are required (createOrResume / update / get /
findActiveRun). Two are optional: implement only the ones your backend needs,
and leave the rest off the object entirely (not undefined, just absent). A
four-method RunStore is a fully valid backend.
withPersistence itself calls none of the three non-createOrResume/update
query methods, so leaving both optional ones off costs nothing in the middleware.
Their consumers are elsewhere, and each absence disables exactly one feature:
| method | consumer | absent ⇒ |
|---|---|---|
findActiveRun | reconstruct.ts (stores.runs?.findActiveRun(threadId)) | required — cannot be absent; stubbing it to null silently kills rejoin |
listReclaimable | reapDetachedRuns in @tanstack/ai-sandbox | the store cannot be reaped at all |
listByThread | application code — nothing in the framework calls it | nothing framework-side breaks |
Consumers of the two OPTIONAL methods feature-detect with store.method?.(...)
and degrade rather than throwing. findActiveRun is required, so nothing
feature-detects it.
The conformance testkit does not feature-detect. An optional method that is
missing and not declared in skipMethods fails the suite, so an omission is
always a choice you made on purpose rather than a check that quietly did not
run. Declare yours and the suite reports them as skipped with a reason:
// The shipped sqlite example implements findActiveRun and listReclaimable and
// declares only the one it omits.
runPersistenceConformance('sqlite', () => persistence, {
skipMethods: ['runs.listByThread'],
})interface RunStore {
// Required
createOrResume(
input: Pick<RunRecord, 'runId' | 'threadId' | 'startedAt'> & {
status?: RunStatus
},
): Promise<RunRecord>
update(
runId: string,
patch: Partial<
Pick<
RunRecord,
| 'status'
| 'finishedAt'
| 'error'
| 'usage'
| 'sandboxKey'
| 'detachedSince'
| 'cancelRequested'
| 'driverEpoch'
>
>,
): Promise<void>
get(runId: string): Promise<RunRecord | null>
findActiveRun(threadId: string): Promise<RunRecord | null>
// Optional
listByThread?(threadId: string): Promise<Array<RunRecord>>
listReclaimable?(opts: {
now: number
ttlMs: number
}): Promise<Array<RunRecord>>
}RunStatus is 'running' | 'interrupted' | 'completed' | 'failed' | 'aborted'.
'interrupted' is a human-in-the-loop pause, not terminal: it is what
interrupt-resume continues from, and must never be conflated with 'aborted'
(an explicit cancellation). TerminalRunStatus narrows to
'completed' | 'failed' | 'aborted'. isTerminalRunStatus(status) is a type
predicate: (status: RunStatus) => status is TerminalRunStatus, so calling it
inside a guard narrows status to TerminalRunStatus for the rest of that
branch, with no cast needed.
RunRecord.usage is optional. withPersistence sums reported numeric fields
across provider calls for that runId, while opaque providerUsageDetails
retains the latest reported bag. Known usage is persisted on interruption and
every terminal status.
RunRecord.error is a structured RunError, not a bare string:
interface RunError {
message: string
code?: string
}message is the provider's prose (it changes between model versions and
cannot be branched on); code is the stable, machine-branchable
classification a consumer switches over to retry, escalate, or show specific
UI. Store both, and omit code from a mapped record when its column is
null rather than writing code: undefined (...(row.errorCode != null ? { code: row.errorCode } : {})).
defineRunStore<const T extends RunStore>(store: T): T returns the passed
object's own type, so an optional method your store implements (say,
listByThread) stays known-present on the returned value instead of widening
back to RunStore's | undefined. You get autocomplete and contract checking
without a separate : RunStore annotation, and without a feature-detection
guard on your own return value.
sandboxKey, detachedSince, cancelRequested, driverEpochThese four RunRecord fields exist for the sandbox/durable-run layer to
reattach a run a client disconnected from, and for out-of-band cancellation.
A RunStore you write must round-trip all four through update → get, even
if your app does not use sandboxes yet — the conformance testkit checks this
unconditionally (it is not behind skipMethods, because update/get are
REQUIRED methods).
sandboxKey — compound key identifying the sandbox this run is bound
to, so a reclaimer can find it to tear down.detachedSince — epoch ms when the last viewer detached; absent while
someone is attached. Read by listReclaimable.cancelRequested — set by an explicit out-of-band cancel (see below),
distinct from a mere disconnect.driverEpoch — monotonic fencing token, bumped by each host that
claims the run, so a superseded host can discover it lost by comparing the
stored value against the one it holds.Fresh-run reads must be undefined, not a coerced falsy default. A
backend that reads a NULL/absent column back as cancelRequested: false or
driverEpoch: 0 is claiming knowledge it does not have ("explicitly not
cancelled") — that is a different fact from "never set". Omit the field from
the mapped record instead
(...(row.cancel_requested != null ? { cancelRequested: row.cancel_requested !== 0 } : {})).
update must use 'field' in patch, not patch.field !== undefined, for
these four. A caller clears detachedSince on reattach by passing it
explicitly as undefined — store.update(runId, { detachedSince: undefined })
— and that must write NULL, not be filtered out of the write. Checking
!== undefined cannot tell "clear this field" apart from "I didn't mention
this field", so it silently drops the clear and the run looks permanently
detached to the reaper forever after. 'detachedSince' in patch is true for
an explicit undefined and false when the caller omitted the key entirely —
that is the distinction you need. The same applies to cancelRequested
(false is a real, meaningful value, not "unset") and to sandboxKey /
driverEpoch. See examples/ts-react-chat/src/lib/sqlite-persistence.ts for
a worked implementation of exactly this pattern.
Cancel intent is never inferred from a disconnect — a user pressing Stop
and a user closing the tab produce an identical connection close, so the two
are indistinguishable from the abort alone. @tanstack/ai exports the actual
primitives:
requestRunCancel — records durable cancel intent (writes
cancelRequested: true through a RunStore), for a run being driven on a
host other than the one handling the cancel request.wasCancelRequested — reads that intent back.RUN_CANCEL_REASON — the well-known abort reason string used for the
in-process case (the same host aborting its own signal), paired with
isCancelRequestedReason to check for it.A RunStore you write does not call these directly — they operate on your
store through update/get — but cancelRequested must round-trip
faithfully (previous section) for the durable path to work at all.
createOrResume (required): if runId exists, return it unchanged,
including its stored usage, and ignore the passed threadId / startedAt /
status. Resuming a run does not reset startedAt or overwrite its current
status. Idempotent retries and double-submit depend on this. status defaults
to 'running' on first creation.update (required): missing runId is a no-op (do not throw, do not
insert).get (required): current record, or null when unknown.listByThread (optional): every run for threadId, ascending by
startedAt. Only needed to render a thread's past agent activity.listReclaimable (optional): runs where status === 'running' AND
detachedSince is set AND detachedSince <= now - ttlMs. The cutoff is
inclusive: a run detached exactly at the cutoff qualifies. This is a query, not
automatic behavior — the consumer is reapDetachedRuns from
@tanstack/ai-sandbox, which the application schedules itself (cron, queue,
alarm(), waitUntil), so returning this list has no side effect until that
sweep runs. detachedSince is written for you by withSandbox's detach path
(alongside sandboxKey) and cleared by the takeover path; drop either field and
nothing can reclaim the sandbox. cancelRequested is written by
requestRunCancel and read by wasCancelRequested, and the reaper's expiry
path goes through requestRunCancel to stop a run past its TTL.findActiveRun (required): the most recent 'running' run for
threadId (max startedAt), or null if none is active. Enables reconnect
from a stable thread id without a client-held run id. Stub it out and
reconnect silently stops working — null is also the correct answer for an
idle thread, so nothing can detect the difference. It was optional for exactly
one release cycle and cost precisely that, which is why it is required now.Capability tiers belong at the STORE level (omit runs entirely and declare
ChatTranscriptStores), not the method level — never ship a RunStore with a
stubbed method. The two list queries above are the only method-level options,
and each must be declared via skipMethods when absent.
InterruptStoreinterface InterruptStore {
create(record: Omit<InterruptRecord, 'status' | 'resolvedAt'>): Promise<void>
resolve(interruptId: string, response?: unknown): Promise<void>
cancel(interruptId: string): Promise<void>
get(interruptId: string): Promise<InterruptRecord | null>
list(threadId: string): Promise<Array<InterruptRecord>>
listPending(threadId: string): Promise<Array<InterruptRecord>>
listByRun(runId: string): Promise<Array<InterruptRecord>>
listPendingByRun(runId: string): Promise<Array<InterruptRecord>>
}create always births 'pending'; insert-if-absent on interruptId
(never clobber resolved back to pending).list* ordered by requestedAt ascending.runs store when used with chat persistence.MetadataStoreinterface MetadataStore {
get(namespace: string, key: string): Promise<unknown | null>
set(namespace: string, key: string, value: unknown): Promise<void>
delete(namespace: string, key: string): Promise<void>
}Scope
identity type — despite SQL backends conventionally naming the column
scope.(namespace, key) — do not join with :
(('a:b','c') and ('a','b:c') must stay distinct).null is type-indistinguishable from absence; wrap if you must
persist real null ({ value: null }).set (NOT NULL JSON columns) with a
clear TypeError — match that or document your semantics.Store records (RunRecord, InterruptRecord) speak epoch milliseconds
(number). Wire/result references that leave the persistence layer speak
ISO-8601 strings; the middleware converts at the boundary. Do not mix the
two on one field.
Type each store with its define*Store helper (defineMessageStore,
defineRunStore, defineInterruptStore, defineMetadataStore): pass the object
literal and get autocomplete + contract checking inline, with no : MessageStore
annotation. The result composes into defineAIPersistence with exact presence.
import { defineMessageStore } from '@tanstack/ai-persistence'
import type { ModelMessage } from '@tanstack/ai'
const threads = new Map<string, Array<ModelMessage>>()
export const messages = defineMessageStore({
async loadThread(threadId) {
return [...(threads.get(threadId) ?? [])]
},
async saveThread(threadId, next) {
threads.set(threadId, [...next])
},
})For durable DBs, preserve the same semantics with upserts / full-row replace.
You rarely need all four stores in the same system. Implement the ones you own
and fill the rest from another base with composePersistence:
import { composePersistence, memoryPersistence } from '@tanstack/ai-persistence'
import { messages, runs } from './my-postgres-stores'
export const persistence = composePersistence(memoryPersistence(), {
overrides: { messages, runs },
})Only listed keys move; others stay on the base. Pass false to drop a store.
There is no cross-store transaction — if messages lives in Postgres and
interrupts in Redis, a write touching both is two writes. The store
invariants (idempotent createOrResume, insert-if-absent create) are exactly
what make those retries safe.
composePersistence accepts the four state keys. Locks and sandbox instance
maps are not composable here.
jsonb,
timestamptz, whatever — convert in the row mapper. The record shape the
methods return is fixed; how you store it is not.user_id, audit columns, a tenant id. Keep
them nullable or defaulted so the store's inserts still succeed. The stores
never read or write columns they do not know about....(row.error != null ? { error: row.error } : {}))
so records compare cleanly.Store methods take bare threadIds. Authorize at the route before
loadThread / saveThread / reconstructChat({ authorize }). Derive user
identity from session, not the client body alone.
import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit'
import { myPersistence } from '../src/persistence'
runPersistenceConformance('my-backend', () => myPersistence())
// Declare intentional omissions. The suite covers all seven stores, so a
// chat-only backend skips the generation half:
// runPersistenceConformance('chat-only', () => p, {
// skip: ['generationRuns', 'artifacts', 'blobs'],
// })
// `skip` never accepts 'locks' — locks are not a store.
// Declare an intentionally-unimplemented OPTIONAL RunStore method with
// skipMethods, so vitest reports it as a real SKIPPED case:
// runPersistenceConformance('my-backend', () => myPersistence(), {
// skipMethods: ['runs.listByThread', 'runs.listReclaimable'],
// })The testkit is the compatibility gate: round-trips, rich message shapes,
empty-thread [], createOrResume idempotency, interrupt insert-if-absent,
list ordering, composite-key non-aliasing. A missing store that is not listed
in skip fails loudly.
skip accepts only 'messages' | 'runs' | 'interrupts' | 'metadata'. Do not
pass 'locks' — it is not a state store and the suite does not cover it.
skipMethods (declare-or-fail for optional RunStore methods). A backend
that omits an OPTIONAL RunStore method (listByThread, listReclaimable —
findActiveRun is required and cannot be declared away) must declare it in
skipMethods as 'runs.<method>', e.g.
skipMethods: ['runs.listByThread', 'runs.listReclaimable']. An omitted
method that is NOT declared throws with an actionable message instead of
silently reporting a pass; a declared one is reported as a SKIPPED vitest
case, never as a pass. A case that did not run must never be
indistinguishable from one that did. See
examples/ts-react-chat/src/lib/sqlite-persistence.test.ts for a worked
example: it declares skipMethods: ['runs.listByThread'] only, keeping both
findActiveRun and listReclaimable under test.
Reference implementation: memoryPersistence() in @tanstack/ai-persistence.
saveThreadBreaks the authoritative-history contract.
createOrResume overwriting existing runsBreaks safe resume / double-submit.
create upserting to pendingCan resurrect a resolved approval.
AIPersistence from the factorywithPersistence rejects it. Annotate a named shape.
list* without stable requestedAt orderMiddleware and tests assume ascending order.
Silent semantic drift shows up as stuck approvals or wiped history in prod.
listReclaimable cutoff off by oneThe cutoff is inclusive (detachedSince <= now - ttlMs). Using a strict <
drops runs detached exactly at the boundary.
listReclaimable as automatic reclamationIt is a query a caller runs, not something the package acts on by itself. Nothing reaps a returned run for you.
2822885
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.