diff --git a/docs/ai-chat/backend.mdx b/docs/ai-chat/backend.mdx
new file mode 100644
index 0000000000..6d80f0fec7
--- /dev/null
+++ b/docs/ai-chat/backend.mdx
@@ -0,0 +1,939 @@
+---
+title: "Backend"
+sidebarTitle: "Backend"
+description: "Three approaches to building your chat backend — chat.task(), session iterator, or raw task primitives."
+---
+
+## chat.task()
+
+The highest-level approach. Handles message accumulation, stop signals, turn lifecycle, and auto-piping automatically.
+
+### Simple: return a StreamTextResult
+
+Return the `streamText` result from `run` and it's automatically piped to the frontend:
+
+```ts
+import { chat } from "@trigger.dev/sdk/ai";
+import { streamText } from "ai";
+import { openai } from "@ai-sdk/openai";
+
+export const simpleChat = chat.task({
+ id: "simple-chat",
+ run: async ({ messages, signal }) => {
+ return streamText({
+ model: openai("gpt-4o"),
+ system: "You are a helpful assistant.",
+ messages,
+ abortSignal: signal,
+ });
+ },
+});
+```
+
+### Using chat.pipe() for complex flows
+
+For complex agent flows where `streamText` is called deep inside your code, use `chat.pipe()`. It works from **anywhere inside a task** — even nested function calls.
+
+```ts trigger/agent-chat.ts
+import { chat } from "@trigger.dev/sdk/ai";
+import { streamText } from "ai";
+import { openai } from "@ai-sdk/openai";
+import type { ModelMessage } from "ai";
+
+export const agentChat = chat.task({
+ id: "agent-chat",
+ run: async ({ messages }) => {
+ // Don't return anything — chat.pipe is called inside
+ await runAgentLoop(messages);
+ },
+});
+
+async function runAgentLoop(messages: ModelMessage[]) {
+ // ... agent logic, tool calls, etc.
+
+ const result = streamText({
+ model: openai("gpt-4o"),
+ messages,
+ });
+
+ // Pipe from anywhere — no need to return it
+ await chat.pipe(result);
+}
+```
+
+### Lifecycle hooks
+
+#### onPreload
+
+Fires when a preloaded run starts — before any messages arrive. Use it to eagerly initialize state (DB records, user context) while the user is still typing.
+
+Preloaded runs are triggered by calling `transport.preload(chatId)` on the frontend. See [Preload](/ai-chat/features#preload) for details.
+
+```ts
+export const myChat = chat.task({
+ id: "my-chat",
+ clientDataSchema: z.object({ userId: z.string() }),
+ onPreload: async ({ chatId, clientData, runId, chatAccessToken }) => {
+ // Initialize early — before the first message arrives
+ const user = await db.user.findUnique({ where: { id: clientData.userId } });
+ userContext.init({ name: user.name, plan: user.plan });
+
+ await db.chat.create({ data: { id: chatId, userId: clientData.userId } });
+ await db.chatSession.upsert({
+ where: { id: chatId },
+ create: { id: chatId, runId, publicAccessToken: chatAccessToken },
+ update: { runId, publicAccessToken: chatAccessToken },
+ });
+ },
+ onChatStart: async ({ preloaded }) => {
+ if (preloaded) return; // Already initialized in onPreload
+ // ... non-preloaded initialization
+ },
+ run: async ({ messages, signal }) => {
+ return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
+ },
+});
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `chatId` | `string` | Chat session ID |
+| `runId` | `string` | The Trigger.dev run ID |
+| `chatAccessToken` | `string` | Scoped access token for this run |
+| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend |
+
+#### onChatStart
+
+Fires once on the first turn (turn 0) before `run()` executes. Use it to create a chat record in your database.
+
+The `continuation` field tells you whether this is a brand new chat or a continuation of an existing one (where the previous run timed out or was cancelled). The `preloaded` field tells you whether `onPreload` already ran.
+
+```ts
+export const myChat = chat.task({
+ id: "my-chat",
+ onChatStart: async ({ chatId, clientData, continuation, preloaded }) => {
+ if (preloaded) return; // Already set up in onPreload
+ if (continuation) return; // Chat record already exists
+
+ const { userId } = clientData as { userId: string };
+ await db.chat.create({
+ data: { id: chatId, userId, title: "New chat" },
+ });
+ },
+ run: async ({ messages, signal }) => {
+ return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
+ },
+});
+```
+
+
+ `clientData` contains custom data from the frontend — either the `clientData` option on the transport constructor (sent with every message) or the `metadata` option on `sendMessage()` (per-message). See [Client data and metadata](/ai-chat/frontend#client-data-and-metadata).
+
+
+#### onTurnStart
+
+Fires at the start of every turn, after message accumulation and `onChatStart` (turn 0), but **before** `run()` executes. Use it to persist messages before streaming begins — so a mid-stream page refresh still shows the user's message.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `chatId` | `string` | Chat session ID |
+| `messages` | `ModelMessage[]` | Full accumulated conversation (model format) |
+| `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) |
+| `turn` | `number` | Turn number (0-indexed) |
+| `runId` | `string` | The Trigger.dev run ID |
+| `chatAccessToken` | `string` | Scoped access token for this run |
+| `continuation` | `boolean` | Whether this run is continuing an existing chat |
+| `preloaded` | `boolean` | Whether this run was preloaded |
+| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend |
+
+```ts
+export const myChat = chat.task({
+ id: "my-chat",
+ onTurnStart: async ({ chatId, uiMessages, runId, chatAccessToken }) => {
+ await db.chat.update({
+ where: { id: chatId },
+ data: { messages: uiMessages },
+ });
+ await db.chatSession.upsert({
+ where: { id: chatId },
+ create: { id: chatId, runId, publicAccessToken: chatAccessToken },
+ update: { runId, publicAccessToken: chatAccessToken },
+ });
+ },
+ run: async ({ messages, signal }) => {
+ return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
+ },
+});
+```
+
+
+ By persisting in `onTurnStart`, the user's message is saved to your database before the AI starts streaming. If the user refreshes mid-stream, the message is already there.
+
+
+#### onTurnComplete
+
+Fires after each turn completes — after the response is captured, before waiting for the next message. This is the primary hook for persisting the assistant's response.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `chatId` | `string` | Chat session ID |
+| `messages` | `ModelMessage[]` | Full accumulated conversation (model format) |
+| `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) |
+| `newMessages` | `ModelMessage[]` | Only this turn's messages (model format) |
+| `newUIMessages` | `UIMessage[]` | Only this turn's messages (UI format) |
+| `responseMessage` | `UIMessage \| undefined` | The assistant's response for this turn |
+| `turn` | `number` | Turn number (0-indexed) |
+| `runId` | `string` | The Trigger.dev run ID |
+| `chatAccessToken` | `string` | Scoped access token for this run |
+| `lastEventId` | `string \| undefined` | Stream position for resumption. Persist this with the session. |
+| `stopped` | `boolean` | Whether the user stopped generation during this turn |
+| `continuation` | `boolean` | Whether this run is continuing an existing chat |
+| `rawResponseMessage` | `UIMessage \| undefined` | The raw assistant response before abort cleanup (same as `responseMessage` when not stopped) |
+
+```ts
+export const myChat = chat.task({
+ id: "my-chat",
+ onTurnComplete: async ({ chatId, uiMessages, runId, chatAccessToken, lastEventId }) => {
+ await db.chat.update({
+ where: { id: chatId },
+ data: { messages: uiMessages },
+ });
+ await db.chatSession.upsert({
+ where: { id: chatId },
+ create: { id: chatId, runId, publicAccessToken: chatAccessToken, lastEventId },
+ update: { runId, publicAccessToken: chatAccessToken, lastEventId },
+ });
+ },
+ run: async ({ messages, signal }) => {
+ return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
+ },
+});
+```
+
+
+ Use `uiMessages` to overwrite the full conversation each turn (simplest). Use `newUIMessages` if you prefer to store messages individually — for example, one database row per message.
+
+
+
+ Persist `lastEventId` alongside the session. When the transport reconnects after a page refresh, it uses this to skip past already-seen events — preventing duplicate messages.
+
+
+### Using prompts
+
+Use [AI Prompts](/ai/prompts) to manage your system prompt as versioned, overridable config. Store the resolved prompt in a lifecycle hook with `chat.prompt.set()`, then spread `chat.toStreamTextOptions()` into `streamText` — it includes the system prompt, model, config, and telemetry automatically.
+
+```ts
+import { chat } from "@trigger.dev/sdk/ai";
+import { prompts } from "@trigger.dev/sdk";
+import { streamText, createProviderRegistry } from "ai";
+import { openai } from "@ai-sdk/openai";
+import { z } from "zod";
+
+const registry = createProviderRegistry({ openai });
+
+const systemPrompt = prompts.define({
+ id: "my-chat-system",
+ model: "openai:gpt-4o",
+ config: { temperature: 0.7 },
+ variables: z.object({ name: z.string() }),
+ content: `You are a helpful assistant for {{name}}.`,
+});
+
+export const myChat = chat.task({
+ id: "my-chat",
+ clientDataSchema: z.object({ userId: z.string() }),
+ onChatStart: async ({ clientData }) => {
+ const user = await db.user.findUnique({ where: { id: clientData.userId } });
+ const resolved = await systemPrompt.resolve({ name: user.name });
+ chat.prompt.set(resolved);
+ },
+ run: async ({ messages, signal }) => {
+ return streamText({
+ ...chat.toStreamTextOptions({ registry }), // system, model, config, telemetry
+ messages,
+ abortSignal: signal,
+ });
+ },
+});
+```
+
+`chat.toStreamTextOptions()` returns an object with `system`, `model` (resolved via the registry), `temperature`, and `experimental_telemetry` — all from the stored prompt. Properties you set after the spread (like a client-selected model) take precedence.
+
+
+ See [Prompts](/ai/prompts) for the full guide — defining templates, variable schemas, dashboard overrides, and the management SDK.
+
+
+### Stop generation
+
+#### How stop works
+
+Calling `stop()` from `useChat` sends a stop signal to the running task via input streams. The task's `streamText` call aborts (if you passed `signal` or `stopSignal`), but the **run stays alive** and waits for the next message. The partial response is captured and accumulated normally.
+
+#### Abort signals
+
+The `run` function receives three abort signals:
+
+| Signal | Fires when | Use for |
+|--------|-----------|---------|
+| `signal` | Stop **or** cancel | Pass to `streamText` — handles both cases. **Use this in most cases.** |
+| `stopSignal` | Stop only (per-turn, reset each turn) | Custom logic that should only run on user stop, not cancellation |
+| `cancelSignal` | Run cancel, expire, or maxDuration exceeded | Cleanup that should only happen on full cancellation |
+
+```ts
+export const myChat = chat.task({
+ id: "my-chat",
+ run: async ({ messages, signal, stopSignal, cancelSignal }) => {
+ return streamText({
+ model: openai("gpt-4o"),
+ messages,
+ abortSignal: signal, // Handles both stop and cancel
+ });
+ },
+});
+```
+
+
+ Use `signal` (the combined signal) in most cases. The separate `stopSignal` and `cancelSignal` are only needed if you want different behavior for stop vs cancel.
+
+
+#### Detecting stop in callbacks
+
+The `onTurnComplete` event includes a `stopped` boolean that indicates whether the user stopped generation during that turn:
+
+```ts
+export const myChat = chat.task({
+ id: "my-chat",
+ onTurnComplete: async ({ chatId, uiMessages, stopped }) => {
+ await db.chat.update({
+ where: { id: chatId },
+ data: { messages: uiMessages, lastStoppedAt: stopped ? new Date() : undefined },
+ });
+ },
+ run: async ({ messages, signal }) => {
+ return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
+ },
+});
+```
+
+You can also check stop status from **anywhere** during a turn using `chat.isStopped()`. This is useful inside `streamText`'s `onFinish` callback where the AI SDK's `isAborted` flag can be unreliable (e.g. when using `createUIMessageStream` + `writer.merge()`):
+
+```ts
+import { chat } from "@trigger.dev/sdk/ai";
+import { streamText } from "ai";
+
+export const myChat = chat.task({
+ id: "my-chat",
+ run: async ({ messages, signal }) => {
+ return streamText({
+ model: openai("gpt-4o"),
+ messages,
+ abortSignal: signal,
+ onFinish: ({ isAborted }) => {
+ // isAborted may be false even after stop when using createUIMessageStream
+ const wasStopped = isAborted || chat.isStopped();
+ if (wasStopped) {
+ // handle stop — e.g. log analytics
+ }
+ },
+ });
+ },
+});
+```
+
+#### Cleaning up aborted messages
+
+When stop happens mid-stream, the captured response message can contain parts in an incomplete state — tool calls stuck in `partial-call`, reasoning blocks still marked as `streaming`, etc. These can cause UI issues like permanent spinners.
+
+`chat.task` automatically cleans up the `responseMessage` when stop is detected before passing it to `onTurnComplete`. If you use `chat.pipe()` manually and capture response messages yourself, use `chat.cleanupAbortedParts()`:
+
+```ts
+const cleaned = chat.cleanupAbortedParts(rawResponseMessage);
+```
+
+This removes tool invocation parts stuck in `partial-call` state and marks any `streaming` text or reasoning parts as `done`.
+
+
+ Stop signal delivery is best-effort. There is a small race window where the model may finish before the stop signal arrives, in which case the turn completes normally with `stopped: false`. This is expected and does not require special handling.
+
+
+### Persistence
+
+#### What needs to be persisted
+
+To build a chat app that survives page refreshes, you need to persist two things:
+
+1. **Messages** — The conversation history. Persisted **server-side** in the task via `onTurnStart` and `onTurnComplete`.
+2. **Sessions** — The transport's connection state (`runId`, `publicAccessToken`, `lastEventId`). Persisted **server-side** via `onTurnStart` and `onTurnComplete`.
+
+
+ Sessions let the transport reconnect to an existing run after a page refresh. Without them, every page load would start a new run — losing the conversation context that was accumulated in the previous run.
+
+
+#### Full persistence example
+
+
+```ts trigger/chat.ts
+import { chat } from "@trigger.dev/sdk/ai";
+import { streamText } from "ai";
+import { openai } from "@ai-sdk/openai";
+import { z } from "zod";
+import { db } from "@/lib/db";
+
+export const myChat = chat.task({
+ id: "my-chat",
+ clientDataSchema: z.object({
+ userId: z.string(),
+ }),
+ onChatStart: async ({ chatId, clientData }) => {
+ await db.chat.create({
+ data: { id: chatId, userId: clientData.userId, title: "New chat", messages: [] },
+ });
+ },
+ onTurnStart: async ({ chatId, uiMessages, runId, chatAccessToken }) => {
+ // Persist messages + session before streaming
+ await db.chat.update({
+ where: { id: chatId },
+ data: { messages: uiMessages },
+ });
+ await db.chatSession.upsert({
+ where: { id: chatId },
+ create: { id: chatId, runId, publicAccessToken: chatAccessToken },
+ update: { runId, publicAccessToken: chatAccessToken },
+ });
+ },
+ onTurnComplete: async ({ chatId, uiMessages, runId, chatAccessToken, lastEventId }) => {
+ // Persist assistant response + stream position
+ await db.chat.update({
+ where: { id: chatId },
+ data: { messages: uiMessages },
+ });
+ await db.chatSession.upsert({
+ where: { id: chatId },
+ create: { id: chatId, runId, publicAccessToken: chatAccessToken, lastEventId },
+ update: { runId, publicAccessToken: chatAccessToken, lastEventId },
+ });
+ },
+ run: async ({ messages, signal }) => {
+ return streamText({
+ model: openai("gpt-4o"),
+ messages,
+ abortSignal: signal,
+ });
+ },
+});
+```
+
+```ts app/actions.ts
+"use server";
+
+import { chat } from "@trigger.dev/sdk/ai";
+import type { myChat } from "@/trigger/chat";
+import { db } from "@/lib/db";
+
+export const getChatToken = () =>
+ chat.createAccessToken("my-chat");
+
+export async function getChatMessages(chatId: string) {
+ const found = await db.chat.findUnique({ where: { id: chatId } });
+ return found?.messages ?? [];
+}
+
+export async function getAllSessions() {
+ const sessions = await db.chatSession.findMany();
+ const result: Record = {};
+ for (const s of sessions) {
+ result[s.id] = {
+ runId: s.runId,
+ publicAccessToken: s.publicAccessToken,
+ lastEventId: s.lastEventId ?? undefined,
+ };
+ }
+ return result;
+}
+
+export async function deleteSession(chatId: string) {
+ await db.chatSession.delete({ where: { id: chatId } }).catch(() => {});
+}
+```
+
+```tsx app/components/chat.tsx
+"use client";
+
+import { useChat } from "@ai-sdk/react";
+import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
+import type { myChat } from "@/trigger/chat";
+import { getChatToken, deleteSession } from "@/app/actions";
+
+export function Chat({ chatId, initialMessages, initialSessions }) {
+ const transport = useTriggerChatTransport({
+ task: "my-chat",
+ accessToken: getChatToken,
+ clientData: { userId: currentUser.id }, // Type-checked against clientDataSchema
+ sessions: initialSessions,
+ onSessionChange: (id, session) => {
+ if (!session) deleteSession(id);
+ },
+ });
+
+ const { messages, sendMessage, stop, status } = useChat({
+ id: chatId,
+ messages: initialMessages,
+ transport,
+ resume: initialMessages.length > 0,
+ });
+
+ return (
+
+ );
+ }
+ // ...other part types
+})}
+```
+
+The `target` option accepts:
+- `"self"` — current run (default)
+- `"parent"` — parent task's run
+- `"root"` — root task's run (the chat task)
+- A specific run ID string
+
+---
+
+## ai.tool() — subtask integration
+
+When a subtask runs via `ai.tool()`, it can access the tool call context and chat context from the parent:
+
+```ts
+import { ai, chat } from "@trigger.dev/sdk/ai";
+import type { myChat } from "./chat";
+
+export const mySubtask = schemaTask({
+ id: "my-subtask",
+ schema: z.object({ query: z.string() }),
+ run: async ({ query }) => {
+ // Get the AI SDK's tool call ID (useful for data-* chunk IDs)
+ const toolCallId = ai.toolCallId();
+
+ // Get typed chat context — pass typeof yourChatTask for typed clientData
+ const { chatId, clientData } = ai.chatContextOrThrow();
+ // clientData is typed based on myChat's clientDataSchema
+
+ // Write a data chunk using the tool call ID
+ const { waitUntilComplete } = chat.stream.writer({
+ target: "root",
+ execute: ({ write }) => {
+ write({
+ type: "data-progress",
+ id: toolCallId,
+ data: { status: "working", query, userId: clientData?.userId },
+ });
+ },
+ });
+ await waitUntilComplete();
+
+ return { result: "done" };
+ },
+});
+```
+
+| Helper | Returns | Description |
+|--------|---------|-------------|
+| `ai.toolCallId()` | `string \| undefined` | The AI SDK tool call ID |
+| `ai.chatContext()` | `{ chatId, turn, continuation, clientData } \| undefined` | Chat context with typed `clientData`. Returns `undefined` if not in a chat context. |
+| `ai.chatContextOrThrow()` | `{ chatId, turn, continuation, clientData }` | Same as above but throws if not in a chat context |
+| `ai.currentToolOptions()` | `ToolCallExecutionOptions \| undefined` | Full tool execution options |
+
+---
+
+## Preload
+
+Preload eagerly triggers a run for a chat before the first message is sent. This allows initialization (DB setup, context loading) to happen while the user is still typing, reducing first-response latency.
+
+### Frontend
+
+Call `transport.preload(chatId)` to start a run early:
+
+```tsx
+import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
+import { useChat } from "@ai-sdk/react";
+
+export function Chat({ chatId }) {
+ const transport = useTriggerChatTransport({
+ task: "my-chat",
+ accessToken: getChatToken,
+ clientData: { userId: currentUser.id },
+ });
+
+ // Preload on mount — run starts before the user types anything
+ useEffect(() => {
+ transport.preload(chatId, { idleTimeoutInSeconds: 60 });
+ }, [chatId]);
+
+ const { messages, sendMessage } = useChat({ id: chatId, transport });
+ // ...
+}
+```
+
+Preload is a no-op if a session already exists for this chatId.
+
+### Backend
+
+On the backend, the `onPreload` hook fires immediately. The run then waits for the first message. When the user sends a message, `onChatStart` fires with `preloaded: true` — you can skip initialization that was already done in `onPreload`:
+
+```ts
+export const myChat = chat.task({
+ id: "my-chat",
+ onPreload: async ({ chatId, clientData }) => {
+ // Eagerly initialize — runs before the first message
+ userContext.init(await loadUser(clientData.userId));
+ await db.chat.create({ data: { id: chatId } });
+ },
+ onChatStart: async ({ preloaded }) => {
+ if (preloaded) return; // Already initialized in onPreload
+ // ... fallback initialization for non-preloaded runs
+ },
+ run: async ({ messages, signal }) => {
+ return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
+ },
+});
+```
+
+With `chat.createSession()` or raw tasks, check `payload.trigger === "preload"` and wait for the first message:
+
+```ts
+if (payload.trigger === "preload") {
+ // Initialize early...
+ const result = await chat.messages.waitWithIdleTimeout({
+ idleTimeoutInSeconds: 60,
+ timeout: "1h",
+ });
+ if (!result.ok) return;
+ currentPayload = result.output;
+}
+```
diff --git a/docs/ai-chat/frontend.mdx b/docs/ai-chat/frontend.mdx
new file mode 100644
index 0000000000..0e7854e4d5
--- /dev/null
+++ b/docs/ai-chat/frontend.mdx
@@ -0,0 +1,234 @@
+---
+title: "Frontend"
+sidebarTitle: "Frontend"
+description: "Transport setup, session management, client data, and frontend patterns for AI Chat."
+---
+
+## Transport setup
+
+Use the `useTriggerChatTransport` hook from `@trigger.dev/sdk/chat/react` to create a memoized transport instance, then pass it to `useChat`:
+
+```tsx
+import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
+import { useChat } from "@ai-sdk/react";
+import type { myChat } from "@/trigger/chat";
+import { getChatToken } from "@/app/actions";
+
+export function Chat() {
+ const transport = useTriggerChatTransport({
+ task: "my-chat",
+ accessToken: getChatToken,
+ });
+
+ const { messages, sendMessage, stop, status } = useChat({ transport });
+ // ... render UI
+}
+```
+
+The transport is created once on first render and reused across re-renders. Pass a type parameter for compile-time validation of the task ID.
+
+
+ The hook keeps `onSessionChange` up to date via a ref internally, so you don't need to memoize the callback or worry about stale closures.
+
+
+### Dynamic access tokens
+
+For token refresh, pass a function instead of a string. It's called on each `sendMessage`:
+
+```ts
+const transport = useTriggerChatTransport({
+ task: "my-chat",
+ accessToken: async () => {
+ const res = await fetch("/api/chat-token");
+ return res.text();
+ },
+});
+```
+
+## Session management
+
+### Session cleanup (frontend)
+
+Since session creation and updates are handled server-side, the frontend only needs to handle session deletion when a run ends:
+
+```tsx
+const transport = useTriggerChatTransport({
+ task: "my-chat",
+ accessToken: getChatToken,
+ sessions: loadedSessions, // Restored from DB on page load
+ onSessionChange: (chatId, session) => {
+ if (!session) {
+ deleteSession(chatId); // Server action — run ended
+ }
+ },
+});
+```
+
+### Restoring on page load
+
+On page load, fetch both the messages and the session from your database, then pass them to `useChat` and the transport. Pass `resume: true` to `useChat` when there's an existing conversation — this tells the AI SDK to reconnect to the stream via the transport.
+
+```tsx app/page.tsx
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
+import { useChat } from "@ai-sdk/react";
+import { getChatToken, getChatMessages, getSession, deleteSession } from "@/app/actions";
+
+export default function ChatPage({ chatId }: { chatId: string }) {
+ const [initialMessages, setInitialMessages] = useState([]);
+ const [initialSession, setInitialSession] = useState(undefined);
+ const [loaded, setLoaded] = useState(false);
+
+ useEffect(() => {
+ async function load() {
+ const [messages, session] = await Promise.all([
+ getChatMessages(chatId),
+ getSession(chatId),
+ ]);
+ setInitialMessages(messages);
+ setInitialSession(session ? { [chatId]: session } : undefined);
+ setLoaded(true);
+ }
+ load();
+ }, [chatId]);
+
+ if (!loaded) return null;
+
+ return (
+
+ );
+}
+
+function ChatClient({ chatId, initialMessages, initialSessions }) {
+ const transport = useTriggerChatTransport({
+ task: "my-chat",
+ accessToken: getChatToken,
+ sessions: initialSessions,
+ onSessionChange: (id, session) => {
+ if (!session) deleteSession(id);
+ },
+ });
+
+ const { messages, sendMessage, stop, status } = useChat({
+ id: chatId,
+ messages: initialMessages,
+ transport,
+ resume: initialMessages.length > 0, // Resume if there's an existing conversation
+ });
+
+ // ... render UI
+}
+```
+
+
+ `resume: true` causes `useChat` to call `reconnectToStream` on the transport when the component mounts. The transport uses the session's `lastEventId` to skip past already-seen stream events, so the frontend only receives new data. Only enable `resume` when there are existing messages — for brand new chats, there's nothing to reconnect to.
+
+
+
+ In React strict mode (enabled by default in Next.js dev), you may see a `TypeError: Cannot read properties of undefined (reading 'state')` in the console when using `resume`. This is a [known bug in the AI SDK](https://github.com/vercel/ai/issues/8477) caused by React strict mode double-firing the resume effect. The error is caught internally and **does not affect functionality** — streaming and message display work correctly. It only appears in development and will not occur in production builds.
+
+
+## Client data and metadata
+
+### Transport-level client data
+
+Set default client data on the transport that's included in every request. When the task uses `clientDataSchema`, this is type-checked to match:
+
+```ts
+const transport = useTriggerChatTransport({
+ task: "my-chat",
+ accessToken: getChatToken,
+ clientData: { userId: currentUser.id },
+});
+```
+
+### Per-message metadata
+
+Pass metadata with individual messages via `sendMessage`. Per-message values are merged with transport-level client data (per-message wins on conflicts):
+
+```ts
+sendMessage(
+ { text: "Hello" },
+ { metadata: { model: "gpt-4o", priority: "high" } }
+);
+```
+
+### Typed client data with clientDataSchema
+
+Instead of manually parsing `clientData` with Zod in every hook, pass a `clientDataSchema` to `chat.task`. The schema validates the data once per turn, and `clientData` is typed in all hooks and `run`:
+
+```ts
+import { chat } from "@trigger.dev/sdk/ai";
+import { streamText } from "ai";
+import { openai } from "@ai-sdk/openai";
+import { z } from "zod";
+
+export const myChat = chat.task({
+ id: "my-chat",
+ clientDataSchema: z.object({
+ model: z.string().optional(),
+ userId: z.string(),
+ }),
+ onChatStart: async ({ chatId, clientData }) => {
+ // clientData is typed as { model?: string; userId: string }
+ await db.chat.create({
+ data: { id: chatId, userId: clientData.userId },
+ });
+ },
+ run: async ({ messages, clientData, signal }) => {
+ // Same typed clientData — no manual parsing needed
+ return streamText({
+ model: openai(clientData?.model ?? "gpt-4o"),
+ messages,
+ abortSignal: signal,
+ });
+ },
+});
+```
+
+The schema also types the `clientData` option on the frontend transport:
+
+```ts
+// TypeScript enforces that clientData matches the schema
+const transport = useTriggerChatTransport({
+ task: "my-chat",
+ accessToken: getChatToken,
+ clientData: { userId: currentUser.id },
+});
+```
+
+Supports Zod, ArkType, Valibot, and other schema libraries supported by the SDK.
+
+## Stop generation
+
+Calling `stop()` from `useChat` sends a stop signal to the running task via input streams. The task aborts the current `streamText` call, but the run stays alive for the next message:
+
+```tsx
+const { messages, sendMessage, stop, status } = useChat({ transport });
+
+{status === "streaming" && (
+
+)}
+```
+
+See [Stop generation](/ai-chat/backend#stop-generation) in the backend docs for how to handle stop signals in your task.
+
+## Self-hosting
+
+If you're self-hosting Trigger.dev, pass the `baseURL` option:
+
+```ts
+const transport = useTriggerChatTransport({
+ task: "my-chat",
+ accessToken,
+ baseURL: "https://your-trigger-instance.com",
+});
+```
diff --git a/docs/ai-chat/overview.mdx b/docs/ai-chat/overview.mdx
new file mode 100644
index 0000000000..a1d207c799
--- /dev/null
+++ b/docs/ai-chat/overview.mdx
@@ -0,0 +1,161 @@
+---
+title: "AI Chat"
+sidebarTitle: "Overview"
+description: "Run AI SDK chat completions as durable Trigger.dev tasks with built-in realtime streaming, multi-turn conversations, and message persistence."
+---
+
+## Overview
+
+The `@trigger.dev/sdk` provides a custom [ChatTransport](https://sdk.vercel.ai/docs/ai-sdk-ui/transport) for the Vercel AI SDK's `useChat` hook. This lets you run chat completions as **durable Trigger.dev tasks** instead of fragile API routes — with automatic retries, observability, and realtime streaming built in.
+
+**How it works:**
+1. The frontend sends messages via `useChat` through `TriggerChatTransport`
+2. The first message triggers a Trigger.dev task; subsequent messages resume the **same run** via input streams
+3. The task streams `UIMessageChunk` events back via Trigger.dev's realtime streams
+4. The AI SDK's `useChat` processes the stream natively — text, tool calls, reasoning, etc.
+5. Between turns, the run stays idle briefly then suspends (freeing compute) until the next message
+
+No custom API routes needed. Your chat backend is a Trigger.dev task.
+
+
+
+### First message flow
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant useChat as useChat + Transport
+ participant API as Trigger.dev API
+ participant Task as chat.task Worker
+ participant LLM as LLM Provider
+
+ User->>useChat: sendMessage("Hello")
+ useChat->>useChat: No session for chatId → trigger new run
+ useChat->>API: triggerTask(payload, tags: [chat:id])
+ API-->>useChat: { runId, publicAccessToken }
+ useChat->>useChat: Store session, subscribe to SSE
+
+ API->>Task: Start run with ChatTaskWirePayload
+ Task->>Task: onChatStart({ chatId, messages, clientData })
+ Task->>Task: onTurnStart({ chatId, messages })
+ Task->>LLM: streamText({ model, messages, abortSignal })
+ LLM-->>Task: Stream response chunks
+ Task->>API: streams.pipe("chat", uiStream)
+ API-->>useChat: SSE: UIMessageChunks
+ useChat-->>User: Render streaming text
+ Task->>API: Write __trigger_turn_complete
+ API-->>useChat: SSE: turn complete + refreshed token
+ useChat->>useChat: Close stream, update session
+ Task->>Task: onTurnComplete({ messages, stopped: false })
+ Task->>Task: Wait for next message (idle → suspend)
+```
+
+### Multi-turn flow
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant useChat as useChat + Transport
+ participant API as Trigger.dev API
+ participant Task as chat.task Worker
+ participant LLM as LLM Provider
+
+ Note over Task: Suspended, waiting for message
+
+ User->>useChat: sendMessage("Tell me more")
+ useChat->>useChat: Session exists → send via input stream
+ useChat->>API: sendInputStream(runId, "chat-messages", payload)
+ Note right of useChat: Only sends new message (not full history)
+
+ API->>Task: Deliver to messagesInput
+ Task->>Task: Wake from suspend
+ Task->>Task: Append to accumulated messages
+ Task->>Task: onTurnStart({ turn: 1 })
+ Task->>LLM: streamText({ messages: [all accumulated] })
+ LLM-->>Task: Stream response
+ Task->>API: streams.pipe("chat", uiStream)
+ API-->>useChat: SSE: UIMessageChunks
+ useChat-->>User: Render streaming text
+ Task->>API: Write __trigger_turn_complete
+ Task->>Task: onTurnComplete({ turn: 1 })
+ Task->>Task: Wait for next message (idle → suspend)
+```
+
+### Stop signal flow
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant useChat as useChat + Transport
+ participant API as Trigger.dev API
+ participant Task as chat.task Worker
+ participant LLM as LLM Provider
+
+ Note over Task: Streaming response...
+
+ User->>useChat: Click "Stop"
+ useChat->>API: sendInputStream(runId, "chat-stop", { stop: true })
+ API->>Task: Deliver to stopInput
+ Task->>Task: stopController.abort()
+ LLM-->>Task: Stream ends (AbortError)
+ Task->>Task: cleanupAbortedParts(responseMessage)
+ Note right of Task: Remove partial tool calls, mark streaming parts as done
+ Task->>API: Write __trigger_turn_complete
+ API-->>useChat: SSE: turn complete
+ Task->>Task: onTurnComplete({ stopped: true })
+ Task->>Task: Wait for next message
+```
+
+
+
+
+ Requires `@trigger.dev/sdk` version **4.4.0 or later** and the `ai` package **v5.0.0 or later**.
+
+
+## How multi-turn works
+
+### One run, many turns
+
+The entire conversation lives in a **single Trigger.dev run**. After each AI response, the run waits for the next message via input streams. The frontend transport handles this automatically — it triggers a new run for the first message, and sends subsequent messages to the existing run.
+
+This means your conversation has full observability in the Trigger.dev dashboard: every turn is a span inside the same run.
+
+### Warm and suspended states
+
+After each turn, the run goes through two phases of waiting:
+
+1. **Warm phase** (default 30s) — The run stays active and responds instantly to the next message. Uses compute.
+2. **Suspended phase** (default up to 1h) — The run suspends, freeing compute. It wakes when the next message arrives. There's a brief delay as the run resumes.
+
+If no message arrives within the turn timeout, the run ends gracefully. The next message from the frontend will automatically start a fresh run.
+
+
+ You are not charged for compute during the suspended phase. Only the idle phase uses compute resources.
+
+
+### What the backend accumulates
+
+The backend automatically accumulates the full conversation history across turns. After the first turn, the frontend transport only sends the new user message — not the entire history. This is handled transparently by the transport and task.
+
+The accumulated messages are available in:
+- `run()` as `messages` (`ModelMessage[]`) — for passing to `streamText`
+- `onTurnStart()` as `uiMessages` (`UIMessage[]`) — for persisting before streaming
+- `onTurnComplete()` as `uiMessages` (`UIMessage[]`) — for persisting after the response
+
+## Three approaches
+
+There are three ways to build the backend, from most opinionated to most flexible:
+
+| Approach | Use when | What you get |
+|----------|----------|--------------|
+| [chat.task()](/ai-chat/backend#chattask) | Most apps | Auto-piping, lifecycle hooks, message accumulation, stop handling |
+| [chat.createSession()](/ai-chat/backend#chatcreatesession) | Need a loop but not hooks | Async iterator with per-turn helpers, message accumulation, stop handling |
+| [Raw task + primitives](/ai-chat/backend#raw-task-with-primitives) | Full control | Manual control of every step — use `chat.messages`, `chat.createStopSignal()`, etc. |
+
+## Related
+
+- [Quick Start](/ai-chat/quick-start) — Get a working chat in 3 steps
+- [Backend](/ai-chat/backend) — Backend approaches in detail
+- [Frontend](/ai-chat/frontend) — Transport setup, sessions, client data
+- [Features](/ai-chat/features) — Per-run data, deferred work, streaming, subtasks
+- [API Reference](/ai-chat/reference) — Complete reference tables
diff --git a/docs/ai-chat/quick-start.mdx b/docs/ai-chat/quick-start.mdx
new file mode 100644
index 0000000000..b8245d9237
--- /dev/null
+++ b/docs/ai-chat/quick-start.mdx
@@ -0,0 +1,108 @@
+---
+title: "Quick Start"
+sidebarTitle: "Quick Start"
+description: "Get a working AI chat in 3 steps — define a task, generate a token, and wire up the frontend."
+---
+
+
+
+ Use `chat.task` from `@trigger.dev/sdk/ai` to define a task that handles chat messages. The `run` function receives `ModelMessage[]` (already converted from the frontend's `UIMessage[]`) — pass them directly to `streamText`.
+
+ If you return a `StreamTextResult`, it's **automatically piped** to the frontend.
+
+ ```ts trigger/chat.ts
+ import { chat } from "@trigger.dev/sdk/ai";
+ import { streamText } from "ai";
+ import { openai } from "@ai-sdk/openai";
+
+ export const myChat = chat.task({
+ id: "my-chat",
+ run: async ({ messages, signal }) => {
+ // messages is ModelMessage[] — pass directly to streamText
+ // signal fires on stop or run cancel
+ return streamText({
+ model: openai("gpt-4o"),
+ messages,
+ abortSignal: signal,
+ });
+ },
+ });
+ ```
+
+
+
+ On your server (e.g. a Next.js server action), create a trigger public token scoped to your chat task:
+
+ ```ts app/actions.ts
+ "use server";
+
+ import { chat } from "@trigger.dev/sdk/ai";
+ import type { myChat } from "@/trigger/chat";
+
+ export const getChatToken = () =>
+ chat.createAccessToken("my-chat");
+ ```
+
+
+
+ Use the `useTriggerChatTransport` hook from `@trigger.dev/sdk/chat/react` to create a memoized transport instance, then pass it to `useChat`:
+
+ ```tsx app/components/chat.tsx
+ "use client";
+
+ import { useChat } from "@ai-sdk/react";
+ import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
+ import type { myChat } from "@/trigger/chat";
+ import { getChatToken } from "@/app/actions";
+
+ export function Chat() {
+ const transport = useTriggerChatTransport({
+ task: "my-chat",
+ accessToken: getChatToken,
+ });
+
+ const { messages, sendMessage, stop, status } = useChat({ transport });
+
+ return (
+