ai-sdk-threads
API

chatHandler

One call that replaces the load, store, stream, store boilerplate every AI SDK app writes by hand.

Returns a (request: Request) => Promise<Response> - usable directly as a Next.js App Router POST, a React Router action, or any fetch-based server. It owns the persistence choreography so your route only says which model to call.

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

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

Options

OptionRequiredWhat it does
storeyesThe ThreadStore to persist into.
executeyesCalled with { threadId, uiMessages, modelMessages, request }; return a streamText result.
createThreadnoCalled when a request names a thread that does not exist yet. Return { userId?, metadata? }.
authorizenoCalled with { thread, request } for an existing thread. Return false to answer 403.
generateTitlenoCalled once per thread with { firstUserMessage }. Runs detached - it never delays the response.
onErrornoReturn a Response to replace the default 500, or undefined to keep it.

What it does, in order

  1. Parses the body, answering 400 on anything malformed.
  2. Loads the thread - creating it (scoped via createThread) if this request is the first to name it, or running authorize if it already exists.
  3. Validates the messages this request adds, and rejects anything the SDK cannot parse with 400 rather than storing it.
  4. Stores the new message before streaming, so a mid-stream crash cannot lose it.
  5. Calls execute with the thread's full validated history.
  6. Streams the reply and stores it on completion, with a server-generated message id.
  7. Runs the model stream to completion server-side, so a slow or suspended client cannot leave the generation half-made.

Securing a thread

Thread ids come from the client, so authorize is what stops one user reading another's conversation. Without it, any caller who knows or guesses an id can post into that thread and get the model's answer with the whole history as context. createThread does not cover this - it only fires for ids that do not exist yet.

// app/api/chat/route.ts
import { openai } from "@ai-sdk/openai";
import { generateText, streamText } from "ai";
import { chatHandler } from "ai-sdk-threads/handler";
import { store } from "@/lib/threads";

declare function userIdFrom(request: Request): Promise<string>;

export const POST = chatHandler({
  store,
  execute: ({ modelMessages }) => streamText({ model: openai("gpt-5"), messages: modelMessages }),

  // New thread: record who owns it.
  createThread: async ({ request }) => ({ userId: await userIdFrom(request) }),

  // Existing thread: prove the caller owns it, or 403.
  authorize: async ({ thread, request }) => thread.userId === (await userIdFrom(request)),

  generateTitle: async ({ firstUserMessage }) => {
    const { text } = await generateText({
      model: openai("gpt-5-mini"),
      prompt: `Title this in under six words:\n\n${JSON.stringify(firstUserMessage.parts)}`,
    });
    return text;
  },
});

What the handler refuses to store

Only user messages are ever stored from a request. A system or assistant message the client sends is dropped - never persisted, so it cannot forge context that later turns inherit - but not rejected, because a client legitimately holds an assistant reply that was truncated and never stored, and reposts it on every turn from then on. Rejecting those would make the thread permanently unusable.

Editing is likewise restricted to the client's own turns: a messageId pointing at an assistant or system message answers 400, so a client cannot rewrite the model's words into its own.

Both wire shapes work

The default transport posts the whole conversation each turn; a custom prepareSendMessagesRequest that posts only { id, message } works too. The handler stores only messages it has not already seen, so neither shape duplicates rows.

Errors and disconnects

A throwing execute answers 500 (or whatever onError returns) and leaves the user's message stored with no half-written reply, so the client can retry.

A client that disconnects mid-stream leaves the reply empty or truncated. The handler detects that and stores nothing, rather than leaving a message that renders as forever-in-progress and feeds a half-sentence to the model on the next turn.

If storing the reply fails, it is logged via console.error rather than thrown into stream teardown, where nothing could act on it.

Without the handler

The store works on its own if you want to own the route. Three things the handler does for you that are easy to get wrong by hand:

  • Pass generateMessageId, and do not pass originalMessages. Rows are keyed by message id. Without generateMessageId the SDK leaves a reply's id empty; and if originalMessages ends with an assistant message, the SDK reuses that id and the new reply collides with the stored row and is lost.
  • Register both onEnd and onFinish. onEnd is ai 7's name, onFinish is ai 6's. Registering only one means no reply is stored on the other major.
  • Store only what is new. The default transport reposts the whole conversation every turn, so filter against what you already have rather than appending what arrived.
// app/api/chat/route.ts
import { openai } from "@ai-sdk/openai";
import { convertToModelMessages, generateId, streamText, type UIMessage } from "ai";
import { store } from "@/lib/threads";

export async function POST(req: Request) {
  const { id, messages } = (await req.json()) as { id: string; messages: UIMessage[] };

  const existing = await store.loadMessages(id);
  const known = new Set(existing.map((m) => m.id));
  const fresh = messages.filter((m) => m.role === "user" && !known.has(m.id));
  if (fresh.length > 0) await store.appendMessages(id, fresh);

  const history = [...existing, ...fresh];
  const result = streamText({
    model: openai("gpt-5"),
    messages: await convertToModelMessages(history),
  });

  let persisted = false;
  const persist = async ({ responseMessage }: { responseMessage: UIMessage }) => {
    if (persisted || responseMessage.parts.length === 0) return;
    persisted = true;
    await store.appendMessages(id, [responseMessage]);
  };

  return result.toUIMessageStreamResponse({
    generateMessageId: generateId,
    onEnd: persist,
    onFinish: persist,
  });
}

That is most of what chatHandler does, minus authorization, branching, and the truncated-reply handling - which is the argument for using it.

On this page