diff --git a/docs/ai-chat/fast-starts.mdx b/docs/ai-chat/fast-starts.mdx
index 7310988964..6910dbb8b3 100644
--- a/docs/ai-chat/fast-starts.mdx
+++ b/docs/ai-chat/fast-starts.mdx
@@ -524,8 +524,27 @@ The handler keeps the SSE response open until the agent run signals turn-complet
| Tool execution runs in | Trigger.dev agent run | Trigger.dev agent run |
| Step 2+ LLM call runs in | Trigger.dev agent run | Trigger.dev agent run |
| `onChatStart` / `onTurnStart` fire | After handover signal arrives | Normally |
+| `hydrateMessages` fires (if registered) | After handover, with the first-turn history as `incomingMessages` | Normally |
| `onTurnComplete` fires | After turn finishes (handover) or skipped (handover-skip) | Normally |
+### Persistence and the handover contract
+
+A head-start turn persists exactly like a normal turn — the handover machinery is invisible to your hooks. The guarantees:
+
+- **One stable assistant `messageId` across the whole turn.** The route handler generates the id, the handover signal carries it to the agent, and the agent's step 2+ stream reuses it — so the browser merges step 1 and step 2+ into a single assistant message, and you can merge-by-id when persisting.
+- **`onTurnComplete` is the canonical persistence point**, same as any turn. It carries the full assistant message under that one id: step-1 text, reasoning, and tool calls plus step-2+ tool results and text. The [database persistence](/ai-chat/patterns/database-persistence) patterns apply unchanged.
+- **Reasoning parts survive the handover.** When step 1 runs on an extended-thinking model, the reasoning streamed by your route handler lands in the durable session history (and `onTurnComplete`) under the same `messageId`, with provider metadata intact — Anthropic thinking signatures survive a replay back to the model. Step-2 reasoning appends to the same message rather than replacing it.
+
+#### With `hydrateMessages`
+
+Head Start composes with [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages). On the first turn, the hook receives the route handler's first-turn history as `incomingMessages` — the canonical upsert-and-return pattern persists the user message exactly as it would on a direct-trigger turn. The runtime splices the warm handler's partial assistant onto your hydrated chain after the hook returns, deduplicated by the assistant `messageId`, so your hook never needs to include the in-flight partial.
+
+
+ **Hydrate hooks must upsert their conversation row, not update it.** Head-start turns skip preload entirely, so row-creating hooks (`onPreload`, or an `onChatStart` create) have not run when `hydrateMessages` first fires. A bare `UPDATE` against a missing row throws and errors the turn.
+
+
+Your hydrate hook shapes **model context**, not the transcript — dropping reasoning-only entries or unresolved tool rows from the returned chain is fine and does not affect what `onTurnComplete` persists or what the UI renders.
+
### The `chat.headStart` API
```ts
diff --git a/docs/ai-chat/lifecycle-hooks.mdx b/docs/ai-chat/lifecycle-hooks.mdx
index c327374e05..6089d9d8b0 100644
--- a/docs/ai-chat/lifecycle-hooks.mdx
+++ b/docs/ai-chat/lifecycle-hooks.mdx
@@ -273,7 +273,7 @@ Use this when the backend should be the source of truth for message history: abu
| `chatId` | `string` | Chat session ID |
| `turn` | `number` | Turn number (0-indexed) |
| `trigger` | `"submit-message" \| "regenerate-message" \| "action"` | The trigger type for this turn |
-| `incomingMessages` | `UIMessage[]` | Validated wire messages from the frontend — 0-or-1-length (empty for actions, regenerates, and continuations; one element for normal `submit-message` and tool-approval responses) |
+| `incomingMessages` | `UIMessage[]` | Validated incoming messages for this turn. Usually 0-or-1 (empty for actions, regenerates, and continuations; one element for normal `submit-message` and tool-approval responses). On a [Head Start](/ai-chat/fast-starts#with-hydratemessages) first turn, this can contain the route handler's first-turn history. |
| `previousMessages` | `UIMessage[]` | Accumulated UI messages before this turn (`[]` on turn 0) |
| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend |
| `continuation` | `boolean` | Whether this run is continuing an existing chat |
@@ -289,9 +289,12 @@ export const myChat = chat.agent({
const stored = record?.messages ?? [];
if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
- await db.chat.update({
+ // Upsert, not update: on a head-start first turn no preload ran,
+ // so the row may not exist yet when this hook fires.
+ await db.chat.upsert({
where: { id: chatId },
- data: { messages: stored },
+ create: { id: chatId, messages: stored },
+ update: { messages: stored },
});
}
@@ -320,7 +323,7 @@ After the hook returns, the runtime overlays the wire's tool-state advances (`ou
- `incomingMessages` is **0-or-1-length** consistently. `submit-message` and tool-approval responses ship a single message; `regenerate-message`, continuations, and actions ship none. Patterns like [tool-result auditing](/ai-chat/patterns/tool-result-auditing) work the same regardless — iterate the array and the loop runs zero or one times.
+ `incomingMessages` is **usually 0-or-1-length**. `submit-message` and tool-approval responses ship a single message; `regenerate-message`, continuations, and actions ship none. The exception is a [Head Start](/ai-chat/fast-starts#with-hydratemessages) first turn, where it carries the route handler's first-turn history. Patterns like [tool-result auditing](/ai-chat/patterns/tool-result-auditing) work the same regardless — iterate the array rather than assuming a single element.
## onTurnStart
diff --git a/docs/ai-chat/patterns/database-persistence.mdx b/docs/ai-chat/patterns/database-persistence.mdx
index fae1e47809..b9d56ab6c7 100644
--- a/docs/ai-chat/patterns/database-persistence.mdx
+++ b/docs/ai-chat/patterns/database-persistence.mdx
@@ -191,7 +191,13 @@ export const myChat = chat.agent({
// advance onto the existing entry). See lifecycle hooks for the
// full pattern: /ai-chat/lifecycle-hooks#hydratemessages
if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
- await db.chat.update({ where: { id: chatId }, data: { messages: stored } });
+ // Upsert, not update: on a head-start first turn no preload ran,
+ // so the row may not exist yet when this hook fires.
+ await db.chat.upsert({
+ where: { id: chatId },
+ create: { id: chatId, messages: stored },
+ update: { messages: stored },
+ });
}
return stored;
@@ -217,6 +223,8 @@ export const myChat = chat.agent({
This replaces the `onTurnStart` persistence pattern — the hook handles both loading and persisting the new message in one place.
+Hydration composes with [Head Start](/ai-chat/fast-starts#with-hydratemessages): on a head-start first turn the route handler's history arrives as `incomingMessages`, and the write path must be an upsert because no preload ran to create the row.
+
## Design notes
- **`chatId`** is stable for the life of a thread and is the only identifier the transport persists. Runs come and go (idle continuation, upgrade, cancel/restart) but the chat keeps its identity.
diff --git a/docs/ai-chat/patterns/persistence-and-replay.mdx b/docs/ai-chat/patterns/persistence-and-replay.mdx
index 4e1bdf4084..2af9cadfc9 100644
--- a/docs/ai-chat/patterns/persistence-and-replay.mdx
+++ b/docs/ai-chat/patterns/persistence-and-replay.mdx
@@ -142,7 +142,13 @@ export const myChat = chat.agent({
// See lifecycle-hooks for the full upsert pattern + rationale:
// /ai-chat/lifecycle-hooks#hydratemessages
if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
- await db.chat.update({ where: { id: chatId }, data: { messages: stored } });
+ // Upsert, not update: head-start first turns run without a preload
+ // to create the row.
+ await db.chat.upsert({
+ where: { id: chatId },
+ create: { id: chatId, messages: stored },
+ update: { messages: stored },
+ });
}
return stored;
diff --git a/docs/ai-chat/sessions.mdx b/docs/ai-chat/sessions.mdx
index 9e03279b21..d5ec3abc4c 100644
--- a/docs/ai-chat/sessions.mdx
+++ b/docs/ai-chat/sessions.mdx
@@ -1,17 +1,81 @@
---
title: "Sessions"
sidebarTitle: "Sessions"
-description: "The durable, task-bound, bi-directional I/O primitive that backs chat.agent — sessions.list / open / start / close plus the SessionHandle (in/out) API."
+description: "A Session is a pair of durable streams — input carries your users' messages to the agent, output carries everything the agent produces back — plus orchestration of the runs that process them."
---
import RcBanner from "/snippets/ai-chat-rc-banner.mdx";
-A **Session** is a durable, task-bound, bi-directional I/O channel pair. It outlives any single run: a Session row is keyed on a stable `externalId` (e.g. `chatId`), holds the conversation's identity across run boundaries, and exposes two realtime streams — `.in` (clients → task) and `.out` (task → clients).
+**A Session is a pair of durable streams.** The input stream (`.in`) carries incoming user messages to your task. The output stream (`.out`) carries everything the agent produces back to your clients: AI generation parts (text, reasoning, tool calls) and any custom data parts you write.
+
+Sessions also **orchestrate the runs that process those streams**. A Session is keyed on your stable id (`externalId` — for chat, the `chatId`) and owns its current run: when a run suspends, idles out, or hands off to a new version, the Session starts or swaps to a fresh run and the streams carry on. Clients keep sending and reading against the same id; they never know a run changed underneath.
+
+```mermaid
+flowchart LR
+ C[Browser / backend clients] -- "user messages" --> IN([Session .in])
+ IN --> R["current run
(runs come and go)"]
+ R -- "text, reasoning, tool calls,
data parts" --> OUT([Session .out])
+ OUT --> C
+```
`chat.agent` is built on Sessions. You can also use them directly for any pattern that needs durable bi-directional streaming across runs: long-lived agent inboxes, multi-step approval flows, server-to-server pipelines that survive worker restarts.
+## A minimal example
+
+A task that echoes whatever lands on its input stream, and a backend that starts the session, sends a message, and reads the reply:
+
+```ts trigger/inbox.ts
+import { task, sessions } from "@trigger.dev/sdk";
+
+export const inboxAgent = task({
+ id: "inbox-agent",
+ run: async (payload: { sessionId: string }) => {
+ const session = sessions.open(payload.sessionId);
+
+ while (true) {
+ // Suspends the run (no compute billed) until a record arrives.
+ const next = await session.in.wait<{ text: string }>({ timeout: "1h" });
+ if (!next.ok) return;
+ await session.out.append({ type: "reply", text: `echo: ${next.output.text}` });
+ }
+ },
+});
+```
+
+```ts Your backend
+import { sessions } from "@trigger.dev/sdk";
+
+// Atomically create the session AND trigger its first run.
+await sessions.start({
+ type: "inbox",
+ externalId: userId,
+ taskIdentifier: "inbox-agent",
+ triggerConfig: { basePayload: { sessionId: userId } },
+});
+
+const session = sessions.open(userId);
+await session.in.send({ text: "hello" });
+
+const stream = await session.out.read({ signal: AbortSignal.timeout(30_000) });
+for await (const chunk of stream) {
+ console.log(chunk); // { type: "reply", text: "echo: hello" }
+}
+```
+
+The run can suspend, crash, or be replaced between the `send` and the `read` — the streams are durable, so nothing is lost and the client code doesn't change.
+
+## Sessions and runs
+
+One Session spans many runs over its lifetime. The Session row tracks `currentRunId`; the runs do the work:
+
+- **First run**: created atomically by `sessions.start` (no gap where the session exists but nothing is listening).
+- **Idle suspend**: a run blocked on `in.wait` suspends and frees compute. A new record on `.in` wakes it.
+- **Continuation**: when a run ends (idle timeout, `chat.endRun`, a crash, a version upgrade), the next incoming record triggers a fresh run against the same Session. The new run picks up the streams where the old one left off.
+
+This is what makes a Session the durable identity for a conversation: runs are an execution detail, the Session (and its `externalId`) is what your clients address. See [How it works](/ai-chat/how-it-works) for how `chat.agent` drives this loop.
+
## When to reach for Sessions directly
`chat.agent` handles 90% of chat-shaped workloads — message accumulation, the turn loop, stop signals, lifecycle hooks. Use the raw `sessions` API when you need any of: