Skip to main content
ClaudeWave
Skill3.1k repo starsupdated 3d ago

ai-persistence/server

>

Install in Claude Code
Copy
git clone --depth 1 https://github.com/TanStack/ai /tmp/ai-persistence-server && cp -r /tmp/ai-persistence-server/packages/ai-persistence/skills/ai-persistence/server ~/.claude/skills/ai-persistence-server
Then start a new Claude Code session; the skill loads automatically.

SKILL.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`** seed the authoritative history. On finish,
  persistence **overwrites** the stored thread with the engine's completed
  canonical transcript. Post the complete history, 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`         | Canonical 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 → `aborted` — **but only sometimes** (see below)                   | Resumes stay pending             |

The canonical transcript already contains the completed terminal assistant
messages. Native-combined output keeps the structured result on its terminal
assistant message. Separate finalization and event-sourced harness output can
preserve plain-text and structured-output assistant messages separately when
those messages use different ids.

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

### `onAbort` writes conditionally, not always

A user pressing Stop and a user closing the tab produce the **identical**
connection close, so `onAbort` can never infer intent from the abort alone.
It writes:

- **`'aborted'`** (terminal, with `finishedAt`) when the abort is an explicit
  cancel — `info.cancelRequested === true`, or a durable cancel request found
  via `wasCancelRequested(runs, runId)` (both from `@tanstack/ai`; paired with
  `requestRunCancel`/`RUN_CANCEL_REASON`) — **or** when the run is not
  detachable at all (no sandbox/journal behind it, so there is nothing to
  reattach to).
- **Nothing** when it is a plain disconnect on a **detachable** run (some
  other middleware, e.g. `@tanstack/ai-sandbox`, has provided
  `DetachableRunCapability` from `@tanstack/ai`). The record deliberately
  stays `'running'` — the agent keeps running and a later attach can take it
  over. (The detaching middleware, not `withPersistence`, is what stamps
  `detachedSince`.)

Chat's `onAbort` and generation's `onAbort` (`withGenerationPersistence`) are
**asymmetric on purpose**: a generation job has no journal and no agent loop
to reattach to, so its `onAbort` always writes `'aborted'` unconditionally.
Do not "fix" that asymmetry by making generation conditional, or chat
unconditional — both are correct for what they wrap.

Never build a client, or a persistence backend, that assumes a disconnect
always finalizes the run — for a detachable run it usually does not, and
inventing a `finishedAt` for a still-`'running'` record breaks takeover.
Use `isTerminalRunStatus(status)` (from `@tanstack/ai-persistence`) to test
whether a status is finished, rather than re-listing
`'completed' | 'failed' | 'aborted'` by hand.

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

Resume