Skip to content

Commit 3dab310

Browse files
committed
feat: add chat.local for per-run typed data with Proxy access and dirty tracking
1 parent b3a9c5c commit 3dab310

File tree

5 files changed

+342
-5
lines changed

5 files changed

+342
-5
lines changed

docs/guides/ai-chat.mdx

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,105 @@ run: async ({ messages, signal }) => {
778778
Longer warm timeout means faster responses but more compute usage. Set to `0` to suspend immediately after each turn (minimum latency cost, slight delay on next message).
779779
</Info>
780780

781+
## Per-run data with `chat.local`
782+
783+
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.
784+
785+
### Declaring and initializing
786+
787+
Declare locals at module level, then initialize them inside a lifecycle hook where you have context (chatId, clientData, etc.):
788+
789+
```ts
790+
import { chat } from "@trigger.dev/sdk/ai";
791+
import { streamText, tool } from "ai";
792+
import { openai } from "@ai-sdk/openai";
793+
import { z } from "zod";
794+
import { db } from "@/lib/db";
795+
796+
// Declare at module level — multiple locals can coexist
797+
const userContext = chat.local<{
798+
name: string;
799+
plan: "free" | "pro";
800+
messageCount: number;
801+
}>();
802+
803+
export const myChat = chat.task({
804+
id: "my-chat",
805+
clientDataSchema: z.object({ userId: z.string() }),
806+
onChatStart: async ({ clientData }) => {
807+
// Initialize with real data from your database
808+
const user = await db.user.findUnique({
809+
where: { id: clientData.userId },
810+
});
811+
userContext.init({
812+
name: user.name,
813+
plan: user.plan,
814+
messageCount: user.messageCount,
815+
});
816+
},
817+
run: async ({ messages, signal }) => {
818+
userContext.messageCount++;
819+
820+
return streamText({
821+
model: openai("gpt-4o"),
822+
system: `Helping ${userContext.name} (${userContext.plan} plan).`,
823+
messages,
824+
abortSignal: signal,
825+
});
826+
},
827+
});
828+
```
829+
830+
### Accessing from tools
831+
832+
Locals are accessible from anywhere during task execution — including AI SDK tools:
833+
834+
```ts
835+
const userContext = chat.local<{ plan: "free" | "pro" }>();
836+
837+
const premiumTool = tool({
838+
description: "Access premium features",
839+
inputSchema: z.object({ feature: z.string() }),
840+
execute: async ({ feature }) => {
841+
if (userContext.plan !== "pro") {
842+
return { error: "This feature requires a Pro plan." };
843+
}
844+
// ... premium logic
845+
},
846+
});
847+
```
848+
849+
### Dirty tracking and persistence
850+
851+
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:
852+
853+
```ts
854+
onTurnComplete: async ({ chatId }) => {
855+
if (userContext.hasChanged()) {
856+
await db.user.update({
857+
where: { id: userContext.get().userId },
858+
data: {
859+
messageCount: userContext.messageCount,
860+
},
861+
});
862+
}
863+
},
864+
```
865+
866+
### API reference
867+
868+
| Method | Description |
869+
|--------|-------------|
870+
| `chat.local<T>()` | Create a typed local (declare at module level) |
871+
| `local.init(value)` | Initialize with a value (call in hooks or `run`) |
872+
| `local.hasChanged()` | Returns `true` if modified since last check, resets flag |
873+
| `local.get()` | Returns a plain object copy (for serialization) |
874+
| `local.property` | Direct property access (read/write via Proxy) |
875+
876+
<Note>
877+
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" }`.
878+
</Note>
879+
781880
## Frontend reference
782881

783882
### TriggerChatTransport options
@@ -897,6 +996,7 @@ See [onTurnComplete](#onturncomplete) for the full field reference.
897996
|--------|-------------|
898997
| `chat.task(options)` | Create a chat task |
899998
| `chat.pipe(source, options?)` | Pipe a stream to the frontend (from anywhere inside a task) |
999+
| `chat.local<T>()` | Create a per-run typed local (see [Per-run data](#per-run-data-with-chatlocal)) |
9001000
| `chat.createAccessToken(taskId)` | Create a public access token for a chat task |
9011001
| `chat.setTurnTimeout(duration)` | Override turn timeout at runtime (e.g. `"2h"`) |
9021002
| `chat.setTurnTimeoutInSeconds(seconds)` | Override turn timeout at runtime (in seconds) |

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

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,6 +1062,165 @@ function setWarmTimeoutInSeconds(seconds: number): void {
10621062
metadata.set(WARM_TIMEOUT_METADATA_KEY, seconds);
10631063
}
10641064

1065+
// ---------------------------------------------------------------------------
1066+
// chat.local — per-run typed data with Proxy access
1067+
// ---------------------------------------------------------------------------
1068+
1069+
/** @internal Symbol for storing the locals key on the proxy target. */
1070+
const CHAT_LOCAL_KEY: unique symbol = Symbol("chatLocalKey");
1071+
/** @internal Symbol for storing the dirty-tracking locals key. */
1072+
const CHAT_LOCAL_DIRTY_KEY: unique symbol = Symbol("chatLocalDirtyKey");
1073+
/** @internal Counter for generating unique locals IDs. */
1074+
let chatLocalCounter = 0;
1075+
1076+
/**
1077+
* A Proxy-backed, run-scoped data object that appears as `T` to users.
1078+
* Includes helper methods for initialization, dirty tracking, and serialization.
1079+
* Internal metadata is stored behind Symbols and invisible to
1080+
* `Object.keys()`, `JSON.stringify()`, and spread.
1081+
*/
1082+
export type ChatLocal<T extends Record<string, unknown>> = T & {
1083+
/** Initialize the local with a value. Call in `onChatStart` or `run()`. */
1084+
init(value: T): void;
1085+
/** Returns `true` if any property was set since the last check. Resets the dirty flag. */
1086+
hasChanged(): boolean;
1087+
/** Returns a plain object copy of the current value. Useful for persistence. */
1088+
get(): T;
1089+
readonly [CHAT_LOCAL_KEY]: ReturnType<typeof locals.create<T>>;
1090+
readonly [CHAT_LOCAL_DIRTY_KEY]: ReturnType<typeof locals.create<boolean>>;
1091+
};
1092+
1093+
/**
1094+
* Creates a per-run typed data object accessible from anywhere during task execution.
1095+
*
1096+
* Declare at module level, then initialize inside a lifecycle hook (e.g. `onChatStart`)
1097+
* using `chat.initLocal()`. Properties are accessible directly via the Proxy.
1098+
*
1099+
* Multiple locals can coexist — each gets its own isolated run-scoped storage.
1100+
*
1101+
* @example
1102+
* ```ts
1103+
* import { chat } from "@trigger.dev/sdk/ai";
1104+
*
1105+
* const userPrefs = chat.local<{ theme: string; language: string }>();
1106+
* const gameState = chat.local<{ score: number; streak: number }>();
1107+
*
1108+
* export const myChat = chat.task({
1109+
* id: "my-chat",
1110+
* onChatStart: async ({ clientData }) => {
1111+
* const prefs = await db.prefs.findUnique({ where: { userId: clientData.userId } });
1112+
* userPrefs.init(prefs ?? { theme: "dark", language: "en" });
1113+
* gameState.init({ score: 0, streak: 0 });
1114+
* },
1115+
* onTurnComplete: async ({ chatId }) => {
1116+
* if (gameState.hasChanged()) {
1117+
* await db.save({ where: { chatId }, data: gameState.get() });
1118+
* }
1119+
* },
1120+
* run: async ({ messages }) => {
1121+
* gameState.score++;
1122+
* return streamText({
1123+
* system: `User prefers ${userPrefs.theme} theme. Score: ${gameState.score}`,
1124+
* messages,
1125+
* });
1126+
* },
1127+
* });
1128+
* ```
1129+
*/
1130+
function chatLocal<T extends Record<string, unknown>>(): ChatLocal<T> {
1131+
const localKey = locals.create<T>(`chat.local.${chatLocalCounter++}`);
1132+
const dirtyKey = locals.create<boolean>(`chat.local.${chatLocalCounter++}.dirty`);
1133+
1134+
const target = {} as any;
1135+
target[CHAT_LOCAL_KEY] = localKey;
1136+
target[CHAT_LOCAL_DIRTY_KEY] = dirtyKey;
1137+
1138+
return new Proxy(target, {
1139+
get(_target, prop, _receiver) {
1140+
// Internal Symbol properties
1141+
if (prop === CHAT_LOCAL_KEY) return _target[CHAT_LOCAL_KEY];
1142+
if (prop === CHAT_LOCAL_DIRTY_KEY) return _target[CHAT_LOCAL_DIRTY_KEY];
1143+
1144+
// Instance methods
1145+
if (prop === "init") {
1146+
return (value: T) => {
1147+
locals.set(localKey, value);
1148+
locals.set(dirtyKey, false);
1149+
};
1150+
}
1151+
if (prop === "hasChanged") {
1152+
return () => {
1153+
const dirty = locals.get(dirtyKey) ?? false;
1154+
locals.set(dirtyKey, false);
1155+
return dirty;
1156+
};
1157+
}
1158+
if (prop === "get") {
1159+
return () => {
1160+
const current = locals.get(localKey);
1161+
if (current === undefined) {
1162+
throw new Error(
1163+
"local.get() called before initialization. Call local.init() first."
1164+
);
1165+
}
1166+
return { ...current };
1167+
};
1168+
}
1169+
// toJSON for serialization (JSON.stringify(local))
1170+
if (prop === "toJSON") {
1171+
return () => {
1172+
const current = locals.get(localKey);
1173+
return current ? { ...current } : undefined;
1174+
};
1175+
}
1176+
1177+
const current = locals.get(localKey);
1178+
if (current === undefined) return undefined;
1179+
return (current as any)[prop];
1180+
},
1181+
1182+
set(_target, prop, value) {
1183+
// Don't allow setting internal Symbols
1184+
if (typeof prop === "symbol") return false;
1185+
1186+
const current = locals.get(localKey);
1187+
if (current === undefined) {
1188+
throw new Error(
1189+
"chat.local can only be modified after initialization. " +
1190+
"Call local.init() in onChatStart or run() first."
1191+
);
1192+
}
1193+
locals.set(localKey, { ...current, [prop]: value });
1194+
locals.set(dirtyKey, true);
1195+
return true;
1196+
},
1197+
1198+
has(_target, prop) {
1199+
if (typeof prop === "symbol") return prop in _target;
1200+
const current = locals.get(localKey);
1201+
return current !== undefined && prop in current;
1202+
},
1203+
1204+
ownKeys() {
1205+
const current = locals.get(localKey);
1206+
return current ? Reflect.ownKeys(current) : [];
1207+
},
1208+
1209+
getOwnPropertyDescriptor(_target, prop) {
1210+
if (typeof prop === "symbol") return undefined;
1211+
const current = locals.get(localKey);
1212+
if (current === undefined || !(prop in current)) return undefined;
1213+
return {
1214+
configurable: true,
1215+
enumerable: true,
1216+
writable: true,
1217+
value: (current as any)[prop],
1218+
};
1219+
},
1220+
}) as ChatLocal<T>;
1221+
}
1222+
1223+
10651224
/**
10661225
* Extracts the client data (metadata) type from a chat task.
10671226
* Use this to type the `metadata` option on the transport.
@@ -1088,6 +1247,8 @@ export const chat = {
10881247
task: chatTask,
10891248
/** Pipe a stream to the chat transport. See {@link pipeChat}. */
10901249
pipe: pipeChat,
1250+
/** Create a per-run typed local. See {@link chatLocal}. */
1251+
local: chatLocal,
10911252
/** Create a public access token for a chat task. See {@link createChatAccessToken}. */
10921253
createAccessToken: createChatAccessToken,
10931254
/** Override the turn timeout at runtime (duration string). See {@link setTurnTimeout}. */
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- AlterTable
2+
ALTER TABLE "Chat" ADD COLUMN "userId" TEXT;
3+
4+
-- CreateTable
5+
CREATE TABLE "User" (
6+
"id" TEXT NOT NULL,
7+
"name" TEXT NOT NULL,
8+
"plan" TEXT NOT NULL DEFAULT 'free',
9+
"preferredModel" TEXT,
10+
"messageCount" INTEGER NOT NULL DEFAULT 0,
11+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
12+
"updatedAt" TIMESTAMP(3) NOT NULL,
13+
14+
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
15+
);
16+
17+
-- AddForeignKey
18+
ALTER TABLE "Chat" ADD CONSTRAINT "Chat_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

references/ai-chat/prisma/schema.prisma

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,23 @@ datasource db {
77
provider = "postgresql"
88
}
99

10+
model User {
11+
id String @id
12+
name String
13+
plan String @default("free") // "free" | "pro"
14+
preferredModel String?
15+
messageCount Int @default(0)
16+
createdAt DateTime @default(now())
17+
updatedAt DateTime @updatedAt
18+
chats Chat[]
19+
}
20+
1021
model Chat {
1122
id String @id
1223
title String
1324
messages Json @default("[]")
25+
userId String?
26+
user User? @relation(fields: [userId], references: [id])
1427
createdAt DateTime @default(now())
1528
updatedAt DateTime @updatedAt
1629
}

0 commit comments

Comments
 (0)