| 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";
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) |
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) |
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) |
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, onTurnComplete, etc.) |
Every turn. | Persist messages, post-process responses. |
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) in the API reference.
Standard task lifecycle hooks 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 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.
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 for that.
This is the right place to initialize anything that lives in the JS process for the lifetime of the run: chat.local state, DB 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.
Branch on continuation to decide whether to load existing state from your DB or start fresh:
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. |
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 |
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 is registered (the customer owns persistence).
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 for the full guide, examples (drop partial, synthesize tool results, persist before boot), and interaction notes.
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 for details.
Per-process state (anything in chat.local, DB connections, etc.) belongs in onBoot — onBoot fires before onPreload on every fresh worker, including on continuation runs where onPreload never fires.
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. |
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 |
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.
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. For per-turn setup, use onTurnStart.
The preloaded field tells you whether onPreload already ran for this chat — useful for skipping setup work that's already done.
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 });
},
});Validate or transform incoming UIMessage[] before they are converted to model messages. Fires once per turn with the raw messages from the wire payload (after cleanup of aborted tool parts), before accumulation and toModelMessages().
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 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 |
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 });
},
});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 wire messages from the frontend — 0-or-1-length (empty for actions, regenerates, and continuations; one element for normal submit-message and tool-approval responses) |
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) |
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 })) {
await db.chat.update({
where: { id: chatId },
data: { 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 and HITL addToolOutput continuations work transparently: ship a slim resolution on the wire, the agent merges the new state onto your DB-backed copy.
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. |
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 |
Stream writer for custom chunks |
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 });
},
});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.
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, plus a writer.
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. |
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) |
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 });
},
});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": afteronPreload, waiting for the first message"turn": afteronTurnComplete, waiting for the next message
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) |
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.
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 });
},
});- Reference for full event-type definitions
- Database persistence for the canonical persistence pattern
- Code execution sandbox for an
onChatSuspenduse case - Backend for
chat.agent({ ... })itself, prompts, stop signals, persistence overview, and runtime configuration