Skip to content
Persistence (Advanced)

Store Reference (Advanced)

These are the store contracts in @tanstack/ai-persistence. Implement the stores you need. The middleware enables a feature when its store is present.

StoreHoldsUsed byGuide
messagesThe model-message history of each threadwithPersistence (required), reconstructChat (required)Chat persistence
activitiesAG-UI activity rows of each threadwithPersistence, reconstructChatAG-UI activity rows
runsRun status, timing, errors, usagewithPersistence, reconstructChatChat persistence
interruptsPending, resolved, or cancelled human waits. Needs runs.withPersistenceChat persistence
metadataApp and integration key/value statewithPersistence, withCompactionCompaction
generationRunsGeneration run status and result metadatawithGenerationPersistence (required), reconstructGeneration (required)Generation persistence
artifactsFile metadata. Needs blobs.withGenerationPersistence, portable snapshotsKeep generated files
blobsFile bytes. Needs artifacts.withGenerationPersistence, portable snapshotsKeep generated files

The named groups of chat stores (ChatTranscriptStores, ChatPersistenceStores, ChatWithInterruptsStores) are in Controls. To build the stores, see Build a chat adapter and Build a generation adapter. To test them, see the conformance suite.

MessageStore

ts
import type { ModelMessage } from '@tanstack/ai'

type MessagePage =
  | {
      messages: Array<ModelMessage>
      truncated: false
      cursor?: never
    }
  | {
      messages: Array<ModelMessage>
      truncated: true
      cursor: string
    }

interface MessageStore {
  loadThread(
    threadId: string,
    options?: { limit?: number; before?: string },
  ): Promise<Array<ModelMessage> | MessagePage>
  saveThread(threadId: string, messages: Array<ModelMessage>): Promise<void>
}

Rules:

  • loadThread with only threadId (the middleware calls it this way) returns the full array, never a MessagePage.
  • loadThread returns [], never null, for a thread that was never saved.
  • limit and before are a paging hint for hydrate. You can ignore them and return the full array. reconstructChat then slices after the UI conversion.
  • To page in the database, return a MessagePage. truncated: true requires a cursor. The words truncated and cursor mean the same as in BlobStore.list.
  • If you ignore before and return the newest array again, reconstructChat loads the full thread and slices it.
  • saveThread gets the full merged list. It replaces the stored thread. It never appends. The merge by id happens in withPersistence, not in this store.

ActivityStore

ts
import type { ActivityRecord } from '@tanstack/ai-persistence'

interface ActivityStore {
  loadActivities(threadId: string): Promise<Array<ActivityRecord>>
  saveActivities(
    threadId: string,
    activities: Array<ActivityRecord>,
  ): Promise<void>
}

Rules:

  • loadActivities returns [], never null, for a thread that was never saved.
  • saveActivities gets the complete activity list for the thread. It replaces the stored list.
  • Activity rows are never model messages. Do not store them in messages.

The store is optional. Without it, activity rows are not durable on the server. Type an implementation with defineActivityStore. See AG-UI activity rows on reload.

RunStore

RunStore and RunRecord come from @tanstack/ai. @tanstack/ai-persistence re-exports both. RunError is exported from @tanstack/ai only. One shared definition lets chat persistence and a sandbox run driver use the same record for one run.

ts
import type { TokenUsage } from '@tanstack/ai'

type TerminalRunStatus = 'completed' | 'failed' | 'aborted'
type RunStatus = 'running' | 'interrupted' | TerminalRunStatus

// Why a run failed. `message` is the provider's text, which changes between
// model versions. `code` is the stable value to switch on (retry, escalate,
// show a specific UI). Providers do not always supply one.
interface RunError {
  message: string
  code?: string
}

interface RunRecord {
  runId: string
  threadId: string
  // Present on a subagent child run. Absent on the parent chat run.
  parentRunId?: string
  subagentRunId?: string
  name?: string
  status: RunStatus
  startedAt: number // epoch ms
  finishedAt?: number // epoch ms, set at a terminal status
  error?: RunError
  usage?: TokenUsage // reported usage added up for this runId
  // ---------------------------------------------------------------------------
  // DURABLE SANDBOXED RUNS ONLY. A chat app never writes these four, and
  // nothing in `@tanstack/ai-persistence` reads them. Leave the columns out
  // until you use `withSandbox(sandbox, { runs, durability })`. See
  // Build a Sandbox Adapter, "The four run fields".
  // ---------------------------------------------------------------------------
  sandboxKey?: string // the sandbox this run is bound to
  detachedSince?: number // epoch ms since the last viewer left
  cancelRequested?: boolean // an out-of-band cancel was recorded
  driverEpoch?: number // fencing token, incremented by each host that claims the run
  // ---------------------------------------------------------------------------
  // HOST SESSIONS ONLY. A host that runs agents and activities for a long
  // session writes these. `chat()` never writes them. A store that keeps the
  // whole record keeps them too.
  // ---------------------------------------------------------------------------
  kind?: 'chat' | 'agent' | 'activity' | 'command' | 'compact'
  activity?: string // the activity of a `kind: 'activity'` run
  agent?: string // the agent name of a `kind: 'agent'` run
  result?: unknown // terminal result metadata (ids, urls, text), never bytes
  artifacts?: Array<{ artifactId: string; name?: string; mimeType?: string; url?: string }>
  principal?: { id: string } // who started the run
  leaseOwner?: string // the host that drives the run now
  leaseExpiresAt?: number // epoch ms when that claim ends
  // Crash-resume data from the last model or tool boundary.
  checkpoint?: {
    at: number
    pendingTools?: Array<{ toolCallId: string; name: string; replay: 'safe' | 'never' }>
  }
}

interface RunStore {
  // Required. Insert-if-absent: an existing runId returns the stored record
  // unchanged, so a resume never resets its startedAt or status.
  createOrResume(input: {
    runId: string
    threadId: string
    status?: RunStatus
    startedAt: number
    // Copied on the first insert only. A later call leaves them unchanged.
    parentRunId?: string
    subagentRunId?: string
    name?: string
    kind?: RunRecord['kind']
    activity?: string
    agent?: string
    principal?: { id: string }
  }): Promise<RunRecord>
  // Required. A patch for an unknown runId does nothing. It does not throw.
  update(
    runId: string,
    patch: Partial<
      Pick<
        RunRecord,
        | 'status'
        | 'finishedAt'
        | 'error'
        | 'usage'
        | 'sandboxKey'
        | 'detachedSince'
        | 'cancelRequested'
        | 'driverEpoch'
        | 'result'
        | 'artifacts'
        | 'leaseOwner'
        | 'leaseExpiresAt'
        | 'checkpoint'
      >
    >,
  ): Promise<void>
  // Required.
  get(runId: string): Promise<RunRecord | null>
  // Required. The most recent 'running' run of a thread (greatest startedAt),
  // or null when the thread is idle. reconstructChat uses it to report
  // activeRun, which lets a hydrating client tail a run that still generates.
  findActiveRun(threadId: string): Promise<RunRecord | null>
  // Optional. Every run of a thread, startedAt ascending.
  listByThread?(threadId: string): Promise<Array<RunRecord>>
  // Optional. The child runs of one parent, oldest startedAt first.
  listByParentRun?(parentRunId: string): Promise<Array<RunRecord>>
  // Optional. Runs with status 'running' and detachedSince <= now - ttlMs.
  listReclaimable?(opts: {
    now: number
    ttlMs: number
  }): Promise<Array<RunRecord>>
}

Required methods and their rules:

MethodRule
createOrResumeIdempotent. A second call for an existing runId returns the complete stored record unchanged, usage included. Retries can repeat the same run id.
createOrResumeCopies parentRunId, subagentRunId, and name on the first insert only. If the caller leaves a field out, leave it out of the record. Do not write ''.
updateA patch for an unknown runId does nothing.
getReturns null for an unknown runId.
findActiveRunMust do a real query. A stub that returns null makes reconstructChat always report activeRun: null. A reloaded client then restores the transcript but never resumes the live reply, and nothing reports the fault, because null is also correct for an idle thread.

Optional methods. Consumers feature-detect each one:

MethodUsed byIf you leave it out
listByThreadreconstructChat (finds the parent run of a child that a tool call started), includeRuns timingsA reload loses those subagent cards. Declare the omission in skipMethods for the conformance suite.
listByParentRunreconstructChat (builds the subagent cards)A reload shows the saved child text, without cards. The conformance suite skips the subagent checks. No skipMethods entry is necessary.
listReclaimablereapDetachedRuns from @tanstack/ai-sandboxThe reaper logs one line and sweeps nothing. Detached runs never finish and their sandboxes are never reclaimed. Declare the omission in skipMethods.

The reaper is a function that your app schedules, so listReclaimable alone is not enough. withSandbox's detach path fills in detachedSince for you. See Takeover & Detached Runs and Reaping & Retention.

Usage: withPersistence adds up the numeric usage fields across the provider calls of one runId. The opaque providerUsageDetails field keeps the latest reported value. Known usage is saved when the run interrupts or reaches a terminal status.

The four durable-run fields are a sandbox concern. runPersistenceConformance does not test them, and a chat-only backend can leave the columns out. If you use durable sandboxed runs, run runDurableRunFieldsConformance from @tanstack/ai-sandbox/testkit against the same runs store. Each field breaks one mechanism without an error when it is dropped. See Build a Sandbox Adapter.

Capability tiers belong at the store level, not the method level. A backend with no run lifecycle declares ChatTranscriptStores and leaves out runs. It does not supply a RunStore with a stubbed method. The type system catches a missing store. An incomplete store fails at runtime with no error.

Two helpers come with the contract:

  • isTerminalRunStatus is a type predicate. Inside the guard, the status is a TerminalRunStatus:

    ts
    import { isTerminalRunStatus } from '@tanstack/ai-persistence'
    import type { RunRecord, TerminalRunStatus } from '@tanstack/ai-persistence'
    
    function finalStatus(run: RunRecord): TerminalRunStatus | null {
      return isTerminalRunStatus(run.status) ? run.status : null
    }
  • defineRunStore is generic over the object you pass, so the result keeps that object's exact shape. An optional method that you implemented needs no ?. in your own code. For example, createRunStore(db).findActiveRun(threadId) is a direct call on the store from the chat walkthrough. A consumer that holds a plain RunStore still feature-detects the same method.

Reference implementations: MemoryRunStore in packages/ai-persistence/src/memory.ts and the SQLite adapter in examples/ts-react-chat (src/lib/sqlite-persistence.ts). Both implement all seven run methods.

InterruptStore

ts
interface InterruptRecord {
  interruptId: string
  runId: string
  threadId: string
  status: 'pending' | 'resolved' | 'cancelled'
  requestedAt: number // epoch ms
  resolvedAt?: number // epoch ms, set when resolved or cancelled
  payload: Record<string, unknown>
  response?: unknown
}

type InterruptCommitEntry =
  | { interruptId: string; status: 'resolved'; response?: unknown }
  | { interruptId: string; status: 'cancelled' }

interface InterruptStore {
  create(record: Omit<InterruptRecord, 'status' | 'resolvedAt'>): Promise<void>
  commitBatch?(entries: ReadonlyArray<InterruptCommitEntry>): 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>>
}

Rules:

  • create takes a record without status and resolvedAt, so every interrupt starts as 'pending'.
  • create is insert-if-absent. A duplicate create never changes an interrupt that is already resolved.
  • resolve and cancel do nothing for an unknown interruptId.
  • The list* methods return records in requestedAt ascending order.
  • With chat persistence, an interrupts store requires a runs store.

commitBatch is optional. If you implement it, write all entries in one database transaction. Reject the whole batch (throw and write nothing) when any entry:

  • repeats an interruptId,
  • names an interrupt that does not exist, or
  • names an interrupt that is not 'pending'.

Without commitBatch, the middleware calls resolve and cancel one entry at a time. That fallback is not atomic.

MetadataStore

MetadataStore comes from @tanstack/ai and is re-exported here.

ts
interface 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>
  // Optional: compare-and-set.
  getVersioned?(
    namespace: string,
    key: string,
  ): Promise<{ value: unknown; revision: string } | null>
  setIf?(
    namespace: string,
    key: string,
    value: unknown,
    expectedRevision: string | null,
  ): Promise<{ ok: true; revision: string } | { ok: false; reason: 'conflict' }>
}

Rules:

  • (namespace, key) is the identity. Your app owns the namespaces and the value schemas.
  • A stored null looks the same as an absent key. To store null, wrap it (for example, { value: null }), or reject nullish values.
  • Do not put the transcript in metadata. The messages store owns it.

Add getVersioned and setIf when two writers can change the same key at the same time. Then the second write cannot erase the first:

  • getVersioned returns the value and its revision, or null when the key is absent.
  • setIf writes only when the stored revision equals expectedRevision. Pass null to write only when the key is absent.
  • setIf returns { ok: true, revision } on success. On a different revision, it returns { ok: false, reason: 'conflict' } and does not write.
  • Every write gives the key a new revision, a plain set too. After delete, the next write gets a new revision. A deleted revision is never used again.

memoryPersistence() has both methods. The conformance suite tests them when your store has them.

withPersistence also provides this store through the core MetadataCapability. Other middleware can then use it without a dependency on @tanstack/ai-persistence. For example, withCompaction stores validated context checkpoints here.

GenerationRunStore

The generation counterpart to RunStore. A record is keyed by its own runId. Its threadId is the slot that findLatestForThread looks it up by. withGenerationPersistence requires this store, not runs.

ts
import type { PersistedArtifactRef, TokenUsage } from '@tanstack/ai'

// The same values as a chat run's `RunStatus`, so one status column and one
// set of checks cover both tables.
type GenerationRunStatus =
  | 'running'
  | 'interrupted'
  | 'completed'
  | 'failed'
  | 'aborted'

interface GenerationRunRecord {
  runId: string
  threadId: string // the slot this run fills, hydrated by findLatestForThread
  activity: string // 'image' | 'audio' | 'tts' | 'video' | 'transcription'
  provider: string
  model: string
  status: GenerationRunStatus
  startedAt: number // epoch ms
  finishedAt?: number // epoch ms, set at a terminal status
  error?: { message: string; code?: string }
  result?: unknown // terminal result metadata (ids, urls), never media bytes
  artifacts?: Array<PersistedArtifactRef> // present with an artifacts + blobs backend
  usage?: TokenUsage
}

interface GenerationRunStore {
  createOrResume(input: {
    runId: string
    activity: string
    provider: string
    model: string
    startedAt: number
    threadId: string
    status?: GenerationRunStatus
  }): Promise<GenerationRunRecord>
  update(
    runId: string,
    patch: Partial<
      Pick<
        GenerationRunRecord,
        'status' | 'finishedAt' | 'error' | 'result' | 'artifacts' | 'usage'
      >
    >,
  ): Promise<void>
  get(runId: string): Promise<GenerationRunRecord | null>
  // The most recent run filed under a thread (greatest startedAt), or null.
  findLatestForThread(threadId: string): Promise<GenerationRunRecord | null>
}

Rules:

  • createOrResume is idempotent. A second call for an existing runId returns the stored record unchanged. It does not change startedAt, activity, provider, model, or threadId. status defaults to 'running' on the first insert.
  • update for an unknown runId does nothing. It does not throw and does not create a record.
  • findLatestForThread is required. It is the only query that hydrates a generation. Without it, persistence: true restores nothing, and nothing reports the fault.

ArtifactStore

Metadata rows for stored media. The bytes live in a BlobStore. Provide both stores together to keep generated bytes.

ts
interface ArtifactRecord {
  artifactId: string
  runId: string
  threadId: string
  blobKey: string // where the bytes live
  name: string
  mimeType: string
  size: number
  sourceUrl?: string // where the bytes were fetched FROM (provenance)
  createdAt: number // epoch ms
}

interface ArtifactStore {
  save: (record: ArtifactRecord) => Promise<void>
  get: (artifactId: string) => Promise<ArtifactRecord | null>
  list: (runId: string) => Promise<Array<ArtifactRecord>> // [] when the run has none
  // Complete thread history, ordered by (createdAt, artifactId) ascending.
  listForThread: (threadId: string) => Promise<Array<ArtifactRecord>>
  delete: (artifactId: string) => Promise<void>
  deleteForRun: (runId: string) => Promise<void>
}

Rules:

  • save inserts or overwrites the record.
  • list and listForThread sort by createdAt first, then by artifactId in bytewise order. Compare the UTF-8 bytes from left to right. Do not use locale collation.
  • delete and deleteForRun do nothing when there is nothing to delete.
  • Every record must have blobKey. With a custom storageKey, the path cannot be computed again from the record. See Keep generated files.

BlobStore

A durable object store for the bytes. By default, withGenerationPersistence writes each generated file under the key artifacts/<runId>/<artifactId>.

ts
type BlobBody =
  | ReadableStream<Uint8Array>
  | ArrayBuffer
  | ArrayBufferView
  | string
  | Blob

interface BlobRecord {
  key: string
  size?: number
  etag?: string
  contentType?: string
  customMetadata?: Record<string, string>
  createdAt?: number // epoch ms first written
  updatedAt?: number // epoch ms last overwritten
}

interface BlobObject extends BlobRecord {
  arrayBuffer(): Promise<ArrayBuffer>
  text(): Promise<string>
  body?: ReadableStream<Uint8Array>
  // The slice served, when a range was requested. Absent on a whole read.
  range?: { offset: number; length: number }
}

interface BlobListPage {
  objects: Array<BlobRecord>
  cursor?: string // present only when `truncated`
  truncated?: boolean
}

interface BlobPutOptions {
  contentType?: string
  customMetadata?: Record<string, string>
  // Exact byte length of `body`, when the producer knows it. Advisory: use it
  // to pick an upload strategy (single-shot or multipart). Do not use it in
  // place of counting the bytes you store.
  expectedLength?: number
}

interface BlobRange {
  offset: number // from the start of the object, must be inside it
  length?: number // defaults to "to the end", clamped when it overshoots
}

interface BlobGetOptions {
  range?: BlobRange
}

interface BlobListOptions {
  prefix?: string
  cursor?: string
  limit?: number
}

interface BlobStore {
  put(key: string, body: BlobBody, options?: BlobPutOptions): Promise<BlobRecord>
  get(key: string, options?: BlobGetOptions): Promise<BlobObject | null>
  head(key: string): Promise<BlobRecord | null>
  delete(key: string): Promise<void>
  list(options?: BlobListOptions): Promise<BlobListPage>
}

Rules for list:

  • prefix matches literally and is case-sensitive. Escape SQL LIKE metacharacters.
  • When limit is set and more keys match, return truncated: true with a cursor. That cursor returns the keys strictly after it, so paging visits every key once.
  • limit: 0 returns an empty page that is not truncated.

Rules for put:

  • The body can be a ReadableStream with no declared length. Your store must read a stream of unknown length to the end. Do not require a length first.
  • A URL-fetched artifact arrives without a length when the origin does not declare one that can be enforced: a chunked reply, or a compressed reply whose content-length counts the compressed bytes.
  • When the origin declares a usable length, the body arrives as fetch made it, and expectedLength has the same number.
  • A backend that needs a declared length for a single-shot upload (Cloudflare R2 on workerd is one) can attach expectedLength when it is present. When it is absent, stream through a multipart upload. The ai-persistence/build-cloudflare-artifact-store skill has that recipe.

The conformance testkit tests the length-less case, so a store that handles only byte bodies fails the suite.

Rules for get:

  • With options.range, return only that slice. size still reports the whole object, and the returned range reports what you served. A media player needs these values for a 206 response when it seeks.
  • Use resolveBlobRange(size, range) to clamp. A length past the end is legal and clamps. An offset past the end throws, because a serve route answers 416 from the record's size before it calls the store.
ts
import { resolveBlobRange } from '@tanstack/ai-persistence'

async get(key: string, options?: BlobGetOptions) {
  const row = await selectBlob(key)
  if (!row) return null
  if (!options?.range) return blobObject(row, row.body)
  const served = resolveBlobRange(row.size, options.range)
  // Slice at the storage layer, not after loading the whole object.
  const bytes = await selectBlobSlice(key, served.offset, served.length)
  return blobObject(row, bytes, served)
}

Range support is required for a store that holds bytes, and the conformance testkit tests it. A store that ignores range sends the whole file on every seek. Then <video> seeking fails, and Safari does not play the video at all. A reference-only backend that stores no bytes leaves out blobs completely. See Keep generated files for the serve route.

How the records relate

The thread is not a table. It exists as the thread_id that the other records refer to. metadata is separate, keyed by (namespace, key). On the generation side, a run is keyed by its own run_id first. Its thread_id names the slot that the run fills, and findLatestForThread hydrates by it.

mermaid
erDiagram
    MESSAGES ||--o{ RUN : "thread_id, a thread has many runs"
    RUN ||--o{ INTERRUPT : "run_id, a run can pause on interrupts"
    MESSAGES ||..o{ GENERATION_RUN : "thread_id, the slot a run fills"
    GENERATION_RUN ||--o{ ARTIFACT : "run_id, a run produces artifacts"
    ARTIFACT ||--|| BLOB : "blob_key, the bytes"

    MESSAGES {
        string thread_id PK
        json messages_json "full transcript, overwritten on save"
    }
    RUN {
        string run_id PK
        string thread_id
        string status "running | interrupted | completed | failed | aborted"
        int started_at
        int finished_at
    }
    INTERRUPT {
        string interrupt_id PK
        string run_id
        string thread_id
        string status "pending | resolved | cancelled"
        int requested_at
    }
    GENERATION_RUN {
        string run_id PK
        string thread_id "the slot this run fills"
        string activity "image | audio | tts | video | transcription"
        string status "running | interrupted | completed | failed | aborted"
    }
    ARTIFACT {
        string artifact_id PK
        string run_id
        string blob_key "where the bytes live"
        string mime_type
        int size
    }
    BLOB {
        string key PK
        blob bytes
    }

Typing an adapter

Each store has a define*Store helper:

HelperStore
defineMessageStoremessages
defineActivityStoreactivities
defineRunStoreruns
defineInterruptStoreinterrupts
defineMetadataStoremetadata
defineGenerationRunStoregenerationRuns
defineArtifactStoreartifacts
defineBlobStoreblobs

The helpers compose into defineAIPersistence, which tracks exactly which stores exist. The stores you passed autocomplete on persistence.stores. A read of a store you did not pass is a compile error.

Those eight keys are the only ones that stores accepts. Any other key throws Unknown AIPersistence store key at construction.

To annotate the value, use a named shape:

ShapeStores
ChatPersistenceAll four chat state stores: messages, runs, interrupts, metadata
ChatTranscriptPersistenceThe messages floor
AIPersistenceAll stores optional. withPersistence rejects it, because stores.messages can be undefined.