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:
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) 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.
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 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:
onRecoveryBoot
Fires once on a continuation boot when the dead predecessor was mid-stream — a partial assistant survives onsession.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).
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.
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 callingtransport.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.
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.
onChatStart
Fires exactly once per chat, on the very first user message of the chat’s lifetime, beforerun() 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.
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.
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.onValidateMessages
Validate or transform incomingUIMessage[] 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 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.
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.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.
hydrateMessages also fires for action turns (trigger: "action") with empty incomingMessages. This lets the action handler work with the latest DB state.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 first turn, where it carries the route handler’s first-turn history. Patterns like 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, whereonChatStart 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.
onBeforeTurnComplete
Fires after the response is captured but before the stream closes. Thewriter 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.
TurnCompleteEvent, plus a writer.
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 awriter since the stream is already closed.
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 genericonWait / 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
exitAfterPreloadIdle
When set totrue, 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.
See also
- 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

