527 lines
35 KiB
Plaintext
527 lines
35 KiB
Plaintext
---
|
|
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
|