|
| 1 | +--- |
| 2 | +title: "chat.local" |
| 3 | +sidebarTitle: "chat.local" |
| 4 | +description: "Typed, run-scoped data accessible from hooks, run(), tools, and subtasks. Survives across turns, auto-cleared between runs, auto-hydrated into subtasks." |
| 5 | +--- |
| 6 | + |
| 7 | +import RcBanner from "/snippets/ai-chat-rc-banner.mdx"; |
| 8 | + |
| 9 | +<RcBanner /> |
| 10 | + |
| 11 | +Use `chat.local` to create typed, run-scoped data that persists across turns and is accessible from anywhere — the run function, tools, nested helpers. Each run gets its own isolated copy, and locals are automatically cleared between runs. |
| 12 | + |
| 13 | +Lifecycle hooks and **`run`** also receive **`ctx`** ([`TaskRunContext`](/ai-chat/reference#task-context-ctx)) — the same object as on a standard `task()` — for tags, metadata, and cleanup that needs the full run record. |
| 14 | + |
| 15 | +When a subtask is invoked via `ai.toolExecute()` (or the deprecated `ai.tool()`), initialized locals are automatically serialized into the subtask's metadata and hydrated on first access — no extra code needed. Subtask changes to hydrated locals are local to the subtask and don't propagate back to the parent. |
| 16 | + |
| 17 | +## Declaring and initializing |
| 18 | + |
| 19 | +Declare locals at module level with a unique `id`, then initialize them inside a lifecycle hook where you have context (chatId, clientData, etc.): |
| 20 | + |
| 21 | +```ts |
| 22 | +import { chat } from "@trigger.dev/sdk/ai"; |
| 23 | +import { streamText, tool } from "ai"; |
| 24 | +import { openai } from "@ai-sdk/openai"; |
| 25 | +import { z } from "zod"; |
| 26 | +import { db } from "@/lib/db"; |
| 27 | + |
| 28 | +// Declare at module level — each local needs a unique id |
| 29 | +const userContext = chat.local<{ |
| 30 | + userId: string; |
| 31 | + name: string; |
| 32 | + plan: "free" | "pro"; |
| 33 | + messageCount: number; |
| 34 | +}>({ id: "userContext" }); |
| 35 | + |
| 36 | +export const myChat = chat.agent({ |
| 37 | + id: "my-chat", |
| 38 | + clientDataSchema: z.object({ userId: z.string() }), |
| 39 | + onBoot: async ({ clientData }) => { |
| 40 | + // Initialize with real data from your database |
| 41 | + const user = await db.user.findUnique({ |
| 42 | + where: { id: clientData.userId }, |
| 43 | + }); |
| 44 | + userContext.init({ |
| 45 | + userId: clientData.userId, |
| 46 | + name: user.name, |
| 47 | + plan: user.plan, |
| 48 | + messageCount: user.messageCount, |
| 49 | + }); |
| 50 | + }, |
| 51 | + run: async ({ messages, signal }) => { |
| 52 | + userContext.messageCount++; |
| 53 | + |
| 54 | + return streamText({ |
| 55 | + model: openai("gpt-4o"), |
| 56 | + system: `Helping ${userContext.name} (${userContext.plan} plan).`, |
| 57 | + messages, |
| 58 | + abortSignal: signal, |
| 59 | + }); |
| 60 | + }, |
| 61 | +}); |
| 62 | +``` |
| 63 | + |
| 64 | +<Warning> |
| 65 | + Initialize `chat.local` in [`onBoot`](/ai-chat/lifecycle-hooks#onboot), not `onChatStart`. `onBoot` fires on every fresh worker — including continuation runs (post-cancel, crash, `endRun`, `requestUpgrade`, OOM retry) — whereas `onChatStart` only fires on the chat's very first message. Initializing in `onChatStart` means `run()` will crash on continuation runs with `chat.local can only be modified after initialization`. |
| 66 | +</Warning> |
| 67 | + |
| 68 | +## Accessing from tools |
| 69 | + |
| 70 | +Locals are accessible from anywhere during task execution — including AI SDK tools: |
| 71 | + |
| 72 | +```ts |
| 73 | +const userContext = chat.local<{ plan: "free" | "pro" }>({ id: "userContext" }); |
| 74 | + |
| 75 | +const premiumTool = tool({ |
| 76 | + description: "Access premium features", |
| 77 | + inputSchema: z.object({ feature: z.string() }), |
| 78 | + execute: async ({ feature }) => { |
| 79 | + if (userContext.plan !== "pro") { |
| 80 | + return { error: "This feature requires a Pro plan." }; |
| 81 | + } |
| 82 | + // ... premium logic |
| 83 | + }, |
| 84 | +}); |
| 85 | +``` |
| 86 | + |
| 87 | +## Accessing from subtasks |
| 88 | + |
| 89 | +When you use `ai.toolExecute()` inside AI SDK `tool()` to expose a subtask, chat locals are automatically available read-only: |
| 90 | + |
| 91 | +```ts |
| 92 | +import { chat, ai } from "@trigger.dev/sdk/ai"; |
| 93 | +import { schemaTask } from "@trigger.dev/sdk"; |
| 94 | +import { streamText, tool } from "ai"; |
| 95 | +import { openai } from "@ai-sdk/openai"; |
| 96 | +import { z } from "zod"; |
| 97 | + |
| 98 | +const userContext = chat.local<{ name: string; plan: "free" | "pro" }>({ id: "userContext" }); |
| 99 | + |
| 100 | +export const analyzeDataTask = schemaTask({ |
| 101 | + id: "analyze-data", |
| 102 | + schema: z.object({ query: z.string() }), |
| 103 | + run: async ({ query }) => { |
| 104 | + // userContext.name just works — auto-hydrated from parent metadata |
| 105 | + console.log(`Analyzing for ${userContext.name}`); |
| 106 | + // Changes here are local to this subtask and don't propagate back |
| 107 | + }, |
| 108 | +}); |
| 109 | + |
| 110 | +const analyzeData = tool({ |
| 111 | + description: analyzeDataTask.description ?? "", |
| 112 | + inputSchema: analyzeDataTask.schema!, |
| 113 | + execute: ai.toolExecute(analyzeDataTask), |
| 114 | +}); |
| 115 | + |
| 116 | +export const myChat = chat.agent({ |
| 117 | + id: "my-chat", |
| 118 | + onBoot: async ({ clientData }) => { |
| 119 | + userContext.init({ name: "Alice", plan: "pro" }); |
| 120 | + }, |
| 121 | + run: async ({ messages, signal }) => { |
| 122 | + return streamText({ |
| 123 | + model: openai("gpt-4o"), |
| 124 | + messages, |
| 125 | + tools: { analyzeData }, |
| 126 | + abortSignal: signal, |
| 127 | + }); |
| 128 | + }, |
| 129 | +}); |
| 130 | +``` |
| 131 | + |
| 132 | +<Note> |
| 133 | + Values must be JSON-serializable for subtask access. Non-serializable values (functions, class instances, etc.) will be lost during transfer. |
| 134 | +</Note> |
| 135 | + |
| 136 | +## Dirty tracking and persistence |
| 137 | + |
| 138 | +The `hasChanged()` method returns `true` if any property was set since the last check, then resets the flag. Use it in lifecycle hooks to only persist when data actually changed: |
| 139 | + |
| 140 | +```ts |
| 141 | +onTurnComplete: async ({ chatId }) => { |
| 142 | + if (userContext.hasChanged()) { |
| 143 | + await db.user.update({ |
| 144 | + where: { id: userContext.get().userId }, |
| 145 | + data: { |
| 146 | + messageCount: userContext.messageCount, |
| 147 | + }, |
| 148 | + }); |
| 149 | + } |
| 150 | +}, |
| 151 | +``` |
| 152 | + |
| 153 | +## API |
| 154 | + |
| 155 | +| Method | Description | |
| 156 | +|--------|-------------| |
| 157 | +| `chat.local<T>({ id })` | Create a typed local with a unique id (declare at module level) | |
| 158 | +| `local.init(value)` | Initialize with a value (call in hooks or `run`) | |
| 159 | +| `local.hasChanged()` | Returns `true` if modified since last check, resets flag | |
| 160 | +| `local.get()` | Returns a plain object copy (for serialization) | |
| 161 | +| `local.property` | Direct property access (read/write via Proxy) | |
| 162 | + |
| 163 | +<Note> |
| 164 | + Locals use shallow proxying. Nested object mutations like `local.prefs.theme = "dark"` won't trigger the dirty flag. Instead, replace the whole property: `local.prefs = { ...local.prefs, theme: "dark" }`. |
| 165 | +</Note> |
| 166 | + |
| 167 | +## See also |
| 168 | + |
| 169 | +- [Lifecycle hooks](/ai-chat/lifecycle-hooks) — `onBoot` is the canonical init site for `chat.local`. |
| 170 | +- [Database persistence pattern](/ai-chat/patterns/database-persistence) — full per-hook breakdown using `chat.local` alongside DB rows. |
| 171 | +- [Code execution sandbox pattern](/ai-chat/patterns/code-sandbox) — example of using `chat.local` to hold a sandbox handle across turns. |
0 commit comments