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

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 (106) hide show
  1. package/dist/commonjs/imports/ai-runtime-cjs.cjs.map +1 -1
  2. package/dist/commonjs/imports/ai-runtime.js +0 -2
  3. package/dist/commonjs/v3/ai.d.ts +16 -199
  4. package/dist/commonjs/v3/ai.js +102 -983
  5. package/dist/commonjs/v3/ai.js.map +1 -1
  6. package/dist/commonjs/v3/chat-client.d.ts +2 -3
  7. package/dist/commonjs/v3/chat-client.js +5 -31
  8. package/dist/commonjs/v3/chat-client.js.map +1 -1
  9. package/dist/commonjs/v3/chat-react.d.ts +0 -34
  10. package/dist/commonjs/v3/chat-react.js +1 -47
  11. package/dist/commonjs/v3/chat-react.js.map +1 -1
  12. package/dist/commonjs/v3/chat-server.d.ts +6 -42
  13. package/dist/commonjs/v3/chat-server.js +7 -52
  14. package/dist/commonjs/v3/chat-server.js.map +1 -1
  15. package/dist/commonjs/v3/chat.d.ts +10 -81
  16. package/dist/commonjs/v3/chat.js +46 -292
  17. package/dist/commonjs/v3/chat.js.map +1 -1
  18. package/dist/commonjs/v3/sessions.d.ts +2 -15
  19. package/dist/commonjs/v3/sessions.js +1 -12
  20. package/dist/commonjs/v3/sessions.js.map +1 -1
  21. package/dist/commonjs/v3/shared.js +36 -30
  22. package/dist/commonjs/v3/shared.js.map +1 -1
  23. package/dist/commonjs/v3/test/mock-chat-agent.d.ts +0 -43
  24. package/dist/commonjs/v3/test/mock-chat-agent.js +0 -90
  25. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  26. package/dist/commonjs/v3/test/test-session-handle.js +0 -6
  27. package/dist/commonjs/v3/test/test-session-handle.js.map +1 -1
  28. package/dist/commonjs/version.js +1 -1
  29. package/dist/esm/imports/ai-runtime.d.ts +2 -2
  30. package/dist/esm/imports/ai-runtime.js +2 -2
  31. package/dist/esm/imports/ai-runtime.js.map +1 -1
  32. package/dist/esm/v3/ai.d.ts +16 -199
  33. package/dist/esm/v3/ai.js +103 -984
  34. package/dist/esm/v3/ai.js.map +1 -1
  35. package/dist/esm/v3/chat-client.d.ts +2 -3
  36. package/dist/esm/v3/chat-client.js +5 -31
  37. package/dist/esm/v3/chat-client.js.map +1 -1
  38. package/dist/esm/v3/chat-react.d.ts +0 -34
  39. package/dist/esm/v3/chat-react.js +1 -46
  40. package/dist/esm/v3/chat-react.js.map +1 -1
  41. package/dist/esm/v3/chat-server.d.ts +6 -42
  42. package/dist/esm/v3/chat-server.js +8 -53
  43. package/dist/esm/v3/chat-server.js.map +1 -1
  44. package/dist/esm/v3/chat.d.ts +10 -81
  45. package/dist/esm/v3/chat.js +47 -293
  46. package/dist/esm/v3/chat.js.map +1 -1
  47. package/dist/esm/v3/sessions.d.ts +2 -15
  48. package/dist/esm/v3/sessions.js +1 -11
  49. package/dist/esm/v3/sessions.js.map +1 -1
  50. package/dist/esm/v3/shared.js +23 -17
  51. package/dist/esm/v3/shared.js.map +1 -1
  52. package/dist/esm/v3/test/mock-chat-agent.d.ts +0 -43
  53. package/dist/esm/v3/test/mock-chat-agent.js +2 -92
  54. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  55. package/dist/esm/v3/test/test-session-handle.js +0 -6
  56. package/dist/esm/v3/test/test-session-handle.js.map +1 -1
  57. package/dist/esm/version.js +1 -1
  58. package/docs/ai-chat/actions.mdx +23 -55
  59. package/docs/ai-chat/anatomy.mdx +3 -3
  60. package/docs/ai-chat/backend.mdx +48 -125
  61. package/docs/ai-chat/background-injection.mdx +19 -67
  62. package/docs/ai-chat/client-protocol.mdx +4 -5
  63. package/docs/ai-chat/compaction.mdx +7 -11
  64. package/docs/ai-chat/custom-agents.mdx +0 -23
  65. package/docs/ai-chat/fast-starts.mdx +20 -27
  66. package/docs/ai-chat/frontend.mdx +14 -17
  67. package/docs/ai-chat/migrating-from-a-route-handler.mdx +14 -16
  68. package/docs/ai-chat/patterns/skills.mdx +10 -7
  69. package/docs/ai-chat/patterns/version-upgrades.mdx +6 -79
  70. package/docs/ai-chat/pending-messages.mdx +3 -3
  71. package/docs/ai-chat/prompt-caching.mdx +25 -23
  72. package/docs/ai-chat/quick-start.mdx +11 -11
  73. package/docs/ai-chat/reference.mdx +5 -12
  74. package/docs/ai-chat/sessions.mdx +1 -6
  75. package/docs/ai-chat/testing.mdx +1 -2
  76. package/docs/ai-chat/tools.mdx +13 -18
  77. package/docs/ai-chat/upgrade-guide.mdx +2 -2
  78. package/docs/apikeys.mdx +45 -27
  79. package/docs/deployment/overview.mdx +8 -4
  80. package/docs/deployment/preview-branches.mdx +4 -4
  81. package/docs/deployment/version-skew-protection.mdx +0 -62
  82. package/docs/manual-setup.mdx +7 -7
  83. package/docs/mcp-tools.mdx +0 -9
  84. package/docs/quick-start.mdx +3 -3
  85. package/docs/realtime/auth.mdx +1 -1
  86. package/docs/self-hosting/security.mdx +0 -5
  87. package/docs/tasks/scheduled.mdx +0 -24
  88. package/docs/triggering.mdx +1 -1
  89. package/package.json +4 -4
  90. package/skills/trigger-authoring-chat-agent/SKILL.md +27 -38
  91. package/skills/trigger-chat-agent-advanced/SKILL.md +12 -31
  92. package/dist/commonjs/v3/chatVersionSkew.d.ts +0 -12
  93. package/dist/commonjs/v3/chatVersionSkew.js +0 -30
  94. package/dist/commonjs/v3/chatVersionSkew.js.map +0 -1
  95. package/dist/commonjs/v3/externalDeploymentId.d.ts +0 -23
  96. package/dist/commonjs/v3/externalDeploymentId.js +0 -43
  97. package/dist/commonjs/v3/externalDeploymentId.js.map +0 -1
  98. package/dist/esm/v3/chatVersionSkew.d.ts +0 -12
  99. package/dist/esm/v3/chatVersionSkew.js +0 -27
  100. package/dist/esm/v3/chatVersionSkew.js.map +0 -1
  101. package/dist/esm/v3/externalDeploymentId.d.ts +0 -23
  102. package/dist/esm/v3/externalDeploymentId.js +0 -38
  103. package/dist/esm/v3/externalDeploymentId.js.map +0 -1
  104. package/docs/ai-chat/patterns/native-compaction.mdx +0 -310
  105. package/docs/reports.mdx +0 -157
  106. package/docs/troubleshooting-zod.mdx +0 -158
@@ -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,9 +33,8 @@ 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,
37
36
  onTurnComplete: async ({ messages }) => {
38
- // Kick off background analysis, doesn't block the turn
37
+ // Kick off background analysis — doesn't block the turn
39
38
  chat.defer(
40
39
  (async () => {
41
40
  const analysis = await analyzeConversation(messages);
@@ -48,8 +47,9 @@ export const myChat = chat.agent({
48
47
  })()
49
48
  );
50
49
  },
51
- run: async ({ messages, signal, streamText }) => {
50
+ run: async ({ messages, signal }) => {
52
51
  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 { generateObject, createProviderRegistry, stepCountIs } from "ai";
80
+ import { streamText, generateObject, createProviderRegistry, stepCountIs } from "ai";
81
81
  import { anthropic } from "@ai-sdk/anthropic";
82
82
  import { z } from "zod";
83
83
 
@@ -98,7 +98,6 @@ Be concise. Only flag issues worth fixing.`,
98
98
 
99
99
  export const myChat = chat.agent({
100
100
  id: "my-chat",
101
- registry,
102
101
  onTurnComplete: async ({ messages }) => {
103
102
  chat.defer(
104
103
  (async () => {
@@ -140,8 +139,9 @@ export const myChat = chat.agent({
140
139
  })()
141
140
  );
142
141
  },
143
- run: async ({ messages, signal, streamText }) => {
142
+ run: async ({ messages, signal }) => {
144
143
  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, because `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 — `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, streamText }) => {
173
+ run: async ({ messages, signal }) => {
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,57 +189,9 @@ 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` becomes an instruction, others join the conversation (see below) | Typically `user` |
192
+ | **Message role** | Any (`system`, `user`, `assistant`) | 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
-
243
195
  ## API reference
244
196
 
245
197
  ### chat.inject()
@@ -248,7 +200,7 @@ ignores it, and may contradict it in front of the user.
248
200
  chat.inject(messages: ModelMessage[]): void
249
201
  ```
250
202
 
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.
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.
252
204
 
253
205
  **Parameters:**
254
206
 
@@ -257,9 +209,9 @@ Queue model messages for injection at the next opportunity. Messages persist acr
257
209
  | `messages` | `ModelMessage[]` | Model messages to inject (from the `ai` package) |
258
210
 
259
211
  Messages are drained (consumed) when:
260
- 1. A new turn starts, before `run()` executes
261
- 2. A `prepareStep` boundary is reached, between tool-call steps during streaming
212
+ 1. A new turn starts — before `run()` executes
213
+ 2. A `prepareStep` boundary is reached — between tool-call steps during streaming
262
214
 
263
215
  <Note>
264
- `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.
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.
265
217
  </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_sk_..." # secret API key for the env
57
+ SECRET_KEY="tr_dev_..." # 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,7 +210,6 @@ 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. |
214
213
  | `triggerConfig.region` | `string` | Region preference. |
215
214
  | `triggerConfig.idleTimeoutInSeconds` | `number` | Surfaced to the agent through the wire payload (1–3600). |
216
215
 
@@ -267,7 +266,7 @@ x-trigger-jwt-claims: {"sub":"...","scopes":["read:runs:run_abc123","write:input
267
266
  Re-calling `POST /api/v1/sessions` with the same `(taskIdentifier, externalId)` pair is **idempotent for the lifetime of the session**:
268
267
 
269
268
  - 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).)
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.
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.
271
270
  - 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.
272
271
 
273
272
  <Warning>
@@ -694,7 +693,7 @@ The body is a JSON-serialized [`ChatInputChunk`](#chatinputchunk), a tagged unio
694
693
  | --- | --- |
695
694
  | `401` | Missing or invalid `Authorization` header. |
696
695
  | `403` | Token doesn't carry `write:sessions:{externalId}`. |
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`. |
696
+ | `409` | The session is closed — `{ "ok": false, "error": "Cannot append to a closed session" }`. |
698
697
  | `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. |
699
698
  | `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). |
700
699
 
@@ -833,7 +832,7 @@ Custom actions (undo, rollback, edit) ride on the same `.in` channel using `kind
833
832
  }
834
833
  ```
835
834
 
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.
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.
837
836
 
838
837
  Raw `chat.customAgent()` tasks receive `action` as `unknown` and must validate it in their own loop.
839
838
 
@@ -19,12 +19,11 @@ Provide `shouldCompact` to decide when to compact and `summarize` to generate th
19
19
 
20
20
  ```ts
21
21
  import { chat } from "@trigger.dev/sdk/ai";
22
- import { generateText, stepCountIs } from "ai";
22
+ import { streamText, generateText, stepCountIs } from "ai";
23
23
  import { anthropic } from "@ai-sdk/anthropic";
24
24
 
25
25
  export const myChat = chat.agent({
26
26
  id: "my-chat",
27
- registry,
28
27
  compaction: {
29
28
  shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000,
30
29
  summarize: async ({ messages }) => {
@@ -35,8 +34,9 @@ export const myChat = chat.agent({
35
34
  return result.text;
36
35
  },
37
36
  },
38
- run: async ({ messages, signal, streamText }) => {
37
+ run: async ({ messages, signal }) => {
39
38
  return streamText({
39
+ ...chat.toStreamTextOptions({ registry }),
40
40
  messages,
41
41
  abortSignal: signal,
42
42
  stopWhen: stepCountIs(15),
@@ -61,10 +61,6 @@ After each turn completes:
61
61
 
62
62
  On the next turn, the LLM receives the compact summary instead of the full history — dramatically reducing token usage while preserving context.
63
63
 
64
- <Note>
65
- This is Trigger.dev's provider-agnostic compaction. To persist a **provider's own** compaction across turns instead (Anthropic context editing or OpenAI stored responses), and to fall back between providers without re-sending history, see [Native compaction & provider fallback](/ai-chat/patterns/native-compaction).
66
- </Note>
67
-
68
64
  ## Customizing what gets persisted
69
65
 
70
66
  By default, compaction only affects model messages — UI messages stay intact so users see the full conversation after a page refresh. You can customize this with `compactUIMessages`:
@@ -95,7 +91,7 @@ export const myChat = chat.agent({
95
91
  ...uiMessages.slice(-4), // Keep the last 4 messages
96
92
  ],
97
93
  },
98
- run: async ({ messages, signal, streamText }) => {
94
+ run: async ({ messages, signal }) => {
99
95
  return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
100
96
  },
101
97
  });
@@ -189,7 +185,7 @@ export const myChat = chat.agent({
189
185
  data: { chatId, summary, totalTokens, messageCount },
190
186
  });
191
187
  },
192
- run: async ({ messages, signal, streamText }) => {
188
+ run: async ({ messages, signal }) => {
193
189
  return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
194
190
  },
195
191
  });
@@ -205,7 +201,7 @@ Define a `compact` action that reuses your existing `summarize` function:
205
201
 
206
202
  ```ts
207
203
  import { chat } from "@trigger.dev/sdk/ai";
208
- import { generateText, generateId, convertToModelMessages } from "ai";
204
+ import { streamText, generateText, generateId, convertToModelMessages } from "ai";
209
205
  import { anthropic } from "@ai-sdk/anthropic";
210
206
  import { z } from "zod";
211
207
 
@@ -247,7 +243,7 @@ export const myChat = chat.agent({
247
243
  ]);
248
244
  },
249
245
 
250
- run: async ({ messages, signal, streamText }) => {
246
+ run: async ({ messages, signal }) => {
251
247
  return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
252
248
  },
253
249
  });
@@ -290,29 +290,6 @@ Read `turn.stopped` to tell a user stop from a full run cancel:
290
290
 
291
291
  A hand-rolled loop wires this itself with `chat.createStopSignal()` and `chat.cleanupAbortedParts()`. Two things `createSession` handles for you are easy to get wrong there — see the [hand-rolled loop checklist](#hand-rolled-loop-checklist).
292
292
 
293
- ### Ending the conversation
294
-
295
- `chat.close({ reason })` works in a custom agent exactly as it does in [`chat.agent`](/ai-chat/backend#ending-the-conversation): the session row is closed, further sends are refused with HTTP 409, and no continuation run is scheduled. Call it from anywhere in your loop.
296
-
297
- ```ts trigger/my-chat.ts
298
- for await (const turn of session) {
299
- const result = streamText({ model, messages: turn.messages, abortSignal: turn.signal });
300
-
301
- // Close BEFORE turn.complete(): that call writes the turn boundary the
302
- // browser stops reading at, and it carries the closed state out with it.
303
- if (await overBudget(turn.chatId)) {
304
- chat.close({ reason: "Monthly budget reached" });
305
- }
306
-
307
- await turn.complete(result);
308
- if (turn.stopped) break;
309
- }
310
- ```
311
-
312
- The close is performed when your `run()` returns, so it lands whether you `break` out of the loop, return early, or keep iterating. A hand-rolled loop with no iterator at all works the same way, as long as you call `chat.close()` before the `chat.writeTurnComplete()` that ends the turn.
313
-
314
- Only `chat.agent` and `chat.customAgent` bind the run to its Session, so `chat.close()` throws in a plain `task()`.
315
-
316
293
  ## Hand-rolled loop with primitives
317
294
 
318
295
  For full control, skip `createSession` and compose the primitives directly:
@@ -239,11 +239,11 @@ This is an **import-chain** problem, not a runtime one. A "we'll strip the execu
239
239
 
240
240
  export const myChat = chat.agent({
241
241
  id: "my-chat",
242
- run: async ({ messages, signal, streamText }) =>
242
+ run: async ({ messages, signal }) =>
243
243
  streamText({
244
+ ...chat.toStreamTextOptions({ tools: chatTools }),
244
245
  model: anthropic("claude-sonnet-4-6"),
245
246
  messages,
246
- tools: chatTools,
247
247
  stopWhen: stepCountIs(10),
248
248
  abortSignal: signal,
249
249
  }),
@@ -251,7 +251,7 @@ This is an **import-chain** problem, not a runtime one. A "we'll strip the execu
251
251
  ```
252
252
  </Step>
253
253
  <Step title="Build the head-start handler">
254
- Call `chat.headStart({ agentId, run })`. It returns a standard Web Fetch handler: `(req: Request) => Promise<Response>`. The `run` callback receives a `streamText` that already carries the SDK-owned wiring: the converted messages, `stopWhen: stepCountIs(1)` and the abort signal. Pass your schema-only tools to it explicitly, and add your own `model` and `system` on top.
254
+ Call `chat.headStart({ agentId, run })`. It returns a standard Web Fetch handler: `(req: Request) => Promise<Response>`. Inside the `run` callback you call `streamText` yourself and spread `chat.toStreamTextOptions({ tools })` to inherit the SDK-owned wiring (messages, schema-only tools, `stopWhen: stepCountIs(1)`, abort signal). Add your own `model` and `system` on top.
255
255
 
256
256
  ```ts lib/chat-handler.ts
257
257
  import { chat } from "@trigger.dev/sdk/chat-server";
@@ -261,23 +261,18 @@ This is an **import-chain** problem, not a runtime one. A "we'll strip the execu
261
261
 
262
262
  export const chatHandler = chat.headStart({
263
263
  agentId: "my-chat",
264
- run: async ({ streamText }) =>
264
+ run: async ({ chat: helper }) =>
265
265
  streamText({
266
+ ...helper.toStreamTextOptions({ tools: headStartTools }),
266
267
  model: anthropic("claude-sonnet-4-6"),
267
268
  system: "You are a helpful assistant.",
268
- tools: headStartTools,
269
269
  }),
270
270
  });
271
271
  ```
272
272
 
273
- <Note>
274
- That `streamText` is the SDK's, not the one from `ai`. It pins `messages`,
275
- `prompt`, `stopWhen: stepCountIs(1)` and `abortSignal`, which the handover depends on:
276
- running past step 1 would splice a stream the agent is supposed to own. Setting
277
- any of the four at the call site is a type error, and a throw if you get past
278
- the types, rather than breaking the handover quietly. `chat.toStreamTextOptions()` is still there if you want to build the
279
- options yourself.
280
- </Note>
273
+ <Warning>
274
+ Don't set `stopWhen` here. The spread pins it to `stepCountIs(1)`, and overriding it makes the handler run steps the agent is supposed to own — the handover then splices a stream that has already moved past step 1.
275
+ </Warning>
281
276
 
282
277
  <Tip>
283
278
  Use the **same model** on both sides (route handler and `chat.agent`) to avoid a tone or style shift between step 1 and step 2+. Your LLM provider keys stay server-side in your warm process — Trigger.dev never holds them in this design.
@@ -630,27 +625,27 @@ chat.headStart<TTools>({
630
625
  export const chatHandler = chat.headStart({
631
626
  agentId: "my-chat",
632
627
  triggerConfig: { tags: ["org:acme"], queue: "chat", machine: "small-2x" },
633
- run: async ({ streamText }) => streamText({ model, system, tools: headStartTools }),
628
+ run: async ({ chat: helper }) =>
629
+ streamText({ ...helper.toStreamTextOptions({ tools: headStartTools }), model, system }),
634
630
  });
635
631
  ```
636
632
 
637
633
  The `run` callback receives:
638
634
 
639
- - `messages: UIMessage[]`: user messages parsed from the request body.
640
- - `signal: AbortSignal`: fires when the request closes or the SDK times out the handover.
641
- - `streamText`: the AI SDK's `streamText` with the keys below already applied. Prefer it.
642
- - `chat: HeadStartChatHelper<TTools>`: exposes `chat.toStreamTextOptions({ tools })` for building the options by hand, plus a `chat.session` escape hatch for power users.
635
+ - `messages: UIMessage[]` — user messages parsed from the request body.
636
+ - `signal: AbortSignal` — fires when the request closes or the SDK times out the handover.
637
+ - `chat: HeadStartChatHelper<TTools>` — exposes `chat.toStreamTextOptions({ tools })` and a `chat.session` escape hatch for power users.
643
638
 
644
- The SDK owns these keys. Passing one to the callback's `streamText` is a type error, and a throw behind that; re-setting one after a `chat.toStreamTextOptions()` spread breaks the protocol with no error at all:
639
+ `chat.toStreamTextOptions({ tools })` returns options to spread into `streamText`. The SDK owns these keys — overriding them will break the protocol:
645
640
 
646
641
  | Key | What the SDK sets | Why |
647
642
  | --- | --- | --- |
648
643
  | `messages` | `convertToModelMessages(uiMessages)` | First-turn user history |
649
- | `prompt` | Nothing, and rejects yours | `messages` already carries the history |
650
- | `stopWhen` | `stepCountIs(1)` | Step 1 only, the agent picks up step 2 onward |
644
+ | `tools` | What you pass | Schema-only tools for step 1 |
645
+ | `stopWhen` | `stepCountIs(1)` | Step 1 only — agent picks up step 2+ |
651
646
  | `abortSignal` | Combined request + idle timeout | Safe cleanup on disconnect |
652
647
 
653
- You bring `model`, `system`, `providerOptions`, `prepareStep`, anything else `streamText` accepts. `tools` is yours too: pass your schema-only set to the callback's `streamText` (or to `chat.toStreamTextOptions({ tools })`), since the SDK cannot know it.
648
+ You bring `model`, `system`, `providerOptions`, `prepareStep`, anything else `streamText` accepts.
654
649
 
655
650
  #### The transport option
656
651
 
@@ -689,11 +684,11 @@ export async function POST(req: Request) {
689
684
  agentId: "my-chat",
690
685
  chatId, // session externalId; reuse it on the destination page
691
686
  messages, // first-turn user history
692
- run: async ({ streamText }) =>
687
+ run: async ({ chat: helper }) =>
693
688
  streamText({
689
+ ...helper.toStreamTextOptions({ tools: headStartTools }),
694
690
  model: anthropic("claude-sonnet-4-6"),
695
691
  system: "You are a helpful assistant.",
696
- tools: headStartTools,
697
692
  }),
698
693
  });
699
694
 
@@ -740,13 +735,11 @@ chat.startHeadStart<TTools>({
740
735
  triggerConfig?: Partial<SessionTriggerConfig>, // tags, queue, machine, …
741
736
  apiClient?: ApiClientConfiguration, // when the agent lives in another project/env
742
737
  metadata?: Record<string, unknown>, // merged into the run payload; never sent to the browser
743
- }): Promise<{ chatId: string; pendingVersion: boolean; completion: Promise<void> }>
738
+ }): Promise<{ chatId: string; completion: Promise<void> }>
744
739
  ```
745
740
 
746
741
  `completion` resolves once the head start finishes; `await` it or hand it to `waitUntil`. It rejects if the warm step or the dispatch fails.
747
742
 
748
- `pendingVersion` is `true` when the agent run is parked waiting for the deployment carrying the session's [external deployment id](/deployment/version-skew-protection#chat-sessions). Step 1 still runs in your process and still reaches the browser, so pass the flag to the destination page if you want it to say a deploy is in progress rather than appear to stall on step 2.
749
-
750
743
  ### Limitations
751
744
 
752
745
  - **First turn only.** Step 2+ and turn 2+ run on the trigger side. There's no per-turn "head start every turn" mode — the win comes from amortizing agent boot across the LLM call once.
@@ -442,48 +442,45 @@ function Chat({ chatId, transport }) {
442
442
 
443
443
  ## Sending actions
444
444
 
445
- Send custom actions (undo, rollback, edit, regenerate) as `useChat` requests, with the action in the request `body`. The transport recognises `body.action` and sends it as an action rather than a message, and because `useChat` made the request it owns the response: an action that returns `chat.turn()` on the server streams its answer into the message list like any turn, with `status`, `error` and `stop` behaving as for a message. An action that returns nothing completes with no message added.
445
+ Send custom actions (undo, rollback, edit) to the agent via `transport.sendAction()`. Actions wake the agent and fire only `hydrateMessages` (if configured) and `onAction` — they're not turns, so `onTurnStart` / `prepareMessages` / `onBeforeTurnComplete` / `onTurnComplete` and `run()` do not fire.
446
446
 
447
- ```tsx
448
- import { useChat } from "@ai-sdk/react";
449
- import { useChatActions, useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
447
+ For optimistic UI, mirror the action's effect on the `useChat` state via `setMessages` while the request is in flight:
450
448
 
449
+ ```tsx
451
450
  function ChatControls({ chatId }: { chatId: string }) {
452
451
  const transport = useTriggerChatTransport({
453
452
  task: "my-chat",
454
453
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
455
- startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }),
454
+ startSession: ({ chatId, clientData }) =>
455
+ startChatSession({ chatId, clientData }),
456
456
  });
457
- const { sendMessage, regenerate, setMessages } = useChat({ id: chatId, transport });
458
- const { sendAction } = useChatActions({ sendMessage });
457
+
458
+ const { setMessages } = useChat({ transport });
459
459
 
460
460
  return (
461
461
  <div>
462
462
  <button
463
463
  onClick={() => {
464
- // Mirror the server-side edit optimistically; the server does not
465
- // push history changes back.
464
+ void transport.sendAction(chatId, { type: "undo" });
466
465
  setMessages((prev) => prev.slice(0, -2));
467
- void sendAction({ type: "undo" });
468
466
  }}
469
467
  >
470
468
  Undo last exchange
471
469
  </button>
472
- {/* regenerate() removes the trailing answer locally; the server does the same before its turn */}
473
- <button onClick={() => regenerate({ body: { action: { type: "regenerate" } } })}>
474
- Regenerate
470
+ <button
471
+ onClick={() => transport.sendAction(chatId, { type: "rollback", targetMessageId: "msg-5" })}
472
+ >
473
+ Rollback to message
475
474
  </button>
476
475
  </div>
477
476
  );
478
477
  }
479
478
  ```
480
479
 
481
- `useChatActions` is a two-line convenience over `sendMessage(undefined, { body: { action } })`. Any `useChat` request can carry an action the same way, `regenerate({ body })` included.
482
-
483
- The action payload is validated against the agent's `actionSchema` on the backend; invalid actions are rejected. See [Actions](/ai-chat/actions) for the backend setup.
480
+ The action payload is validated against the agent's `actionSchema` on the backend — invalid actions are rejected. See [Actions](/ai-chat/actions) for the backend setup.
484
481
 
485
482
  <Note>
486
- `transport.sendAction()` still exists for callers outside `useChat` (server to server, or a custom client). It returns the response as a raw `ReadableStream<UIMessageChunk>` that the caller must read; `useChat` does not consume it.
483
+ `sendAction` returns a `ReadableStream<UIMessageChunk>`. For side-effect-only actions (where `onAction` returns `void`), the stream completes immediately with `trigger:turn-complete`. For actions where `onAction` returns a `StreamTextResult`, the stream carries the assistant chunks the same way `sendMessages` does — `useChat` consumes them automatically.
487
484
  </Note>
488
485
 
489
486
  For server-to-server usage, `AgentChat` has the same method:
@@ -56,7 +56,7 @@ Then make these changes:
56
56
  - Create a `chat.agent` task in `trigger/chat.ts`. Move the existing `streamText` call
57
57
  into its `run` function UNCHANGED — same model, same `system`, same `temperature`,
58
58
  same `stopWhen`, same provider options. Do not rewrite the prompt or swap the model.
59
- - Take `streamText` from the `run` argument, so the managed options apply to that
59
+ - Spread `...chat.toStreamTextOptions({ tools })` as the FIRST property of that
60
60
  `streamText` call, so the explicit options after it still win.
61
61
  - `run` receives `ModelMessage[]` already. Delete the `convertToModelMessages` call.
62
62
  - Forward the `signal` from `run` as `abortSignal` so Stop works.
@@ -145,9 +145,10 @@ import { tools } from "@/lib/tools";
145
145
  export const myChat = chat.agent({
146
146
  id: "my-chat",
147
147
  tools,
148
- run: async ({ messages, tools, signal, streamText }) =>
148
+ run: async ({ messages, tools, signal }) =>
149
149
  streamText({
150
- tools,
150
+ // Spread first, so every option below still wins.
151
+ ...chat.toStreamTextOptions({ tools }),
151
152
  model: anthropic("claude-sonnet-4-5"),
152
153
  system: "You are a helpful assistant.",
153
154
  messages,
@@ -162,10 +163,10 @@ Four things changed inside the `streamText` call, and `tools` moved onto the age
162
163
  - **`messages` arrives as `ModelMessage[]`.** The runtime converts the frontend's `UIMessage[]` for you, so `convertToModelMessages` is gone.
163
164
  - **`abortSignal` comes from `signal` on the payload**, not `req.signal`. It fires on stop and on cancel.
164
165
  - **Return the `StreamTextResult`.** It's piped to the frontend automatically — no `toUIMessageStreamResponse`. If `streamText` is buried in a helper, call `await chat.pipe(result)` from anywhere in the task instead and let `run` resolve `void`.
165
- - **`streamText` comes from the `run` argument, not from `ai`.** It carries the `prepareStep` behind [compaction](/ai-chat/compaction), [mid-turn steering](/ai-chat/pending-messages) and [background injection](/ai-chat/background-injection), plus the system prompt set via [`chat.prompt()`](/ai-chat/backend#using-prompts) and telemetry.
166
+ - **`...chat.toStreamTextOptions()` is spread first.** It wires up the `prepareStep` callback behind [compaction](/ai-chat/compaction), [mid-turn steering](/ai-chat/pending-messages), and [background injection](/ai-chat/background-injection), plus the system prompt set via [`chat.prompt()`](/ai-chat/backend#using-prompts) and telemetry.
166
167
 
167
168
  <Warning>
168
- Importing `streamText` from `ai` instead throws no error: compaction, steering and background injection never run. Spreading `chat.toStreamTextOptions()` into the imported one is the equivalent, and is what a custom agent has to do, since it has no `run` argument. A `chat.headStart` route gets a bound `streamText` of its own.
169
+ Omitting `...chat.toStreamTextOptions()` throws no error — compaction, steering, and background injection just silently never run. Spread it first so any explicit override you write after it takes precedence.
169
170
  </Warning>
170
171
 
171
172
  There's no `maxDuration` equivalent to set. A turn isn't bounded by a function timeout; a run stays alive across turns and suspends when nothing is happening.
@@ -356,9 +357,9 @@ export const myChat = chat.agent({
356
357
  }),
357
358
  ]);
358
359
  },
359
- run: async ({ messages, tools, signal, streamText }) =>
360
+ run: async ({ messages, tools, signal }) =>
360
361
  streamText({
361
- tools,
362
+ ...chat.toStreamTextOptions({ tools }),
362
363
  model: anthropic("claude-sonnet-4-5"),
363
364
  system: "You are a helpful assistant.",
364
365
  messages,
@@ -467,21 +468,18 @@ Head Start brings the route handler back for exactly that first turn. It runs st
467
468
 
468
469
  export const chatHandler = chat.headStart({
469
470
  agentId: "my-chat",
470
- run: async ({ streamText }) =>
471
+ run: async ({ chat: helper }) =>
471
472
  streamText({
473
+ ...helper.toStreamTextOptions({ tools: headStartTools }),
472
474
  model: anthropic("claude-sonnet-4-5"),
473
475
  system: "You are a helpful assistant.",
474
- tools: headStartTools,
475
476
  }),
476
477
  });
477
478
  ```
478
479
 
479
- <Note>
480
- That `streamText` owns `messages`, `prompt`, `abortSignal` and `stopWhen`. Passing one is
481
- a type error, and a runtime throw if you get past the types. Unlike the agent side, re-setting one breaks the
482
- handover rather than degrading it: `stopWhen` is pinned to `stepCountIs(1)`
483
- because the agent, not the handler, runs step 2 onward.
484
- </Note>
480
+ <Warning>
481
+ Spread `toStreamTextOptions()` first and add only your own keys after it. It owns `messages`, `tools`, `abortSignal`, and `stopWhen` — and unlike the agent-side spread, re-setting any of those breaks the handover rather than degrading it. `stopWhen` in particular is pinned to `stepCountIs(1)`: the agent, not the handler, runs step 2 onward.
482
+ </Warning>
485
483
 
486
484
  Your provider keys never leave your server — the first-turn model call runs in your process, so that environment needs whatever the model requires.
487
485
  </Step>
@@ -560,7 +558,7 @@ The shape is identical outside Next.js. The agent task and the React component d
560
558
 
561
559
  **The head-start route dies mid-turn on Vercel.** The handler holds the SSE response open until the agent signals turn-complete, so the function timeout has to cover the whole turn, not just step 1. Set `maxDuration` on that route segment.
562
560
 
563
- **Compaction and steering do nothing.** `run` is calling the `streamText` imported from `ai` rather than the one in its argument. Destructure `streamText` from the `run` argument, or spread `chat.toStreamTextOptions()` into the imported one.
561
+ **Compaction and steering do nothing.** The `...chat.toStreamTextOptions()` spread is missing, or something before it in the object is overwriting `prepareStep`. Spread it as the first property.
564
562
 
565
563
  **`toModelOutput` works on the first turn, then stops.** Tools are declared only on `streamText`. Declare the same set on `chat.agent({ tools })` too, and read it back off the `run` payload.
566
564