# Getting started (/en/docs/getting-started)
## Prerequisites [#prerequisites]
* Node.js `>=20`
* A Postgres database and a drizzle instance pointed at it, or [SQLite](/en/docs/api/sqlite)
* `ai` `>=6 <8`. CI runs the whole suite against both `7.0.x` and the `6.x` floor.
## Install [#install]
```bash
npm install ai-sdk-threads drizzle-orm
```
`ai-threads` and `ai-sdk-persistence` on npm are name reservations only - they contain no code and are not maintained. Install `ai-sdk-threads`.
## Add the tables to your schema [#add-the-tables-to-your-schema]
The two tables are plain drizzle `pgTable` objects. Re-export them from your schema file so your existing migration tooling picks them up:
```ts
// db/schema.ts
export { messages, threads } from "ai-sdk-threads/drizzle";
```
Then generate and run a migration the way you already do, for example with drizzle-kit:
```bash
npx drizzle-kit generate
npx drizzle-kit migrate
```
This creates `ai_sdk_threads` and `ai_sdk_messages`. The `ai_sdk_` prefix keeps them from colliding with your application tables. The full column list is in [Schema](/en/docs/api/schema).
## Create the store once [#create-the-store-once]
```ts
// lib/threads.ts
import { createThreadStore } from "ai-sdk-threads/drizzle";
import { db } from "./db";
export const store = createThreadStore(db);
```
## The chat route [#the-chat-route]
Your whole route is the handler. It loads the thread, stores the incoming message before streaming, streams the answer, and stores the reply with a server-generated id:
```ts
// 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 }),
});
```
That route is missing one thing before production: **authorization**. Thread ids come from the client, so without an `authorize` callback any caller who guesses an id can read that conversation. See [Securing a thread](/en/docs/api/chat-handler#securing-a-thread) before you deploy.
## Load the history [#load-the-history]
Read the thread on the server and hand it straight to `useChat`:
```tsx
// app/chat/[id]/page.tsx
import { notFound } from "next/navigation";
import { currentUserId } from "@/lib/auth";
import { store } from "@/lib/threads";
import { Chat } from "./chat";
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const userId = await currentUserId();
const thread = await store.getThread(id);
// The id comes from the URL, so the page needs the same ownership check the route does:
// `authorize` guards chatHandler, not this render. Answer 404 rather than 403, so an
// unauthorized reader cannot tell an existing thread from a missing one.
if (thread && thread.userId !== userId) notFound();
// loadMessages throws for a thread that does not exist, and a fresh chat id is exactly that.
const messages = thread ? await store.loadMessages(id) : [];
return ;
}
```
```tsx
// app/chat/[id]/chat.tsx
"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 });
return <>{/* your UI */}>;
}
```
`loadMessages` returns the messages already validated by the SDK's own `validateUIMessages`, so a row that no longer matches the SDK's shape fails here rather than inside your renderer.
## Create a thread before the first message [#create-a-thread-before-the-first-message]
If you would rather the page never meet an id without a thread behind it, create the thread server-side and redirect to it. The thread starts empty, so the first render still shows no messages - but it renders without the missing-thread case:
```ts fragment
const thread = await store.createThread({ userId: "user_123", title: "New chat" });
// redirect(`/chat/${thread.id}`)
```
## Where to go next [#where-to-go-next]
* [`chatHandler`](/en/docs/api/chat-handler) - every option, and what the handler does in order
* [Securing a thread](/en/docs/api/chat-handler#securing-a-thread) - the one thing not to skip
* [Branching](/en/docs/api/branching) - regenerate and edit-fork, which work with no extra code
* [`resumableChat`](/en/docs/api/resumable-chat) - survive a reload mid-answer
# Importing from the Vercel template (/en/docs/importing)
If you started from Vercel's `ai-chatbot` template, its `Chat` and `Message_v2` tables map straight across - it already stores `UIMessage` parts, so this is a copy plus the parent chain this schema uses.
```bash
npx ai-sdk-threads import-vercel --database-url "$DATABASE_URL" --dry-run
npx ai-sdk-threads import-vercel --database-url "$DATABASE_URL"
```
Threads that already exist are skipped, so a rerun after a partial import is safe.
## Against a database handle you already have [#against-a-database-handle-you-already-have]
Both commands are exported, if you would rather run them programmatically than pass a connection string:
```ts
import { importVercelChat, migrateDatabase } from "ai-sdk-threads/cli";
```
## What the import does [#what-the-import-does]
* Each `Chat` row becomes an `ai_sdk_threads` row, keeping its id and `userId`.
* Each `Message_v2` row becomes an `ai_sdk_messages` row with its `parts` verbatim.
* Messages are chained into a linear `parent_id` path in `createdAt` order, and the thread's `active_leaf_id` is set to the last one - so the imported conversation is the live path and [branching](/en/docs/api/branching) works from there on.
* Rows are stamped with the `ai` major that the store is configured for.
The CLI needs a Postgres driver of its own - `npm i -D pg` - because this package ships none.
## Why `Message_v2` exists at all [#why-message_v2-exists-at-all]
When the stored message shape changed, migrating the original `Message` table in place was not practical, so the template added `Message_v2` beside it, backfilled, and later dropped the original. The old table is gone; the version number is still in the name of the one you have. That is the problem `sdk_version` exists to avoid here - see [Migrating](/en/docs/migrating).
# Introduction (/en/docs)
The AI SDK gives you `useChat` and a streaming route. It does not give you anywhere to put the conversation. Every project ends up writing the same two tables, the same append-on-finish hook, and the same load-on-mount query - and usually flattens `UIMessage.parts` into a `content` string on the way in, which quietly loses tool calls, reasoning, and files.
ai-sdk-threads is those two tables and a small typed store over them. Message `parts` go into `jsonb` exactly as the SDK produced them, so what comes back out is what `useChat` rendered - tool invocations and their outputs included. It is your database and your rows; this package owns no service and phones nothing home.
## Why it exists [#why-it-exists]
The stored message shape is the part of an AI SDK app most exposed to the SDK's release cadence. Vercel's own `ai-chatbot` template carries a `Message_v2` table because migrating the original one in place was not practical. Every message row here records the `ai` major that wrote it in `sdk_version`, which is what turns an upgrade into something checkable rather than hopeful.
Two gaps in the SDK are filled directly:
* **`ModelMessage` to `UIMessage`** conversion, which the SDK still does not ship as of `ai` 7 ([vercel/ai#7180](https://github.com/vercel/ai/issues/7180)). See [convertToUIMessages](/en/docs/api/convert).
* **Branching storage.** The UI for regenerate and edit-fork exists in ai-elements; nothing in the ecosystem persists the tree behind it ([vercel/ai#2929](https://github.com/vercel/ai/issues/2929), open since 2024). See [Branching](/en/docs/api/branching).
## What you get [#what-you-get]
| | |
| ---------------------------------------------- | ------------------------------------------------------------------------------ |
| [`chatHandler`](/en/docs/api/chat-handler) | One call replaces the load, store, stream, store boilerplate |
| [`resumableChat`](/en/docs/api/resumable-chat) | The POST, GET and DELETE trio, so a reload mid-answer picks the stream back up |
| [Branching](/en/docs/api/branching) | Edit or regenerate and the old version survives as a sibling |
| [The store](/en/docs/api/store) | Threads and messages over Postgres, with keyset pagination |
| [SQLite](/en/docs/api/sqlite) | The same contract, verified by a parity suite against both |
| [Migrating](/en/docs/migrating) | `sdk_version` on every row, plus a CLI that checks rather than assumes |
Zero runtime dependencies: `ai`, `drizzle-orm` and `resumable-stream` are peers, the last two optional. The core carries no Node globals, enforced by a second typecheck in CI, so it runs on edge runtimes.
## What it is not [#what-it-is-not]
* **Not a chat UI.** ai-elements and assistant-ui own that; this stores what they render.
* **Not an agent framework or a memory system.** No tool orchestration, no vector search, no summarisation.
* **Not a hosted service.** Your database by default. **Everything the package does today stays free and MIT** - the store, the route handlers, `convertToUIMessages`, branching, resumable streams, both adapters and the migration tooling. If a managed layer ever exists it could only add cross-device sync and hosted search over conversations, never take any of this back. For a managed layer today, assistant-ui's cloud and Convex both do it well.
* **Not for `ai` 4 or older.** `ai` 5 was a rewrite; the supported range is `>=6 <8`.
## Next [#next]
Start with [Getting started](/en/docs/getting-started) - two tables, a store, and a route handler.
# Migrating between AI SDK versions (/en/docs/migrating)
Every message row records the `ai` major that wrote it, in `sdk_version`. That is what makes an upgrade checkable rather than hopeful.
## As it stands, there is nothing to convert [#as-it-stands-there-is-nothing-to-convert]
Stored `UIMessage.parts` are the same shape in ai 5, 6 and 7 - **measured, not assumed**. Real payloads captured from `ai@5.0.228` and `ai@6.0.246` are byte-identical to v7's, and are accepted unchanged by v7's own validator.
Those payloads are committed as test fixtures in the package, so if a future major does change the format, the test suite says so rather than a user discovering it.
This is worth stating plainly because the opposite is widely assumed. The AI SDK does break things between majors - but for *stored* `UIMessage.parts` across 5, 6 and 7, it has not.
## Two ways to handle it when that day comes [#two-ways-to-handle-it-when-that-day-comes]
### Lazily, in your app [#lazily-in-your-app]
`migrateParts` brings one message's parts up to the current major. Call it on read and you never need a migration step at all. Today it is a pass-through; when a future major diverges, the transform lands inside it with no change to your code:
```ts
import { migrateParts } from "ai-sdk-threads";
declare const row: { parts: unknown[]; sdkVersion: 5 | 6 | 7 };
const parts = migrateParts(row.parts, row.sdkVersion);
```
### In bulk, with the CLI [#in-bulk-with-the-cli]
`migrate` walks every row stamped with an older major, checks the current SDK can still read it, and restamps it:
```bash
npx ai-sdk-threads migrate --database-url "$DATABASE_URL" --dry-run
npx ai-sdk-threads migrate --database-url "$DATABASE_URL"
```
It reports per-major counts and, importantly, **lists any row the current SDK cannot read instead of restamping it as though it were fine**. `--dry-run` writes nothing. The whole pass is one transaction.
The CLI needs a Postgres driver of its own - `npm i -D pg` - because this package ships none.
## If you are on ai 6 [#if-you-are-on-ai-6]
Tell the store, or rows get stamped with the wrong major:
```ts
// lib/threads.ts
import { createThreadStore } from "ai-sdk-threads/drizzle";
import { db } from "./db";
export const store = createThreadStore(db, { sdkVersion: 6 });
```
## Supported versions [#supported-versions]
| | |
| ------------------ | --------------------------------------------------------------------------- |
| `ai` | `>=6 <8`. CI runs the whole suite against both `7.0.x` and the `6.x` floor. |
| `drizzle-orm` | `^0.45` for the `./drizzle` and `./sqlite` adapters (optional peer) |
| `resumable-stream` | `^2.2` for the `./resume` module (optional peer) |
| Node.js | `>=20` |
| Database | Postgres, or SQLite via [`./sqlite`](/en/docs/api/sqlite) |
| Module format | ESM only, no CJS build |
# Branching (/en/docs/api/branching)
Messages have always formed a tree here - `parentId` on every row, `activeLeafId` on the thread. These are the operations that use it. **Nothing is ever deleted**: editing or regenerating leaves the old version in place as a sibling.
The UI for this exists in ai-elements; the AI SDK has no storage behind it ([vercel/ai#2929](https://github.com/vercel/ai/issues/2929), open since September 2024). Elsewhere, assistant-ui tracks branches in its client runtime and `@ably/ai-transport` keeps a tree on its realtime channels - both leave durable storage to you, which is the part this owns.
Every method on this page runs in the [playground](/en/playground) against a real Postgres in
your browser, and shows the call it made plus the rows it produced.
## The two common cases are automatic [#the-two-common-cases-are-automatic]
`chatHandler` wires these for you, from what the SDK client already sends:
| The user does | The client sends | The handler does |
| ------------------------ | ----------------------------------------------------- | ---------------------------------------------------- |
| `regenerate()` | `trigger: "regenerate-message"`, `messageId` optional | `regenerateFrom` - the new answer becomes a sibling |
| Edits an earlier message | `messageId` with changed parts | `replaceMessage` - the old version becomes a sibling |
| Retries unchanged | `messageId` with identical parts | nothing new is stored |
A bare `regenerate()` sends no `messageId`, meaning "redo the last answer" - that is handled. Regenerating a **user** message re-answers it rather than removing it. An id that is not on the thread's current path answers **400** rather than silently dropping the edit.
So regenerate and edit work with no extra code.
## The store methods [#the-store-methods]
| Method | Returns | Notes |
| ------------------------------------------ | -------------------------- | -------------------------------------------------------------------------------------- |
| `siblingsOf(threadId, messageId)` | `{ siblings, index }` | Everything sharing that message's parent, oldest first. |
| `setActiveLeaf(threadId, messageId)` | `Promise` | Switches which path is live. Any message in the thread will do. |
| `getTree(threadId)` | `Promise` | Every message, flat. Walk `parentId` to rebuild the shape. |
| `forkAt(threadId, messageId, messages)` | `Promise` | New branch from that message's parent. |
| `replaceMessage(threadId, messageId, msg)` | `Promise` | Rewrites a message, keeping its id; the old version becomes a sibling. |
| `regenerateFrom(threadId, messageId)` | `Promise<{ leafId }>` | Points the leaf where a fresh answer belongs. |
| `pruneBranches(threadId)` | `Promise` | Deletes every message the active leaf cannot reach, and returns them. Never automatic. |
**Every one takes a `threadId`.** Message ids are a global primary key, so an id passed without its thread could reach another user's messages - these refuse an id that does not belong to the thread named.
## Previous and next over an answer's variants [#previous-and-next-over-an-answers-variants]
`siblingsOf` plus `setActiveLeaf` is the whole interaction:
```ts fragment
const { siblings, index } = await store.siblingsOf(threadId, messageId);
// "< 2 / 3 >"
const target = siblings[index + 1];
if (target) await store.setActiveLeaf(threadId, target.id);
```
`loadMessages` then returns the newly selected path, so re-rendering from it is all the UI has to do. This is the shape [ai-elements' `MessageBranch`](https://ai-sdk.dev/elements) expects.
## An edit keeps the message id [#an-edit-keeps-the-message-id]
The edited row is rewritten in place and the previous version is archived under a new id, taking the replies that answered it. That means your client's id stays valid, so the same message can be edited repeatedly without reloading, and switching back to an older version brings its whole conversation with it.
`replaceMessage` refuses a role change: you cannot replace a `user` message with an `assistant` one, which is what stops a client rewriting the model's words into its own.
## Repairing a broken leaf [#repairing-a-broken-leaf]
`setActiveLeaf` is also the repair path if a leaf ever points at a message that no longer exists: point it at any message still in the thread and the thread is readable again.
```ts fragment
const tree = await store.getTree(threadId);
if (tree[0]) await store.setActiveLeaf(threadId, tree[0].id);
```
## What abandoned branches cost [#what-abandoned-branches-cost]
Nothing is ever deleted, which is the point - but the cost is not free, and it is not where you would look for it. `loadMessages` runs two queries at any depth: one for the thread, one that fetches **every row in the thread** and walks the path in memory. The query count stays flat as a conversation grows; the payload does not. Abandoned branches ride along on every read.
Measured on a 50-message conversation (`bench/growth.measure.ts` in the package repo, re-runnable):
| Thread state | Live path | Rows stored | Rows fetched per read |
| ----------------------------------------- | --------- | ----------- | --------------------- |
| 25 turns, never branched | 50 | 50 | **1.00x** |
| After 20 regenerations of the last answer | 50 | 70 | **1.40x** |
| After editing question 5 | 12 | 72 | **6.00x** |
| After `pruneBranches` | 12 | 12 | **1.00x** |
**Regenerating is cheap; editing early is not.** A regeneration strands exactly one message. Rewriting question 5 of a 25-turn thread strands every one of the 38 messages below it at once, and the thread carries that weight on every load from then on.
## Discarding the branches [#discarding-the-branches]
`pruneBranches` is permanent. It deletes the rows, and abandoned branches are the history the tree exists to keep - so it is never automatic, never a side effect of a regeneration, and always something your application decides to do.
```ts fragment
const dropped = await store.pruneBranches(threadId);
console.log(`discarded ${dropped.length} messages off the live path`);
```
It returns what it removed, oldest first - but the rows are already deleted by the time the promise resolves, so those are in-memory copies and nothing more. Log them if it helps; if you need the branches to survive, write them somewhere durable **before** you call this. It is idempotent - a second call on an unbranched thread returns `[]` - and a thread with no active leaf prunes nothing, because "delete everything unreachable" must not collapse into "delete everything".
Reach for it on a schedule or behind a user action ("clear alternatives"), not in the chat route.
# chatHandler (/en/docs/api/chat-handler)
Returns a `(request: Request) => Promise` - 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.
```ts
// 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 [#options]
| Option | Required | What it does |
| --------------- | -------- | ------------------------------------------------------------------------------------------------- |
| `store` | yes | The `ThreadStore` to persist into. |
| `execute` | yes | Called with `{ threadId, uiMessages, modelMessages, request }`; return a `streamText` result. |
| `createThread` | no | Called when a request names a thread that does not exist yet. Return `{ userId?, metadata? }`. |
| `authorize` | no | Called with `{ thread, request }` for an **existing** thread. Return `false` to answer 403. |
| `generateTitle` | no | Called once per thread with `{ firstUserMessage }`. Runs detached - it never delays the response. |
| `onError` | no | Return a `Response` to replace the default 500, or `undefined` to keep it. |
## What it does, in order [#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 [#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.
```ts
// 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;
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 `authorize` does and does not cover [#what-authorize-does-and-does-not-cover]
It guards the **request**, not the store - and the distinction is the one that catches people out.
| Route | When `authorize` runs | On refusal |
| -------------------------------- | ---------------------------------------------------------------------------- | ---------------- |
| `chatHandler` `POST` | Once, after the body parses and the thread loads, for an **existing** thread | `403 Forbidden` |
| `resumeHandler` `GET` / `DELETE` | Separately on each | `204 No Content` |
| Any store call you make yourself | **Never** | - |
One check covers the whole `POST`, because a turn is a single logical operation on a single thread: append, stream and persist all sit behind it. A malformed body is rejected with a 400 before `authorize` is reached, since there is no thread id to authorize against until the body parses. The resume routes answer `204` rather than `403` on purpose, so a prober cannot tell "not your thread" from "nothing to resume".
The store methods check nothing. `loadMessages`, `appendMessages`, `regenerateFrom`, `setActiveLeaf`, `siblingsOf`, `getTree` and `pruneBranches` are raw data access - they trust the thread id you hand them. So the branching routes you write yourself (the previous/next control, the edit button, a thread list) are **yours to guard**. Wiring `chatHandler` correctly does not protect them.
A route of your own wants the same two lines the handler runs internally:
```ts
// app/api/threads/[id]/leaf/route.ts - a branching route of your own
import { store } from "@/lib/threads";
declare function userIdFrom(request: Request): Promise;
export async function POST(request: Request): Promise {
const { threadId, messageId } = await request.json();
// The same check chatHandler runs internally. Nothing below does it for you.
const thread = await store.getThread(threadId);
if (!thread || thread.userId !== (await userIdFrom(request))) {
return new Response("Forbidden", { status: 403 });
}
await store.setActiveLeaf(threadId, messageId);
return new Response(null, { status: 204 });
}
```
### What the handler refuses to store [#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 [#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 [#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 [#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.
```ts
// 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.
# convertToUIMessages (/en/docs/api/convert)
The AI SDK converts `UIMessage[]` to `ModelMessage[]`. As of `ai` 7 it does not ship the reverse, which is what you need when the messages you have came from a provider, a log, or an older table ([vercel/ai#7180](https://github.com/vercel/ai/issues/7180), open at the time of writing - if the SDK ships it, prefer theirs).
This is a pure function. It needs no store and no database - the [playground](/en/playground) has a panel that runs it on whatever you paste in.
```ts
import { convertToUIMessages } from "ai-sdk-threads";
const uiMessages = convertToUIMessages([
{ role: "user", content: "weather?" },
{
role: "assistant",
content: [
{ type: "tool-call", toolCallId: "c1", toolName: "getWeather", input: { city: "x" } },
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "c1",
toolName: "getWeather",
output: { type: "json", value: { temp: 21 } },
},
],
},
]);
// -> 2 messages. The tool result is folded into the assistant message as a
// `tool-getWeather` part with state 'output-available'.
```
## The mapping [#the-mapping]
* `text` and `reasoning` content becomes `text` and `reasoning` parts.
* A `tool-call` becomes a `tool-` part, `input-available` until its result arrives, then `output-available` - or `output-error` for a failure.
* `tool` role messages are folded into the assistant message that called them, never emitted on their own.
* Ids come from `options.generateId`, defaulting to the SDK's `generateId`.
## What it refuses [#what-it-refuses]
Content this version does not model - `file`, `image`, and provider-specific parts - **throws** rather than being guessed at, so a lossy conversion cannot reach your database.
That is deliberate. A converter that silently dropped an image part would leave you with a row that renders differently from the conversation it came from, and no way to know which rows were affected.
# API reference (/en/docs/api)
Everything here builds on one object. A store, created once from your drizzle instance, holds the threads and messages; the rest of the API is what that store can do and the route handlers that drive it.
New to the package? [Getting started](/en/docs/getting-started) is a shorter path in.
## The route [#the-route]
[`chatHandler`](/en/docs/api/chat-handler) is the whole chat route: it loads the thread, stores the incoming message, streams the answer, and stores the reply. Every option, what it does in order, and how to secure a thread.
[`resumableChat`](/en/docs/api/resumable-chat) adds the POST, GET and DELETE trio, so a reload mid-answer picks the stream back up. Covers the Redis-backed context you need beyond a single instance.
## Storing and reading [#storing-and-reading]
[The store](/en/docs/api/store) covers `createThreadStore` and every thread and message method, keyset pagination, `orderPath`, and how many round trips each operation costs.
[Branching](/en/docs/api/branching) is regenerate, edit-and-fork and sibling navigation - the tree behind them, why nothing is ever overwritten, and what keeping every branch costs a read.
## Converting [#converting]
[`convertToUIMessages`](/en/docs/api/convert) turns `ModelMessage[]` back into `UIMessage[]`, the direction the AI SDK still does not ship.
## Your database [#your-database]
[Schema](/en/docs/api/schema) lists both tables, every column, and why the timestamps are millisecond precision.
[SQLite](/en/docs/api/sqlite) is the same contract over SQLite, plus the three constraints that are not optional.
## Migrating [#migrating]
Moving between AI SDK majors, or arriving from elsewhere? [Migrating](/en/docs/migrating) explains `sdk_version` and the `migrate` CLI, and [Importing](/en/docs/importing) brings over the Vercel `ai-chatbot` template's tables.
# resumableChat (/en/docs/api/resumable-chat)
A reload halfway through an answer normally loses it: the stream was tied to a request that no longer exists. `resumableChat` takes every [`chatHandler`](/en/docs/api/chat-handler) option and returns the three handlers a resuming client needs.
```ts
// 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 }),
});
```
```ts
// 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:
```tsx
"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 [#the-three-handlers]
| Handler | Behaviour |
| -------- | -------------------------------------------------------------------------------------------------------------------- |
| `POST` | `chatHandler`, plus: records a stream id on the thread before answering, and clears it once the stream ends. |
| `GET` | Replays a stream that is still in flight. **204** when there is nothing to resume, which is what the client expects. |
| `DELETE` | Forgets 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 [#extra-options]
| Option | Required | What it does |
| --------------- | -------- | ------------------------------------------------------------------------------------------------------ |
| `streamContext` | no | Where in-flight streams live. Defaults to in-process - see below. |
| `threadIdFrom` | no | How to read the thread id from a GET/DELETE. Defaults to the `/stream` path the client uses. |
## The default context is in-process [#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:
```ts
// 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 [#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 [#migration]
This module needs one nullable column, which [the schema](/en/docs/api/schema) already includes:
```sql notype
ALTER TABLE ai_sdk_threads ADD COLUMN active_stream_id text;
```
# Schema (/en/docs/api/schema)
```ts
import { messages, threads } from "ai-sdk-threads/drizzle";
```
They are plain drizzle objects, so they land in your own schema and migration history. The `ai_sdk_` prefix keeps them from colliding with your application tables.
## `ai_sdk_threads` [#ai_sdk_threads]
| Column | Type | Notes |
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------ |
| `id` | `text` PK | |
| `user_id` | `text` | Indexed. Nullable for anonymous chats. |
| `title` | `text` | |
| `visibility` | `text` | `'private'` (default) or `'public'`. |
| `active_leaf_id` | `text` | The last message on the live path. |
| `active_stream_id` | `text` | Set while a reply streams; [`resumableChat`](/en/docs/api/resumable-chat) resumes from it. |
| `metadata` | `jsonb` | Yours to use. |
| `created_at` | `timestamptz(3)` | Millisecond precision on purpose - see below. |
| `updated_at` | `timestamptz(3)` | Moved by `appendMessages` and `updateThread`. |
## `ai_sdk_messages` [#ai_sdk_messages]
| Column | Type | Notes |
| ------------- | ---------------- | --------------------------------------- |
| `id` | `text` PK | The `UIMessage` id. |
| `thread_id` | `text` | Indexed, `ON DELETE CASCADE`. |
| `parent_id` | `text` | The message this one answers. |
| `role` | `text` | `'system'`, `'user'`, or `'assistant'`. |
| `parts` | `jsonb` | `UIMessage.parts`, verbatim. |
| `metadata` | `jsonb` | `UIMessage.metadata`, verbatim. |
| `sdk_version` | `smallint` | The `ai` major that wrote the row. |
| `created_at` | `timestamptz(3)` | |
## Why millisecond precision [#why-millisecond-precision]
The timestamp columns are **millisecond** precision, not Postgres' microsecond default.
`listThreads`' cursor carries `created_at` through a JavaScript `Date`, which cannot represent microseconds. At the default precision the cursor rounds down and the following page silently skips every row sharing that millisecond - so a list of six threads paged to exhaustion could return three, with no error anywhere.
Keep the precision if you hand-write the migration.
## Messages form a tree [#messages-form-a-tree]
Each message row points at its parent, so the messages of a thread form a tree rather than a flat list, and the thread's `active_leaf_id` marks which path through that tree is the live conversation. `loadMessages` returns exactly that path.
A thread is genuinely a tree once anything has been edited or regenerated. If you query the tables directly, walk `parent_id` from `active_leaf_id` - or call [`orderPath`](/en/docs/api/store#orderpathrows-activeleafid) - rather than sorting by `created_at`. A plain sort interleaves branches that were never part of the same conversation.
`sdk_version` records which `ai` major wrote each row. Nothing reads it yet; it is there so a future SDK major can migrate stored parts instead of guessing what shape they are in. See [Migrating](/en/docs/migrating).
# SQLite (/en/docs/api/sqlite)
The same contract, over a drizzle SQLite database on an **async** driver - libsql is what CI runs:
```ts
import { createThreadStore } from "ai-sdk-threads/sqlite";
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
const client = createClient({ url: "file:local.db" });
export const store = createThreadStore(drizzle(client));
```
```ts
// db/schema.ts - the SQLite tables, same names and columns
export { messages, threads } from "ai-sdk-threads/sqlite";
```
Every method behaves identically. A parity suite runs the whole contract against both adapters, so the two cannot drift.
## Three things that will bite you [#three-things-that-will-bite-you]
### Do not use libsql's bare `:memory:` [#do-not-use-libsqls-bare-memory]
That database belongs to a single connection, and writes here run in a transaction which opens another - so it sees no tables at all. Use a file (`file:local.db`) or a remote libsql URL.
This is the single most common way to get a confusing failure with this adapter: the schema appears to exist, and then every write reports that the table is missing.
### Async drivers only [#async-drivers-only]
Writes use interactive transactions with an async callback, which `better-sqlite3` rejects outright and Bun's driver does not await - and Cloudflare D1 has no interactive transactions at all. libsql, local file or remote, is the supported and tested driver.
On a sync driver, atomicity would be silently lost rather than reported, which is why this is a hard requirement rather than a recommendation.
### `PRAGMA foreign_keys = ON` [#pragma-foreign_keys--on]
SQLite defaults it **off**, per connection. Without it, deleting a thread will not delete its messages, and you are left with orphan rows that no query returns.
```sql notype
PRAGMA foreign_keys = ON;
```
## Two columns necessarily differ [#two-columns-necessarily-differ]
* `parts` and `metadata` are `text` with drizzle's `json` mode instead of `jsonb`.
* Timestamps are integer milliseconds instead of `timestamptz(3)` - the same precision the keyset cursor needs.
Everything else, including the tree columns and the indexes, matches [the Postgres schema](/en/docs/api/schema).
# The store (/en/docs/api/store)
## `createThreadStore(db)` [#createthreadstoredb]
Returns a `ThreadStore` backed by the two tables. `db` is any drizzle Postgres database - `PgDatabase`, which covers node-postgres, postgres.js, Neon, Vercel Postgres, and PGlite.
```ts
// lib/threads.ts
import { createThreadStore } from "ai-sdk-threads/drizzle";
import { db } from "./db";
export const store = createThreadStore(db);
```
If you are on `ai` 6.x, pass the major you are writing so a future migration reads the right stamp:
```ts fragment
const sixStore = store; // createThreadStore(db, { sdkVersion: 6 })
```
## Threads [#threads]
| Method | Returns | Notes |
| ------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------- |
| `createThread(input?)` | `Promise` | `input`: `{ id?, userId?, title?, metadata? }`. An id is generated when omitted. |
| `getThread(id)` | `Promise` | `null` rather than a throw, so a missing thread is a 404 you handle. |
| `listThreads(query?)` | `Promise<{ threads, nextCursor? }>` | `query`: `{ userId?, limit?, cursor? }`. Newest first, default limit 20. |
| `updateThread(id, patch)` | `Promise` | `patch`: `{ title?, visibility?, metadata? }`. Throws if the thread does not exist. |
| `deleteThread(id)` | `Promise` | Messages cascade with it. Deleting an absent thread is a no-op. |
A `Thread` is `{ id, userId, title, visibility, activeLeafId, metadata, createdAt, updatedAt }`.
### Keyset pagination [#keyset-pagination]
`listThreads` pages with a keyset cursor over `(createdAt, id)`, not `OFFSET`. Pass `nextCursor` back and stop when it comes back `undefined`:
```ts fragment
let cursor: string | undefined;
do {
const page = await store.listThreads({ userId: "user_123", limit: 20, cursor });
console.log(page.threads);
cursor = page.nextCursor;
} while (cursor);
```
The timestamp columns are deliberately millisecond precision for this reason - see [Schema](/en/docs/api/schema#why-millisecond-precision).
Pinned and measured, not asserted. [`test/queries.test.ts`](https://github.com/nixrajput/ai-sdk-threads/blob/main/test/queries.test.ts) walks every cursor page of a full traversal and requires each to cost one statement, and it pins the *shape* of the predicate too - a row-value comparison, `(created_at, id) < ($1, $2)`, which Postgres can use as an index bound.
That shape is the whole property, and the difference is not subtle. The classic `created_at < $1 OR (created_at = $1 AND id < $2)` form costs one statement too, but Postgres can only use it as an index *filter*: it walks the user's range from the top and discards until the predicate passes, so the cost grows with depth. A row-value comparison is an index *bound*, so it does not.
| Rows before the cursor | `created_at < $1 OR (…)` | `(created_at, id) < ($1, $2)` |
| ---------------------- | ------------------------ | ----------------------------- |
| \~0 | 0.029 ms | 0.043 ms |
| \~5,000 | 1.75 ms | 0.031 ms |
| \~50,000 | **9.22 ms** | 0.012 ms |
| \~90,000 | **8.09 ms** | 0.014 ms |
`EXPLAIN (ANALYZE)` on Postgres 16, 100,000 threads, pages of 20, statistics refreshed before
timing. End to end through the store rather than at the server, a page 50,000 rows deep came back
at **1.13x** the first page and 90,000 rows deep at the same 1.13x - medians over six and three
runs. [The harness is in the repo](https://github.com/nixrajput/ai-sdk-threads/blob/main/bench/paging.measure.ts),
so you can re-run all of it: `PG_URL=… npx tsx bench/paging.measure.ts`.
## Round trips [#round-trips]
Every operation sends a fixed number of statements, independent of how much data is involved. That matters more than milliseconds, because round trips multiplied by your database's latency is the real cost:
| Operation | Statements |
| -------------------------------------------------------------------------- | ---------- |
| `getThread`, `listThreads`, `createThread`, `updateThread`, `deleteThread` | 1 |
| `loadMessages`, `getTree`, `siblingsOf` | 2 |
| `appendMessages`, `regenerateFrom`, `setActiveLeaf` | 3 |
| `forkAt`, `pruneBranches` | 4 |
| `replaceMessage` | 6 |
`loadMessages` stays at two - one select for the active leaf, one for the thread's rows - at depth 1 or depth 500, because the root-to-leaf path is walked in memory by [`orderPath`](#orderpathrows-activeleafid) rather than with a recursive CTE. That second select fetches the whole thread, abandoned branches included, so the statement count is flat while the payload is not - see [what abandoned branches cost](/en/docs/api/branching#what-abandoned-branches-cost). `pruneBranches` stays at four however many rows it drops: one delete, not one per row.
These are the statements the query builder issues. The write paths run in a transaction, so on the wire they cost **two more** - `begin` and `commit` - which server logs confirm: one `appendMessages` sends `begin`, `select`, `insert`, `update`, `commit`. Budget five round trips for an append and six for a fork. `listThreads` stays at one statement for a deep page as well as the first. The counts are pinned by a test in the package (`test/queries.test.ts`), so an N+1 introduced anywhere fails CI, and they were confirmed unchanged against a real PostgreSQL 16 server as well as the in-process PGlite the suite uses.
## Messages [#messages]
| Method | Returns | Notes |
| -------------------------------- | -------------------------- | ------------------------------------------------------------------- |
| `appendMessages(threadId, msgs)` | `Promise` | Takes `UIMessage[]`. Transactional. Throws if the thread is absent. |
| `loadMessages(threadId)` | `Promise` | Ordered oldest first, validated by the SDK before it is returned. |
`appendMessages` uses each `UIMessage`'s own `id` as the row's primary key, because `useChat` already owns message ids. Messages are chained to the end of the thread and the thread's `activeLeafId` moves to the last one, inside one transaction that locks the thread row - so two concurrent appends cannot interleave.
`loadMessages` returns `[]` for a thread with no messages yet, but **throws** for a thread that does not exist - two different states, and a client-supplied chat id that has never been posted to is the second one. Server-rendering a page from a fresh id therefore needs a `getThread` check first (see [Getting started](/en/docs/getting-started)), or the first render is a 500. Before returning, rows pass through the SDK's own `validateUIMessages`, so a row that no longer matches the SDK's shape fails loudly here rather than in your renderer.
```ts fragment
const messages = await store.loadMessages(threadId);
console.log(messages.map((m) => m.role));
```
## `orderPath(rows, activeLeafId)` [#orderpathrows-activeleafid]
Walks `parentId` links from a leaf back to the root and returns the rows oldest first. `loadMessages` uses it; it is exported for when you query the tables yourself.
```ts
import { orderPath } from "ai-sdk-threads";
```
Returns `[]` for a `null` leaf and throws on a broken link, naming the id it could not find. If a leaf ever points at a message that no longer exists, [`setActiveLeaf`](/en/docs/api/branching) is the repair path.
Because a thread is genuinely a tree once anything has been edited or regenerated, walk `parent_id` from `active_leaf_id` rather than sorting by `created_at` if you query the tables directly - a plain sort interleaves branches that were never part of the same conversation.