Skip to content

Commit 9e8c604

Browse files
committed
docs(ai-chat): document onBoot lifecycle hook
1 parent d773cf9 commit 9e8c604

4 files changed

Lines changed: 129 additions & 12 deletions

File tree

docs/ai-chat/lifecycle-hooks.mdx

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,24 @@ sidebarTitle: "Lifecycle hooks"
44
description: "Hook into every stage of a chat agent's run: preload, turn start, turn complete, suspend, resume, and more."
55
---
66

7-
`chat.agent({ ... })` accepts a set of lifecycle hooks for persisting state, validating input, transforming messages, and reacting to suspension and resumption. They fire in a fixed order around each turn.
7+
`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.
8+
9+
**Once per worker process (every fresh run boot):** `onBoot``onPreload` (preloaded runs only).
10+
11+
**Once per chat (first message of the chat's lifetime):** `onChatStart`.
812

913
**Per-turn order:** `onValidateMessages``hydrateMessages``onChatStart` (chat's first message only) → `onTurnStart``run()``onBeforeTurnComplete``onTurnComplete`.
1014

1115
**Suspend / resume:** `onChatSuspend` fires when the run transitions from idle to suspended (waiting on the next message); `onChatResume` fires on wake.
1216

17+
**Three scopes to keep straight:**
18+
19+
| Scope | Fires when | Use for |
20+
| --- | --- | --- |
21+
| **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. |
22+
| **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. |
23+
| **Turn** ([`onTurnStart`](#onturnstart), [`onTurnComplete`](#onturncomplete), etc.) | Every turn. | Persist messages, post-process responses. |
24+
1325
## Task context (`ctx`)
1426

1527
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.
@@ -18,21 +30,80 @@ Standard [task lifecycle hooks](/tasks/overview) such as `onWait`, `onResume`, `
1830

1931
Chat agents also have two dedicated suspension hooks, `onChatSuspend` and `onChatResume`, that fire at the idle-to-suspended transition with full chat context. Use them for resource cleanup (e.g. tearing down sandboxes) and re-initialization. See [onChatSuspend / onChatResume](#onchatsuspend--onchatresume) and the [Code execution sandbox](/ai-chat/patterns/code-sandbox) pattern.
2032

33+
## onBoot
34+
35+
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.
36+
37+
This is the right place to initialize anything that lives in the JS process for the lifetime of the run: [`chat.local`](/ai-chat/reference#chatlocal) 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.
38+
39+
<Warning>
40+
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`.
41+
</Warning>
42+
43+
Branch on `continuation` to decide whether to load existing state from your DB or start fresh:
44+
45+
```ts
46+
export const myChat = chat.agent({
47+
id: "my-chat",
48+
clientDataSchema: z.object({ userId: z.string() }),
49+
onBoot: async ({ chatId, clientData, continuation, previousRunId }) => {
50+
const user = await db.user.findUnique({ where: { id: clientData.userId } });
51+
userContext.init({ name: user.name, plan: user.plan });
52+
53+
if (continuation) {
54+
// Re-hydrate per-chat in-memory state from your DB.
55+
// `previousRunId` is the public id of the prior run (use it for
56+
// logging or to look up persisted state keyed on run id).
57+
const saved = await db.chatState.findUnique({ where: { chatId } });
58+
if (saved) {
59+
// Re-apply your saved per-chat state into wherever your
60+
// run() reads it from (a chat.local slot, an in-memory map, etc.).
61+
userContext.applySaved(saved);
62+
}
63+
}
64+
},
65+
run: async ({ messages, signal }) => {
66+
return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
67+
},
68+
});
69+
```
70+
71+
| Field | Type | Description |
72+
| ----------------- | ----------------------------- | --------------------------------------------------------------------------- |
73+
| `ctx` | `TaskRunContext` | Full task run context. See [reference](/ai-chat/reference#task-context-ctx). |
74+
| `chatId` | `string` | Chat session ID |
75+
| `runId` | `string` | The Trigger.dev run ID for this run boot |
76+
| `chatAccessToken` | `string` | Scoped access token for this run |
77+
| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend |
78+
| `continuation` | `boolean` | `true` when this run is taking over from a prior dead run |
79+
| `previousRunId` | `string \| undefined` | Public id of the prior run when `continuation` is true |
80+
| `preloaded` | `boolean` | Whether this run was triggered as a preload |
81+
82+
<Tip>
83+
`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).
84+
</Tip>
85+
2186
## onPreload
2287

23-
Fires when a preloaded run starts, before any messages arrive. Use it to eagerly initialize state (DB records, user context) while the user is still typing.
88+
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.
2489

2590
Preloaded runs are triggered by calling `transport.preload(chatId)` on the frontend. See [Preload](/ai-chat/fast-starts#preload) for details.
2691

92+
Per-process state (anything in [`chat.local`](/ai-chat/reference#chatlocal), DB connections, etc.) belongs in [`onBoot`](#onboot)`onBoot` fires before `onPreload` on every fresh worker, including on continuation runs where `onPreload` never fires.
93+
2794
```ts
2895
export const myChat = chat.agent({
2996
id: "my-chat",
3097
clientDataSchema: z.object({ userId: z.string() }),
31-
onPreload: async ({ ctx, chatId, clientData, runId, chatAccessToken }) => {
32-
// Initialize early, before the first message arrives
98+
onBoot: async ({ clientData }) => {
99+
// Per-process state — runs on every fresh worker (initial,
100+
// preloaded, continuation). See onBoot above.
33101
const user = await db.user.findUnique({ where: { id: clientData.userId } });
34102
userContext.init({ name: user.name, plan: user.plan });
35-
103+
},
104+
onPreload: async ({ chatId, clientData, runId, chatAccessToken }) => {
105+
// Chat-scoped DB rows — only matters on preload (and onChatStart as
106+
// a fallback when not preloaded).
36107
await db.chat.create({ data: { id: chatId, userId: clientData.userId } });
37108
await db.chatSession.upsert({
38109
where: { id: chatId },
@@ -42,7 +113,7 @@ export const myChat = chat.agent({
42113
},
43114
onChatStart: async ({ preloaded }) => {
44115
if (preloaded) return; // Already initialized in onPreload
45-
// ... non-preloaded initialization
116+
// ... non-preloaded chat-row initialization
46117
},
47118
run: async ({ messages, signal }) => {
48119
return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
@@ -63,14 +134,18 @@ Every lifecycle callback receives a `writer`, a lazy stream writer that lets you
63134

64135
## onChatStart
65136

66-
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 setup — create the Chat DB row, initialize per-chat in-memory state, mint resources tied to the chat's lifetime.
137+
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.
67138

68139
`onChatStart` does **not** fire on:
69140

70-
- **Continuation runs** — a new run picking up an existing session after the prior run ended (`chat.endRun`, waitpoint timeout, `chat.requestUpgrade`). The chat already started.
141+
- **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.
71142
- **OOM-retry attempts** — same chat, same conversation, just on a larger machine.
72143

73-
If you need per-turn setup that runs on every turn including continuations, use [`onTurnStart`](#onturnstart) instead.
144+
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).
145+
146+
<Warning>
147+
Do not initialize [`chat.local`](/ai-chat/reference#chatlocal) 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.
148+
</Warning>
74149

75150
The `preloaded` field tells you whether [`onPreload`](#onpreload) already ran for this chat — useful for skipping setup work that's already done.
76151

docs/ai-chat/patterns/database-persistence.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ Storing the current **`runId`** is optional — useful for telemetry / dashboard
3030

3131
## Where each hook writes
3232

33+
This pattern covers **durable DB rows** (the conversation and the active session). Per-process in-memory state ([`chat.local`](/ai-chat/reference#chatlocal), DB connection pools, sandboxes, etc.) belongs in [`onBoot`](/ai-chat/lifecycle-hooks#onboot) — it fires on every fresh worker including continuation runs, where `onPreload` and `onChatStart` do not.
34+
3335
### `onPreload` (optional)
3436

3537
When the user triggers [preload](/ai-chat/fast-starts#preload), the run starts **before** the first user message.

docs/ai-chat/reference.mdx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Options for `chat.agent()`.
1313
| `id` | `string` | required | Task identifier |
1414
| `run` | `(payload: ChatTaskRunPayload) => Promise<unknown>` | required | Handler for each turn |
1515
| `clientDataSchema` | `TaskSchema` || Schema for validating and typing `clientData` |
16+
| `onBoot` | `(event: BootEvent) => Promise<void> \| void` || Fires once per worker process — initial, preloaded, AND reactive continuation. Use for `chat.local` init and per-process resources. See [onBoot](/ai-chat/lifecycle-hooks#onboot). |
1617
| `onPreload` | `(event: PreloadEvent) => Promise<void> \| void` || Fires on preloaded runs before the first message |
1718
| `onChatStart` | `(event: ChatStartEvent) => Promise<void> \| void` || Fires once per chat, on the very first user message. Does NOT fire on continuation runs or OOM-retries — see [onChatStart](/ai-chat/lifecycle-hooks#onchatstart). |
1819
| `onValidateMessages` | `(event: ValidateMessagesEvent) => UIMessage[] \| Promise<UIMessage[]>` || Validate/transform UIMessages before model conversion. See [onValidateMessages](/ai-chat/lifecycle-hooks#onvalidatemessages) |
@@ -42,7 +43,7 @@ Plus most standard [TaskOptions](/tasks/overview) — `queue`, `machine`, `maxDu
4243

4344
## Task context (`ctx`)
4445

45-
All **`chat.agent`** lifecycle events (**`onPreload`**, **`onChatStart`**, **`onTurnStart`**, **`onBeforeTurnComplete`**, **`onTurnComplete`**, **`onCompacted`**) and the object passed to **`run`** include **`ctx`**: the same **`TaskRunContext`** shape as the `ctx` in `task({ run: (payload, { ctx }) => ... })`.
46+
All **`chat.agent`** lifecycle events (**`onBoot`**, **`onPreload`**, **`onChatStart`**, **`onTurnStart`**, **`onBeforeTurnComplete`**, **`onTurnComplete`**, **`onCompacted`**) and the object passed to **`run`** include **`ctx`**: the same **`TaskRunContext`** shape as the `ctx` in `task({ run: (payload, { ctx }) => ... })`.
4647

4748
<Note>
4849
**`onValidateMessages`** does not include `ctx` — it fires before message accumulation and is designed for pure validation/transformation of incoming messages.
@@ -80,6 +81,21 @@ The payload passed to the `run` function.
8081
| `previousTurnUsage` | `LanguageModelUsage \| undefined` | Token usage from the previous turn (undefined on turn 0) |
8182
| `totalUsage` | `LanguageModelUsage` | Cumulative token usage across completed turns so far |
8283

84+
## BootEvent
85+
86+
Passed to the `onBoot` callback.
87+
88+
| Field | Type | Description |
89+
| ----------------- | --------------------------- | ---------------------------------------------------------------------------------------------------- |
90+
| `ctx` | `TaskRunContext` | Full task run context — see [Task context](#task-context-ctx) |
91+
| `chatId` | `string` | Chat session ID |
92+
| `runId` | `string` | The Trigger.dev run ID for this run boot |
93+
| `chatAccessToken` | `string` | Scoped access token for this run |
94+
| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend |
95+
| `continuation` | `boolean` | `true` when this run is taking over from a prior dead run (cancel / crash / `endRun` / OOM retry) |
96+
| `previousRunId` | `string \| undefined` | Public id of the prior run when `continuation` is true |
97+
| `preloaded` | `boolean` | Whether this run was triggered as a preload |
98+
8399
## PreloadEvent
84100

85101
Passed to the `onPreload` callback.

docs/ai-chat/upgrade-guide.mdx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,9 +444,9 @@ hydrateMessages: async ({ incomingMessages }) => {
444444

445445
### `onChatStart` is now once-per-chat
446446

447-
`onChatStart` no longer fires on continuation runs (post-`endRun`, post-waitpoint-timeout, post-`chat.requestUpgrade`) or on OOM-retry attempts. It fires **exactly once per chat**, on the very first user message of the chat's lifetime. The `continuation` and `previousRunId` fields on `ChatStartEvent` are now `@deprecated` (always `false` / `undefined` when the hook fires).
447+
`onChatStart` no longer fires on continuation runs (post-`endRun`, post-waitpoint-timeout, post-`chat.requestUpgrade`, post-cancel, post-crash) or on OOM-retry attempts. It fires **exactly once per chat**, on the very first user message of the chat's lifetime. The `continuation` and `previousRunId` fields on `ChatStartEvent` are now `@deprecated` (always `false` / `undefined` when the hook fires).
448448

449-
This makes once-per-chat setup code (create the Chat DB row, init user context) safe to write without continuation gates. Drop any `if (continuation) return;` checks from `onChatStart`:
449+
This makes once-per-chat setup code (create the Chat DB row, mint chat-scoped resources) safe to write without continuation gates. Drop any `if (continuation) return;` checks from `onChatStart`:
450450

451451
```ts before
452452
onChatStart: async ({ continuation, chatId, clientData }) => {
@@ -463,6 +463,30 @@ onChatStart: async ({ chatId, clientData }) => {
463463

464464
If you need per-turn setup that **does** run on continuations, move it to [`onTurnStart`](/ai-chat/lifecycle-hooks#onturnstart) — that hook still fires on every turn, including the first turn of a continuation run.
465465

466+
### Move `chat.local` init from `onChatStart` to `onBoot`
467+
468+
Because `onChatStart` no longer fires on continuation runs, **`chat.local`** state initialized there will be missing when a continuation run starts — `run()` then crashes with `"chat.local can only be modified after initialization"`. The fix is to move per-process initialization to the new [`onBoot`](/ai-chat/lifecycle-hooks#onboot) hook, which fires once per worker boot (initial, preloaded, AND continuation):
469+
470+
```ts before
471+
const userContext = chat.local<{ name: string; plan: string }>({ id: "userContext" });
472+
473+
onChatStart: async ({ clientData }) => {
474+
const user = await db.user.findUnique({ where: { id: clientData.userId } });
475+
userContext.init({ name: user.name, plan: user.plan }); // ❌ never runs on continuation
476+
}
477+
```
478+
479+
```ts after
480+
const userContext = chat.local<{ name: string; plan: string }>({ id: "userContext" });
481+
482+
onBoot: async ({ clientData }) => {
483+
const user = await db.user.findUnique({ where: { id: clientData.userId } });
484+
userContext.init({ name: user.name, plan: user.plan }); // ✅ runs on every fresh worker
485+
}
486+
```
487+
488+
Anything else that's per-process (DB connection pools, sandbox handles, in-memory caches) belongs in `onBoot` for the same reason. Branch on `continuation` inside `onBoot` if you need to re-load state from your DB on takeover.
489+
466490
### Client-side `setMessages` doesn't round-trip
467491

468492
The new wire makes one thing explicit that was implicit before: **mutating `useChat()`'s messages on the client doesn't change the agent's history.** Full-history mutations were silently overwritten by the wire's accumulator before this release; now they aren't even shipped.

0 commit comments

Comments
 (0)