Skip to main content

Documentation Index

Fetch the complete documentation index at: https://s2.dev/docs/llms.txt

Use this file to discover all available pages before exploring further.

S2 integrates with TanStack AI through the @s2-dev/resumable-stream package. The integration persists TanStack StreamChunk events to S2 and replays them through a useChat connection adapter.

Prerequisites

npm install @s2-dev/resumable-stream @tanstack/ai @tanstack/ai-react @tanstack/ai-client
If you use the OpenAI adapter from TanStack AI:
npm install @tanstack/ai-openai
Create an S2 access token and basin first:
  • Sign up here, generate an access token, and set it as S2_ACCESS_TOKEN in your env.
  • Create a basin from the Basins tab with Create Stream on Append and Create Stream on Read enabled, and set it as S2_BASIN in your env.
The TanStack helper does not create streams manually. Stream creation should be handled by the basin configuration above.

Import paths

The integration has separate server and client entrypoints.
// Server routes only
import { createResumableChat } from '@s2-dev/resumable-stream/tanstack-ai';

// Browser/client code only
import { createS2Connection } from '@s2-dev/resumable-stream/tanstack-ai/client';
Keep these split. The server entrypoint creates S2 clients and uses access tokens; the client entrypoint is browser-safe and only talks to your HTTP routes.

Setup

Create one chat helper and share it across your routes:
lib/s2.ts
import { createResumableChat } from '@s2-dev/resumable-stream/tanstack-ai';

export const chat = createResumableChat({
  accessToken: process.env.S2_ACCESS_TOKEN!,
  basin: process.env.S2_BASIN!,
  mode: 'session',
});
Use one stable S2 stream name per chat:
lib/stream-name.ts
const CHAT_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;

export function isValidChatId(value: unknown): value is string {
  return typeof value === 'string' && CHAT_ID_PATTERN.test(value);
}

export function streamName(chatId: string): string {
  return `tanstack-ai-chat-${chatId}`;
}

Server: POST route

In session mode, the POST route starts generation and returns quickly. Chunk delivery happens through the replay route, not the POST response.
src/routes/api.chat.ts
import { createFileRoute } from '@tanstack/react-router';
import { chat as tanstackChat } from '@tanstack/ai';
import { openaiText } from '@tanstack/ai-openai';
import {
  getLatestUserText,
  toTextMessages,
} from '@s2-dev/resumable-stream/tanstack-ai';
import { chat } from '@/lib/s2';
import { isValidChatId, streamName } from '@/lib/stream-name';

export const Route = createFileRoute('/api/chat')({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const body = await request.json();
        if (!isValidChatId(body.id)) {
          return new Response('Missing or invalid id', { status: 400 });
        }

        if (!getLatestUserText(body.messages)) {
          return new Response('Expected at least one user message', { status: 400 });
        }

        return chat.makeSessionResponse(streamName(body.id), {
          messages: body.messages,
          source: (currentMessages) =>
            tanstackChat({
              adapter: openaiText(process.env.OPENAI_MODEL ?? 'gpt-4o-mini'),
              messages: toTextMessages(currentMessages),
            }),
        });
      },
    },
  },
});
makeSessionResponse treats S2 as the source of truth. It reads the durable chat stream, appends the latest submitted user message if it is not already stored, then starts your source function with the S2-derived messages. The POST route returns 202; the replay route delivers the stored user chunk and model chunks. The example omits waitUntil because TanStack Start route handlers do not have a Next.js-style after API. If your deployment runtime can stop background work after a response is returned, pass that runtime’s background-task hook as waitUntil.

Server: replay route

The replay route owns all session delivery.
src/routes/api.chat.replay.ts
import { createFileRoute } from '@tanstack/react-router';
import { chat } from '@/lib/s2';
import { isValidChatId, streamName } from '@/lib/stream-name';

function parseFromSeqNum(value: string | null): number | undefined {
  if (value === null) return undefined;
  const parsed = Number.parseInt(value, 10);
  return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
}

export const Route = createFileRoute('/api/chat/replay')({
  server: {
    handlers: {
      GET: ({ request }) => {
        const url = new URL(request.url);
        const id = url.searchParams.get('id');
        if (!isValidChatId(id)) {
          return new Response('Missing id query parameter', { status: 400 });
        }

        return chat.replay(streamName(id), {
          fromSeqNum: parseFromSeqNum(url.searchParams.get('from')),
        });
      },
    },
  },
});
When fromSeqNum is omitted, chat.replay first emits a TanStack MESSAGES_SNAPSHOT, then tails from the snapshot cursor. When fromSeqNum is present, replay starts from that cursor. Replay responses include SSE id fields, and the client adapter stores those ids as the next reconnect cursor.

Client

Pass the send and replay endpoints to the TanStack connection.
app/page.tsx
'use client';

import { useChat } from '@tanstack/ai-react';
import { createS2Connection } from '@s2-dev/resumable-stream/tanstack-ai/client';
import { useMemo } from 'react';

function Chat({ chatId }: { chatId: string }) {
  const connection = useMemo(
    () =>
      createS2Connection({
        mode: 'session',
        sendUrl: '/api/chat',
        subscribeUrl: `/api/chat/replay?id=${encodeURIComponent(chatId)}`,
        body: { id: chatId },
      }),
    [chatId],
  );

  const chat = useChat({
    connection,
    live: true,
  });

  // render your chat UI...
  return null;
}
The important pieces are:
  • subscribeUrl is both the initial snapshot read and the live replay stream.
  • live: true tells TanStack useChat to call connection.subscribe() on mount.
  • body: { id: chatId } includes the chat id on send requests.
Session mode requires live: true because makeSessionResponse returns 202 and chunks arrive through the replay subscription.

Flow

  1. The browser mounts and calls connection.subscribe().
  2. chat.replay reads the current S2 stream and emits MESSAGES_SNAPSHOT.
  3. The same replay response keeps tailing new chunks.
  4. The browser sends a message with the normal TanStack messages array.
  5. makeSessionResponse rereads S2, stores only the latest missing user message, then starts the model.
  6. Model chunks are written to S2 and delivered through the open replay response.
  7. Reconnects continue from the last SSE id.

Modes

mode controls how generations map to S2 streams.
modeuse whenrefresh behavior
single-useEach assistant turn has its own stream name.Replays only the active turn.
sharedOne stream name should hold the active generation only.Replays the active generation after lease/fence coordination.
sessionYou are building a full chat session.Replay bootstraps a snapshot, then tails later chunks.
For single-use and shared, you can omit subscribeUrl and stream chunks directly on the POST response. If you pass subscribeUrl, the client can recover an active generation on mount.

Configuration

Relevant options on createResumableChat:
optiondefaultwhat it controls
mode"single-use""single-use" uses one stream per generation. "shared" reuses one active-generation stream. "session" stores a durable chat session log.
endpointsS2 defaultsOptional endpoint overrides, for example when using S2 Lite.
leaseDurationMs5000Only for shared and session. Max pause within an active generation before a new claim can take it over.
onErrorgeneric messageMaps upstream errors to the RUN_ERROR chunk shown to the client.
batchSize / lingerDuration10 / 50msS2 append batching knobs.
Relevant options on createS2Connection:
optionwhat it controls
modeMust match the server mode. Use "session" for chat sessions.
sendUrlPOST endpoint that starts generation.
subscribeUrlGET endpoint that streams chunks. Required for session; optional for single-use and shared.
headers / credentialsAuth and fetch options for both send and subscribe requests.
bodyExtra fields merged into the POST request body.
fetchCustom fetch implementation for tests or framework integrations.

Example

A runnable TanStack Start chat app demonstrating session replay is available in the SDK repo: examples/tanstack-ai-chat.