Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/ai-chat/fast-starts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Warning>
**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.
</Warning>

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
Expand Down
9 changes: 6 additions & 3 deletions docs/ai-chat/lifecycle-hooks.mdx
Comment thread
ericallam marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 },
});
Comment thread
ericallam marked this conversation as resolved.
}

Expand Down
10 changes: 9 additions & 1 deletion docs/ai-chat/patterns/database-persistence.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
68 changes: 66 additions & 2 deletions docs/ai-chat/sessions.mdx
Original file line number Diff line number Diff line change
@@ -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";

<RcBanner />

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<br/>(runs come and go)"]
R -- "text, reasoning, tool calls,<br/>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:
Expand Down