--- 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. 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`. 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 | `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). ## 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. Don't put `chat.local` initialization in `onRecoveryBoot` — use [`onBoot`](#onboot). `onRecoveryBoot` is for recovery decisions, not per-process setup. `onBoot` fires first. ## 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). 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. The `preloaded` field tells you whether [`onPreload`](#onpreload) already ran for this chat — useful for skipping setup work that's already done. 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. ```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 }); }, }); ``` `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). ## 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 }); }, }); ``` 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. `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. ## 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. `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. 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. `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 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 }); }, }); ``` 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. ## 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 }); }, }); ``` 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. 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. For a full **conversation + session** persistence pattern (including preload, continuation, and token renewal), see [Database persistence](/ai-chat/patterns/database-persistence). ## 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**) | 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`. ## 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