You have a store you want streams to survive on: Redis, Postgres, a queue, Electric, an object store. By the end of this page you have a StreamDurability adapter that plugs into toServerSentEventsResponse / toHttpResponse, so a client can reconnect to an in-flight run without re-running the model.
Core never understands your store. It only round-trips opaque offset strings you hand it. You implement four methods:
| Method | Job |
|---|---|
| resumeFrom() | Return the resume offset from this request, or null for a fresh run. |
| append(chunks) | Persist a batch before delivery; return one offset per chunk, in order. |
| read(offset, signal) | Replay chunks strictly after offset. |
| close() | Mark the run complete and wake any parked readers. |
Get these wrong and resume breaks in subtle ways:
Offsets are opaque, unique, and round-trip-safe. Return a distinct offset per chunk. It travels on an SSE id: line or inside an NDJSON { id, chunk } envelope, so it must survive that: core rejects an empty offset, one containing NUL/CR/LF, one with leading or trailing whitespace, or a duplicate.
read replays strictly after the offset, oldest first, and stops at the first RUN_FINISHED / RUN_ERROR.
read must never end the response empty while the run is still producing. Park (wait for the next append) instead. A clean end with no new data tells the client the run is over; if it isn't, the client fails with DurableStreamIncompleteError. Honor the abort signal so a gone client stops the wait.
You do not handle ordering or append-before-deliver. Core buffers, calls append, and only forwards a chunk once you return its offset.
Write the adapter against your store's operations. Here it is over an append-only per-run log you provide; swap RunLog for your backend:
import { EventType } from '@tanstack/ai'
import type { StreamChunk, StreamDurability } from '@tanstack/ai'
// Your backend, one append-only log per run. Back it with Redis Streams, a
// Postgres table, a queue. Anything that returns a stable cursor per entry.
interface RunLog {
append: (chunks: Array<StreamChunk>) => Promise<Array<string>>
readAfter: (
cursor: string | null,
) => Promise<Array<{ cursor: string; chunk: StreamChunk }>>
isComplete: () => Promise<boolean>
waitForChange: (signal?: AbortSignal) => Promise<void>
markComplete: () => Promise<void>
}
function isTerminal(chunk: StreamChunk): boolean {
return chunk.type === EventType.RUN_FINISHED || chunk.type === EventType.RUN_ERROR
}
export function customDurability(
request: Request,
openLog: (runId: string) => RunLog,
): StreamDurability {
const url = new URL(request.url)
// The resume offset: native SSE reconnect header first, then a join's ?offset.
const resume =
request.headers.get('Last-Event-ID') ?? url.searchParams.get('offset')
// Your adapter owns run identity. A real backend decodes the runId from the
// resume offset; this example takes the client's chosen id from the
// X-Run-Id header (a POST producer) or the ?runId query (a GET join), and
// otherwise mints a fresh one.
const runId =
request.headers.get('X-Run-Id') ??
url.searchParams.get('runId') ??
crypto.randomUUID()
const log = openLog(runId)
return {
resumeFrom: () => resume,
append: (chunks) => log.append(chunks),
close: () => log.markComplete(),
read: async function* (offset, signal) {
// '-1' / 'now' are the from-start / from-tail join sentinels.
let cursor: string | null = offset === '-1' ? null : offset
for (;;) {
if (signal?.aborted) return
const entries = await log.readAfter(cursor)
for (const entry of entries) {
cursor = entry.cursor
yield { offset: entry.cursor, chunk: entry.chunk }
if (isTerminal(entry.chunk)) return
}
if (await log.isComplete()) return
// Park. Do NOT end the response here while the producer is alive.
await log.waitForChange(signal)
}
},
}
}Wire it up exactly like the built-in adapters:
import { chat, chatParamsFromRequest, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
// Your modules: the adapter above, and your backend's per-run log factory.
import { customDurability } from './durability'
import { openRunLog } from './run-log'
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request)
const stream = chat({ adapter: openaiText('gpt-5.5'), messages, threadId, runId })
return toServerSentEventsResponse(stream, {
durability: { adapter: customDurability(request, openRunLog) },
})
}For NDJSON, swap toServerSentEventsResponse for toHttpResponse. The adapter is identical; only the wire encoding changes.
StreamDurability<TOffset> is generic over the offset string. Brand it so a raw string can't be passed where one of your offsets is expected:
import type { StreamDurability } from '@tanstack/ai'
type MyOffset = string & { readonly __brand: 'MyOffset' }
// Your adapter is then StreamDurability<MyOffset>; append/read/resumeFrom all
// speak MyOffset, and a plain string won't type-check where one is expected.
type MyAdapter = StreamDurability<MyOffset>Core still treats the value as opaque; the brand only tightens your own code.
Core awaits close() on every producer exit (normal completion, cancellation, and failure) and appends a terminal RUN_ERROR on cancel/failure before closing. Your close() must make read's isComplete() return true and wake parked readers, so a caught-up reader stops rather than hanging. If your backend producer can die without running close() (process crash), add a lease/reaper that terminalizes abandoned logs. See Process death.