Your data lives in your own database (Postgres behind Prisma, a SQLite file, D1, Mongo) and you do not want another service just for chat history. You do not need one. An adapter is a plain object of store functions. The core never looks at your tables, so the schema stays yours.
One store, messages, is enough for withPersistence. This is the whole thing:
import {
defineAIPersistence,
defineMessageStore,
} from '@tanstack/ai-persistence'
import { db } from './db'
export const persistence = defineAIPersistence({
stores: {
messages: defineMessageStore({
// Return [] for a thread that was never saved, never null.
loadThread: (threadId) => db.threads.messages(threadId),
// The full transcript, not a delta. Overwrite what you had.
saveThread: (threadId, messages) => db.threads.save(threadId, messages),
}),
},
})Hand it to the middleware and you are done:
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { withPersistence } from '@tanstack/ai-persistence'
import { persistence } from './persistence'
export const stream = chat({
adapter: openaiText('gpt-5.5'),
messages: [{ role: 'user', content: 'hi' }],
threadId: 'support-chat',
middleware: [withPersistence(persistence)],
})Already have tables? Nothing above assumes new ones. Name your columns whatever you like, use your native types (jsonb, timestamptz), and convert inside the store functions. Extra columns such as user_id or audit timestamps are fine as long as they are nullable or defaulted, because these stores never touch a column they do not know about. There is one define*Store helper per store, and each type-checks your object inline so you never annotate it by hand.
Each store switches on one capability. Find your column and implement the rows marked with a tick:
| Store | Save the transcript | Rejoin a run after reload | Durable approvals | App key/value | Persist generation runs | Keep generated files | | --- | :-: | :-: | :-: | :-: | :-: | :-: | | messages | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | | runs | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | interrupts | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | | metadata | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | | generationRuns | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | | artifacts | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | | blobs | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Columns stack. Durable approvals and generated files means the union of both.
Two pairs cannot be split. interrupts needs runs, and artifacts needs blobs.
The generation stores feed withGenerationPersistence instead, and need none of the chat stores. See Generation persistence.
The common production shape is messages + runs + interrupts.
You can also own only part of it. Put messages and runs in your database and fill the rest from somewhere else with composePersistence:
import { composePersistence, memoryPersistence } from '@tanstack/ai-persistence'
import { messages, runs } from './my-postgres-stores'
export const persistence = composePersistence(memoryPersistence(), {
overrides: { messages, runs },
})That gives you no transaction across the two systems, so a write touching both is two writes. The store invariants (idempotent creates, insert-if-absent) are what make retrying them safe.
@tanstack/ai-persistence ships Agent Skills that turn this into a recipe against your stack: your ORM config, your schema file, your database handle.
pnpm add @tanstack/ai-persistence
npx @tanstack/intent@latest installThen ask for "add chat persistence to this app". There are recipes for Drizzle, Prisma, Cloudflare D1, and anything else (raw pg, Kysely, SQLite, Mongo, Supabase). The skills are plain Markdown under node_modules/@tanstack/ai-persistence/skills/ if you would rather read them.
Do not eyeball it. The same suite every packaged backend runs is shipped for yours. It exercises every method of every store you provide, including the ordering and idempotency rules that are easy to get subtly wrong.
import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit'
import { sqlitePersistence } from './sqlite-persistence'
runPersistenceConformance('my sqlite adapter', () =>
sqlitePersistence({ url: ':memory:', migrate: true }),
)Declare what you left out. A store you do not provide goes in skip, and an optional runs method goes in skipMethods:
import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit'
import { chatOnlyPersistence } from './chat-only'
runPersistenceConformance('chat-only adapter', () => chatOnlyPersistence(), {
skip: ['generationRuns', 'artifacts', 'blobs'],
skipMethods: ['runs.listByThread'],
})Anything absent and undeclared fails with a message naming exactly what to add, so a half-wired adapter cannot report a pass. When this is green, your adapter is a drop-in for withPersistence, and with the generation stores for withGenerationPersistence too.