@trigger.dev/sdk 4.5.16 → 4.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/commonjs/imports/ai-runtime-cjs.cjs.map +1 -1
  2. package/dist/commonjs/imports/ai-runtime.js +2 -0
  3. package/dist/commonjs/v3/ai-shared.d.ts +15 -0
  4. package/dist/commonjs/v3/ai-shared.js +35 -1
  5. package/dist/commonjs/v3/ai-shared.js.map +1 -1
  6. package/dist/commonjs/v3/ai.d.ts +311 -25
  7. package/dist/commonjs/v3/ai.js +1633 -380
  8. package/dist/commonjs/v3/ai.js.map +1 -1
  9. package/dist/commonjs/v3/auth.d.ts +10 -4
  10. package/dist/commonjs/v3/auth.js.map +1 -1
  11. package/dist/commonjs/v3/chat-client.d.ts +11 -2
  12. package/dist/commonjs/v3/chat-client.js +227 -111
  13. package/dist/commonjs/v3/chat-client.js.map +1 -1
  14. package/dist/commonjs/v3/chat-react.d.ts +88 -0
  15. package/dist/commonjs/v3/chat-react.js +130 -1
  16. package/dist/commonjs/v3/chat-react.js.map +1 -1
  17. package/dist/commonjs/v3/chat-server.d.ts +42 -6
  18. package/dist/commonjs/v3/chat-server.js +52 -7
  19. package/dist/commonjs/v3/chat-server.js.map +1 -1
  20. package/dist/commonjs/v3/chat.d.ts +92 -10
  21. package/dist/commonjs/v3/chat.js +328 -73
  22. package/dist/commonjs/v3/chat.js.map +1 -1
  23. package/dist/commonjs/v3/chatSnapshotIo.d.ts +2 -0
  24. package/dist/commonjs/v3/chatSnapshotIo.js +173 -0
  25. package/dist/commonjs/v3/chatSnapshotIo.js.map +1 -0
  26. package/dist/commonjs/v3/chatVersionSkew.d.ts +12 -0
  27. package/dist/commonjs/v3/chatVersionSkew.js +30 -0
  28. package/dist/commonjs/v3/chatVersionSkew.js.map +1 -0
  29. package/dist/commonjs/v3/externalDeploymentId.d.ts +23 -0
  30. package/dist/commonjs/v3/externalDeploymentId.js +43 -0
  31. package/dist/commonjs/v3/externalDeploymentId.js.map +1 -0
  32. package/dist/commonjs/v3/retry.d.ts +4 -0
  33. package/dist/commonjs/v3/retry.js +22 -13
  34. package/dist/commonjs/v3/retry.js.map +1 -1
  35. package/dist/commonjs/v3/sessions.d.ts +15 -2
  36. package/dist/commonjs/v3/sessions.js +12 -1
  37. package/dist/commonjs/v3/sessions.js.map +1 -1
  38. package/dist/commonjs/v3/shared.js +30 -36
  39. package/dist/commonjs/v3/shared.js.map +1 -1
  40. package/dist/commonjs/v3/test/index.d.ts +1 -0
  41. package/dist/commonjs/v3/test/index.js +3 -1
  42. package/dist/commonjs/v3/test/index.js.map +1 -1
  43. package/dist/commonjs/v3/test/mock-chat-agent.d.ts +47 -4
  44. package/dist/commonjs/v3/test/mock-chat-agent.js +116 -10
  45. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  46. package/dist/commonjs/v3/test/test-session-handle.js +6 -0
  47. package/dist/commonjs/v3/test/test-session-handle.js.map +1 -1
  48. package/dist/commonjs/v3/test/transcript-storage-tests.d.ts +40 -0
  49. package/dist/commonjs/v3/test/transcript-storage-tests.js +258 -0
  50. package/dist/commonjs/v3/test/transcript-storage-tests.js.map +1 -0
  51. package/dist/commonjs/v3/transcriptStorage.d.ts +261 -0
  52. package/dist/commonjs/v3/transcriptStorage.js +374 -0
  53. package/dist/commonjs/v3/transcriptStorage.js.map +1 -0
  54. package/dist/commonjs/version.js +1 -1
  55. package/dist/esm/imports/ai-runtime.d.ts +2 -2
  56. package/dist/esm/imports/ai-runtime.js +2 -2
  57. package/dist/esm/imports/ai-runtime.js.map +1 -1
  58. package/dist/esm/v3/ai-shared.d.ts +15 -0
  59. package/dist/esm/v3/ai-shared.js +33 -0
  60. package/dist/esm/v3/ai-shared.js.map +1 -1
  61. package/dist/esm/v3/ai.d.ts +311 -25
  62. package/dist/esm/v3/ai.js +1625 -377
  63. package/dist/esm/v3/ai.js.map +1 -1
  64. package/dist/esm/v3/auth.d.ts +10 -4
  65. package/dist/esm/v3/auth.js.map +1 -1
  66. package/dist/esm/v3/chat-client.d.ts +11 -2
  67. package/dist/esm/v3/chat-client.js +228 -112
  68. package/dist/esm/v3/chat-client.js.map +1 -1
  69. package/dist/esm/v3/chat-react.d.ts +88 -0
  70. package/dist/esm/v3/chat-react.js +127 -1
  71. package/dist/esm/v3/chat-react.js.map +1 -1
  72. package/dist/esm/v3/chat-server.d.ts +42 -6
  73. package/dist/esm/v3/chat-server.js +53 -8
  74. package/dist/esm/v3/chat-server.js.map +1 -1
  75. package/dist/esm/v3/chat.d.ts +92 -10
  76. package/dist/esm/v3/chat.js +329 -74
  77. package/dist/esm/v3/chat.js.map +1 -1
  78. package/dist/esm/v3/chatSnapshotIo.d.ts +2 -0
  79. package/dist/esm/v3/chatSnapshotIo.js +165 -0
  80. package/dist/esm/v3/chatSnapshotIo.js.map +1 -0
  81. package/dist/esm/v3/chatVersionSkew.d.ts +12 -0
  82. package/dist/esm/v3/chatVersionSkew.js +27 -0
  83. package/dist/esm/v3/chatVersionSkew.js.map +1 -0
  84. package/dist/esm/v3/externalDeploymentId.d.ts +23 -0
  85. package/dist/esm/v3/externalDeploymentId.js +38 -0
  86. package/dist/esm/v3/externalDeploymentId.js.map +1 -0
  87. package/dist/esm/v3/retry.d.ts +4 -0
  88. package/dist/esm/v3/retry.js +14 -8
  89. package/dist/esm/v3/retry.js.map +1 -1
  90. package/dist/esm/v3/sessions.d.ts +15 -2
  91. package/dist/esm/v3/sessions.js +11 -1
  92. package/dist/esm/v3/sessions.js.map +1 -1
  93. package/dist/esm/v3/shared.js +17 -23
  94. package/dist/esm/v3/shared.js.map +1 -1
  95. package/dist/esm/v3/test/index.d.ts +1 -0
  96. package/dist/esm/v3/test/index.js +1 -0
  97. package/dist/esm/v3/test/index.js.map +1 -1
  98. package/dist/esm/v3/test/mock-chat-agent.d.ts +47 -4
  99. package/dist/esm/v3/test/mock-chat-agent.js +118 -12
  100. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  101. package/dist/esm/v3/test/test-session-handle.js +6 -0
  102. package/dist/esm/v3/test/test-session-handle.js.map +1 -1
  103. package/dist/esm/v3/test/transcript-storage-tests.d.ts +40 -0
  104. package/dist/esm/v3/test/transcript-storage-tests.js +255 -0
  105. package/dist/esm/v3/test/transcript-storage-tests.js.map +1 -0
  106. package/dist/esm/v3/transcriptStorage.d.ts +261 -0
  107. package/dist/esm/v3/transcriptStorage.js +360 -0
  108. package/dist/esm/v3/transcriptStorage.js.map +1 -0
  109. package/dist/esm/version.js +1 -1
  110. package/docs/ai-chat/actions.mdx +55 -23
  111. package/docs/ai-chat/anatomy.mdx +47 -27
  112. package/docs/ai-chat/backend.mdx +125 -48
  113. package/docs/ai-chat/background-injection.mdx +122 -19
  114. package/docs/ai-chat/client-protocol.mdx +5 -4
  115. package/docs/ai-chat/compaction.mdx +13 -7
  116. package/docs/ai-chat/custom-agents.mdx +23 -0
  117. package/docs/ai-chat/fast-starts.mdx +27 -20
  118. package/docs/ai-chat/frontend.mdx +19 -14
  119. package/docs/ai-chat/how-it-works.mdx +1 -1
  120. package/docs/ai-chat/lifecycle-hooks.mdx +6 -2
  121. package/docs/ai-chat/migrating-from-a-route-handler.mdx +16 -14
  122. package/docs/ai-chat/migrating-from-hydrate-messages.mdx +299 -0
  123. package/docs/ai-chat/patterns/database-persistence.mdx +24 -9
  124. package/docs/ai-chat/patterns/native-compaction.mdx +310 -0
  125. package/docs/ai-chat/patterns/persistence-and-replay.mdx +34 -23
  126. package/docs/ai-chat/patterns/skills.mdx +7 -10
  127. package/docs/ai-chat/patterns/version-upgrades.mdx +79 -6
  128. package/docs/ai-chat/pending-messages.mdx +3 -3
  129. package/docs/ai-chat/prompt-caching.mdx +23 -25
  130. package/docs/ai-chat/quick-start.mdx +11 -11
  131. package/docs/ai-chat/reference.mdx +69 -10
  132. package/docs/ai-chat/sessions.mdx +6 -1
  133. package/docs/ai-chat/side-channels.mdx +1 -1
  134. package/docs/ai-chat/testing.mdx +2 -1
  135. package/docs/ai-chat/tools.mdx +18 -13
  136. package/docs/ai-chat/transcript-storage.mdx +247 -0
  137. package/docs/ai-chat/upgrade-guide.mdx +2 -2
  138. package/docs/apikeys.mdx +27 -45
  139. package/docs/cli-env-commands.mdx +114 -0
  140. package/docs/cli-projects-commands.mdx +62 -0
  141. package/docs/cli-runs-commands.mdx +94 -0
  142. package/docs/config/extensions/syncEnvVars.mdx +6 -0
  143. package/docs/deploy-environment-variables.mdx +23 -2
  144. package/docs/deployment/overview.mdx +4 -8
  145. package/docs/deployment/preview-branches.mdx +4 -4
  146. package/docs/deployment/version-skew-protection.mdx +62 -0
  147. package/docs/introduction.mdx +58 -152
  148. package/docs/manual-setup.mdx +7 -7
  149. package/docs/mcp-tools.mdx +9 -0
  150. package/docs/quick-start.mdx +3 -3
  151. package/docs/realtime/auth.mdx +1 -1
  152. package/docs/reports.mdx +157 -0
  153. package/docs/self-hosting/security.mdx +17 -0
  154. package/docs/tasks/scheduled.mdx +24 -0
  155. package/docs/triggering.mdx +1 -1
  156. package/docs/troubleshooting-zod.mdx +158 -0
  157. package/package.json +4 -4
  158. package/skills/trigger-authoring-chat-agent/SKILL.md +41 -29
  159. package/skills/trigger-chat-agent-advanced/SKILL.md +54 -19
@@ -0,0 +1,310 @@
1
+ ---
2
+ title: "Native compaction & provider fallback"
3
+ sidebarTitle: "Native compaction"
4
+ description: "Persist provider-native compaction (Anthropic context editing, OpenAI stored responses) across chat.agent turns so history is never re-sent, and fall back between providers without losing the conversation."
5
+ ---
6
+
7
+ Providers compact a conversation within a single request. Anthropic's [context editing](https://docs.anthropic.com/en/docs/build-with-claude/context-editing) clears old tool-use blocks server-side, and OpenAI's [stored responses](https://platform.openai.com/docs/guides/conversation-state) keep the thread server-side so you only send the delta. Neither changes what your agent has accumulated, so on its own the next turn re-sends the whole transcript again and the token saving is lost.
8
+
9
+ This is the gap this page closes. After each turn, mirror what the provider compacted into the agent's stored history with [`chat.history.set()`](/ai-chat/reference#chat-namespace), so the next turn is derived from the already-reduced conversation. And because a native handle is provider-specific, this page also shows how a provider-agnostic [Trigger.dev compaction](/ai-chat/compaction) summary lets you fall back between providers without re-expanding the context.
10
+
11
+ <Note>
12
+ The full runnable example is [`triggerdotdev/resilient-chat-example`](https://github.com/triggerdotdev/resilient-chat-example). See `native-persist.ts` for the Anthropic persistence flow and `resilient-chat.ts` for OpenAI stored responses plus provider fallback.
13
+ </Note>
14
+
15
+ ## Two kinds of compaction
16
+
17
+ They are not competing; they compose. Native compaction is the per-turn optimization, and Trigger.dev compaction is the durable, portable checkpoint.
18
+
19
+ | | Native (provider) | Trigger.dev `compaction` |
20
+ | --- | --- | --- |
21
+ | Runs | Inside one provider request | Between steps / turns, in your run |
22
+ | Scope | Provider-specific (Anthropic edits, OpenAI stored thread) | Provider-agnostic |
23
+ | Portable across a provider switch | No, the handle is a cache miss on the other provider | Yes, `summarize` returns a plain string |
24
+ | Persisted by default | No, you mirror it in `onTurnComplete` | Yes, replaces model messages and keeps UI messages |
25
+
26
+ ## Persist Anthropic native context editing
27
+
28
+ Anthropic's `contextManagement` clears old tool-use/tool-result blocks server-side per request, and reports how many it cleared in `providerMetadata.anthropic.contextManagement.appliedEdits` (`clearedToolUses`, `clearedInputTokens`). It does not touch your accumulated history, so on its own the next turn still re-sends everything.
29
+
30
+ The fix: read the `appliedEdits` counts as they stream in `onStepFinish`, then after the turn mirror that clearing into stored history with `chat.history.set()`. No custom summarizer is involved, since the provider's native editing drives what gets persisted.
31
+
32
+ ```ts /trigger/native-persist.ts
33
+ import { chat } from "@trigger.dev/sdk/ai";
34
+ import { streamText, stepCountIs, tool, type UIMessage } from "ai";
35
+ import { anthropic } from "@ai-sdk/anthropic";
36
+ import { z } from "zod";
37
+
38
+ const fetchRecord = tool({
39
+ description: "Fetch the full text of a record by its numeric id.",
40
+ inputSchema: z.object({ id: z.number() }),
41
+ execute: async ({ id }) => ({ id, text: `RECORD ${id}: ...` }),
42
+ });
43
+
44
+ // How many tool-uses Anthropic cleared this turn, per chat. Captured in run(),
45
+ // applied in onTurnComplete. An in-memory Map is enough because the run stays
46
+ // alive across turns (idleTimeoutInSeconds).
47
+ const clearedByChat = new Map<string, number>();
48
+
49
+ const isToolPart = (p: { type?: string }) =>
50
+ typeof p?.type === "string" && (p.type.startsWith("tool-") || p.type === "dynamic-tool");
51
+
52
+ // Drop the oldest n tool parts, mirroring what the provider cleared. A tool call
53
+ // and its result live in one part, so pairing stays intact.
54
+ function pruneOldestToolParts(messages: UIMessage[], n: number): UIMessage[] {
55
+ let toRemove = n;
56
+ const out: UIMessage[] = [];
57
+ for (const m of messages) {
58
+ if (toRemove <= 0 || m.role !== "assistant" || !m.parts) {
59
+ out.push(m);
60
+ continue;
61
+ }
62
+ const kept = m.parts.filter((p) => {
63
+ if (toRemove > 0 && isToolPart(p)) {
64
+ toRemove--;
65
+ return false;
66
+ }
67
+ return true;
68
+ });
69
+ if (kept.length > 0) out.push({ ...m, parts: kept });
70
+ }
71
+ return out;
72
+ }
73
+
74
+ export const nativePersist = chat.agent({
75
+ id: "native-persist",
76
+ idleTimeoutInSeconds: 120,
77
+ tools: { fetchRecord },
78
+ run: async ({ messages, chatId, tools, signal }) => {
79
+ return streamText({
80
+ model: anthropic("claude-sonnet-4-5"),
81
+ messages,
82
+ tools,
83
+ abortSignal: signal,
84
+ stopWhen: stepCountIs(12),
85
+ providerOptions: {
86
+ anthropic: {
87
+ contextManagement: {
88
+ edits: [
89
+ {
90
+ type: "clear_tool_uses_20250919",
91
+ trigger: { type: "tool_uses", value: 2 },
92
+ keep: { type: "tool_uses", value: 1 },
93
+ clearToolInputs: true,
94
+ },
95
+ ],
96
+ },
97
+ },
98
+ },
99
+ onStepFinish: ({ providerMetadata }) => {
100
+ const cm = providerMetadata?.anthropic?.contextManagement as
101
+ | { appliedEdits?: Array<{ type?: string; clearedToolUses?: number }> }
102
+ | undefined;
103
+ let stepCleared = 0;
104
+ for (const e of cm?.appliedEdits ?? []) {
105
+ if (e.type === "clear_tool_uses_20250919") stepCleared += e.clearedToolUses ?? 0;
106
+ }
107
+ if (stepCleared > 0) {
108
+ clearedByChat.set(chatId, (clearedByChat.get(chatId) ?? 0) + stepCleared);
109
+ }
110
+ },
111
+ });
112
+ },
113
+ // After the turn, mirror the server-side clearing into stored history.
114
+ onTurnComplete: async ({ chatId, uiMessages }) => {
115
+ const cleared = clearedByChat.get(chatId) ?? 0;
116
+ if (cleared <= 0) return;
117
+ chat.history.set(pruneOldestToolParts(uiMessages, cleared));
118
+ clearedByChat.set(chatId, 0);
119
+ },
120
+ });
121
+ ```
122
+
123
+ Turn 1 sends the user message and accumulates six tool results. Anthropic clears four of them server-side. `onTurnComplete` prunes those four from stored history, so turn 2 re-sends the smaller conversation (one tool result, not six) instead of the full transcript.
124
+
125
+ <Note>
126
+ `onTurnComplete` is where persistence happens. Action turns fire `onAction` only, and a `chat.history.set()` inside `run()` is overwritten by the accumulator at turn end. See [Persistence and replay](/ai-chat/patterns/persistence-and-replay#action-turns-no-snapshot-write).
127
+ </Note>
128
+
129
+ ## Persist OpenAI stored responses
130
+
131
+ OpenAI's `store: true` keeps the thread server-side and returns a `responseId`. Pass that back as `previousResponseId` on the next turn and send only the messages since the last assistant reply; everything before it lives on OpenAI's side.
132
+
133
+ ```ts /trigger/openai-store.ts
134
+ import { chat } from "@trigger.dev/sdk/ai";
135
+ import { streamText, stepCountIs, type ModelMessage } from "ai";
136
+ import { openai } from "@ai-sdk/openai";
137
+
138
+ // Persist the stored-response handle between turns. Replace with your database.
139
+ const nativeStore = new Map<string, { previousResponseId: string }>();
140
+
141
+ // When OpenAI already holds the thread, send only what is new since the last
142
+ // assistant reply. Everything before that lives server-side.
143
+ function messagesSinceLastAssistant(messages: ModelMessage[]): ModelMessage[] {
144
+ let last = -1;
145
+ for (let i = 0; i < messages.length; i++) {
146
+ if (messages[i]!.role === "assistant") last = i;
147
+ }
148
+ return last === -1 ? messages : messages.slice(last + 1);
149
+ }
150
+
151
+ export const openaiStore = chat.agent({
152
+ id: "openai-store",
153
+ idleTimeoutInSeconds: 120,
154
+ run: async ({ messages, chatId, signal }) => {
155
+ const native = nativeStore.get(chatId);
156
+ const outbound = native ? messagesSinceLastAssistant(messages) : messages;
157
+
158
+ const result = streamText({
159
+ model: openai("gpt-4o"),
160
+ messages: outbound,
161
+ abortSignal: signal,
162
+ stopWhen: stepCountIs(5),
163
+ providerOptions: {
164
+ openai: native ? { store: true, previousResponseId: native.previousResponseId } : { store: true },
165
+ },
166
+ });
167
+
168
+ // Capture the response id off the metadata for the next turn.
169
+ void result.providerMetadata.then((meta) => {
170
+ const rid = typeof meta?.openai?.responseId === "string" ? meta.openai.responseId : undefined;
171
+ if (rid) nativeStore.set(chatId, { previousResponseId: rid });
172
+ });
173
+
174
+ return result;
175
+ },
176
+ });
177
+ ```
178
+
179
+ Turn 1 stores the thread and sends all three messages. Turn 2 sends only the new user message (`1/3`), because OpenAI already has the rest.
180
+
181
+ ## Fall back between providers without losing history
182
+
183
+ A native handle is a per-provider cache. An OpenAI `previousResponseId` means nothing to Anthropic, and Anthropic's server-side edits don't exist on OpenAI. So when a provider is down and you fall back to another, the native optimization is a cache miss, and a naive fallback re-sends the entire raw transcript to the new provider.
184
+
185
+ [Trigger.dev's `compaction`](/ai-chat/compaction) is the portable checkpoint that closes this gap. `summarize` returns a plain string and `compactModelMessages` returns neutral `ModelMessage[]`, so the summary survives any provider switch. Tag each native handle with the provider that produced it. On a switch it's a cache miss, and you rebuild from the summary instead of re-expanding the context.
186
+
187
+ ```ts /trigger/resilient-chat.ts
188
+ import { chat } from "@trigger.dev/sdk/ai";
189
+ import { streamText, generateText, stepCountIs, generateId, type ModelMessage } from "ai";
190
+ import { anthropic } from "@ai-sdk/anthropic";
191
+ import { openai } from "@ai-sdk/openai";
192
+
193
+ type Provider = "anthropic" | "openai";
194
+ const FALLBACK_ORDER: Provider[] = ["anthropic", "openai"];
195
+
196
+ // Native handle, tagged with the provider that produced it. Replace with your DB.
197
+ type NativeState = { provider: "openai"; previousResponseId: string };
198
+ const nativeStore = new Map<string, NativeState>();
199
+
200
+ // Provider-agnostic summary: a plain string, portable across any provider.
201
+ async function summarizeConversation(messages: ModelMessage[]): Promise<string> {
202
+ const { text } = await generateText({
203
+ model: openai("gpt-4o-mini"),
204
+ messages: [
205
+ ...messages,
206
+ {
207
+ role: "user",
208
+ content:
209
+ "Summarize this conversation so it can continue with ANY model. " +
210
+ "Preserve decisions made, facts established, open questions, and the user's intent.",
211
+ },
212
+ ],
213
+ });
214
+ return text;
215
+ }
216
+
217
+ export const resilientChat = chat.agent({
218
+ id: "resilient-chat",
219
+ idleTimeoutInSeconds: 120,
220
+
221
+ compaction: {
222
+ shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000,
223
+ summarize: ({ messages }) => summarizeConversation(messages),
224
+ compactModelMessages: ({ modelMessages, summary }) => [
225
+ { role: "user", content: `Summary of the conversation so far:\n\n${summary}` },
226
+ ...modelMessages.slice(-2),
227
+ ],
228
+ compactUIMessages: ({ uiMessages, summary }) => [
229
+ {
230
+ id: generateId(),
231
+ role: "assistant",
232
+ parts: [{ type: "text", text: `[Conversation summary]\n\n${summary}` }],
233
+ },
234
+ ...uiMessages.slice(-2),
235
+ ],
236
+ },
237
+
238
+ // A Trigger.dev compaction is the reset point: the provider's server-side thread
239
+ // no longer matches the compacted baseline, so invalidate the native handle.
240
+ onCompacted: async ({ chatId }) => {
241
+ if (chatId) nativeStore.delete(chatId);
242
+ },
243
+
244
+ run: async ({ messages, chatId, signal }) => {
245
+ let lastError: unknown;
246
+ for (const providerId of FALLBACK_ORDER) {
247
+ const native = nativeStore.get(chatId);
248
+ try {
249
+ if (providerId === "openai") {
250
+ // On a switch to OpenAI with no matching handle, `messages` is already the
251
+ // compacted baseline (summary + recent), so raw history is not re-sent.
252
+ const useHandle = native?.provider === "openai";
253
+ const result = streamText({
254
+ model: openai("gpt-4o"),
255
+ messages,
256
+ abortSignal: signal,
257
+ stopWhen: stepCountIs(5),
258
+ providerOptions: {
259
+ openai: useHandle
260
+ ? { store: true, previousResponseId: native!.previousResponseId }
261
+ : { store: true },
262
+ },
263
+ });
264
+ void result.providerMetadata.then((meta) => {
265
+ const rid = typeof meta?.openai?.responseId === "string" ? meta.openai.responseId : undefined;
266
+ if (rid) nativeStore.set(chatId, { provider: "openai", previousResponseId: rid });
267
+ });
268
+ return result;
269
+ }
270
+
271
+ return streamText({
272
+ model: anthropic("claude-sonnet-4-5"),
273
+ messages,
274
+ abortSignal: signal,
275
+ stopWhen: stepCountIs(5),
276
+ providerOptions: {
277
+ anthropic: {
278
+ contextManagement: {
279
+ edits: [{ type: "clear_tool_uses_20250919", trigger: { type: "input_tokens", value: 80_000 }, keep: { type: "tool_uses", value: 3 } }],
280
+ },
281
+ },
282
+ },
283
+ });
284
+ } catch (error) {
285
+ lastError = error; // Provider failed, try the next one in the order.
286
+ }
287
+ }
288
+ throw lastError;
289
+ },
290
+ });
291
+ ```
292
+
293
+ When Anthropic is down, the loop falls through to OpenAI. Because `compaction` has already reduced `messages` to a summary plus the last couple of exchanges, the switch sends the portable baseline, not megabytes of raw transcript.
294
+
295
+ <Warning>
296
+ Fallback here retries a turn that hasn't started streaming yet. Once a response is streaming to the client, a mid-stream provider failure can't be swapped transparently. Surface the error and let the frontend regenerate the turn. See [Error handling](/ai-chat/error-handling).
297
+ </Warning>
298
+
299
+ ## Production notes
300
+
301
+ - **Persist the handles.** The `Map`s above (`nativeStore`, `clearedByChat`) work in the example because the run stays alive across turns, but they don't survive a run boundary. Store native handles and summaries in your database keyed by `chatId`, alongside your [message persistence](/ai-chat/patterns/database-persistence).
302
+ - **No cross-provider translation.** Native compaction from one provider never transfers to another. The Trigger.dev `compaction` summary is the only portable baseline across a switch.
303
+ - **Native compaction is opt-in per turn.** It applies only for the provider whose `providerOptions` you set on that turn's `streamText` call.
304
+
305
+ ## See also
306
+
307
+ - [Compaction](/ai-chat/compaction): the provider-agnostic `compaction` option, `onCompacted`, and manual `chat.compact()`.
308
+ - [Prompt caching](/ai-chat/prompt-caching): the other per-turn token optimization, and how it interacts with a growing history.
309
+ - [Database persistence](/ai-chat/patterns/database-persistence): where to store native handles and summaries for real.
310
+ - [Lifecycle hooks](/ai-chat/lifecycle-hooks): `onTurnComplete` and `onCompacted` in the broader hook taxonomy.
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  title: "Persistence and replay"
3
3
  sidebarTitle: "Persistence and replay"
4
- description: "How chat.agent rebuilds conversation history at run boot — durable JSON snapshot in object storage plus session.out replay, with a hydrateMessages short-circuit for backend-owned history."
4
+ description: "How chat.agent rebuilds conversation history at run boot — the transcript storage's persisted conversation plus session.out replay, and what changes when your app owns the model's context."
5
5
  ---
6
6
 
7
7
  `chat.agent` runs are processes — they boot, stream a turn, and either suspend (waiting for the next message) or exit. When the next message arrives at a session whose previous run already exited, a **fresh** run boots with no in-memory state. Something has to rebuild the conversation history before that turn can produce a coherent response.
8
8
 
9
- This page walks through the **snapshot + replay** model the runtime uses by default, and the [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) short-circuit that turns the whole thing off when the customer owns history.
9
+ This page walks through the **storage + replay** model. The persisted conversation comes from the agent's [transcript storage](/ai-chat/transcript-storage); the default storage is the snapshot in object storage described below, and a storage you bring is read the same way. Replay of the session streams covers what happened after the last save, and it runs for every agent, including one that owns the model's context.
10
10
 
11
11
  ## Why a snapshot at all
12
12
 
@@ -32,7 +32,7 @@ sequenceDiagram
32
32
  User->>Run1: u1
33
33
  Run1->>SessionOut: assistant chunks for a1
34
34
  Run1->>Run1: onTurnComplete
35
- Run1->>Snapshot: write { messages: [u1, a1], lastOutEventId, lastOutTimestamp }
35
+ Run1->>Snapshot: write { messages: [u1, a1], lastOutEventId, lastInEventId }
36
36
  Note over Run1: idle suspend (or exit)
37
37
 
38
38
  User->>Run2: u2 (delta only)
@@ -52,15 +52,21 @@ The accumulator starts empty. The wire delivers `u1`. After the model finishes,
52
52
 
53
53
  ```json
54
54
  {
55
- "version": 1,
55
+ "version": 2,
56
56
  "savedAt": 1715180400000,
57
- "messages": [u1, a1],
57
+ "messages": [
58
+ { "id": "u1", "final": true, "message": u1 },
59
+ { "id": "a1", "final": true, "message": a1 }
60
+ ],
61
+ "state": null,
58
62
  "lastOutEventId": "42",
59
- "lastOutTimestamp": 1715180399000
63
+ "lastInEventId": "7"
60
64
  }
61
65
  ```
62
66
 
63
- The key is `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json` overwritten every turn, never appended. The write is **awaited**, not fire-and-forget if the run idle-suspends immediately after, in-flight promises don't reliably complete and the snapshot would be lost.
67
+ `state` holds what the runtime cannot rebuild from the messages, such as a [compaction](/ai-chat/compaction) summary; `final` is false for a partial answer captured from a failed turn. Snapshots written by older SDK versions have `version: 1` and are read as if every message were final with no state.
68
+
69
+ The key is `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json` — overwritten every turn, never appended. With your own storage, the equivalent is whatever `save` writes: the runtime hands it the two new messages as `put` changes and the same cursors, and a row-per-message store writes two rows instead of the whole conversation. The write is **awaited**, not fire-and-forget — if the run idle-suspends immediately after, in-flight promises don't reliably complete and the snapshot would be lost.
64
70
 
65
71
  ### Run 2 — boot
66
72
 
@@ -110,21 +116,25 @@ Replay carries the conversation across the crash boundary with zero customer cod
110
116
 
111
117
  ## OOM-retry interaction
112
118
 
113
- The runtime already had an OOM-retry path that scans `session.out` for the latest `trigger:turn-complete` timestamp to use as a cutoff for `session.in` (so the retry doesn't re-process completed turns — see [OOM resilience](/ai-chat/patterns/oom-resilience)). The snapshot includes a `lastOutTimestamp` field that is exactly that high-water mark.
119
+ The runtime already had an OOM-retry path that scans `session.out` for the latest `trigger:turn-complete` timestamp to use as a cutoff for `session.in` (so the retry doesn't re-process completed turns — see [OOM resilience](/ai-chat/patterns/oom-resilience)). The snapshot's `lastInEventId` field is exactly that committed `.in` cursor.
114
120
 
115
- When a snapshot exists, the OOM-retry path reads `lastOutTimestamp` directly instead of scanning `session.out`. One fewer stream subscription per retry. Free win.
121
+ When a snapshot exists, the OOM-retry path reads `lastInEventId` directly instead of scanning `session.out`. One fewer stream subscription per retry. Free win.
116
122
 
117
123
  If no snapshot exists (first turn, or `hydrateMessages` registered), the path falls back to the scan.
118
124
 
119
- ## Action turns — no snapshot write
125
+ ## Action turns
126
+
127
+ [Actions](/ai-chat/actions) (`trigger: "action"`) don't fire `onTurnComplete` — they fire `onAction` only. An action that changed the conversation is saved on its own, with `reason: "action"` and the same resume cursors as the last turn, so an undo survives the run ending. See [Actions and persistence](/ai-chat/actions#actions-and-persistence).
128
+
129
+ ## When your app owns the model's context
120
130
 
121
- [Action turns](/ai-chat/actions) (`trigger: "action"`) don't fire `onTurnComplete` they fire `onAction` only. The snapshot write site is gated on `onTurnComplete`, so action turns don't snapshot.
131
+ A storage with [`loadContext`](/ai-chat/transcript-storage#owning-the-models-context), or the deprecated [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) hook, decides what the model sees on every turn instead of the runtime's accumulated transcript. That changes the boot sequence in one place: with `hydrateMessages` the storage read is skipped, because the hook is the source of truth. Everything else still runs. The `session.out` and `session.in` tails are replayed, a partial answer and unacknowledged messages are recovered, and `onRecoveryBoot` fires. The hook then receives the recovered tail in `previousMessages`, so it can persist an answer a crashed run had already started.
122
132
 
123
- If `onAction` mutates `chat.history.*` and then the run crashes before the next regular turn, the mutation is lost. The user re-fires the action. This matches `chat.history` semantics in general mutations are persisted at turn boundaries, not action boundaries.
133
+ With `loadContext` on a storage, the storage is still read and written: `load` restores the cursors and the runtime's `state` (a compaction summary survives), `save` still receives every change, and only the model's context comes from `loadContext`.
124
134
 
125
- ## The `hydrateMessages` short-circuit
135
+ ### The `hydrateMessages` hook
126
136
 
127
- When the customer registers a [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) hook, the runtime trusts the hook to be the source of truth for history. Snapshot read and replay are **skipped entirely** at boot. The hook fires per turn, returns the canonical chain from the customer's database, and the accumulator is set to whatever the hook returned.
137
+ When the customer registers a [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) hook, the runtime trusts the hook to be the source of truth for history. The snapshot is neither read nor written. The hook fires per turn, returns the canonical chain from the customer's database, and the accumulator is set to whatever the hook returned.
128
138
 
129
139
  ```ts
130
140
  import { chat, upsertIncomingMessage } from "@trigger.dev/sdk/ai";
@@ -160,24 +170,24 @@ export const myChat = chat.agent({
160
170
 
161
171
  What you gain:
162
172
 
163
- - **Zero object-store traffic per turn.** No snapshot read, no snapshot write, no replay subscription. `OBJECT_STORE_*` env vars don't have to be set.
173
+ - **Zero object-store traffic per turn.** No snapshot read, no snapshot write. `OBJECT_STORE_*` env vars don't have to be set.
164
174
  - **Branching, undo, edit, abuse prevention** — patterns that need a backend-side single source of truth work naturally because the customer mediates every read.
165
175
 
166
176
  What you give up:
167
177
 
168
- - **You own persistence end-to-end.** A bug in `hydrateMessages` that returns the wrong chain corrupts the conversation visible to the model.
169
- - **OOM-retry needs a `session.out` scan again** because there's no snapshot to short-circuit it. (Same as the pre-snapshot baseline — not a regression, just a missed optimization.)
178
+ - **You own persistence end-to-end.** A bug in `hydrateMessages` that returns the wrong chain corrupts the conversation visible to the model, and a compaction summary has nowhere durable to live.
179
+ - **OOM-retry needs a `session.out` scan again** because there's no snapshot to short-circuit it.
170
180
 
171
- The runtime's snapshot+replay is the safer default. `hydrateMessages` is the right choice when you already have authoritative storage for messages and want one consistent persistence path.
181
+ A [transcript storage](/ai-chat/transcript-storage) with `loadContext` gives you the same ownership of the model's context while the runtime keeps writing every change and its own state to your store. It is the recommended path; `hydrateMessages` is deprecated.
172
182
 
173
- ## When neither is configured
183
+ ## When no storage is configured
174
184
 
175
- If `hydrateMessages` is not registered **and** no object store is configured, conversations don't survive run boundaries. A continuation boots empty. The runtime logs a warning at agent registration time so you see this at deploy time, not at user-traffic time.
185
+ If no object store is configured and the agent has no `storage` of its own, conversations don't survive run boundaries. A continuation boots empty. The runtime logs a warning at agent registration time so you see this at deploy time, not at user-traffic time.
176
186
 
177
187
  For local development this is sometimes fine — you're not testing continuations. For production it isn't. Configure one of:
178
188
 
179
189
  - **Object store** (`OBJECT_STORE_*` env vars on your webapp) — easiest, default behavior.
180
- - **`hydrateMessages` + your own database** — stronger control, suits multi-tenant apps with audit needs.
190
+ - **A transcript storage over your own database** — stronger control, suits multi-tenant apps with audit needs.
181
191
 
182
192
  ## Snapshot key & lifecycle
183
193
 
@@ -188,7 +198,7 @@ For local development this is sometimes fine — you're not testing continuation
188
198
  | Key suffix | `sessions/{sessionId}/snapshot.json` |
189
199
  | Final key | `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json` |
190
200
  | Size | Tens of KB typical, capped only by object-store limits |
191
- | Cadence | Overwritten after every successful `onTurnComplete` |
201
+ | Cadence | Overwritten after every successful `onTurnComplete`, and after a history-changing action |
192
202
 
193
203
  Snapshots accumulate per-session forever unless you set a lifecycle policy on the bucket. A 90-day expiry on `packets/*/sessions/*/snapshot.json` is a reasonable default if your chats don't typically resume after that window. Closed sessions are not auto-cleaned today.
194
204
 
@@ -201,7 +211,8 @@ For local development against `pnpm run docker`, the bundled MinIO container is
201
211
  ## See also
202
212
 
203
213
  - [Client Protocol](/ai-chat/client-protocol#how-history-is-rebuilt) — the wire-level view of the same model
204
- - [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) — the short-circuit hook
214
+ - [Transcript storage](/ai-chat/transcript-storage) — the adapter the runtime persists through, and how to bring your own
215
+ - [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) — the deprecated context hook
205
216
  - [OOM resilience](/ai-chat/patterns/oom-resilience) — how `session.in` cutoffs interact with snapshots
206
217
  - [Database persistence](/ai-chat/patterns/database-persistence) — the canonical persistence pattern using `onTurnComplete`
207
218
  - [v4.5 upgrade guide](/ai-chat/upgrade-guide#v45-wire-format-change) — when this model landed and what changed
@@ -79,7 +79,7 @@ The **body** is loaded on demand via the `loadSkill` tool when the agent decides
79
79
  ```ts trigger/chat.ts
80
80
  import { chat } from "@trigger.dev/sdk/ai";
81
81
  import { skills } from "@trigger.dev/sdk";
82
- import { streamText, stepCountIs } from "ai";
82
+ import { stepCountIs } from "ai";
83
83
  import { anthropic } from "@ai-sdk/anthropic";
84
84
 
85
85
  const timeUtilsSkill = skills.define({
@@ -92,12 +92,11 @@ export const agent = chat.agent({
92
92
  onChatStart: async () => {
93
93
  chat.skills.set([await timeUtilsSkill.local()]);
94
94
  },
95
- run: async ({ messages, signal }) => {
95
+ run: async ({ messages, signal, streamText }) => {
96
96
  return streamText({
97
97
  model: anthropic("claude-sonnet-4-5"),
98
98
  messages,
99
99
  abortSignal: signal,
100
- ...chat.toStreamTextOptions(),
101
100
  stopWhen: stepCountIs(15),
102
101
  });
103
102
  },
@@ -111,7 +110,7 @@ export const agent = chat.agent({
111
110
 
112
111
  `skill.local()` reads the bundled `SKILL.md` from disk and returns a `ResolvedSkill` with the parsed frontmatter + body + on-disk path.
113
112
 
114
- `chat.skills.set([...])` stores the resolved skills for the current run. `chat.toStreamTextOptions()` spreads them into `streamText` automatically:
113
+ `chat.skills.set([...])` stores the resolved skills for the current run. The `streamText` from `run`'s argument picks them up automatically:
115
114
 
116
115
  - The frontmatter `description` lands in the system prompt under "Available skills:".
117
116
  - Three tools are added: `loadSkill`, `readFile`, `bash` — scoped per skill.
@@ -169,12 +168,10 @@ return streamText({
169
168
  model: anthropic("claude-sonnet-4-5"),
170
169
  messages,
171
170
  abortSignal: signal,
172
- ...chat.toStreamTextOptions({
173
- tools: {
174
- webFetch, // your tool
175
- deepResearch, // your tool
176
- },
177
- }),
171
+ tools: {
172
+ webFetch, // your tool
173
+ deepResearch, // your tool
174
+ },
178
175
  stopWhen: stepCountIs(15),
179
176
  });
180
177
  ```
@@ -8,6 +8,17 @@ Chat agent runs are pinned to the worker version they started on. When you deplo
8
8
 
9
9
  `chat.requestUpgrade()` is the managed upgrade signal for `chat.agent()` and the `chat.createSession()` iterator. Fully hand-rolled custom agents use `chat.endAndContinue()` between turns to immediately hand the Session to a new run.
10
10
 
11
+ <Note>
12
+ If your sessions are pinned by [version skew
13
+ protection](/deployment/version-skew-protection#chat-sessions), you do not need this page to move a
14
+ conversation onto a new deployment. A pinned session follows its pin on its own: when the stored
15
+ `externalDeploymentId` stops naming the deployment a run is on, the agent hands over at the next
16
+ turn boundary. Set [`versionSkew: "hold"`](#staying-put) to turn that off for one agent.
17
+
18
+ Read on for the cases that are still yours to decide — leaving a pin for a version nobody named,
19
+ a session that was never pinned, and hand-rolled custom agents.
20
+ </Note>
21
+
11
22
  ## How it works
12
23
 
13
24
  When `chat.requestUpgrade()` is called in `onTurnStart` or `onValidateMessages`:
@@ -19,22 +30,44 @@ When `chat.requestUpgrade()` is called in `onTurnStart` or `onValidateMessages`:
19
30
 
20
31
  The new run lives on the **same Session** as the old one. `chatId` is the durable identity; only the underlying `currentRunId` rotates. The audit log records the new run with `reason: "upgrade"`.
21
32
 
33
+ ### What "the latest deployment" means
34
+
35
+ The handoff clears the session's [external deployment id](/deployment/version-skew-protection#chat-sessions) so the new run can land on the current version — re-applying the pin the agent just rejected would make the upgrade impossible. The cleared pin is persisted on the session, so the next continuation doesn't fall back to it either.
36
+
37
+ To move to a specific deployment rather than to whatever is current, name it:
38
+
39
+ ```ts
40
+ chat.requestUpgrade({ externalDeploymentId: clientData.commitSha });
41
+ ```
42
+
43
+ That is usually what you want when the client told you which build it is on: it upgrades to the version the client expects instead of merely to the newest one.
44
+
45
+ <Warning>
46
+ `lockToVersion` is a different thing and is **never** cleared. A session started with an explicit
47
+ `lockToVersion` re-applies it on every run including upgrade handoffs, so `chat.requestUpgrade()`
48
+ cannot escape it — the new run lands on the same version the old one did. Use the external
49
+ deployment id if you want a pin an agent can opt out of.
50
+ </Warning>
51
+
22
52
  When called from inside `run()` or `chat.defer()`, the current turn completes normally first and the run exits afterward. The next message triggers the continuation on the same session.
23
53
 
24
54
  ```mermaid
25
55
  sequenceDiagram
26
56
  participant User
27
57
  participant Transport
58
+ participant Session as session.in
28
59
  participant RunV1 as Run (v1)
29
60
  participant RunV2 as Run (v2)
30
61
 
31
62
  User->>Transport: send message
32
- Transport->>RunV1: input stream
63
+ Transport->>Session: append message
64
+ Session->>RunV1: input stream
33
65
  RunV1->>RunV1: onTurnStart → requestUpgrade()
34
- RunV1-->>Transport: trigger:upgrade-required
35
- RunV1->>RunV1: exit (run() never called)
36
- Transport->>RunV2: trigger new run (continuation, same message)
37
- RunV2-->>Transport: response stream
66
+ RunV1->>RunV2: end-and-continue triggers the successor
67
+ RunV1-->>Transport: trigger:upgrade-required (filtered, no re-send)
68
+ RunV1->>RunV1: exit (run() never called, message left unacknowledged)
69
+ Session->>RunV2: same message, replayed on boot
70
+ RunV2-->>Transport: response stream (same session.out)
38
71
  Transport-->>User: response (seamless)
39
72
  ```
40
73
 
@@ -103,6 +136,13 @@ This pattern is useful when:
103
136
 
104
137
  ## Auto-detect from build ID (Next.js / Vercel)
105
138
 
139
+ <Warning>
140
+ You probably don't need this any more. If your sessions are pinned, following the pin is the
141
+ built-in behaviour and it needs no `clientData` and no `chat.local`. Reach for the recipe below
142
+ only when you want to upgrade on a signal the pin doesn't carry — a frontend build id that moves
143
+ independently of the deployment your app names.
144
+ </Warning>
145
+
106
146
  For automatic upgrade on every deploy, pass your platform's build ID via `clientData` instead of a manual version. The agent stores the ID from the first message and upgrades when it changes:
107
147
 
108
148
  ```tsx title="app/components/Chat.tsx"
@@ -151,6 +191,38 @@ export const myChat = chat
151
191
 
152
192
  This upgrades on **every** deploy, not just breaking changes. Good for fast-moving projects where you always want the latest code.
153
193
 
194
+ ## Staying put
195
+
196
+ A pinned session follows its pin by default. To keep one agent where it is — a long tool chain you
197
+ don't want interrupted, or a conversation you'd rather move on your own terms — set `versionSkew`:
198
+
199
+ ```ts
200
+ export const myChat = chat.agent({
201
+ id: "my-chat",
202
+ versionSkew: "hold",
203
+ run: async ({ messages, signal }) => { ... },
204
+ });
205
+ ```
206
+
207
+ `"hold"` only stops the automatic handoff. `chat.requestUpgrade()` still works, so you can keep the
208
+ decision and still get the seamless swap.
209
+
210
+ Two cases never hand over automatically, whatever `versionSkew` says:
211
+
212
+ - **A session with no pin.** There is nothing to compare against, and an unpinned session already
213
+ lands on the current version every time it starts a run.
214
+ - **A session using `lockToVersion`.** That pin outranks the external deployment id and
215
+ `chat.requestUpgrade()` cannot escape it, so handing over would land on the same version and
216
+ repeat.
217
+
218
+ <Note>
219
+ Following the pin costs one session read per turn on pinned chats, and the handoff happens at a
220
+ turn boundary — never mid-turn. If the pin names a deployment that hasn't landed yet, the successor
221
+ parks: your messages stay durable, and the transport emits `run-pending-version` with
222
+ `source: "upgrade"` so you can say so in the UI. See [parked
223
+ chats](/deployment/version-skew-protection#chat-sessions).
224
+ </Note>
225
+
154
226
  ## Custom agents
155
227
 
156
228
  Use `chat.requestUpgrade()` with `chat.agent()`. With `chat.createSession()`, call `chat.requestUpgrade()`, then advance the iterator once more so it can exit normally. For an immediate handoff, close the iterator before calling `chat.endAndContinue()`. In a fully hand-rolled `chat.customAgent()` task, detach input listeners, persist the completed turn, write its boundary, then call `chat.endAndContinue()` and return immediately:
@@ -166,7 +238,7 @@ await chat.endAndContinue();
166
238
  return;
167
239
  ```
168
240
 
169
- The continuation uses the same durable Session and receives `.in` records that the old run has not consumed. It starts on the latest deployed task version unless the Session's trigger configuration sets `lockToVersion`.
241
+ The continuation uses the same durable Session and receives `.in` records that the old run has not consumed. It starts on the latest deployed task version only if the Session is unpinned a Session carrying `lockToVersion` or an `externalDeploymentId` re-applies that pin, so the continuation lands where the Session says rather than on the newest build.
170
242
 
171
243
  If input has been dispatched to the old run but should be processed by the continuation, detach the old listeners and skip the final `chat.writeTurnComplete()`. A turn-complete boundary acknowledges the latest input dispatched to the old run, so writing one after that dispatch would cause the continuation to resume past the input.
172
244
 
@@ -182,6 +254,7 @@ Both are graceful exits. [`onRecoveryBoot`](/ai-chat/patterns/recovery-boot) doe
182
254
 
183
255
  ## See also
184
256
 
257
+ - [Version skew protection](/deployment/version-skew-protection#chat-sessions) — pin a session to the deployment matching the app build that started it
185
258
  - [Lifecycle hooks](/ai-chat/lifecycle-hooks) — where `onTurnStart` and `onChatResume` fit in the turn cycle
186
259
  - [Recovery boot](/ai-chat/patterns/recovery-boot) — the sibling hook for mid-stream interruptions (does NOT fire on `requestUpgrade`)
187
260
  - [Database persistence](/ai-chat/patterns/database-persistence) — how continuations interact with session state