ai-persistence/server · git:20260727.4ab149f · 2026-07-27 · sha256 935839ff693b9d4b

ai-persistence/server git:20260727.4ab149fA

Immutable. This exact content is served forever at /api/v1/blob/935839ff693b9d4b.

---
name: ai-persistence/server
description: >
  Server chat state with withPersistence from @tanstack/ai-persistence.
  Authoritative transcript, run lifecycle, durable interrupts/approvals,
  chatParamsFromRequest, reconstructChat, snapshotStreaming. Use when the
  server owns history, multi-device, or durable tool approvals. NOT client
  localStorage (see ai-core/client-persistence in @tanstack/ai) and NOT
  stream reconnect
  alone.
type: sub-skill
library: tanstack-ai
library_version: '0.0.0'
sources:
  - 'TanStack/ai:docs/persistence/chat-persistence.md'
  - 'TanStack/ai:docs/persistence/overview.md'
  - 'TanStack/ai:docs/persistence/controls.md'
---

# Server Chat Persistence

> Builds on **ai-persistence**. Package: `@tanstack/ai-persistence`.

`withPersistence(persistence)` is a `ChatMiddleware` that writes chat **state**
to a backend: messages, runs, interrupts (optional metadata). It does not
mutate the chunk stream and does not replace delivery durability.

## Setup

```ts
import {
  chat,
  chatParamsFromRequest,
  toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { withPersistence } from '@tanstack/ai-persistence'
// Your adapter — see ai-persistence/stores.
import { persistence } from './persistence'

export async function POST(request: Request) {
  const params = await chatParamsFromRequest(request)
  const stream = chat({
    adapter: openaiText('gpt-5.5'),
    messages: params.messages,
    threadId: params.threadId,
    runId: params.runId,
    ...(params.resume ? { resume: params.resume } : {}),
    middleware: [withPersistence(persistence)],
  })
  return toServerSentEventsResponse(stream)
}
```

Always pass `threadId` and `runId` from the client (via
`chatParamsFromRequest` / body helpers). Forward `resume` when the client
resolves pending interrupts.

For dev and tests, `memoryPersistence()` from `@tanstack/ai-persistence` is a
drop-in backend that implements all four stores in process.

## What each store does

| Store        | Role                                    | Required?                                 |
| ------------ | --------------------------------------- | ----------------------------------------- |
| `messages`   | Full model-message transcript load/save | **Yes** for `withPersistence`             |
| `runs`       | Run status, timing, usage, errors       | Optional; needed for interrupt durability |
| `interrupts` | Pending/resolved tool approvals & waits | Optional; **requires** `runs`             |
| `metadata`   | App-owned namespaced key/value          | Optional                                  |

Named shapes: `ChatTranscriptPersistence` (floor), `ChatPersistence` (all four).
**Annotate your factory with one of these**, not with bare `AIPersistence` —
the unparameterized type is the all-optional bag, and `withPersistence` rejects
it because `stores.messages` is possibly `undefined`.

## Authoritative-history contract

- **Non-empty `messages`** → finish **overwrites** the stored thread with that
  array. Post the **complete** transcript, never a delta.
- **Empty `messages`** → middleware **loads** the stored thread and continues.

## When state is written

| Moment             | Writes                                                            | Best-effort?                     |
| ------------------ | ----------------------------------------------------------------- | -------------------------------- |
| `onStart`          | Pending turn snapshot (user + history)                            | Yes — failure does not abort     |
| Interrupt boundary | New interrupts, run → `interrupted`, message snapshot             | No                               |
| `onFinish`         | Full transcript **first**, then run → `completed`, commit resumes | No                               |
| Stream (optional)  | Throttled partial assistant text                                  | Yes if `snapshotStreaming: true` |
| `onError`          | Run → `failed`                                                    | Resumes stay pending             |
| `onAbort`          | Run → `interrupted`                                               | Resumes stay pending             |

```ts
withPersistence(persistence, {
  snapshotStreaming: true,
  snapshotIntervalMs: 1000, // default
})
```

Streaming snapshots default **off** (finish is authoritative). Enable only when
partial-output durability is worth extra writes.

Resumes accepted in `onConfig` commit only at a success boundary (interrupt or
finish). A failed run leaves interrupts pending so the same resume batch can
retry.

## Interrupt / resume flow

1. Middleware records pending interrupts and **gates** new input: if pending
   exist, the request must include a matching `resume` batch or `onConfig`
   throws.
2. On valid resume, middleware builds `resumeToolState` and clears
   `config.resume` so the engine does not double-reconstruct from client
   history (server owns transcript).
3. On success boundary, interrupts are marked resolved/cancelled.

## Hydrate a thread for the client (`reconstructChat`)

Server-authoritative clients load history by `threadId` (often `GET`):

```ts
import { reconstructChat } from '@tanstack/ai-persistence'

export async function GET(request: Request) {
  return reconstructChat(persistence, request, {
    // Multi-user: required in production
    authorize: async (threadId, req) => {
      const userId = await sessionUserId(req)
      return userOwnsThread(userId, threadId)
    },
  })
}
```

Returns `{ messages, activeRun, interrupts }`:

- `messages` — UI messages for paint
- `activeRun` — `{ runId }` if a run is still generating (`runs.findActiveRun`)
- `interrupts` — pending human-in-the-loop state for re-prompt

**Without `authorize`, anyone who guesses `?threadId=` gets the transcript.**

## Generation activities

`withGenerationPersistence(persistence)` tracks run records for non-chat
activities (image, audio, TTS, video, transcription). Do not fake
`threadId = requestId` on chat run stores — use the generation helper.

## Common mistakes

### CRITICAL: Posting a message delta as `messages`

Wipes the stored thread down to that delta. Always send full history or `[]`.

### HIGH: Omitting `threadId` / `runId`

Persistence keys and resume need stable ids. Use `chatParamsFromRequest`.

### HIGH: Interrupts without `runs`

`interrupts` requires `runs`; `withPersistence` throws otherwise.

### HIGH: Typing a factory as bare `AIPersistence`

`AIPersistence` defaults to the sparse all-optional bag, so `withPersistence`
and `reconstructChat` reject the value. Return `ChatPersistence` (or
`ChatTranscriptPersistence`) instead.

### MEDIUM: Expecting `withPersistence` to reconnect a dropped stream

That is delivery durability (resumable streams), not state persistence.

## Cross-references

- **ai-persistence** — layers and recommended stack
- **ai-persistence/stores** — implement the store interfaces
- **ai-core/client-persistence** (`@tanstack/ai`) — browser half
- **ai-core/locks** — multi-instance coordination