Skip to content

Commit 950c0b5

Browse files
committed
feat(sdk): add onBoot lifecycle hook to chat.agent
1 parent cf2426f commit 950c0b5

2 files changed

Lines changed: 190 additions & 5 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@trigger.dev/sdk": minor
3+
---
4+
5+
Adds `onBoot` to `chat.agent` — a lifecycle hook that fires once per worker process picking up the chat. Runs for the initial run, preloaded runs, AND reactive continuation runs (post-cancel, crash, `endRun`, `requestUpgrade`, OOM retry), before any other hook. Use it to initialize `chat.local`, open per-process resources, or re-hydrate state from your DB on continuation — anywhere the SAME run picking up after suspend/resume isn't enough.
6+
7+
```ts
8+
const userContext = chat.local<{ name: string; plan: string }>({ id: "userContext" });
9+
10+
export const myChat = chat.agent({
11+
id: "my-chat",
12+
onBoot: async ({ clientData, continuation }) => {
13+
const user = await db.user.findUnique({ where: { id: clientData.userId } });
14+
userContext.init({ name: user.name, plan: user.plan });
15+
},
16+
run: async ({ messages, signal }) =>
17+
streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }),
18+
});
19+
```
20+
21+
If you previously initialized `chat.local` in `onChatStart`, move it to `onBoot``onChatStart` is once-per-chat and won't fire on a continuation, leaving `chat.local` uninitialized when `run()` tries to use it. See the upgrade guide for the migration pattern.

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 169 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3583,6 +3583,50 @@ export type PreloadEvent<TClientData = unknown> = {
35833583
writer: ChatWriter;
35843584
};
35853585

3586+
/**
3587+
* Event passed to the `onBoot` callback.
3588+
*
3589+
* Fires once at the start of every run boot — for the initial first-message
3590+
* run, for preloaded runs, AND for reactive continuation runs (post-cancel,
3591+
* post-crash, post-`endRun`, `chat.requestUpgrade`, OOM-retry attempts).
3592+
* Does NOT fire when the SAME run resumes from snapshot via the
3593+
* idle-window suspend/resume path — use `onChatResume` for that.
3594+
*
3595+
* Use this for per-process setup that needs to run every time a fresh
3596+
* worker picks up the chat: initialize `chat.local` state, open
3597+
* per-process resources (DB connections, sandboxes, etc.), or
3598+
* re-hydrate customer state from your DB on continuation.
3599+
*
3600+
* Ordering:
3601+
* - First message of a chat: `onBoot` → `onChatStart` → `onTurnStart` → `run()`
3602+
* - Preloaded run: `onBoot` → `onPreload` → (wait) → `onChatStart` → ...
3603+
* - Continuation run: `onBoot` → (wait for first message) → `onTurnStart` → `run()`
3604+
* (`onChatStart` does NOT fire on continuation runs)
3605+
*/
3606+
export type BootEvent<TClientData = unknown> = {
3607+
/** Task run context — same as `task({ run })` second-argument `ctx`. */
3608+
ctx: TaskRunContext;
3609+
/** The unique identifier for the chat session. */
3610+
chatId: string;
3611+
/** The Trigger.dev run ID for this run boot. */
3612+
runId: string;
3613+
/** A scoped access token for this chat run. Persist this for frontend reconnection. */
3614+
chatAccessToken: string;
3615+
/** Custom data from the frontend (passed via `metadata` on `sendMessage()` or the transport). */
3616+
clientData: TClientData;
3617+
/**
3618+
* True when this run is a reactive continuation — the prior run died
3619+
* (cancel / crash / `endRun` / `requestUpgrade` / OOM retry) and this
3620+
* fresh worker is taking over the chat. Branch on this to re-load
3621+
* customer-owned state from your DB.
3622+
*/
3623+
continuation: boolean;
3624+
/** Public id of the prior run when `continuation` is true. */
3625+
previousRunId?: string;
3626+
/** Whether this run was triggered as a preload. */
3627+
preloaded: boolean;
3628+
};
3629+
35863630
/**
35873631
* Event passed to the `onChatStart` callback.
35883632
*
@@ -4070,6 +4114,36 @@ export type ChatAgentOptions<
40704114
*/
40714115
run: (payload: ChatTaskRunPayload<inferSchemaOut<TClientDataSchema>>) => Promise<unknown>;
40724116

4117+
/**
4118+
* Called once at the start of every run boot — for the initial run, for
4119+
* preloaded runs, AND for reactive continuation runs (post-cancel /
4120+
* crash / `endRun` / `requestUpgrade` / OOM retry).
4121+
*
4122+
* Use this for per-process setup that needs to run every time a fresh
4123+
* worker picks up the chat: initialize `chat.local` state, open
4124+
* per-process resources, or re-hydrate customer state from your DB
4125+
* on continuation. Branch on `continuation` to decide whether to load
4126+
* existing state vs. start fresh.
4127+
*
4128+
* Fires BEFORE `onPreload` (preloaded path) and `onChatStart`
4129+
* (first-message path), so `chat.local` is safe to read in those hooks.
4130+
*
4131+
* Does NOT fire when the same run resumes from snapshot via the
4132+
* idle-window suspend/resume path — use `onChatResume` for that.
4133+
*
4134+
* @example
4135+
* ```ts
4136+
* onBoot: async ({ chatId, clientData, continuation }) => {
4137+
* const user = await db.user.findFirst({ where: { id: clientData.userId } });
4138+
* userContext.init({ name: user.name, plan: user.plan });
4139+
* if (continuation) {
4140+
* // re-hydrate any per-chat in-memory state from your DB
4141+
* }
4142+
* }
4143+
* ```
4144+
*/
4145+
onBoot?: (event: BootEvent<inferSchemaOut<TClientDataSchema>>) => Promise<void> | void;
4146+
40734147
/**
40744148
* Called when a preloaded run starts, before the first message arrives.
40754149
*
@@ -4618,6 +4692,7 @@ function chatAgent<
46184692
const {
46194693
run: userRun,
46204694
clientDataSchema,
4695+
onBoot,
46214696
onPreload,
46224697
onChatStart,
46234698
onValidateMessages,
@@ -4887,6 +4962,74 @@ function chatAgent<
48874962
let stopSub: { off: () => void } | undefined;
48884963

48894964
try {
4965+
// ── onBoot — fires once per fresh worker process picking up the chat.
4966+
//
4967+
// Runs BEFORE `onPreload`, `onChatStart`, the continuation-wait
4968+
// branch, and any turn. This is the hook customers use to do
4969+
// per-process setup that has to repeat for every boot (initial
4970+
// run, preloaded run, AND reactive continuation): `chat.local`
4971+
// initialization, opening per-process resources, re-hydrating
4972+
// customer state from their DB on continuation.
4973+
//
4974+
// `onChatStart` is once-per-chat (`!couldHavePriorState` gate);
4975+
// when the prior run dies and a fresh worker boots, `onChatStart`
4976+
// does NOT fire — which is why customers initializing
4977+
// `chat.local` only in `onChatStart` previously crashed in
4978+
// `run()` on continuation. `onBoot` fills that gap.
4979+
if (onBoot) {
4980+
const bootClientData = (
4981+
parseClientData ? await parseClientData(payload.metadata) : payload.metadata
4982+
) as inferSchemaOut<TClientDataSchema>;
4983+
4984+
let bootAccessToken = "";
4985+
const bootRunId = ctx.run.id;
4986+
if (bootRunId) {
4987+
try {
4988+
bootAccessToken = await auth.createPublicToken({
4989+
scopes: {
4990+
read: {
4991+
runs: bootRunId,
4992+
sessions: payload.chatId,
4993+
},
4994+
write: {
4995+
inputStreams: bootRunId,
4996+
sessions: payload.chatId,
4997+
},
4998+
},
4999+
expirationTime: chatAccessTokenTTL,
5000+
});
5001+
} catch {
5002+
// Token mint is best-effort — customers can re-mint from
5003+
// their own backend if they need one.
5004+
}
5005+
}
5006+
5007+
await tracer.startActiveSpan(
5008+
"onBoot()",
5009+
async () => {
5010+
await onBoot({
5011+
ctx,
5012+
chatId: payload.chatId,
5013+
runId: bootRunId,
5014+
chatAccessToken: bootAccessToken,
5015+
clientData: bootClientData,
5016+
continuation,
5017+
previousRunId,
5018+
preloaded,
5019+
});
5020+
},
5021+
{
5022+
attributes: {
5023+
[SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
5024+
[SemanticInternalAttributes.COLLAPSED]: true,
5025+
"chat.id": payload.chatId,
5026+
"chat.continuation": continuation,
5027+
"chat.preloaded": preloaded,
5028+
},
5029+
}
5030+
);
5031+
}
5032+
48905033
// Handle preloaded runs — fire onPreload, then wait for the first real message
48915034
if (preloaded) {
48925035
if (activeSpan) {
@@ -6810,6 +6953,11 @@ export interface ChatBuilder<
68106953
schema: TSchema;
68116954
}): ChatBuilder<TUIMessage, TSchema>;
68126955

6956+
/** Register a builder-level `onBoot` hook. Runs before the task-level hook if both are set. */
6957+
onBoot(
6958+
fn: (event: BootEvent<inferSchemaOut<TClientDataSchema>>) => Promise<void> | void
6959+
): ChatBuilder<TUIMessage, TClientDataSchema>;
6960+
68136961
/** Register a builder-level `onPreload` hook. Runs before the task-level hook if both are set. */
68146962
onPreload(
68156963
fn: (event: PreloadEvent<inferSchemaOut<TClientDataSchema>>) => Promise<void> | void
@@ -6894,6 +7042,7 @@ export interface ChatBuilder<
68947042

68957043
/** @internal */
68967044
type ChatBuilderHooks = {
7045+
onBoot?: (event: any) => Promise<void> | void;
68977046
onPreload?: (event: any) => Promise<void> | void;
68987047
onChatStart?: (event: any) => Promise<void> | void;
68997048
onTurnStart?: (event: any) => Promise<void> | void;
@@ -6942,6 +7091,14 @@ function createChatBuilder<
69427091
});
69437092
},
69447093

7094+
onBoot(
7095+
fn: (event: BootEvent<inferSchemaOut<TClientDataSchema>>) => Promise<void> | void
7096+
) {
7097+
return createChatBuilder<TUIMessage, TClientDataSchema>({
7098+
...config,
7099+
hooks: { ...config.hooks, onBoot: fn },
7100+
});
7101+
},
69457102
onPreload(
69467103
fn: (event: PreloadEvent<inferSchemaOut<TClientDataSchema>>) => Promise<void> | void
69477104
) {
@@ -7025,6 +7182,7 @@ function createChatBuilder<
70257182
...options,
70267183
...(config.clientDataSchema ? { clientDataSchema: config.clientDataSchema } : {}),
70277184
uiMessageStreamOptions: mergedUiStream,
7185+
onBoot: composeHooks(config.hooks.onBoot, options.onBoot),
70287186
onPreload: composeHooks(config.hooks.onPreload, options.onPreload),
70297187
onChatStart: composeHooks(config.hooks.onChatStart, options.onChatStart),
70307188
onTurnStart: composeHooks(config.hooks.onTurnStart, options.onTurnStart),
@@ -8307,8 +8465,11 @@ export type ChatLocal<T extends Record<string, unknown>> = T & {
83078465
/**
83088466
* Creates a per-run typed data object accessible from anywhere during task execution.
83098467
*
8310-
* Declare at module level, then initialize inside a lifecycle hook (e.g. `onChatStart`)
8311-
* using `chat.initLocal()`. Properties are accessible directly via the Proxy.
8468+
* Declare at module level, then initialize inside `onBoot` (recommended — fires
8469+
* on every fresh worker including continuation runs). Do NOT initialize in
8470+
* `onChatStart` alone: `onChatStart` only fires on the chat's very first
8471+
* message, so `chat.local` would be uninitialized on continuation runs and
8472+
* `run()` would throw.
83128473
*
83138474
* Multiple locals can coexist — each gets its own isolated run-scoped storage.
83148475
*
@@ -8325,7 +8486,7 @@ export type ChatLocal<T extends Record<string, unknown>> = T & {
83258486
*
83268487
* export const myChat = chat.agent({
83278488
* id: "my-chat",
8328-
* onChatStart: async ({ clientData }) => {
8489+
* onBoot: async ({ clientData }) => {
83298490
* const prefs = await db.prefs.findUnique({ where: { userId: clientData.userId } });
83308491
* userPrefs.init(prefs ?? { theme: "dark", language: "en" });
83318492
* gameState.init({ score: 0, streak: 0 });
@@ -8384,7 +8545,9 @@ function chatLocal<T extends Record<string, unknown>>(options: { id: string }):
83848545
current = locals.get(localKey);
83858546
}
83868547
if (current === undefined) {
8387-
throw new Error("local.get() called before initialization. Call local.init() first.");
8548+
throw new Error(
8549+
"local.get() called before initialization. Call local.init() in onBoot (recommended — fires on every fresh worker including continuation runs) or run() first."
8550+
);
83888551
}
83898552
return { ...current };
83908553
};
@@ -8419,7 +8582,8 @@ function chatLocal<T extends Record<string, unknown>>(options: { id: string }):
84198582
if (current === undefined) {
84208583
throw new Error(
84218584
"chat.local can only be modified after initialization. " +
8422-
"Call local.init() in onChatStart or run() first."
8585+
"Call local.init() in onBoot (recommended — fires on every fresh worker including continuation runs) or run() first. " +
8586+
"If you previously initialized in onChatStart, move it to onBoot — onChatStart only fires on the chat's very first message and will not run on a continuation."
84238587
);
84248588
}
84258589
locals.set(localKey, { ...current, [prop]: value });

0 commit comments

Comments
 (0)