From d2924d272b98e1aa60ad2186648a43abca489954 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 11 Jun 2026 16:11:27 +0100 Subject: [PATCH 1/5] docs(ai-chat): document the head-start persistence and handover contract Adds a persistence section to the fast starts page covering the stable assistant messageId across the handover, onTurnComplete as the canonical persistence point, reasoning parts surviving into durable history, and how Head Start composes with hydrateMessages. Switches the hydrate hook examples to upsert their conversation row, since head-start first turns run without a preload to create it. --- docs/ai-chat/fast-starts.mdx | 19 +++++++++++++++++++ docs/ai-chat/lifecycle-hooks.mdx | 9 ++++++--- .../ai-chat/patterns/database-persistence.mdx | 10 +++++++++- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/docs/ai-chat/fast-starts.mdx b/docs/ai-chat/fast-starts.mdx index 7310988964c..6910dbb8b34 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 c327374e05c..5f8195c8937 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 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). On a [Head Start](/ai-chat/fast-starts#with-hydratemessages) first turn, carries 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 }, }); } diff --git a/docs/ai-chat/patterns/database-persistence.mdx b/docs/ai-chat/patterns/database-persistence.mdx index fae1e478092..b9d56ab6c7d 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. From e4c81d0566e3a7138fbeb47adf6916f04f2d37d9 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 11 Jun 2026 16:24:39 +0100 Subject: [PATCH 2/5] docs(ai-chat): lead the sessions page with a plain definition The opening defined a Session as a durable, task-bound, bi-directional I/O channel pair, which reads as jargon and omits run orchestration entirely. The page now leads with the plain mental model (a pair of durable streams: input carries user messages, output carries everything the agent produces) plus the Session's role orchestrating runs, adds a diagram, a minimal runnable example, and a Sessions-and-runs section covering the one-session-many-runs lifecycle. --- docs/ai-chat/sessions.mdx | 68 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/docs/ai-chat/sessions.mdx b/docs/ai-chat/sessions.mdx index 9e03279b218..d5ec3abc4ca 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: From b80d6c49118eb3a32fef1bf46b75c0c9f448f94d Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 11 Jun 2026 17:20:37 +0100 Subject: [PATCH 3/5] docs(ai-chat): soften the incomingMessages cardinality claim The head-start addition contradicted the absolute 0-or-1-length wording in the same cell. Now reads as usually 0-or-1 with the head-start first turn called out as the exception. --- docs/ai-chat/lifecycle-hooks.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ai-chat/lifecycle-hooks.mdx b/docs/ai-chat/lifecycle-hooks.mdx index 5f8195c8937..97b6328c600 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). On a [Head Start](/ai-chat/fast-starts#with-hydratemessages) first turn, carries the route handler's first-turn history | +| `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 | From d89c04d6de88e16e03576e7465980397b02b1a34 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 11 Jun 2026 17:43:56 +0100 Subject: [PATCH 4/5] docs(ai-chat): fix the second incomingMessages cardinality claim The Note further down the hydrateMessages section repeated the absolute 0-or-1-length wording the previous commit softened in the field table. --- docs/ai-chat/lifecycle-hooks.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ai-chat/lifecycle-hooks.mdx b/docs/ai-chat/lifecycle-hooks.mdx index 97b6328c600..6089d9d8b07 100644 --- a/docs/ai-chat/lifecycle-hooks.mdx +++ b/docs/ai-chat/lifecycle-hooks.mdx @@ -323,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 From 864711c3876cdc0f91a29d93b18a2895ffb978e8 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 11 Jun 2026 18:44:40 +0100 Subject: [PATCH 5/5] docs(ai-chat): upsert the hydrate row in the persistence-and-replay example Same fragile update-against-a-possibly-missing-row pattern the other hydrate examples already moved away from. --- docs/ai-chat/patterns/persistence-and-replay.mdx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/ai-chat/patterns/persistence-and-replay.mdx b/docs/ai-chat/patterns/persistence-and-replay.mdx index 4e1bdf4084e..2af9cadfc93 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;