ai-sdk-threads
API

resumableChat

The POST, GET and DELETE trio, so a reload halfway through an answer picks the stream back up.

A reload halfway through an answer normally loses it: the stream was tied to a request that no longer exists. resumableChat takes every chatHandler option and returns the three handlers a resuming client needs.

// app/api/chat/route.ts
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
import { resumableChat } from "ai-sdk-threads/resume";
import { store } from "@/lib/threads";

export const { POST } = resumableChat({
  store,
  execute: ({ modelMessages }) => streamText({ model: openai("gpt-5"), messages: modelMessages }),
});
// app/api/chat/[id]/stream/route.ts - the path the SDK's client resumes from
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
import { resumableChat } from "ai-sdk-threads/resume";
import { store } from "@/lib/threads";

export const { GET, DELETE } = resumableChat({
  store,
  execute: ({ modelMessages }) => streamText({ model: openai("gpt-5"), messages: modelMessages }),
});

Splitting the handlers across two route files works because the default stream context is a single process-wide instance.

Then turn resuming on in the client:

"use client";
import { useChat } from "@ai-sdk/react";
import type { UIMessage } from "ai";

export function Chat({ id, initialMessages }: { id: string; initialMessages: UIMessage[] }) {
  const { messages, sendMessage } = useChat({ id, messages: initialMessages, resume: true });
  return <>{/* your UI */}</>;
}

The three handlers

HandlerBehaviour
POSTchatHandler, plus: records a stream id on the thread before answering, and clears it once the stream ends.
GETReplays a stream that is still in flight. 204 when there is nothing to resume, which is what the client expects.
DELETEForgets the active stream, so a later GET answers 204. Always 204.

authorize runs on GET and DELETE too, not just POST - otherwise anyone with a thread id could read another user's in-flight reply.

Extra options

OptionRequiredWhat it does
streamContextnoWhere in-flight streams live. Defaults to in-process - see below.
threadIdFromnoHow to read the thread id from a GET/DELETE. Defaults to the <threadId>/stream path the client uses.

The default context is in-process

That is a real limitation, not a detail. It resumes only within the instance that served the POST, so on more than one instance - or any serverless deployment - a resume can land on a process that never saw the stream and gets a 204. Pass a Redis-backed context for those:

// app/api/chat/route.ts
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
import { resumableChat } from "ai-sdk-threads/resume";
import { createResumableStreamContext } from "resumable-stream/ioredis";
import { store } from "@/lib/threads";

export const { POST, GET, DELETE } = resumableChat({
  store,
  execute: ({ modelMessages }) => streamText({ model: openai("gpt-5"), messages: modelMessages }),
  streamContext: createResumableStreamContext({ waitUntil: null }),
});

resumable-stream is an optional peer, so add it when you use this module: npm install resumable-stream. Redis on top of that is your choice, not this package's - resumable-stream itself ships with no dependencies.

DELETE forgets, it does not abort

resumable-stream exposes no abort, so the generation finishes server-side - and is still persisted - but stops being resumable. That is what a stop button wants in practice: the answer is saved, the client stops following it.

Migration

This module needs one nullable column, which the schema already includes:

ALTER TABLE ai_sdk_threads ADD COLUMN active_stream_id text;

On this page