@trigger.dev/sdk 0.0.0-prerelease-streamfix-20260909094302 → 0.0.0-prerelease-20260909121655

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 (142) 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 +275 -25
  7. package/dist/commonjs/v3/ai.js +1336 -340
  8. package/dist/commonjs/v3/ai.js.map +1 -1
  9. package/dist/commonjs/v3/chat-client.d.ts +11 -2
  10. package/dist/commonjs/v3/chat-client.js +227 -111
  11. package/dist/commonjs/v3/chat-client.js.map +1 -1
  12. package/dist/commonjs/v3/chat-react.d.ts +88 -0
  13. package/dist/commonjs/v3/chat-react.js +130 -1
  14. package/dist/commonjs/v3/chat-react.js.map +1 -1
  15. package/dist/commonjs/v3/chat-server.d.ts +42 -6
  16. package/dist/commonjs/v3/chat-server.js +52 -7
  17. package/dist/commonjs/v3/chat-server.js.map +1 -1
  18. package/dist/commonjs/v3/chat.d.ts +92 -10
  19. package/dist/commonjs/v3/chat.js +328 -73
  20. package/dist/commonjs/v3/chat.js.map +1 -1
  21. package/dist/commonjs/v3/chatSnapshotIo.d.ts +2 -0
  22. package/dist/commonjs/v3/chatSnapshotIo.js +174 -0
  23. package/dist/commonjs/v3/chatSnapshotIo.js.map +1 -0
  24. package/dist/commonjs/v3/chatVersionSkew.d.ts +12 -0
  25. package/dist/commonjs/v3/chatVersionSkew.js +30 -0
  26. package/dist/commonjs/v3/chatVersionSkew.js.map +1 -0
  27. package/dist/commonjs/v3/externalDeploymentId.d.ts +23 -0
  28. package/dist/commonjs/v3/externalDeploymentId.js +43 -0
  29. package/dist/commonjs/v3/externalDeploymentId.js.map +1 -0
  30. package/dist/commonjs/v3/sessions.d.ts +15 -2
  31. package/dist/commonjs/v3/sessions.js +12 -1
  32. package/dist/commonjs/v3/sessions.js.map +1 -1
  33. package/dist/commonjs/v3/shared.js +30 -36
  34. package/dist/commonjs/v3/shared.js.map +1 -1
  35. package/dist/commonjs/v3/test/index.d.ts +1 -0
  36. package/dist/commonjs/v3/test/index.js +3 -1
  37. package/dist/commonjs/v3/test/index.js.map +1 -1
  38. package/dist/commonjs/v3/test/mock-chat-agent.d.ts +47 -4
  39. package/dist/commonjs/v3/test/mock-chat-agent.js +116 -10
  40. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  41. package/dist/commonjs/v3/test/test-session-handle.js +6 -0
  42. package/dist/commonjs/v3/test/test-session-handle.js.map +1 -1
  43. package/dist/commonjs/v3/test/transcript-storage-tests.d.ts +40 -0
  44. package/dist/commonjs/v3/test/transcript-storage-tests.js +258 -0
  45. package/dist/commonjs/v3/test/transcript-storage-tests.js.map +1 -0
  46. package/dist/commonjs/v3/transcriptStorage.d.ts +224 -0
  47. package/dist/commonjs/v3/transcriptStorage.js +291 -0
  48. package/dist/commonjs/v3/transcriptStorage.js.map +1 -0
  49. package/dist/commonjs/version.js +1 -1
  50. package/dist/esm/imports/ai-runtime.d.ts +2 -2
  51. package/dist/esm/imports/ai-runtime.js +2 -2
  52. package/dist/esm/imports/ai-runtime.js.map +1 -1
  53. package/dist/esm/v3/ai-shared.d.ts +15 -0
  54. package/dist/esm/v3/ai-shared.js +33 -0
  55. package/dist/esm/v3/ai-shared.js.map +1 -1
  56. package/dist/esm/v3/ai.d.ts +275 -25
  57. package/dist/esm/v3/ai.js +1328 -337
  58. package/dist/esm/v3/ai.js.map +1 -1
  59. package/dist/esm/v3/chat-client.d.ts +11 -2
  60. package/dist/esm/v3/chat-client.js +228 -112
  61. package/dist/esm/v3/chat-client.js.map +1 -1
  62. package/dist/esm/v3/chat-react.d.ts +88 -0
  63. package/dist/esm/v3/chat-react.js +127 -1
  64. package/dist/esm/v3/chat-react.js.map +1 -1
  65. package/dist/esm/v3/chat-server.d.ts +42 -6
  66. package/dist/esm/v3/chat-server.js +53 -8
  67. package/dist/esm/v3/chat-server.js.map +1 -1
  68. package/dist/esm/v3/chat.d.ts +92 -10
  69. package/dist/esm/v3/chat.js +329 -74
  70. package/dist/esm/v3/chat.js.map +1 -1
  71. package/dist/esm/v3/chatSnapshotIo.d.ts +2 -0
  72. package/dist/esm/v3/chatSnapshotIo.js +166 -0
  73. package/dist/esm/v3/chatSnapshotIo.js.map +1 -0
  74. package/dist/esm/v3/chatVersionSkew.d.ts +12 -0
  75. package/dist/esm/v3/chatVersionSkew.js +27 -0
  76. package/dist/esm/v3/chatVersionSkew.js.map +1 -0
  77. package/dist/esm/v3/externalDeploymentId.d.ts +23 -0
  78. package/dist/esm/v3/externalDeploymentId.js +38 -0
  79. package/dist/esm/v3/externalDeploymentId.js.map +1 -0
  80. package/dist/esm/v3/sessions.d.ts +15 -2
  81. package/dist/esm/v3/sessions.js +11 -1
  82. package/dist/esm/v3/sessions.js.map +1 -1
  83. package/dist/esm/v3/shared.js +17 -23
  84. package/dist/esm/v3/shared.js.map +1 -1
  85. package/dist/esm/v3/test/index.d.ts +1 -0
  86. package/dist/esm/v3/test/index.js +1 -0
  87. package/dist/esm/v3/test/index.js.map +1 -1
  88. package/dist/esm/v3/test/mock-chat-agent.d.ts +47 -4
  89. package/dist/esm/v3/test/mock-chat-agent.js +118 -12
  90. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  91. package/dist/esm/v3/test/test-session-handle.js +6 -0
  92. package/dist/esm/v3/test/test-session-handle.js.map +1 -1
  93. package/dist/esm/v3/test/transcript-storage-tests.d.ts +40 -0
  94. package/dist/esm/v3/test/transcript-storage-tests.js +255 -0
  95. package/dist/esm/v3/test/transcript-storage-tests.js.map +1 -0
  96. package/dist/esm/v3/transcriptStorage.d.ts +224 -0
  97. package/dist/esm/v3/transcriptStorage.js +280 -0
  98. package/dist/esm/v3/transcriptStorage.js.map +1 -0
  99. package/dist/esm/version.js +1 -1
  100. package/docs/ai-chat/actions.mdx +55 -23
  101. package/docs/ai-chat/anatomy.mdx +3 -3
  102. package/docs/ai-chat/backend.mdx +125 -48
  103. package/docs/ai-chat/background-injection.mdx +69 -19
  104. package/docs/ai-chat/client-protocol.mdx +5 -4
  105. package/docs/ai-chat/compaction.mdx +13 -7
  106. package/docs/ai-chat/custom-agents.mdx +23 -0
  107. package/docs/ai-chat/fast-starts.mdx +27 -20
  108. package/docs/ai-chat/frontend.mdx +19 -14
  109. package/docs/ai-chat/lifecycle-hooks.mdx +6 -2
  110. package/docs/ai-chat/migrating-from-a-route-handler.mdx +16 -14
  111. package/docs/ai-chat/patterns/database-persistence.mdx +6 -2
  112. package/docs/ai-chat/patterns/native-compaction.mdx +310 -0
  113. package/docs/ai-chat/patterns/persistence-and-replay.mdx +34 -23
  114. package/docs/ai-chat/patterns/skills.mdx +7 -10
  115. package/docs/ai-chat/patterns/version-upgrades.mdx +79 -6
  116. package/docs/ai-chat/pending-messages.mdx +3 -3
  117. package/docs/ai-chat/prompt-caching.mdx +23 -25
  118. package/docs/ai-chat/quick-start.mdx +11 -11
  119. package/docs/ai-chat/reference.mdx +67 -9
  120. package/docs/ai-chat/sessions.mdx +6 -1
  121. package/docs/ai-chat/testing.mdx +2 -1
  122. package/docs/ai-chat/tools.mdx +18 -13
  123. package/docs/ai-chat/transcript-storage.mdx +233 -0
  124. package/docs/ai-chat/upgrade-guide.mdx +2 -2
  125. package/docs/apikeys.mdx +27 -45
  126. package/docs/config/extensions/syncEnvVars.mdx +6 -0
  127. package/docs/deploy-environment-variables.mdx +23 -2
  128. package/docs/deployment/overview.mdx +4 -8
  129. package/docs/deployment/preview-branches.mdx +4 -4
  130. package/docs/deployment/version-skew-protection.mdx +62 -0
  131. package/docs/manual-setup.mdx +7 -7
  132. package/docs/mcp-tools.mdx +9 -0
  133. package/docs/quick-start.mdx +3 -3
  134. package/docs/realtime/auth.mdx +1 -1
  135. package/docs/reports.mdx +157 -0
  136. package/docs/self-hosting/security.mdx +5 -0
  137. package/docs/tasks/scheduled.mdx +24 -0
  138. package/docs/triggering.mdx +1 -1
  139. package/docs/troubleshooting-zod.mdx +158 -0
  140. package/package.json +4 -4
  141. package/skills/trigger-authoring-chat-agent/SKILL.md +41 -29
  142. package/skills/trigger-chat-agent-advanced/SKILL.md +31 -12
@@ -18,7 +18,7 @@ Everything below maps onto one annotated agent:
18
18
 
19
19
  ```ts trigger/my-agent.ts
20
20
  import { chat } from "@trigger.dev/sdk/ai";
21
- import { streamText, stepCountIs } from "ai";
21
+ import { stepCountIs } from "ai";
22
22
  import { anthropic } from "@ai-sdk/anthropic";
23
23
 
24
24
  export const myAgent = chat.agent({
@@ -36,9 +36,9 @@ export const myAgent = chat.agent({
36
36
 
37
37
  // The turn loop. Messages arrive accumulated; you stream back.
38
38
  // Options, levels, and alternatives — see Backend.
39
- run: async ({ messages, tools, signal }) =>
39
+ run: async ({ messages, tools, signal, streamText }) =>
40
40
  streamText({
41
- ...chat.toStreamTextOptions({ tools }),
41
+ tools,
42
42
  model: anthropic("claude-sonnet-4-5"),
43
43
  messages,
44
44
  abortSignal: signal,
@@ -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,25 +161,25 @@ 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`.** 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.
183
183
  </Warning>
184
184
 
185
185
  ## How it differs from pending messages
@@ -189,9 +189,57 @@ export const myChat = chat.agent({
189
189
  | **Source** | Backend task code | Frontend user input |
190
190
  | **Triggered by** | Your code (e.g. `onTurnComplete` + `chat.defer()`) | User sending a message during streaming |
191
191
  | **Injection point** | Start of next turn, or next `prepareStep` boundary | Next `prepareStep` boundary only |
192
- | **Message role** | Any (`system`, `user`, `assistant`) | Typically `user` |
192
+ | **Message role** | Any. `system` becomes an instruction, others join the conversation (see below) | Typically `user` |
193
193
  | **Frontend visibility** | Not visible unless you write custom `data-*` chunks | Visible via `usePendingMessages` hook |
194
194
 
195
+ ## Two lanes: trusted and untrusted
196
+
197
+ The role you inject with decides more than position. It decides whether the model
198
+ treats the content as trustworthy.
199
+
200
+ **`role: "system"` goes to the instructions lane.** The block is appended to the
201
+ system instructions for subsequent inference calls, so it carries the same standing
202
+ as your system prompt. This is the lane for context the agent should believe:
203
+ entitlements, plan changes, operational notices.
204
+
205
+ It has to work this way. On AI SDK 7 a system message inside `messages` is rejected
206
+ for every provider. `standardizePrompt` throws before any provider is called, and
207
+ its own advice is to use the instructions option, so the injected block goes there
208
+ rather than into the transcript.
209
+
210
+ <Warning>
211
+ The instructions lane is delivered by `chat.toStreamTextOptions()`, because that
212
+ is the only place the SDK can set `streamText`'s instructions for you. If your
213
+ `run()` calls `streamText({ model, messages, abortSignal })` without spreading
214
+ `chat.toStreamTextOptions()`, a `role: "system"` injection never reaches the
215
+ model. The conversational lane has no such requirement: it arrives through
216
+ `messages` either way.
217
+ </Warning>
218
+
219
+ - An injection applies to the next turn only. A block injected in `onTurnComplete`
220
+ shapes the following turn and is cleared after it, so it is not repeated on every
221
+ turn from then on. Within that turn it is consumed once rather than once per read,
222
+ so a `run()` that builds options more than once sees the same instructions in
223
+ every build.
224
+ - The injected text is merged into a single instruction rather than added as a
225
+ second block, because AI SDK 5 rejects an array of system blocks while accepting
226
+ one structured block. Merging changes the cached prefix, so a cached system prompt
227
+ gets no cache hit for as long as an injection is live. If you rely on prompt
228
+ caching, inject sparingly and prefer facts that go stale, so the injection clears.
229
+
230
+ **Any other role joins the conversation, and is untrusted by construction.** A
231
+ message injected as `user` is indistinguishable from something the user typed, and a
232
+ well-aligned model treats it accordingly, and may say so and re-derive the answer
233
+ from tools instead of taking it at face value:
234
+
235
+ > "that text arrived embedded in your message, not from a tool I called, so I
236
+ > verified it myself rather than trusting it"
237
+
238
+ That is correct behaviour, not a bug. So inject **checkable facts** in the
239
+ conversational lane and put **directives** in the instructions lane. A conclusion
240
+ injected as a user message is the worst of both: the model neither trusts it nor
241
+ ignores it, and may contradict it in front of the user.
242
+
195
243
  ## API reference
196
244
 
197
245
  ### chat.inject()
@@ -200,7 +248,9 @@ export const myChat = chat.agent({
200
248
  chat.inject(messages: ModelMessage[]): void
201
249
  ```
202
250
 
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.
251
+ 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.
252
+
253
+ 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
254
 
205
255
  **Parameters:**
206
256
 
@@ -209,9 +259,9 @@ Queue model messages for injection at the next opportunity. Messages persist acr
209
259
  | `messages` | `ModelMessage[]` | Model messages to inject (from the `ai` package) |
210
260
 
211
261
  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
262
+ 1. A new turn starts, before `run()` executes
263
+ 2. A `prepareStep` boundary is reached, between tool-call steps during streaming
214
264
 
215
265
  <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.
266
+ `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
267
  </Note>
@@ -54,7 +54,7 @@ A single-shell walk-through of the whole protocol — copy, fill in `BASE_URL` /
54
54
 
55
55
  ```bash
56
56
  BASE_URL="https://api.trigger.dev" # or your local webapp
57
- SECRET_KEY="tr_dev_..." # secret API key for the env
57
+ SECRET_KEY="tr_dev_sk_..." # secret API key for the env
58
58
  TASK_ID="ai-chat" # your chat.agent task id
59
59
  CHAT_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
60
60
 
@@ -210,6 +210,7 @@ Pick `"preload"` when the UI has rendered but the user hasn't typed (warms the a
210
210
  | `triggerConfig.maxAttempts` | `number` | Per-run retry cap (1–10). |
211
211
  | `triggerConfig.maxDuration` | `number` | Per-run wall-clock cap, seconds. |
212
212
  | `triggerConfig.lockToVersion` | `string` | Pin every run to a specific worker version. |
213
+ | `triggerConfig.externalDeploymentId` | `string \| null` | Pin every run to the deployment carrying this [external deployment id](/deployment/version-skew-protection#chat-sessions). Discovered from the environment when omitted; `null` opts the chat out. |
213
214
  | `triggerConfig.region` | `string` | Region preference. |
214
215
  | `triggerConfig.idleTimeoutInSeconds` | `number` | Surfaced to the agent through the wire payload (1–3600). |
215
216
 
@@ -266,7 +267,7 @@ x-trigger-jwt-claims: {"sub":"...","scopes":["read:runs:run_abc123","write:input
266
267
  Re-calling `POST /api/v1/sessions` with the same `(taskIdentifier, externalId)` pair is **idempotent for the lifetime of the session**:
267
268
 
268
269
  - If the session is still alive: returns the existing row with `isCached: true`, `runId` unchanged, and a **fresh** 60-minute `publicAccessToken`. No duplicate run is triggered. (Idle/exited runs are different — see [Continuations](#continuations).)
269
- - If the session has been closed (`POST /api/v1/sessions/{id}/close`): returns **HTTP 409**. Closed is one-way; reuse a different `externalId` to start a new conversation.
270
+ - If the session has been closed (`POST /api/v1/sessions/{id}/close`, or `chat.close()` from inside the agent): returns **HTTP 409**. Closed is one-way; reuse a different `externalId` to start a new conversation.
270
271
  - Any tags / metadata / expiresAt / triggerConfig fields you send on the cached path are written through to the row, so you can update e.g. `triggerConfig.basePayload.metadata` mid-conversation. The new fields apply to **future** runs (continuations); the currently-live run keeps its original config.
271
272
 
272
273
  <Warning>
@@ -693,7 +694,7 @@ The body is a JSON-serialized [`ChatInputChunk`](#chatinputchunk), a tagged unio
693
694
  | --- | --- |
694
695
  | `401` | Missing or invalid `Authorization` header. |
695
696
  | `403` | Token doesn't carry `write:sessions:{externalId}`. |
696
- | `409` | The session is closed — `{ "ok": false, "error": "Cannot append to a closed session" }`. |
697
+ | `409` | The session is closed: `{ "ok": false, "error": "Cannot append to a closed session", "code": "session_closed", "closedReason": "<reason or null>" }`. Key on `code`, not the message. Terminal: do not retry, and stop reconnecting to `.out`. |
697
698
  | `413` | Body exceeds 1 MiB **or** the wrapped record would exceed S2's ~1 MiB per-record metered ceiling. A normal `kind: "message"` payload is a few KB; if you hit this you're shipping more than one message per record or pushing a single tool output that's itself oversized. Carries CORS headers so browser fetches can read the status. |
698
699
  | `500` | Transient backend failure on the durable stream. Safe to retry — appends are idempotent on `(externalId, X-Part-Id)` if you set the optional `X-Part-Id` request header (the built-in clients set it from a UUID). |
699
700
 
@@ -832,7 +833,7 @@ Custom actions (undo, rollback, edit) ride on the same `.in` channel using `kind
832
833
  }
833
834
  ```
834
835
 
835
- For managed `chat.agent()` tasks, actions wake the agent from suspension (same as messages) and fire the `onAction` hook — they are not turns, so `run()` and turn lifecycle hooks do not fire. If `onAction` returns a `StreamTextResult`, the response is auto-piped to the frontend (but still no `run()` or `onTurnComplete`). The `action` payload is validated against the agent's `actionSchema`. If the agent didn't register an `actionSchema` (or your `action` payload doesn't match it), validation fails the same way `metadata` does — `.in/append` returns `200 OK`, but the run trace shows `chat turn N [ERROR]` and the wire emits a `turn-complete` control record with no other chunks. See [Actions](/ai-chat/actions) for the agent-side schema setup.
836
+ For managed `chat.agent()` tasks, actions wake the agent from suspension (same as messages) and fire the `onAction` hook — they are not turns, so `run()` and turn lifecycle hooks do not fire. If `onAction` returns `chat.turn()`, a turn runs on the edited history and its chunks follow on `.out` like any turn's.
836
837
 
837
838
  Raw `chat.customAgent()` tasks receive `action` as `unknown` and must validate it in their own loop.
838
839