- MCP Server ToolsUpdated
- Managed MCP with chat()Updated
- Manual MCPUpdated
- MCP Type GenerationUpdated
- MCP Client InputUpdated
- MCP Server Tools: AdvancedNew
- MCP EventsNew
- MCP AppsUpdated
- Serve Tools over HTTPUpdated
- Serve Resources and PromptsUpdated
- Ask for InputUpdated
- Draft Text from a ToolUpdated
- MCP Server TasksUpdated
- MCP Server AuthUpdated
- MCP Server SessionsUpdated
- MCP Server on stdioUpdated
Persistence (Advanced)
- GenerationsUpdated
- Realtime Voice ChatUpdated
- Text-to-SpeechUpdated
- TranscriptionUpdated
- Transcription: AdvancedNew
- Voice CreationUpdated
- Audio RecordingUpdated
- Audio GenerationUpdated
- Image GenerationUpdated
- Image Generation: AdvancedNew
- Video GenerationUpdated
- Video Generation: AdvancedNew
- Live GenerationUpdated
- World GenerationUpdated
- Generation HooksUpdated
- OverviewUpdated
- Quick StartUpdated
- ProvidersUpdated
- HarnessesUpdated
- Harness AuthUpdated
- WorkspaceUpdated
- ToolsUpdated
- PolicyUpdated
- Lifecycle & SnapshotsUpdated
- Portable SnapshotsUpdated
- Keep Files After ReloadUpdated
- Save a Named VersionUpdated
- Branch From a VersionUpdated
- Send a Frozen FileUpdated
- Let the Agent Save and ForkUpdated
- Pick Which Files to KeepUpdated
- What a Snapshot StoresUpdated
- Instance DurabilityUpdated
- Durable RunsUpdated
- EventsUpdated
Sandboxes (Advanced)
- Runtime ContextUpdated
- Bring Your Own KeyUpdated
- Debug LoggingUpdated
- Multimodal ContentUpdated
- Files APIUpdated
- Per-Model Type SafetyUpdated
- Runtime Adapter SwitchingUpdated
- Tree-ShakingUpdated
- Extend AdapterUpdated
- Extend Adapter: AdvancedNew
- Typed Pre-Configured OptionsUpdated
- Test with a Fake ModelUpdated
- Prompt CachingUpdated
- Mid-Conversation ChangesUpdated
- Approval Flow ProcessingUpdated
Custom Durability Adapter
A StreamDurability adapter stores the stream log in Redis, Postgres, a queue, or an object store. A client can reconnect without calling the model again.
Note
On Upstash Redis, a third-party adapter is available: upstashStream() from @upstash/agentkit-tanstack-ai. See Upstash.
Build the adapter#
- Implement the five methods over your store. Core never reads your store. It only passes back the offset strings that you give it.
| Method | Job |
|---|---|
| resumeFrom() | Return the resume offset of this request, or null for a fresh run. |
| append(chunks) | Store 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 waiting readers. |
| snapshot() | Return everything stored for this run now, without waiting. |
Here the adapter wraps an append-only log per run. Replace RunLog with your backend:
import type { StreamChunk, StreamDurability } from '@tanstack/ai'
// Your backend, one append-only log per run. Use Redis Streams, a Postgres
// table, or 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>
// Everything stored so far, in append order. Must not wait for more.
readAll: () => Promise<Array<{ cursor: string; chunk: StreamChunk }>>
}
export function customDurability(
request: Request,
openLog: (runId: string) => RunLog,
): StreamDurability {
const url = new URL(request.url)
// The resume offset: the 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. Use the same order as core's
// `resolveResumeRunId` and `durableStream`: the X-Run-Id header first (a
// POST producer sends it), then the ?runId query (a GET join sends it).
// Never make up a new id when both are missing. No attach request can
// name that log, so the run writes where nobody reads.
const runId =
request.headers.get('X-Run-Id') ?? url.searchParams.get('runId')
if (runId === null) {
throw new Error(
'a runId is required: send it as an X-Run-Id header or a ?runId query param',
)
}
const log = openLog(runId)
return {
resumeFrom: () => resume,
append: (chunks) => log.append(chunks),
close: () => log.markComplete(),
read: async function* (offset, signal) {
// '-1' and 'now' are the from-start and from-tail join values.
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 terminal chunks like any other. An agent-loop run emits a
// RUN_FINISHED per iteration, so a return here cuts a resumed
// tool-calling run at its first tool call.
yield { offset: entry.cursor, chunk: entry.chunk }
}
// The ONLY end-of-log condition: the producer called `close()`.
if (await log.isComplete()) return
// Wait. Do NOT end the response here while the producer is alive.
await log.waitForChange(signal)
}
},
snapshot: async () => {
const entries = await log.readAll()
return entries.map((entry) => ({
offset: entry.cursor,
chunk: entry.chunk,
}))
},
}
}- Pass the adapter to the response, the same way as a built-in adapter:
import {
chat,
chatParamsFromRequest,
resumeServerSentEventsResponse,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
// Your modules: the adapter above, and the per-run log factory of your backend.
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-6.1-sol'), messages, threadId, runId })
return toServerSentEventsResponse(stream, {
durability: { adapter: customDurability(request, openRunLog) },
})
}
export async function GET(request: Request) {
// Replays the run from your log. No model call happens here.
return resumeServerSentEventsResponse({
adapter: customDurability(request, openRunLog),
})
}- Start a reply, then reload the page. The client reconnects and the reply finishes from your store.
For NDJSON, use toHttpResponse and resumeHttpResponse. The adapter is the same. Only the wire encoding changes.
How it works#
Core buffers the chunks, calls append, and sends a chunk only after you return its offset. You do not handle ordering or the append-before-send rule. On a reconnect, core calls resumeFrom() and then read from that offset. On every producer exit, core awaits close().
The rules that matter#
Break one of these and resume fails in ways that are hard to see:
- Offsets are opaque, unique, and safe to send back. Return a different offset for each chunk. An offset travels on an SSE id: line or inside an NDJSON { id, chunk } envelope. Core rejects an empty offset, an offset with NUL, CR, or LF, an offset with leading or trailing whitespace, and a duplicate.
- read replays strictly after the offset, oldest first.
- read ends only when the log is closed, never when it sees a terminal chunk. See the next section for the reason.
- read must never end the response empty while the run still produces. Wait for the next append. A clean end with no new data tells the client that the run is over. If the run is not over, the client fails with DurableStreamIncompleteError. Obey the abort signal, so a client that left stops the wait.
- snapshot never waits. It returns what is stored now, in append order, even while the run still produces. It returns [] for a run with nothing stored.
Why read ignores terminal chunks#
The read loop of memoryStream in packages/ai/src/stream-durability.ts follows this rule, and a test pins it:
- A terminal chunk (RUN_FINISHED or RUN_ERROR) does not end the read.
- An agent-loop run emits one terminal per iteration (finishReason "tool_calls", then "stop"). A read that stops on the first one cuts a tool-calling run at its first tool call.
- The producer signals the real end by calling close() on every exit. That sets log.complete.
- The read follows the log until then, or until the caller aborts.
An adapter that returns at the first terminal chunk cuts every resumed tool-calling run. The resumed client sees the first tool call and then a clean end, which it reads as "the run is over". close() is your only end-of-log signal. Core awaits it on every producer exit (completion, cancellation, and failure), so a read that waits for it always ends.
Why snapshot must not wait#
A caller uses snapshot to see the prefix that a previous host stored before it resumes. It does not want to follow the log. A producer that crashed never called close(), so its log stays open forever, and a read over it never ends. If snapshot waits, resume hangs on exactly the logs that need it.
Do not reuse the unknown-run failure of a from-start read. read('-1') on an empty log can fail. snapshot() must return []. A rejection for a transport, protocol, or authorization failure is still correct.
Advanced#
Re-persisting a stored range#
Some stores can write at a key that the caller chooses. If yours can, you can expose that as an extra capability. A caller that replays a range it already streamed can then put each chunk back on the entry it already has.
You do not need this to resume a run. To restart in the middle of a run, read the stored prefix with snapshot(), skip it, and append only the rest. That keeps the log append-only and works on every adapter. See Resuming a run without duplicating what you already streamed.
The capability is a separate method, upsert:
- Implement it, and your adapter is an UpsertableStreamDurability.
- Leave it out, and your adapter is a plain StreamDurability.
A consumer that needs the capability asks for UpsertableStreamDurability. A mismatch is then a compile error where you wire it, not a failure in a run log.
Offer upsert only if your store can write at a key that the caller chose. Examples are a Postgres INSERT ... ON CONFLICT (cursor) DO UPDATE, or a Redis XADD with an explicit, de-duplicated ID. A store that sets its own cursor on each write cannot. It does not expose upsert. durableStream is such a store. memoryStream does expose upsert.
Each entry pairs a chunk with its offset, so nothing is matched by position. Validate the whole batch before you change stored state, so a rejected call never applies part of the batch. Reject:
- an offset that you did not make yourself (every offset you made is resumable by definition),
- an offset that repeats inside one batch,
- an offset that is not stored yet and is not after the current tail. A caller that replays an overlap must replay a continuous suffix. It cannot write into a position it skipped.
import type { StreamChunk, UpsertableStreamDurability } from '@tanstack/ai'
// Your backend, plus the one operation `upsert` needs beyond `append`: a write
// at a cursor you supply that replaces what is already stored there.
interface UpsertableRunLog {
tailSeq: () => Promise<number>
hasOffset: (offset: string) => Promise<boolean>
write: (
entries: Array<{ chunk: StreamChunk; offset: string }>,
) => Promise<Array<string>>
}
// Offsets here are `${runId}:${seq}`. Yours can use any format that you can
// decode back into a run id and a position.
function decodeSeq(runId: string, offset: string, index: number): number {
const prefix = `${runId}:`
const seq = offset.startsWith(prefix)
? Number(offset.slice(prefix.length))
: Number.NaN
if (!Number.isSafeInteger(seq) || seq < 1) {
throw new Error(
`entries[${index}].offset ${JSON.stringify(offset)} was not minted by this run`,
)
}
return seq
}
// The returned function is `async`, so every rejection reaches the caller as a
// rejected promise, not a synchronous throw.
export function makeUpsert(
log: UpsertableRunLog,
runId: string,
): UpsertableStreamDurability['upsert'] {
return async (entries) => {
let tail = await log.tailSeq()
const seen = new Set<string>()
for (const [index, entry] of entries.entries()) {
const seq = decodeSeq(runId, entry.offset, index)
if (seen.has(entry.offset)) {
throw new Error(
`entries[${index}].offset ${JSON.stringify(entry.offset)} is repeated in this batch`,
)
}
seen.add(entry.offset)
if (await log.hasOffset(entry.offset)) continue
if (seq <= tail) {
throw new Error(
`entries[${index}].offset ${JSON.stringify(entry.offset)} is not stored yet but claims position ${seq}, at or before the tail ${tail}`,
)
}
tail = seq
}
// Every entry passed, so the write below cannot fail partway and leave a
// prefix of the batch applied.
return log.write(entries)
}
}Give that to the adapter as upsert: makeUpsert(log, runId) next to the five methods. Then type the result as UpsertableStreamDurability, so the extra capability shows in the type.
Type your offsets#
StreamDurability<TOffset> is generic over the offset string. Brand it, so a plain string cannot go where one of your offsets is expected:
import type { StreamDurability } from '@tanstack/ai'
type MyOffset = string & { readonly __brand: 'MyOffset' }
// append, read, and resumeFrom all use MyOffset. A plain string does not
// type-check where a MyOffset is expected.
type MyAdapter = StreamDurability<MyOffset>Core still treats the value as opaque. The brand only makes your own code stricter.
Terminalization is on you#
Core awaits close() on every producer exit: normal completion, cancellation, and failure. On cancellation and failure, it appends a terminal RUN_ERROR before the close.
Your close() must make isComplete() return true and wake waiting readers, so a reader that caught up stops and does not hang. It is the only thing that ends a read, because the terminal chunk does not (see the rules).
If your producer can die without calling close() (a process crash), add a lease and a reaper that terminalize abandoned logs. See Process death.