Resumable Streams

Custom Durability Adapter

Custom Durability Adapter

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:

MethodJob
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.

The rules that matter

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.

Implement it

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:

ts
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:

ts
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.

Type your offsets (optional)

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:

ts
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.

Terminalization is on you

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.