chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
+526
View File
@@ -0,0 +1,526 @@
---
title: "Lifecycle hooks"
sidebarTitle: "Lifecycle hooks"
description: "Hook into every stage of a chat agent's run: preload, turn start, turn complete, suspend, resume, and more."
---
`chat.agent({ ... })` accepts a set of lifecycle hooks for persisting state, validating input, transforming messages, and reacting to suspension and resumption. They fire at well-defined points in the chat agent's lifetime.
**Once per worker process (every fresh run boot):** `onBoot` → `onPreload` (preloaded runs only).
**Once per chat (first message of the chat's lifetime):** `onChatStart`.
**Per-turn order:** `onValidateMessages` → `hydrateMessages` → `onChatStart` (chat's first message only) → `onTurnStart` → `run()` → `onBeforeTurnComplete` → `onTurnComplete`.
**Suspend / resume:** `onChatSuspend` fires when the run transitions from idle to suspended (waiting on the next message); `onChatResume` fires on wake.
**Four scopes to keep straight:**
| Scope | Fires when | Use for |
| --- | --- | --- |
| **Process** ([`onBoot`](#onboot)) | Every fresh worker boots — initial, preloaded, and reactive continuation (post-cancel/crash/`endRun`/upgrade). | Initialize `chat.local`, open per-process resources, re-hydrate state from your DB on continuation. |
| **Recovery** ([`onRecoveryBoot`](#onrecoveryboot)) | Continuation boot where the dead run was mid-stream — a partial assistant survives on `session.out`. | Override the smart default — drop the partial, synthesize tool results, emit a recovery banner. |
| **Chat** ([`onChatStart`](#onchatstart)) | First message of a chat's lifetime. Does NOT fire on continuation runs or OOM retries. | One-time DB rows for the chat, resources tied to the chat's lifetime. |
| **Turn** ([`onTurnStart`](#onturnstart), [`onTurnComplete`](#onturncomplete), etc.) | Every turn. | Persist messages, post-process responses. |
## Task context (`ctx`)
Every chat lifecycle callback and the `run` payload include `ctx`: the same run context object as `task({ run: (payload, { ctx }) => ... })`. Import the type with `import type { TaskRunContext } from "@trigger.dev/sdk"` (the `Context` export is the same type). Use `ctx` for tags, metadata, or any API that needs the full run record. The string `runId` on chat events is always `ctx.run.id` (both are provided for convenience). See [Task context (`ctx`)](/ai-chat/reference#task-context-ctx) in the API reference.
Standard [task lifecycle hooks](/tasks/overview) such as `onWait`, `onResume`, `onComplete`, and `onFailure` are also available on `chat.agent()` with the same shapes as on a normal `task()` — but prefer the chat-specific [`onChatSuspend` / `onChatResume`](#onchatsuspend--onchatresume) for any chat-related work. The generic hooks fire on every wait/resume (including ones the runtime uses internally for non-chat reasons); the chat-specific ones fire only at the idle-to-suspended transition you actually care about and carry full chat context.
## onBoot
Fires **once per worker process picking up the chat** — for the initial run, for preloaded runs, AND for reactive continuation runs (post-cancel, crash, `endRun`, `requestUpgrade`, OOM retry). Does NOT fire when the same run resumes from snapshot via the idle-window suspend/resume path — use [`onChatResume`](#onchatsuspend--onchatresume) for that.
This is the right place to initialize anything that lives in the JS process for the lifetime of the run: [`chat.local`](/ai-chat/chat-local) state, [DB connections](/database-connections), sandboxes, in-memory caches. It runs before `onPreload`, `onChatStart`, the continuation-wait branch, and any turn — so anything you set up here is available everywhere downstream.
<Warning>
If you initialize `chat.local` only in `onChatStart`, your `run()` will crash on continuation runs with `chat.local can only be modified after initialization`. `onChatStart` is once-per-chat by contract; `chat.local` is per-process and needs `onBoot`.
</Warning>
Branch on `continuation` to decide whether to load existing state from your DB or start fresh:
```ts
export const myChat = chat.agent({
id: "my-chat",
clientDataSchema: z.object({ userId: z.string() }),
onBoot: async ({ chatId, clientData, continuation, previousRunId }) => {
const user = await db.user.findUnique({ where: { id: clientData.userId } });
userContext.init({ name: user.name, plan: user.plan });
if (continuation) {
// Re-hydrate per-chat in-memory state from your DB.
// `previousRunId` is the public id of the prior run (use it for
// logging or to look up persisted state keyed on run id).
const saved = await db.chatState.findUnique({ where: { chatId } });
if (saved) {
// Re-apply your saved per-chat state into wherever your
// run() reads it from (a chat.local slot, an in-memory map, etc.).
userContext.applySaved(saved);
}
}
},
run: async ({ messages, signal }) => {
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
});
```
| Field | Type | Description |
| ----------------- | ----------------------------- | --------------------------------------------------------------------------- |
| `ctx` | `TaskRunContext` | Full task run context. See [reference](/ai-chat/reference#task-context-ctx). |
| `chatId` | `string` | Chat session ID |
| `runId` | `string` | The Trigger.dev run ID for this run boot |
| `chatAccessToken` | `string` | Scoped access token for this run |
| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend |
| `continuation` | `boolean` | `true` when this run is taking over from a prior dead run |
| `previousRunId` | `string \| undefined` | Public id of the prior run when `continuation` is true |
| `preloaded` | `boolean` | Whether this run was triggered as a preload |
<Tip>
`onBoot` and `onChatStart` are complementary — keep DB-row creation in `onChatStart` (it only needs to happen once per chat) and put process-level setup (`chat.local`, connections, caches) in `onBoot` (it needs to happen on every fresh worker).
</Tip>
## onRecoveryBoot
Fires once on a continuation boot when the dead predecessor was mid-stream — a partial assistant survives on `session.out`. The runtime reconstructs context automatically via a smart default; this hook is the override path for policies that need something different.
The hook does NOT fire when there's no partial — clean continuations after `chat.endRun()` or `chat.requestUpgrade()`, fresh chats, OOM retries on top of a complete snapshot. Those paths dispatch any in-flight user message as a normal turn on the new run without involving the hook. It also does NOT fire when [`hydrateMessages`](#hydratemessages) is registered (the customer owns persistence).
```ts
export const myChat = chat.agent({
id: "my-chat",
onRecoveryBoot: async ({ partialAssistant, inFlightUsers, writer, cause, previousRunId }) => {
writer.write({
type: "data-chat-recovery",
data: { cause, previousRunId, partialPresent: partialAssistant !== undefined },
transient: true,
});
// Return nothing → fall through to the smart default
// (splice partial + first user into chain, dispatch the rest).
},
run: async ({ messages, signal }) =>
streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal }),
});
```
| Field | Type | Description |
| ------------------ | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `ctx` | `TaskRunContext` | Full task run context |
| `chatId` | `string` | Chat session ID |
| `runId` | `string` | The Trigger.dev run ID for this run boot |
| `previousRunId` | `string` | Public id of the prior run that died |
| `cause` | `"cancelled" \| "crashed" \| "unknown"` | Best-effort cause. Currently always `"unknown"` — don't branch on it |
| `settledMessages` | `TUIMessage[]` | The chain persisted by the predecessor's last `onTurnComplete` |
| `inFlightUsers` | `TUIMessage[]` | User messages on `session.in` past the cursor — the message(s) the predecessor never acknowledged |
| `partialAssistant` | `TUIMessage \| undefined` | The trailing assistant message whose stream never received `finish` |
| `pendingToolCalls` | `Array<{ toolCallId, toolName, input, partIndex }>` | Tool calls in `input-available` state extracted from `partialAssistant` |
| `writer` | `ChatWriter` | Lazy session.out writer — write a recovery banner / signal here |
Returns `{ chain?, recoveredTurns?, beforeBoot? }` — every field optional. Omitted fields fall through to the smart default. See [Recovery boot](/ai-chat/patterns/recovery-boot) for the full guide, examples (drop partial, synthesize tool results, persist before boot), and interaction notes.
<Tip>
Don't put `chat.local` initialization in `onRecoveryBoot` — use [`onBoot`](#onboot). `onRecoveryBoot` is for recovery decisions, not per-process setup. `onBoot` fires first.
</Tip>
## onPreload
Fires when a **preloaded run** starts, before any messages arrive. Use it to eagerly create chat-scoped DB rows (the Chat row, the ChatSession row) while the user is still typing — so the very first message lands fast.
Preloaded runs are triggered by calling `transport.preload(chatId)` on the frontend. See [Preload](/ai-chat/fast-starts#preload) for details.
Per-process state (anything in [`chat.local`](/ai-chat/chat-local), DB connections, etc.) belongs in [`onBoot`](#onboot) — `onBoot` fires before `onPreload` on every fresh worker, including on continuation runs where `onPreload` never fires.
```ts
export const myChat = chat.agent({
id: "my-chat",
clientDataSchema: z.object({ userId: z.string() }),
onBoot: async ({ clientData }) => {
// Per-process state — runs on every fresh worker (initial,
// preloaded, continuation). See onBoot above.
const user = await db.user.findUnique({ where: { id: clientData.userId } });
userContext.init({ name: user.name, plan: user.plan });
},
onPreload: async ({ chatId, clientData, runId, chatAccessToken }) => {
// Chat-scoped DB rows — only matters on preload (and onChatStart as
// a fallback when not preloaded).
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 chat-row initialization
},
run: async ({ messages, signal }) => {
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
});
```
| Field | Type | Description |
| ----------------- | --------------------------------------------- | -------------------------------- |
| `ctx` | `TaskRunContext` | Full task run context. See [reference](/ai-chat/reference#task-context-ctx). |
| `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 |
| `writer` | [`ChatWriter`](/ai-chat/reference#chatwriter) | Stream writer for custom chunks |
Every lifecycle callback receives a `writer`, a lazy stream writer that lets you send custom `UIMessageChunk` parts (like `data-*` parts) to the frontend. Non-transient `data-*` chunks written via the `writer` are automatically added to the response message and available in `onTurnComplete`. Add `transient: true` for ephemeral chunks (progress indicators, etc.) that should not persist. See [Custom data parts](/ai-chat/backend#custom-data-parts).
## onChatStart
Fires **exactly once per chat**, on the very first user message of the chat's lifetime, before `run()` executes. Use it for one-time chat-scoped setup — create the Chat DB row, mint resources tied to the chat's lifetime.
`onChatStart` does **not** fire on:
- **Continuation runs** — a new run picking up an existing session after the prior run ended (`chat.endRun`, waitpoint timeout, `chat.requestUpgrade`, cancel, crash). The chat already started.
- **OOM-retry attempts** — same chat, same conversation, just on a larger machine.
For per-process state that has to be initialized on every fresh worker (including continuation runs), use [`onBoot`](#onboot). For per-turn setup, use [`onTurnStart`](#onturnstart).
<Warning>
Do not initialize [`chat.local`](/ai-chat/chat-local) here. `chat.local` is per-process state that must survive continuation runs, but `onChatStart` only fires on the chat's very first message. Use [`onBoot`](#onboot) instead.
</Warning>
The `preloaded` field tells you whether [`onPreload`](#onpreload) already ran for this chat — useful for skipping setup work that's already done.
<Note>
Because `onChatStart` fires only on the chat's first ever message, `messages` is either empty (when no message exists yet — e.g. a preloaded run that hasn't received its first turn) or contains just the first user message. There's no prior history to load here.
</Note>
```ts
export const myChat = chat.agent({
id: "my-chat",
onChatStart: async ({ chatId, clientData, preloaded }) => {
if (preloaded) return; // Already set up in onPreload
const { userId } = clientData as { userId: string };
await db.chat.create({
data: { id: chatId, userId, title: "New chat" },
});
},
run: async ({ messages, signal }) => {
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
});
```
<Tip>
`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).
</Tip>
## onValidateMessages
Validate or transform incoming `UIMessage[]` before they are converted to model messages. Fires on turns that carry incoming messages, with the raw messages from the wire payload (after cleanup of aborted tool parts), **before** accumulation and `toModelMessages()`. Turns with no incoming messages — preload, close, and regenerate with nothing re-sent — skip it.
Return the validated messages array. Throw to abort the turn with an error.
This is the right place to call the AI SDK's [`validateUIMessages`](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-message-persistence#validating-messages-on-the-server) to catch malformed messages from storage or untrusted input before they reach the model, especially useful when persisting conversations to a database where tool schemas may drift between deploys.
| Field | Type | Description |
| --------- | --------------------------------------------------------------- | ---------------------------------------- |
| `messages` | `UIMessage[]` | Incoming UI messages for this turn |
| `chatId` | `string` | Chat session ID |
| `turn` | `number` | Turn number (0-indexed) |
| `trigger` | `"submit-message" \| "regenerate-message" \| "preload" \| "close"` | The trigger type for this turn |
```ts
import { validateUIMessages } from "ai";
export const myChat = chat.agent({
id: "my-chat",
onValidateMessages: async ({ messages }) => {
const userMessages = messages.filter((m) => m.role === "user");
if (userMessages.length > 0) {
await validateUIMessages({ messages: userMessages, tools: chatTools });
}
return messages;
},
run: async ({ messages, signal }) => {
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, tools: chatTools, abortSignal: signal });
},
});
```
<Warning>
On HITL continuations (`addToolOutput` / `addToolApproveResponse`) the assistant entry in `messages` is **slim** — `state` + `output` / `errorText` / `approval` only, no `input` or other parts. `validateUIMessages` against the AI SDK schema rejects that shape (the schema requires `input` on resolved tool parts), so filter to user messages first (or skip validation entirely on those turns). The example above does the filter.
</Warning>
<Note>
`onValidateMessages` fires **before** `onTurnStart` and message accumulation. If you need to validate messages loaded from a database, do the loading in `onChatStart` or `onPreload` and let `onValidateMessages` validate the full incoming set each turn.
</Note>
## hydrateMessages
Load the full message history from your backend on every turn, replacing the built-in linear accumulator. When set, the hook's return value becomes the accumulated state; the normal accumulation logic (append for submit, replace for regenerate) is skipped entirely.
Use this when the backend should be the source of truth for message history: abuse prevention, branching conversations (DAGs), or rollback/undo support.
| Field | Type | Description |
| ------------------ | ----------------------------------------------------- | --------------------------------------------------------- |
| `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 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 |
| `previousRunId` | `string \| undefined` | The previous run ID (if continuation) |
```ts
import { chat, upsertIncomingMessage } from "@trigger.dev/sdk/ai";
export const myChat = chat.agent({
id: "my-chat",
hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
const record = await db.chat.findUnique({ where: { id: chatId } });
const stored = record?.messages ?? [];
if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
// 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;
},
run: async ({ messages, signal }) => {
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
});
```
`upsertIncomingMessage` (exported from `@trigger.dev/sdk/ai`) handles the three cases that matter — fresh user messages get pushed, HITL continuations (`addToolOutput` / `addToolApproveResponse`) no-op because the incoming wire shares the existing assistant's id and the runtime overlays the new tool-state advance onto that entry, and non-`submit-message` triggers (`regenerate-message` / `action`) skip persistence. It returns `true` when it mutated `stored`, so the caller knows whether to persist.
If you need branching, rollback, or other custom hydrate logic, you can still write the upsert by hand — `upsertIncomingMessage` is a convenience for the common case, not the only supported shape.
**Lifecycle position:** `onValidateMessages` → **`hydrateMessages`** → `onChatStart` (chat's first message only) → `onTurnStart` → `run()`
After the hook returns, the runtime overlays the wire's tool-state advances (`output-available` / `output-error` / `approval-responded` / `output-denied`) onto matching hydrated entries by id. Everything else on the hydrated entry — text, reasoning, tool `input`, providerMetadata — stays put. This makes [tool approvals](/ai-chat/frontend#tool-approvals) and HITL `addToolOutput` continuations work transparently: ship a slim resolution on the wire, the agent merges the new state onto your DB-backed copy.
<Note>
`hydrateMessages` also fires for [action](/ai-chat/actions) turns (`trigger: "action"`) with empty `incomingMessages`. This lets the action handler work with the latest DB state.
</Note>
<Tip>
Registering `hydrateMessages` short-circuits the runtime's [snapshot + replay](/ai-chat/patterns/persistence-and-replay) reconstruction at run boot — your hook is the single source of truth for history, so the runtime skips reading or writing the snapshot entirely. No object storage traffic, no replay cost. The trade-off is that you own persistence end-to-end.
</Tip>
<Note>
`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.
</Note>
## onTurnStart
Fires at the start of **every turn** — including the first turn of a continuation run, where `onChatStart` doesn't fire. Runs after message accumulation and (when applicable) `onChatStart`, 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 |
| ----------------- | --------------------------------------------- | ----------------------------------------------- |
| `ctx` | `TaskRunContext` | Full task run context. See [reference](/ai-chat/reference#task-context-ctx). |
| `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 |
| `writer` | [`ChatWriter`](/ai-chat/reference#chatwriter) | Stream writer for custom chunks |
```ts
export const myChat = chat.agent({
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: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
});
```
<Tip>
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.
</Tip>
## onBeforeTurnComplete
Fires after the response is captured but **before** the stream closes. The `writer` can send custom chunks that appear in the current turn. Use this for post-processing indicators, compaction progress, or any data the user should see before the turn ends.
```ts
export const myChat = chat.agent({
id: "my-chat",
onBeforeTurnComplete: async ({ writer, usage, uiMessages }) => {
// Write a custom data part while the stream is still open
writer.write({
type: "data-usage-summary",
data: {
tokens: usage?.totalTokens,
messageCount: uiMessages.length,
},
});
// You can also compact messages here and write progress
if (usage?.totalTokens && usage.totalTokens > 50_000) {
writer.write({ type: "data-compaction", data: { status: "compacting" } });
chat.setMessages(compactedMessages);
writer.write({ type: "data-compaction", data: { status: "complete" } });
}
},
run: async ({ messages, signal }) => {
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
});
```
Receives the same fields as [`TurnCompleteEvent`](/ai-chat/reference#turncompleteevent), plus a [`writer`](/ai-chat/reference#chatwriter).
## onTurnComplete
Fires after each turn completes, after the response is captured and the stream is closed. This is the primary hook for persisting the assistant's response. Does not include a `writer` since the stream is already closed.
| Field | Type | Description |
| -------------------- | ------------------------ | -------------------------------------------------------------------------------------------- |
| `ctx` | `TaskRunContext` | Full task run context. See [reference](/ai-chat/reference#task-context-ctx). |
| `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.agent({
id: "my-chat",
onTurnComplete: async ({ chatId, uiMessages, runId, chatAccessToken, lastEventId }) => {
// Atomic write — see Database persistence for the race-condition rationale
await db.$transaction([
db.chat.update({
where: { id: chatId },
data: { messages: uiMessages },
}),
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: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
});
```
<Tip>
Use `uiMessages` to overwrite the full conversation each turn (simplest). Use `newUIMessages` if
you prefer to store messages individually, e.g. one database row per message.
</Tip>
<Tip>
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.
</Tip>
<Tip>
For a full **conversation + session** persistence pattern (including preload, continuation, and token renewal), see [Database persistence](/ai-chat/patterns/database-persistence).
</Tip>
## onChatSuspend / onChatResume
Chat-specific hooks that fire at the **idle-to-suspended** transition: the moment the run stops using compute and waits for the next message. These replace the need for the generic `onWait` / `onResume` task hooks for chat-specific work.
The `phase` discriminator tells you **when** the suspend/resume happened:
- `"preload"`: after `onPreload`, waiting for the first message
- `"turn"`: after `onTurnComplete`, waiting for the next message
```ts
export const myChat = chat.agent({
id: "my-chat",
onChatSuspend: async (event) => {
// Tear down expensive resources before suspending
await disposeCodeSandbox(event.ctx.run.id);
if (event.phase === "turn") {
logger.info("Suspending after turn", { turn: event.turn });
}
},
onChatResume: async (event) => {
// Re-initialize after waking up
logger.info("Resumed", { phase: event.phase });
},
run: async ({ messages, signal }) => {
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
});
```
| Field | Type | Description |
| ------------ | ---------------- | ------------------------------------------------------------ |
| `phase` | `"preload" \| "turn"` | Whether this is a preload or post-turn suspension |
| `ctx` | `TaskRunContext` | Full task run context |
| `chatId` | `string` | Chat session ID |
| `runId` | `string` | The Trigger.dev run ID |
| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend |
| `turn` | `number` | Turn number (**`"turn"` phase only**) |
| `messages` | `ModelMessage[]` | Accumulated model messages (**`"turn"` phase only**) |
| `uiMessages` | `UIMessage[]` | Accumulated UI messages (**`"turn"` phase only**) |
<Tip>
Unlike `onWait` (which fires for all wait types: duration, task, batch, token), `onChatSuspend` fires only at chat suspension points with full chat context. No need to filter on `wait.type`.
</Tip>
## exitAfterPreloadIdle
When set to `true`, a preloaded run completes successfully after the idle timeout elapses instead of suspending. Use this for "fire and forget" preloads. If the user doesn't send a message during the idle window, the run ends cleanly.
```ts
export const myChat = chat.agent({
id: "my-chat",
preloadIdleTimeoutInSeconds: 10,
exitAfterPreloadIdle: true,
onPreload: async ({ chatId, clientData }) => {
// Eagerly set up state. If no message comes, the run just ends.
await initializeChat(chatId, clientData);
},
run: async ({ messages, signal }) => {
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
});
```
## See also
- [Reference](/ai-chat/reference) for full event-type definitions
- [Database persistence](/ai-chat/patterns/database-persistence) for the canonical persistence pattern
- [Code execution sandbox](/ai-chat/patterns/code-sandbox) for an `onChatSuspend` use case
- [Backend](/ai-chat/backend) for `chat.agent({ ... })` itself, prompts, stop signals, persistence overview, and runtime configuration