@trigger.dev/sdk 4.5.16 → 4.6.0

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 (156) 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/config/extensions/syncEnvVars.mdx +6 -0
  140. package/docs/deploy-environment-variables.mdx +23 -2
  141. package/docs/deployment/overview.mdx +4 -8
  142. package/docs/deployment/preview-branches.mdx +4 -4
  143. package/docs/deployment/version-skew-protection.mdx +62 -0
  144. package/docs/introduction.mdx +58 -152
  145. package/docs/manual-setup.mdx +7 -7
  146. package/docs/mcp-tools.mdx +9 -0
  147. package/docs/quick-start.mdx +3 -3
  148. package/docs/realtime/auth.mdx +1 -1
  149. package/docs/reports.mdx +157 -0
  150. package/docs/self-hosting/security.mdx +17 -0
  151. package/docs/tasks/scheduled.mdx +24 -0
  152. package/docs/triggering.mdx +1 -1
  153. package/docs/troubleshooting-zod.mdx +158 -0
  154. package/package.json +4 -4
  155. package/skills/trigger-authoring-chat-agent/SKILL.md +41 -29
  156. package/skills/trigger-chat-agent-advanced/SKILL.md +54 -19
@@ -30,14 +30,13 @@ Return the `streamText` result from `run` and it's automatically piped to the fr
30
30
 
31
31
  ```ts
32
32
  import { chat } from "@trigger.dev/sdk/ai";
33
- import { streamText, stepCountIs } from "ai";
33
+ import { stepCountIs } from "ai";
34
34
  import { anthropic } from "@ai-sdk/anthropic";
35
35
 
36
36
  export const simpleChat = chat.agent({
37
37
  id: "simple-chat",
38
- run: async ({ messages, signal }) => {
38
+ run: async ({ messages, signal, streamText }) => {
39
39
  return streamText({
40
- ...chat.toStreamTextOptions(), // prepareStep, system, telemetry (see note below)
41
40
  model: anthropic("claude-sonnet-4-5"),
42
41
  system: "You are a helpful assistant.",
43
42
  messages,
@@ -48,29 +47,64 @@ export const simpleChat = chat.agent({
48
47
  });
49
48
  ```
50
49
 
51
- <Warning>
52
- **Always spread `chat.toStreamTextOptions()` first** (as above) so your explicit overrides win. It wires up the `prepareStep` callback behind [compaction](/ai-chat/compaction), [steering](/ai-chat/pending-messages), and [background injection](/ai-chat/background-injection), all of which silently no-op without it, and injects the system prompt from `chat.prompt()`, the resolved model (when you pass a `registry`), and telemetry metadata. Examples below keep the spread implicit for brevity, so include it in real code.
53
- </Warning>
50
+ <Note>
51
+ The `streamText` destructured from `run`'s argument is the SDK's, not the one
52
+ imported from `ai`. It carries the agent's managed options, so nothing has to be
53
+ spread in. [The managed streamText](#the-managed-streamtext) covers what those
54
+ options are and what happens when yours collide with them.
55
+ </Note>
56
+
57
+ ### The managed streamText
58
+
59
+ `run()` is handed a `streamText` that already carries everything the spread provides, so the managed state cannot be lost by leaving the spread out:
60
+
61
+ ```ts
62
+ export const simpleChat = chat.agent({
63
+ id: "simple-chat",
64
+ run: async ({ messages, signal, streamText }) =>
65
+ streamText({
66
+ model: anthropic("claude-sonnet-4-5"),
67
+ messages,
68
+ abortSignal: signal,
69
+ stopWhen: stepCountIs(15),
70
+ }),
71
+ });
72
+ ```
73
+
74
+ Note the destructured `streamText`: it shadows the one imported from `ai` inside `run`, so the managed options apply without a spread. Spreading `chat.toStreamTextOptions()` into the imported `streamText` is still supported and equivalent.
75
+
76
+ It differs from the spread in three ways, all of them about what happens when your options collide with the managed ones:
77
+
78
+ | Option | Spread | Managed `streamText` |
79
+ | --- | --- | --- |
80
+ | `tools` | Passing `tools` after the spread replaces the skill tools | Merged, so skill tools survive |
81
+ | `prepareStep` | Passing your own after the spread replaces the managed one, silently disabling steering, compaction and injection | Composed, yours runs after the managed one |
82
+ | `system` | Yours replaces the managed prompt and any injected instructions | Throws |
83
+
84
+ `system` throws rather than merging because there is no shape that combines two system values on every supported AI SDK version: v5 rejects an array of blocks, and a structured block carries the provider options that make [prompt caching](/ai-chat/prompt-caching) work, so concatenating discards the cache entry. Set a static prompt with [`chat.prompt.set()`](#using-prompts) and add per-turn context with [`chat.inject()`](/ai-chat/background-injection).
85
+
86
+ If the managed prompt names a model, pass a registry on the agent so the runtime can resolve it: `chat.agent({ registry, run })`.
54
87
 
55
88
  ### Using chat.pipe() for complex flows
56
89
 
57
90
  For complex agent flows where `streamText` is called deep inside your code, use `chat.pipe()`. It works from **anywhere inside a task** — even nested function calls.
58
91
 
59
92
  ```ts trigger/agent-chat.ts
60
- import { chat } from "@trigger.dev/sdk/ai";
61
- import { streamText } from "ai";
93
+ import { chat, type ChatStreamText } from "@trigger.dev/sdk/ai";
62
94
  import { anthropic } from "@ai-sdk/anthropic";
63
- import type { ModelMessage } from "ai";
95
+ import { stepCountIs, type ModelMessage } from "ai";
64
96
 
65
97
  export const agentChat = chat.agent({
66
98
  id: "agent-chat",
67
- run: async ({ messages }) => {
68
- // Don't return anything chat.pipe is called inside
69
- await runAgentLoop(messages);
99
+ run: async ({ messages, streamText }) => {
100
+ // Don't return anything, chat.pipe is called inside
101
+ await runAgentLoop(messages, streamText);
70
102
  },
71
103
  });
72
104
 
73
- async function runAgentLoop(messages: ModelMessage[]) {
105
+ // A loop factored out of `run` takes `streamText` as an argument so it keeps the
106
+ // managed options. `ChatStreamText` types the parameter.
107
+ async function runAgentLoop(messages: ModelMessage[], streamText: ChatStreamText) {
74
108
  // ... agent logic, tool calls, etc.
75
109
 
76
110
  const result = streamText({
@@ -102,7 +136,7 @@ export const myChat = chat.agent({
102
136
  // responseMessage.parts includes the data-metadata part
103
137
  await db.messages.save(responseMessage);
104
138
  },
105
- run: async ({ messages, signal }) => {
139
+ run: async ({ messages, signal, streamText }) => {
106
140
  // Also works from run() via chat.response
107
141
  chat.response.write({
108
142
  type: "data-context",
@@ -177,9 +211,9 @@ const tools = { searchDocs };
177
211
  export const myChat = chat.agent({
178
212
  id: "my-chat",
179
213
  tools,
180
- run: async ({ messages, tools, signal }) =>
214
+ run: async ({ messages, tools, signal, streamText }) =>
181
215
  streamText({
182
- ...chat.toStreamTextOptions({ tools }),
216
+ tools,
183
217
  model: anthropic("claude-sonnet-4-5"),
184
218
  messages,
185
219
  abortSignal: signal,
@@ -200,12 +234,12 @@ See [Tools](/ai-chat/tools) for `toModelOutput` across turns, per-turn dynamic t
200
234
 
201
235
  ### Using prompts
202
236
 
203
- Use [AI Prompts](/ai/prompts) to manage your system prompt as versioned, overridable config. Store the resolved prompt in a lifecycle hook with `chat.prompt.set()`, then spread `chat.toStreamTextOptions()` into `streamText` it includes the system prompt, model, config, and telemetry automatically.
237
+ Use [AI Prompts](/ai/prompts) to manage your system prompt as versioned, overridable config. Store the resolved prompt in a lifecycle hook with `chat.prompt.set()`. The `streamText` from `run`'s argument picks it up: system prompt, model, config and telemetry.
204
238
 
205
239
  ```ts
206
240
  import { chat } from "@trigger.dev/sdk/ai";
207
241
  import { prompts } from "@trigger.dev/sdk";
208
- import { streamText, createProviderRegistry } from "ai";
242
+ import { createProviderRegistry } from "ai";
209
243
  import { anthropic } from "@ai-sdk/anthropic";
210
244
  import { z } from "zod";
211
245
 
@@ -221,15 +255,15 @@ const systemPrompt = prompts.define({
221
255
 
222
256
  export const myChat = chat.agent({
223
257
  id: "my-chat",
258
+ registry,
224
259
  clientDataSchema: z.object({ userId: z.string() }),
225
260
  onChatStart: async ({ clientData }) => {
226
261
  const user = await db.user.findUnique({ where: { id: clientData.userId } });
227
262
  const resolved = await systemPrompt.resolve({ name: user.name });
228
263
  chat.prompt.set(resolved);
229
264
  },
230
- run: async ({ messages, signal }) => {
265
+ run: async ({ messages, signal, streamText }) => {
231
266
  return streamText({
232
- ...chat.toStreamTextOptions({ registry }), // system, model, config, telemetry
233
267
  messages,
234
268
  abortSignal: signal,
235
269
  stopWhen: stepCountIs(15),
@@ -238,16 +272,9 @@ export const myChat = chat.agent({
238
272
  });
239
273
  ```
240
274
 
241
- `chat.toStreamTextOptions()` returns an object with `system`, `model` (resolved via the registry), `temperature`, and `experimental_telemetry` all from the stored prompt. Properties you set after the spread (like a client-selected model) take precedence.
242
-
243
- **Which form to call:**
275
+ The managed `streamText` carries the stored prompt's `system`, `model` (resolved through the agent's `registry`), sampling config, and `experimental_telemetry`. Options you pass at the call site win, apart from `system`, which throws when the prompt already set one.
244
276
 
245
- | Form | Use when |
246
- |---|---|
247
- | `chat.toStreamTextOptions()` | Default. Wires up `prepareStep` (compaction, steering, background injection), the stored prompt's `system` / `model` / `config`, and telemetry metadata. |
248
- | `chat.toStreamTextOptions({ registry })` | You're using [Prompts](/ai/prompts) with a provider-prefixed model string (e.g. `"anthropic:claude-sonnet-4-5"`). The registry resolves the prefix to a real model instance via `createProviderRegistry({ anthropic, openai, ... })`. |
249
- | `chat.toStreamTextOptions({ tools })` | You want HITL tool approvals — pass the same `tools` object you give to `streamText`. The SDK then knows which tool calls need to pause on `needsApproval: true`. |
250
- | `chat.toStreamTextOptions({ registry, tools })` | Both of the above. |
277
+ `chat.toStreamTextOptions()` remains available for the same job, and is the only option in a [custom agent](#custom-agents), which has no `run` argument to take a bound `streamText` from. A `chat.headStart` route does get one, and there it also owns `messages`, `stopWhen` and `abortSignal`, since the handover depends on them. Pass `{ registry }` when a prompt names a provider-prefixed model, and `{ tools }` when you want HITL tool approvals, so the SDK knows which calls pause on `needsApproval`.
251
278
 
252
279
  <Tip>
253
280
  See [Prompts](/ai/prompts) for the full guide — defining templates, variable schemas, dashboard
@@ -273,7 +300,7 @@ The `run` function receives three abort signals:
273
300
  ```ts
274
301
  export const myChat = chat.agent({
275
302
  id: "my-chat",
276
- run: async ({ messages, signal, stopSignal, cancelSignal }) => {
303
+ run: async ({ messages, signal, stopSignal, cancelSignal, streamText }) => {
277
304
  return streamText({
278
305
  model: anthropic("claude-sonnet-4-5"),
279
306
  messages,
@@ -302,7 +329,7 @@ export const myChat = chat.agent({
302
329
  data: { messages: uiMessages, lastStoppedAt: stopped ? new Date() : undefined },
303
330
  });
304
331
  },
305
- run: async ({ messages, signal }) => {
332
+ run: async ({ messages, signal, streamText }) => {
306
333
  return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
307
334
  },
308
335
  });
@@ -312,11 +339,10 @@ You can also check stop status from **anywhere** during a turn using `chat.isSto
312
339
 
313
340
  ```ts
314
341
  import { chat } from "@trigger.dev/sdk/ai";
315
- import { streamText } from "ai";
316
342
 
317
343
  export const myChat = chat.agent({
318
344
  id: "my-chat",
319
- run: async ({ messages, signal }) => {
345
+ run: async ({ messages, signal, streamText }) => {
320
346
  return streamText({
321
347
  model: anthropic("claude-sonnet-4-5"),
322
348
  messages,
@@ -369,7 +395,7 @@ const sendEmail = tool({
369
395
 
370
396
  export const myChat = chat.agent({
371
397
  id: "my-chat",
372
- run: async ({ messages, signal }) => {
398
+ run: async ({ messages, signal, streamText }) => {
373
399
  return streamText({
374
400
  model: anthropic("claude-sonnet-4-5"),
375
401
  messages,
@@ -405,12 +431,12 @@ Users can send messages while the agent is executing tool calls. With `pendingMe
405
431
  ```ts
406
432
  export const myChat = chat.agent({
407
433
  id: "my-chat",
434
+ registry,
408
435
  pendingMessages: {
409
436
  shouldInject: ({ steps }) => steps.length > 0,
410
437
  },
411
- run: async ({ messages, signal }) => {
438
+ run: async ({ messages, signal, streamText }) => {
412
439
  return streamText({
413
- ...chat.toStreamTextOptions({ registry }),
414
440
  messages,
415
441
  tools: {
416
442
  /* ... */
@@ -436,6 +462,7 @@ Inject context from background work into the conversation using `chat.inject()`.
436
462
  ```ts
437
463
  export const myChat = chat.agent({
438
464
  id: "my-chat",
465
+ registry,
439
466
  onTurnComplete: async ({ messages }) => {
440
467
  chat.defer(
441
468
  (async () => {
@@ -453,8 +480,8 @@ export const myChat = chat.agent({
453
480
  })()
454
481
  );
455
482
  },
456
- run: async ({ messages, signal }) => {
457
- return streamText({ ...chat.toStreamTextOptions({ registry }), messages, abortSignal: signal });
483
+ run: async ({ messages, signal, streamText }) => {
484
+ return streamText({ messages, abortSignal: signal });
458
485
  },
459
486
  });
460
487
  ```
@@ -565,7 +592,7 @@ export const myChat = chat.agent({
565
592
  },
566
593
  ];
567
594
  },
568
- run: async ({ messages, signal }) => {
595
+ run: async ({ messages, signal, streamText }) => {
569
596
  return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
570
597
  },
571
598
  });
@@ -590,7 +617,7 @@ By default, a chat agent stays idle after each turn waiting for the next user me
590
617
  ```ts
591
618
  chat.agent({
592
619
  id: "one-shot",
593
- run: async ({ messages, signal }) => {
620
+ run: async ({ messages, signal, streamText }) => {
594
621
  // Single-response agent — exit after this turn.
595
622
  chat.endRun();
596
623
  return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
@@ -606,6 +633,57 @@ Use this when the agent knows its work is done (budget exhausted, goal achieved,
606
633
  If you persist `lastEventId` to your own storage for cross-page-load resume, **don't clear it on `chat.endRun()`**. The cursor is sessionId-keyed and stays valid across Run boundaries — clearing it forces the next `sendMessages` to subscribe from `seq_num=0`, where it may hit the prior turn's stale `turn-complete` record and close the stream empty before the new Run's chunks arrive.
607
634
  </Warning>
608
635
 
636
+ ### Ending the conversation
637
+
638
+ `chat.close()` ends the whole conversation. The session row is marked closed, further appends are refused with HTTP 409, and no continuation run is scheduled. Call it from `run()`, `prepareStep`, or `onBeforeTurnComplete`.
639
+
640
+ ```ts
641
+ chat.agent({
642
+ id: "budgeted-agent",
643
+ run: async ({ messages, signal }) =>
644
+ streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal }),
645
+ // onBeforeTurnComplete carries the same fields as onTurnComplete, including
646
+ // `usage`, and still runs while the response stream is open.
647
+ onBeforeTurnComplete: async ({ chatId }) => {
648
+ if (await overBudget(chatId)) {
649
+ chat.close({ reason: "Monthly budget reached" });
650
+ }
651
+ },
652
+ });
653
+ ```
654
+
655
+ The current turn finishes and streams through normally. Called mid-step, `chat.close()` aborts the in-flight `streamText` the same way the stop signal does, so the partial response is still captured and delivered. The transport then flips to a closed state, carrying `reason` so you can render why the conversation ended.
656
+
657
+ <Warning>
658
+ **Decide before the turn ends, not in `onTurnComplete`.** The closed state reaches the browser on the turn's final `turn-complete` record. `onTurnComplete` runs after that record is written and after the response stream has closed, so a close decided there does not reach a reader that is already done with the turn: the user sees a normal answer and learns the conversation ended only when their next message is refused. Use `onBeforeTurnComplete` for the same information one step earlier, and the user sees the closed state as soon as the answer finishes.
659
+ </Warning>
660
+
661
+ Closing is one-way. A closed session cannot be reopened, and its transcript stays readable. Start a new conversation under a different `chatId` to continue.
662
+
663
+ On the client, read the closed state off the transport:
664
+
665
+ ```tsx app/components/Chat.tsx
666
+ if (transport.sessionStatus(chatId) === "closed") {
667
+ return <p>This conversation has ended: {transport.sessionClosedReason(chatId)}</p>;
668
+ }
669
+ ```
670
+
671
+ ### Three levels of stopping
672
+
673
+ `chat.agent` stops at three levels, each with a different "what happens next".
674
+
675
+ | Level | Primitive | What stops | What happens next |
676
+ | --- | --- | --- | --- |
677
+ | Turn | `stopWhen`, or the [stop signal](/ai-chat/backend#stop-generation) aborting `signal` | The current `streamText` | The turn completes, the agent idles for the next message |
678
+ | Run | `chat.endRun()` | The current worker | The next message on the same `chatId` starts a fresh continuation run |
679
+ | Session | `chat.close()` | The conversation, permanently | Nothing. Appends are refused with 409 and no run is triggered |
680
+
681
+ Reach for the narrowest level that does the job: a turn budget is a `stopWhen`, a finished one-shot answer is `chat.endRun()`, and an exhausted account or a signed-out user is `chat.close()`.
682
+
683
+ `chat.close()` is available to [custom agents](/ai-chat/custom-agents#ending-the-conversation) too, where the close is performed when your `run()` returns.
684
+
685
+ A session can also be closed from outside the run: `sessions.close(chatId)` from your backend, or the Close action in the dashboard. A live run is told either way: the close lands on the session's input channel, so an idle or suspended agent leaves its loop on the next wake instead of waiting out its idle timeout.
686
+
609
687
  ### Runtime configuration
610
688
 
611
689
  #### chat.setTurnTimeout()
@@ -613,7 +691,7 @@ Use this when the agent knows its work is done (budget exhausted, goal achieved,
613
691
  Override how long the run stays suspended waiting for the next message. Call from inside `run()`:
614
692
 
615
693
  ```ts
616
- run: async ({ messages, signal }) => {
694
+ run: async ({ messages, signal, streamText }) => {
617
695
  chat.setTurnTimeout("2h"); // Wait longer for this conversation
618
696
  return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
619
697
  },
@@ -624,7 +702,7 @@ run: async ({ messages, signal }) => {
624
702
  Override how long the run stays idle (active, using compute) after each turn:
625
703
 
626
704
  ```ts
627
- run: async ({ messages, signal }) => {
705
+ run: async ({ messages, signal, streamText }) => {
628
706
  chat.setIdleTimeoutInSeconds(60); // Stay idle for 1 minute
629
707
  return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
630
708
  },
@@ -659,7 +737,7 @@ export const myChat = chat.agent({
659
737
  return "Something went wrong. Please try again.";
660
738
  },
661
739
  },
662
- run: async ({ messages, signal }) => {
740
+ run: async ({ messages, signal, streamText }) => {
663
741
  return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
664
742
  },
665
743
  });
@@ -690,7 +768,7 @@ export const myChat = chat.agent({
690
768
  sendReasoning: true, // Forward model reasoning (default: true)
691
769
  sendSources: true, // Forward source citations (default: false)
692
770
  },
693
- run: async ({ messages, signal }) => {
771
+ run: async ({ messages, signal, streamText }) => {
694
772
  return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
695
773
  },
696
774
  });
@@ -708,7 +786,7 @@ export const myChat = chat.agent({
708
786
  uiMessageStreamOptions: {
709
787
  generateMessageId: () => uuidv7(),
710
788
  },
711
- run: async ({ messages, signal }) => {
789
+ run: async ({ messages, signal, streamText }) => {
712
790
  return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
713
791
  },
714
792
  });
@@ -728,7 +806,7 @@ export const myChat = chat
728
806
  })
729
807
  .agent({
730
808
  id: "my-chat",
731
- run: async ({ messages, signal }) => {
809
+ run: async ({ messages, signal, streamText }) => {
732
810
  return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
733
811
  },
734
812
  });
@@ -746,7 +824,7 @@ export const myChat = chat
746
824
  Override per-turn with `chat.setUIMessageStreamOptions()` — per-turn values merge with the static config (per-turn wins on conflicts). The override is cleared automatically after each turn.
747
825
 
748
826
  ```ts
749
- run: async ({ messages, clientData, signal }) => {
827
+ run: async ({ messages, clientData, signal, streamText }) => {
750
828
  // Enable reasoning only for certain models
751
829
  if (clientData.model?.includes("claude")) {
752
830
  chat.setUIMessageStreamOptions({ sendReasoning: true });
@@ -772,7 +850,6 @@ If you need full control over task options, use the standard `task()` with `Chat
772
850
  ```ts
773
851
  import { task } from "@trigger.dev/sdk";
774
852
  import { chat, type ChatTaskPayload } from "@trigger.dev/sdk/ai";
775
- import { streamText } from "ai";
776
853
  import { anthropic } from "@ai-sdk/anthropic";
777
854
 
778
855
  export const manualChat = task({
@@ -1,14 +1,14 @@
1
1
  ---
2
2
  title: "Background injection"
3
3
  sidebarTitle: "Background injection"
4
- description: "Inject context from background work into the agent's conversation self-review, RAG augmentation, or any async analysis."
4
+ description: "Inject context from background work into the agent's conversation: self-review, RAG augmentation, or any async analysis."
5
5
  ---
6
6
 
7
7
  ## Overview
8
8
 
9
9
  `chat.inject()` queues model messages for injection into the conversation. Messages are picked up at the start of the next turn or at the next `prepareStep` boundary (between tool-call steps).
10
10
 
11
- This is the backend counterpart to [pending messages](/ai-chat/pending-messages) pending messages come from the user via the frontend, while `chat.inject()` comes from your task code.
11
+ This is the backend counterpart to [pending messages](/ai-chat/pending-messages). Pending messages come from the user via the frontend, while `chat.inject()` comes from your task code.
12
12
 
13
13
  ## Basic usage
14
14
 
@@ -33,8 +33,9 @@ The most powerful pattern combines `chat.defer()` (background work) with `chat.i
33
33
  ```ts
34
34
  export const myChat = chat.agent({
35
35
  id: "my-chat",
36
+ registry,
36
37
  onTurnComplete: async ({ messages }) => {
37
- // Kick off background analysis doesn't block the turn
38
+ // Kick off background analysis, doesn't block the turn
38
39
  chat.defer(
39
40
  (async () => {
40
41
  const analysis = await analyzeConversation(messages);
@@ -47,9 +48,8 @@ export const myChat = chat.agent({
47
48
  })()
48
49
  );
49
50
  },
50
- run: async ({ messages, signal }) => {
51
+ run: async ({ messages, signal, streamText }) => {
51
52
  return streamText({
52
- ...chat.toStreamTextOptions({ registry }),
53
53
  messages,
54
54
  abortSignal: signal,
55
55
  stopWhen: stepCountIs(15),
@@ -77,7 +77,7 @@ A cheap model reviews the agent's response after each turn and injects coaching
77
77
  ```ts
78
78
  import { chat } from "@trigger.dev/sdk/ai";
79
79
  import { prompts } from "@trigger.dev/sdk";
80
- import { streamText, generateObject, createProviderRegistry, stepCountIs } from "ai";
80
+ import { generateObject, createProviderRegistry, stepCountIs } from "ai";
81
81
  import { anthropic } from "@ai-sdk/anthropic";
82
82
  import { z } from "zod";
83
83
 
@@ -98,6 +98,7 @@ Be concise. Only flag issues worth fixing.`,
98
98
 
99
99
  export const myChat = chat.agent({
100
100
  id: "my-chat",
101
+ registry,
101
102
  onTurnComplete: async ({ messages }) => {
102
103
  chat.defer(
103
104
  (async () => {
@@ -139,9 +140,8 @@ export const myChat = chat.agent({
139
140
  })()
140
141
  );
141
142
  },
142
- run: async ({ messages, signal }) => {
143
+ run: async ({ messages, signal, streamText }) => {
143
144
  return streamText({
144
- ...chat.toStreamTextOptions({ registry }),
145
145
  messages,
146
146
  abortSignal: signal,
147
147
  stopWhen: stepCountIs(15),
@@ -150,7 +150,7 @@ export const myChat = chat.agent({
150
150
  });
151
151
  ```
152
152
 
153
- The self-review runs on `claude-haiku-4-5` (fast, cheap) in the background. If the user sends another message before it completes, the coaching is still injected `chat.inject()` persists across the idle wait.
153
+ The self-review runs on `claude-haiku-4-5` (fast, cheap) in the background. If the user sends another message before it completes, the coaching is still injected, because `chat.inject()` persists across the idle wait.
154
154
 
155
155
  ## Other use cases
156
156
 
@@ -161,27 +161,80 @@ The self-review runs on `claude-haiku-4-5` (fast, cheap) in the background. If t
161
161
 
162
162
  ## `chat.defer` standalone
163
163
 
164
- `chat.defer()` is also useful on its own, without `chat.inject()`. Any work whose timing has no resume implication analytics, audit logs, search-index writes, cache warming can run in parallel with streaming instead of in the critical path. All deferred promises are awaited (with a 5s timeout) before `onTurnComplete` fires.
164
+ `chat.defer()` is also useful on its own, without `chat.inject()`. Any work whose timing has no resume implication (analytics, audit logs, search-index writes, cache warming) can run in parallel with streaming instead of in the critical path. All deferred promises are awaited (with a 5s timeout) before `onTurnComplete` fires.
165
165
 
166
166
  ```ts
167
167
  export const myChat = chat.agent({
168
168
  id: "my-chat",
169
169
  onTurnStart: async ({ chatId, runId }) => {
170
- // Analytics fire-and-forget, irrelevant to resume.
170
+ // Analytics: fire-and-forget, irrelevant to resume.
171
171
  chat.defer(analytics.track("turn_started", { chatId, runId }));
172
172
  },
173
- run: async ({ messages, signal }) => {
173
+ run: async ({ messages, signal, streamText }) => {
174
174
  return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
175
175
  },
176
176
  });
177
177
  ```
178
178
 
179
- `chat.defer()` can be called from anywhere during a turn hooks, `run()`, or nested helpers. All deferred promises are collected and awaited together before `onTurnComplete`.
179
+ `chat.defer()` can be called from anywhere during a turn: hooks, `run()`, or nested helpers. All deferred promises are collected and awaited together before `onTurnComplete`.
180
180
 
181
181
  <Warning>
182
- **Don't use `chat.defer()` for the message-history write in `onTurnStart`.** That write must land *before* the model starts streaming, otherwise a mid-stream page refresh will read `[]` from your DB and lose the user's message from the rendered conversation. See [Database persistence — `onTurnStart`](/ai-chat/patterns/database-persistence#onturnstart). Reserve `chat.defer` for writes whose timing has no resume implication.
182
+ **Don't use `chat.defer()` for the message-history write in `onTurnStart`.** `chat.defer` only promises the work finished before `onTurnComplete`, which is long after the answer started streaming. A page refresh in between reads `[]` from your DB and loses the user's message from the rendered conversation. Use [`chat.deferBeforeOutput()`](#chat-deferbeforeoutput) for that write instead, and reserve `chat.defer` for writes whose timing has no resume implication.
183
183
  </Warning>
184
184
 
185
+ ## `chat.deferBeforeOutput`
186
+
187
+ `chat.deferBeforeOutput()` is `chat.defer()` for work the next page load has to see. The work starts immediately and the hook does not await it, so it runs alongside the model and costs no time to first token. The difference is that the output stream waits for it: no part of the answer is written to the session until it settles.
188
+
189
+ That ordering is the point. A reader that can see the answer can also see whatever the work persisted, so a refresh mid-answer never renders a reply to a question that is missing.
190
+
191
+ ```ts
192
+ export const myChat = chat.agent({
193
+ id: "my-chat",
194
+ onTurnStart: async ({ chatId, uiMessages }) => {
195
+ // The next page load has to see this, so the answer waits for it.
196
+ chat.deferBeforeOutput(
197
+ db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } })
198
+ );
199
+ },
200
+ run: async ({ messages, signal, streamText }) => {
201
+ return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
202
+ },
203
+ });
204
+ ```
205
+
206
+ Use it for the conversation write, a message insert, or anything else the frontend reads back on reload. Keep analytics, audit logs and search-index updates on `chat.defer`.
207
+
208
+ <Warning>
209
+ **This is not a consistency barrier for the turn.** It orders the write against what the frontend can see, and nothing else. The work is still in flight while the model runs, so a tool, a `prepareStep`, or another service reading the same row during the turn can still see the state as it was before the write.
210
+
211
+ If the turn's own code reads the write back, `await` it instead:
212
+
213
+ ```ts
214
+ onTurnStart: async ({ chatId, uiMessages }) => {
215
+ // A tool later in this turn reads this row, so the turn has to wait for it.
216
+ await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
217
+ },
218
+ ```
219
+
220
+ That costs time to first token, which is the price of the stronger guarantee. `chat.deferBeforeOutput` is for writes whose only reader is the next page load.
221
+ </Warning>
222
+
223
+ A registered promise that rejects, or that runs longer than the internal timeout, lets the answer through rather than stalling the conversation. It is a best-effort ordering guarantee, not a lock.
224
+
225
+ | | `chat.defer()` | `chat.deferBeforeOutput()` |
226
+ |---|---|---|
227
+ | **Awaited by the hook** | No | No |
228
+ | **Costs time to first token** | No | No |
229
+ | **Answer waits for it** | No | Yes |
230
+ | **Settled by** | Before `onTurnComplete` | Before the first chunk reaches the session |
231
+ | **Read back by tools in the same turn** | No | No |
232
+ | **Use for** | Analytics, audit logs, index writes | Conversation and message writes the frontend reads back |
233
+
234
+ <Note>
235
+ If your agent persists through a [transcript storage](/ai-chat/transcript-storage), the runtime already does this for the incoming message on your behalf. `chat.deferBeforeOutput` is for writes your app owns on top of that.
236
+ </Note>
237
+
185
238
  ## How it differs from pending messages
186
239
 
187
240
  | | `chat.inject()` | [Pending messages](/ai-chat/pending-messages) |
@@ -189,9 +242,57 @@ export const myChat = chat.agent({
189
242
  | **Source** | Backend task code | Frontend user input |
190
243
  | **Triggered by** | Your code (e.g. `onTurnComplete` + `chat.defer()`) | User sending a message during streaming |
191
244
  | **Injection point** | Start of next turn, or next `prepareStep` boundary | Next `prepareStep` boundary only |
192
- | **Message role** | Any (`system`, `user`, `assistant`) | Typically `user` |
245
+ | **Message role** | Any. `system` becomes an instruction, others join the conversation (see below) | Typically `user` |
193
246
  | **Frontend visibility** | Not visible unless you write custom `data-*` chunks | Visible via `usePendingMessages` hook |
194
247
 
248
+ ## Two lanes: trusted and untrusted
249
+
250
+ The role you inject with decides more than position. It decides whether the model
251
+ treats the content as trustworthy.
252
+
253
+ **`role: "system"` goes to the instructions lane.** The block is appended to the
254
+ system instructions for subsequent inference calls, so it carries the same standing
255
+ as your system prompt. This is the lane for context the agent should believe:
256
+ entitlements, plan changes, operational notices.
257
+
258
+ It has to work this way. On AI SDK 7 a system message inside `messages` is rejected
259
+ for every provider. `standardizePrompt` throws before any provider is called, and
260
+ its own advice is to use the instructions option, so the injected block goes there
261
+ rather than into the transcript.
262
+
263
+ <Warning>
264
+ The instructions lane is delivered by `chat.toStreamTextOptions()`, because that
265
+ is the only place the SDK can set `streamText`'s instructions for you. If your
266
+ `run()` calls `streamText({ model, messages, abortSignal })` without spreading
267
+ `chat.toStreamTextOptions()`, a `role: "system"` injection never reaches the
268
+ model. The conversational lane has no such requirement: it arrives through
269
+ `messages` either way.
270
+ </Warning>
271
+
272
+ - An injection applies to the next turn only. A block injected in `onTurnComplete`
273
+ shapes the following turn and is cleared after it, so it is not repeated on every
274
+ turn from then on. Within that turn it is consumed once rather than once per read,
275
+ so a `run()` that builds options more than once sees the same instructions in
276
+ every build.
277
+ - The injected text is merged into a single instruction rather than added as a
278
+ second block, because AI SDK 5 rejects an array of system blocks while accepting
279
+ one structured block. Merging changes the cached prefix, so a cached system prompt
280
+ gets no cache hit for as long as an injection is live. If you rely on prompt
281
+ caching, inject sparingly and prefer facts that go stale, so the injection clears.
282
+
283
+ **Any other role joins the conversation, and is untrusted by construction.** A
284
+ message injected as `user` is indistinguishable from something the user typed, and a
285
+ well-aligned model treats it accordingly, and may say so and re-derive the answer
286
+ from tools instead of taking it at face value:
287
+
288
+ > "that text arrived embedded in your message, not from a tool I called, so I
289
+ > verified it myself rather than trusting it"
290
+
291
+ That is correct behaviour, not a bug. So inject **checkable facts** in the
292
+ conversational lane and put **directives** in the instructions lane. A conclusion
293
+ injected as a user message is the worst of both: the model neither trusts it nor
294
+ ignores it, and may contradict it in front of the user.
295
+
195
296
  ## API reference
196
297
 
197
298
  ### chat.inject()
@@ -200,7 +301,9 @@ export const myChat = chat.agent({
200
301
  chat.inject(messages: ModelMessage[]): void
201
302
  ```
202
303
 
203
- Queue model messages for injection at the next opportunity. Messages persist across the idle wait between turns they are not reset when a new turn starts.
304
+ Queue model messages for injection at the next opportunity. Messages persist across the idle wait between turns, and are not reset when a new turn starts.
305
+
306
+ Lifetime: a conversational message (`role: "user"` or `"assistant"`) becomes part of the model's context from the next turn onward, for the rest of the conversation. It is written to the [transcript storage](/ai-chat/transcript-storage)'s `state`, anchored to the message it followed, so it survives a continuation run and comes back in the same place. It does not appear in the UI transcript. A history edit that rebuilds the context drops it. This holds however the message reached the model: drained before `run()` or at a step boundary inside a multi-step turn. A message that is still queued when the run ends (injected from the last `onTurnComplete` before an exit, for example) is carried in the storage's `state` too and is queued again when the next run boots, so it reaches the next turn. A `role: "system"` message is appended to the instructions for the next turn only and is consumed once. Injecting the same notice every turn adds a copy every turn; dedupe on your side.
204
307
 
205
308
  **Parameters:**
206
309
 
@@ -209,9 +312,9 @@ Queue model messages for injection at the next opportunity. Messages persist acr
209
312
  | `messages` | `ModelMessage[]` | Model messages to inject (from the `ai` package) |
210
313
 
211
314
  Messages are drained (consumed) when:
212
- 1. A new turn starts before `run()` executes
213
- 2. A `prepareStep` boundary is reached between tool-call steps during streaming
315
+ 1. A new turn starts, before `run()` executes
316
+ 2. A `prepareStep` boundary is reached, between tool-call steps during streaming
214
317
 
215
318
  <Note>
216
- `chat.inject()` writes to an in-memory queue in the current process. It works from any code running in the same task lifecycle hooks, deferred work, tool execute functions, etc. It does not work from subtasks or other runs.
319
+ `chat.inject()` writes to an in-memory queue in the current process. It works from any code running in the same task: lifecycle hooks, deferred work, tool execute functions, etc. It does not work from subtasks or other runs.
217
320
  </Note>