Documentation Index
Fetch the complete documentation index at: https://trigger.dev/docs/llms.txt
Use this file to discover all available pages before exploring further.
The AI Agents and Prompts surface ships as part of the v4.5 release candidate. Install with @trigger.dev/sdk@rc (or pin 4.5.0-rc.0 or later) to use these features — they aren’t yet on the latest stable, and APIs may still change before the 4.5.0 GA. See supported AI SDK versions and the AI chat changelog for details.
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. |
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.
If you initialize chat.local only in onChatStart, your run() will crash on continuation runs with chat.local can only be modified after initialization. onChatStart is once-per-chat by contract; chat.local is per-process and needs onBoot.
Branch on continuation to decide whether to load existing state from your DB or start fresh:
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 |
onBoot and onChatStart are complementary — keep DB-row creation in onChatStart (it only needs to happen once per chat) and put process-level setup (chat.local, connections, caches) in onBoot (it needs to happen on every fresh worker).
onRecoveryBoot
Fires once on a continuation boot when the dead predecessor was mid-stream — a partial assistant survives on session.out. The runtime reconstructs context automatically via a smart default; this hook is the override path for policies that need something different.
The hook does NOT fire when there’s no partial — clean continuations after chat.endRun() or chat.requestUpgrade(), fresh chats, OOM retries on top of a complete snapshot. Those paths dispatch any in-flight user message as a normal turn on the new run without involving the hook. It also does NOT fire when hydrateMessages 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.
Don’t put chat.local initialization in onRecoveryBoot — use onBoot. onRecoveryBoot is for recovery decisions, not per-process setup. onBoot fires first.
onPreload
Fires when a preloaded run starts, before any messages arrive. Use it to eagerly create chat-scoped DB rows (the Chat row, the ChatSession row) while the user is still typing — so the very first message lands fast.
Preloaded runs are triggered by calling transport.preload(chatId) on the frontend. See Preload 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.
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. For per-turn setup, use onTurnStart.
Do not initialize 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 instead.
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.
export const myChat = chat.agent({
id: "my-chat",
onChatStart: async ({ chatId, clientData, preloaded }) => {
if (preloaded) return; // Already set up in onPreload
const { userId } = clientData as { userId: string };
await db.chat.create({
data: { id: chatId, userId, title: "New chat" },
});
},
run: async ({ messages, signal }) => {
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
});
clientData contains custom data from the frontend: either the clientData option on the
transport constructor (sent with every message) or the metadata option on sendMessage()
(per-message). See Client data and metadata.
onValidateMessages
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 }) => {
return validateUIMessages({ messages, tools: chatTools });
},
run: async ({ messages, signal }) => {
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, tools: chatTools, abortSignal: signal });
},
});
onValidateMessages fires before onTurnStart and message accumulation. If you need to validate messages loaded from a database, do the loading in onChatStart or onPreload and let onValidateMessages validate the full incoming set each turn.
hydrateMessages
Load the full message history from your backend on every turn, replacing the built-in linear accumulator. When set, the hook’s return value becomes the accumulated state; the normal accumulation logic (append for submit, replace for regenerate) is skipped entirely.
Use this when the backend should be the source of truth for message history: abuse prevention, branching conversations (DAGs), or rollback/undo support.
| Field | Type | Description |
|---|
chatId | string | Chat session ID |
turn | number | Turn number (0-indexed) |
trigger | "submit-message" | "regenerate-message" | "action" | The trigger type for this turn |
incomingMessages | UIMessage[] | Validated 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) |
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 ?? [];
// Append the new user message and persist
if (trigger === "submit-message" && incomingMessages.length > 0) {
const newMsg = incomingMessages[incomingMessages.length - 1]!;
stored.push(newMsg);
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 });
},
});
Lifecycle position: onValidateMessages → hydrateMessages → onChatStart (chat’s first message only) → onTurnStart → run()
After the hook returns, any incoming wire message whose ID matches a hydrated message is auto-merged. This makes tool approvals work transparently with hydration.
hydrateMessages also fires for action turns (trigger: "action") with empty incomingMessages. This lets the action handler work with the latest DB state.
Registering hydrateMessages short-circuits the runtime’s snapshot + replay reconstruction at run boot — your hook is the single source of truth for history, so the runtime skips reading or writing the snapshot entirely. No object storage traffic, no replay cost. The trade-off is that you own persistence end-to-end.
incomingMessages is 0-or-1-length consistently. submit-message and tool-approval responses ship a single message; regenerate-message, continuations, and actions ship none. Patterns like tool-result auditing work the same regardless — iterate the array and the loop runs zero or one times.
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. |
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 });
},
});
By persisting in onTurnStart, the user’s message is saved to your database before the AI starts
streaming. If the user refreshes mid-stream, the message is already there.
onBeforeTurnComplete
Fires after the response is captured but before the stream closes. The writer can send custom chunks that appear in the current turn. Use this for post-processing indicators, compaction progress, or any data the user should see before the turn ends.
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.
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. |
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 });
},
});
Use uiMessages to overwrite the full conversation each turn (simplest). Use newUIMessages if
you prefer to store messages individually, e.g. one database row per message.
Persist lastEventId alongside the session. When the transport reconnects after a page refresh,
it uses this to skip past already-seen events, preventing duplicate messages.
For a full conversation + session persistence pattern (including preload, continuation, and token renewal), see Database persistence.
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
export const myChat = chat.agent({
id: "my-chat",
onChatSuspend: async (event) => {
// Tear down expensive resources before suspending
await disposeCodeSandbox(event.ctx.run.id);
if (event.phase === "turn") {
logger.info("Suspending after turn", { turn: event.turn });
}
},
onChatResume: async (event) => {
// Re-initialize after waking up
logger.info("Resumed", { phase: event.phase });
},
run: async ({ messages, signal }) => {
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
});
| Field | Type | Description |
|---|
phase | "preload" | "turn" | Whether this is a preload or post-turn suspension |
ctx | TaskRunContext | Full task run context |
chatId | string | Chat session ID |
runId | string | The Trigger.dev run ID |
clientData | Typed by clientDataSchema | Custom data from the frontend |
turn | number | Turn number ("turn" phase only) |
messages | ModelMessage[] | Accumulated model messages ("turn" phase only) |
uiMessages | UIMessage[] | Accumulated UI messages ("turn" phase only) |
Unlike onWait (which fires for all wait types: duration, task, batch, token), onChatSuspend fires only at chat suspension points with full chat context. No need to filter on wait.type.
exitAfterPreloadIdle
When set to true, a preloaded run completes successfully after the idle timeout elapses instead of suspending. Use this for “fire and forget” preloads. If the user doesn’t send a message during the idle window, the run ends cleanly.
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