---
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."
---

import RcBanner from "/snippets/ai-chat-rc-banner.mdx";

<RcBanner />

`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
