@@ -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 */
68967044type 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