@trigger.dev/sdk 4.5.12 → 4.5.14

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 (59) hide show
  1. package/dist/commonjs/v3/ai.d.ts +154 -20
  2. package/dist/commonjs/v3/ai.js +1241 -387
  3. package/dist/commonjs/v3/ai.js.map +1 -1
  4. package/dist/commonjs/v3/chat.d.ts +7 -2
  5. package/dist/commonjs/v3/chat.js +22 -7
  6. package/dist/commonjs/v3/chat.js.map +1 -1
  7. package/dist/commonjs/v3/chat.test.js +13 -4
  8. package/dist/commonjs/v3/chat.test.js.map +1 -1
  9. package/dist/commonjs/v3/envvars.js.map +1 -1
  10. package/dist/commonjs/v3/sessions.d.ts +4 -10
  11. package/dist/commonjs/v3/sessions.js +73 -47
  12. package/dist/commonjs/v3/sessions.js.map +1 -1
  13. package/dist/commonjs/v3/streams.js +1 -0
  14. package/dist/commonjs/v3/streams.js.map +1 -1
  15. package/dist/commonjs/v3/test/mock-chat-agent.js +1 -0
  16. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
  17. package/dist/commonjs/v3/test/test-session-handle.js +22 -23
  18. package/dist/commonjs/v3/test/test-session-handle.js.map +1 -1
  19. package/dist/commonjs/version.js +1 -1
  20. package/dist/esm/v3/ai.d.ts +154 -20
  21. package/dist/esm/v3/ai.js +1239 -387
  22. package/dist/esm/v3/ai.js.map +1 -1
  23. package/dist/esm/v3/chat.d.ts +7 -2
  24. package/dist/esm/v3/chat.js +22 -7
  25. package/dist/esm/v3/chat.js.map +1 -1
  26. package/dist/esm/v3/chat.test.js +13 -4
  27. package/dist/esm/v3/chat.test.js.map +1 -1
  28. package/dist/esm/v3/envvars.js.map +1 -1
  29. package/dist/esm/v3/sessions.d.ts +4 -10
  30. package/dist/esm/v3/sessions.js +73 -47
  31. package/dist/esm/v3/sessions.js.map +1 -1
  32. package/dist/esm/v3/streams.js +1 -0
  33. package/dist/esm/v3/streams.js.map +1 -1
  34. package/dist/esm/v3/test/mock-chat-agent.js +2 -1
  35. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
  36. package/dist/esm/v3/test/test-session-handle.js +23 -24
  37. package/dist/esm/v3/test/test-session-handle.js.map +1 -1
  38. package/dist/esm/version.js +1 -1
  39. package/docs/ai-chat/client-protocol.mdx +8 -3
  40. package/docs/ai-chat/custom-agents.mdx +181 -46
  41. package/docs/ai-chat/patterns/recovery-boot.mdx +9 -2
  42. package/docs/ai-chat/patterns/version-upgrades.mdx +26 -6
  43. package/docs/ai-chat/pending-messages.mdx +5 -3
  44. package/docs/ai-chat/reference.mdx +26 -10
  45. package/docs/ai-chat/types.mdx +5 -1
  46. package/docs/deployment/atomic-deployment.mdx +12 -0
  47. package/docs/deployment/overview.mdx +7 -1
  48. package/docs/deployment/version-skew-protection.mdx +430 -0
  49. package/docs/github-actions.mdx +33 -5
  50. package/docs/github-integration.mdx +12 -0
  51. package/docs/realtime/auth.mdx +18 -0
  52. package/docs/realtime/react-hooks/session-stream.mdx +109 -0
  53. package/docs/realtime/react-hooks/streams.mdx +71 -3
  54. package/docs/self-hosting/env/webapp.mdx +1 -0
  55. package/docs/self-hosting/security.mdx +1 -1
  56. package/docs/tasks/streams.mdx +3 -0
  57. package/docs/vercel-integration.mdx +43 -9
  58. package/docs/versioning.mdx +2 -0
  59. package/package.json +2 -2
@@ -19,61 +19,113 @@ Inside the wrapper, pick one of two loop styles:
19
19
  - **[Managed loop](#managed-loop-chatcreatesession)** — `chat.createSession()` yields turns; the SDK handles stop signals, accumulation, idle suspend/resume, and turn-complete signaling. You write the turn body.
20
20
  - **[Hand-rolled loop](#hand-rolled-loop-with-primitives)** — you write the loop itself with `chat.messages`, `MessageAccumulator`, `pipeAndCapture`, and `writeTurnComplete`. The right choice when you need complete control over `.toUIMessageStream()` (e.g. `onFinish`, `originalMessages`) beyond what `chat.setUIMessageStreamOptions()` provides, or you're implementing a custom protocol.
21
21
 
22
+ ### Validating client data
23
+
24
+ Use `chat.withClientData({ schema })` to validate `payload.metadata`. Custom agents parse the metadata on the initial payload and every later non-close input frame before passing it to `run`, `chat.messages`, or `chat.createSession`. Schema defaults and transforms are included in the value your code receives.
25
+
26
+ This only validates `metadata`. A raw custom agent does not expose an action schema, so `payload.action` remains `unknown`. Validate the full frame or action payload in your own loop when you need that boundary.
27
+
28
+ If validation fails for a submitted turn or an async read such as `wait()`, the SDK consumes and skips the invalid frame, writes an `Invalid client data` error followed by `turn-complete`, then waits for the next valid frame. The invalid value is not returned to the raw caller. The detailed validator error is available in the task log and `onClientDataValidationError`, but it is not sent to the client.
29
+
30
+ This convenience path settles the invalid input before the read returns. If your raw loop needs to coordinate validation with persistence or settlement, omit `withClientData({ schema })` and validate the full wire frame in the loop instead. A messageless preload or continuation boot has no submitted turn to complete, so the SDK reports the error through the task log and callback while it waits.
31
+
32
+ An invalid [head-start handover](/ai-chat/fast-starts#handover-with-custom-agents) boot fails closed. The SDK waits for the warm handler to finish so stream ordering stays intact. A handover skip ends the run. A real handover writes the validation error and `turn-complete` after the warm output, then ends the run. Without a schema, metadata is passed through unchanged.
33
+
34
+ `chat.messages.on()` is different because a subscribed frame can arrive while the current response is still streaming. Ending the turn at that point would cut off the response. While the subscription is active, the SDK skips an invalid frame, logs the validation error, and calls `onClientDataValidationError` if you set it. A raw subscription has no turn boundary the SDK can key a later write to, so it reports through the callback and the task log only and never writes to the stream.
35
+
36
+ The steering subscription created by `chat.createSession({ pendingMessages })` skips an invalid frame the same way, but the session does own the turn boundary, so it can write the client-visible error once the turn has closed. `reportErrorAt` governs that write and applies to steering frames only, not to `chat.messages.on()`.
37
+
38
+ By default the `Invalid client data` error for a steering frame is held until the turn ends, so a bad send cannot truncate an answer the user is already reading. Pass `reportErrorAt: "arrival"` to `withClientData` if you would rather surface it as soon as validation fails, accepting that it ends the response in progress:
39
+
40
+ ```ts
41
+ chat.withClientData({
42
+ schema: z.object({ userId: z.string() }),
43
+ reportErrorAt: "arrival",
44
+ onValidationError: ({ error, payload }) => logger.warn("bad client data", { error }),
45
+ });
46
+ ```
47
+
48
+ The validation callback and the task log fire on arrival in both modes, and the frame is never delivered as a turn either way.
49
+
50
+ Calling `off()` stops the subscription from accepting new frames. A valid frame accepted before `off()` still finishes validation and is delivered to the handler. An invalid frame that finishes validation after `off()` is logged without calling the handler or error callback.
51
+
52
+ ```ts
53
+ import { chat } from "@trigger.dev/sdk/ai";
54
+ import { z } from "zod";
55
+
56
+ export const myChat = chat
57
+ .withClientData({ schema: z.object({ userId: z.string() }) })
58
+ .customAgent({
59
+ id: "my-chat",
60
+ onClientDataValidationError: ({ error, payload }) => {
61
+ console.warn("Invalid client data", { error, trigger: payload.trigger });
62
+ },
63
+ run: async (payload) => {
64
+ // ...
65
+ },
66
+ });
67
+ ```
68
+
69
+ `chat.messages.peek()` validates synchronously and throws validation errors to the caller. If your schema only supports asynchronous parsing, use `once()`, `wait()`, or `waitWithIdleTimeout()` instead.
70
+
22
71
  ## Managed loop: chat.createSession()
23
72
 
24
73
  `chat.createSession()` gives you an async iterator of `ChatTurn` objects. Each turn arrives with the accumulated history, a combined stop+cancel signal, and helpers to finish the turn:
25
74
 
26
75
  ```ts trigger/my-chat.ts
27
- import { chat, type ChatTaskWirePayload } from "@trigger.dev/sdk/ai";
76
+ import { chat } from "@trigger.dev/sdk/ai";
28
77
  import { streamText, stepCountIs } from "ai";
29
78
  import { anthropic } from "@ai-sdk/anthropic";
30
-
31
- export const myChat = chat.customAgent({
32
- id: "my-chat",
33
- run: async (payload: ChatTaskWirePayload, { signal }) => {
34
- // One-time initialization — plain code, no hooks. Upsert, not create:
35
- // continuation runs boot with the row already in place.
36
- const clientData = payload.metadata as { userId: string };
37
- await db.chat.upsert({
38
- where: { id: payload.chatId },
39
- create: { id: payload.chatId, userId: clientData.userId },
40
- update: {},
41
- });
42
-
43
- const session = chat.createSession(payload, {
44
- signal,
45
- idleTimeoutInSeconds: 60,
46
- timeout: "1h",
47
- });
48
-
49
- for await (const turn of session) {
50
- // Persist the incoming user message BEFORE streaming — this is your
51
- // onTurnStart equivalent. Without it, a page reload mid-stream
52
- // restores the assistant text (replayed from the session) but loses
53
- // the user message that prompted it.
54
- await db.chat.update({
55
- where: { id: turn.chatId },
56
- data: { messages: turn.uiMessages },
79
+ import { z } from "zod";
80
+
81
+ export const myChat = chat
82
+ .withClientData({ schema: z.object({ userId: z.string() }) })
83
+ .customAgent({
84
+ id: "my-chat",
85
+ run: async (payload, { signal }) => {
86
+ // One-time initialization — plain code, no hooks. Upsert, not create:
87
+ // continuation runs boot with the row already in place.
88
+ const clientData = payload.metadata!;
89
+ await db.chat.upsert({
90
+ where: { id: payload.chatId },
91
+ create: { id: payload.chatId, userId: clientData.userId },
92
+ update: {},
57
93
  });
58
94
 
59
- const result = streamText({
60
- model: anthropic("claude-sonnet-4-5"),
61
- messages: turn.messages,
62
- abortSignal: turn.signal,
63
- stopWhen: stepCountIs(15),
95
+ const session = chat.createSession(payload, {
96
+ signal,
97
+ idleTimeoutInSeconds: 60,
98
+ timeout: "1h",
64
99
  });
65
100
 
66
- // Pipe, capture, accumulate, and signal turn-complete all in one call
67
- await turn.complete(result);
68
-
69
- // Persist the full exchange after the turn your onTurnComplete equivalent
70
- await db.chat.update({
71
- where: { id: turn.chatId },
72
- data: { messages: turn.uiMessages },
73
- });
74
- }
75
- },
76
- });
101
+ for await (const turn of session) {
102
+ // Persist the incoming user message BEFORE streaming — this is your
103
+ // onTurnStart equivalent. Without it, a page reload mid-stream
104
+ // restores the assistant text (replayed from the session) but loses
105
+ // the user message that prompted it.
106
+ await db.chat.update({
107
+ where: { id: turn.chatId },
108
+ data: { messages: turn.uiMessages },
109
+ });
110
+
111
+ const result = streamText({
112
+ model: anthropic("claude-sonnet-4-5"),
113
+ messages: turn.messages,
114
+ abortSignal: turn.signal,
115
+ stopWhen: stepCountIs(15),
116
+ });
117
+
118
+ // Pipe, capture, accumulate, and signal turn-complete — all in one call
119
+ await turn.complete(result);
120
+
121
+ // Persist the full exchange after the turn — your onTurnComplete equivalent
122
+ await db.chat.update({
123
+ where: { id: turn.chatId },
124
+ data: { messages: turn.uiMessages },
125
+ });
126
+ }
127
+ },
128
+ });
77
129
  ```
78
130
 
79
131
  <Warning>
@@ -102,7 +154,7 @@ Each turn yielded by the iterator provides:
102
154
  | `number` | `number` | Turn number (0-indexed) |
103
155
  | `chatId` | `string` | Chat session ID |
104
156
  | `trigger` | `string` | What triggered this turn |
105
- | `clientData` | `unknown` | Client data from the transport |
157
+ | `clientData` | Schema output or `unknown` | Parsed client data when `withClientData` is configured |
106
158
  | `messages` | `ModelMessage[]` | Full accumulated model messages — pass to `streamText` |
107
159
  | `uiMessages` | `UIMessage[]` | Full accumulated UI messages — use for persistence |
108
160
  | `signal` | `AbortSignal` | Combined stop+cancel signal (fresh each turn) |
@@ -144,6 +196,31 @@ for await (const turn of session) {
144
196
 
145
197
  Without this, a resumed chat silently loses its history: the model sees only the message that triggered the continuation. In a hand-rolled loop, seed by passing the stored history into the turn-0 `addIncoming` call — shown in the example below.
146
198
 
199
+ ### Rotating to a new deployment
200
+
201
+ With `chat.createSession()`, use `chat.requestUpgrade()` and let the iterator exit normally. For an immediate handoff, close the iterator before calling `chat.endAndContinue()`; the method rejects until the iterator and any active `next()` call have settled. In a fully hand-rolled custom agent, call it directly to hand the Session to a fresh run.
202
+
203
+ Close the iterator between reads. If `return()` races a `next()` that is already waiting for input, it waits for that read to settle before releasing the handoff guard. Input dispatched while the iterator is closing is not yielded as a turn and remains available to the continuation unless you write another turn-complete boundary.
204
+
205
+ Call it between turns, after detaching the old run's input listeners. If the old run completed its current turn, persist its state and write the turn-complete boundary before the handoff:
206
+
207
+ ```ts
208
+ // Detach any chat.messages.on() subscriptions you created.
209
+ stop.cleanup();
210
+ await persistMessages(conversation.uiMessages);
211
+ await chat.writeTurnComplete();
212
+ await chat.endAndContinue();
213
+ return;
214
+ ```
215
+
216
+ The server starts a continuation run using the Session's existing trigger configuration and atomically makes it the current run. The Session and its streams stay open, so input that the old run has not consumed remains on `.in` for the continuation run. The new run uses the latest deployed task version unless the Session's trigger configuration sets `lockToVersion`.
217
+
218
+ If input has been dispatched to the old run but should be processed by the continuation, detach the listeners and do not write another turn-complete boundary before handing off. `chat.writeTurnComplete()` acknowledges the latest input dispatched to the old run; writing it after that dispatch would make the continuation resume after the input.
219
+
220
+ <Warning>
221
+ `chat.endAndContinue()` starts the new run but does not stop the caller. Await it and return from `run()` immediately; continuing to read or write can race the new run on the same Session. If the handoff fails, the promise rejects.
222
+ </Warning>
223
+
147
224
  ### turn.complete() vs manual control
148
225
 
149
226
  `turn.complete(result)` is the one-call path — it handles piping, capturing the response, accumulating messages, cleaning up aborted parts on a stop, and writing the turn-complete chunk.
@@ -179,6 +256,12 @@ for await (const turn of session) {
179
256
 
180
257
  The frontend stops a turn with [`transport.stopGeneration(chatId)`](/ai-chat/frontend#stop-generation), which writes a stop signal to the session's input stream. It aborts the current turn's generation but keeps the run alive, so the next message continues on the same session.
181
258
 
259
+ A stop only applies to the turn that was live when it arrived. If the run crashes
260
+ and a later run recovers a message that had not been answered yet, a stop that
261
+ was already applied before the crash is not applied again, so the turn answering
262
+ the recovered message runs to completion. A stop sent after the recovery is live
263
+ and aborts that turn as normal.
264
+
182
265
  `turn.signal` is a combined stop-and-cancel `AbortSignal`, fresh each turn. Pass it to `streamText` so the stop reaches the model, then let `turn.complete()` finish the turn:
183
266
 
184
267
  ```ts trigger/my-chat.ts
@@ -213,14 +296,66 @@ For full control, skip `createSession` and compose the primitives directly:
213
296
 
214
297
  | Primitive | Description |
215
298
  | ------------------------------- | -------------------------------------------------------------------------------------------- |
216
- | `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` to wait for the next turn |
299
+ | `chat.messages` | Mailbox for incoming messages — inspect buffered input, consume one record, or suspend until the next turn |
217
300
  | `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream |
218
301
  | `chat.pipeAndCapture(result)` | Pipe a stream and capture the response; returns `{ message, status, error }` |
219
302
  | `chat.writeTurnComplete()` | Signal turn complete; returns `{ lastEventId, sessionInEventId }` resume cursors |
303
+ | `chat.endAndContinue()` | Hand off the Session to a continuation run; call between turns, then return |
220
304
  | `chat.MessageAccumulator` | Accumulates conversation messages across turns |
221
305
  | `chat.pipe(stream)` | Pipe a stream to the frontend (no response capture) |
222
306
  | `chat.cleanupAbortedParts(msg)` | Clean up incomplete parts from a stopped response |
223
307
 
308
+ ### `chat.messages` mailbox
309
+
310
+ `chat.messages` exposes the incoming message mailbox for hand-rolled loops:
311
+
312
+ | Method | Behavior |
313
+ | --- | --- |
314
+ | `peek()` | Return the next queued message without consuming it, or `undefined` when none is queued |
315
+ | `hasPending()` | Resolve `true` when a message is queued; does not consume it |
316
+ | `next({ timeoutInSeconds? })` | Consume exactly one message record in channel order, or resolve `undefined` when the optional timeout elapses |
317
+ | `on(handler)` | Consume messages as they arrive and invoke the handler |
318
+ | `waitWithIdleTimeout(options)` | Wait warm, then suspend the run until the next message arrives |
319
+
320
+ `hasPending()` checks whether a message has already been delivered locally and is
321
+ waiting for `next()` to take it. It does not query the remote Session channel or
322
+ start a subscription. Use `waitWithIdleTimeout()` when the loop needs to idle
323
+ until future input arrives.
324
+
325
+ `next({ timeoutInSeconds: 0 })` is also a local, non-blocking read. Call
326
+ `next()` without a timeout, or with a positive timeout, to subscribe for future
327
+ input.
328
+
329
+ `next()` returns a readonly record envelope:
330
+
331
+ ```ts
332
+ const record = await chat.messages.next({ timeoutInSeconds: 5 });
333
+ if (record) {
334
+ console.log(record.id, record.seqNum);
335
+ currentPayload = record.payload;
336
+ }
337
+ ```
338
+
339
+ - `id` is the append's stable idempotency key.
340
+ - `seqNum` is the monotonic sequence on this Session's `.in` channel.
341
+ - `payload` is the existing `ChatTaskWirePayload` delivered by the other mailbox methods.
342
+
343
+ Both identifiers remain the same if the record is delivered again after a
344
+ reconnect. Each `next()` call commits only the record it returns, so a loop that
345
+ owns its own turn sequencing never advances past input it has not taken. By
346
+ contrast, `on()` commits a record as soon as it dispatches the handler; avoid
347
+ mixing `on()` and `next()` when a single loop owns mailbox consumption.
348
+
349
+ The Session `.in` channel also carries control records such as stops and
350
+ handovers. Those are routed to their own consumers and never block messages: a
351
+ message that arrived behind one is still reported by `hasPending()` and still
352
+ returned by `next()`, in channel order. The same holds for a record kind this
353
+ version of the SDK does not recognise, which is discarded rather than left where
354
+ it would make every message behind it undeliverable.
355
+
356
+ `next()` returns `undefined` when no message became consumable before the
357
+ timeout.
358
+
224
359
  A complete loop:
225
360
 
226
361
  ```ts trigger/my-chat-raw.ts
@@ -32,7 +32,7 @@ On a continuation boot, the runtime reads:
32
32
  - **`session.out` tail past the snapshot cursor** — closed assistant turns plus, optionally, a `partialAssistant` (the trailing message whose stream never received a `finish` chunk). `cleanupAbortedParts` has already stripped streaming-in-progress fragments.
33
33
  - **`session.in` tail past the last `turn-complete` cursor** — user messages the dead run hadn't acknowledged.
34
34
 
35
- If both `partialAssistant` and `inFlightUsers` are non-empty, the runtime splices `[firstInFlightUser, partialAssistant]` onto the chain. The remaining in-flight users dispatch as fresh turns. The model sees:
35
+ If there's a `partialAssistant` and two or more `inFlightUsers`, the runtime splices `[firstInFlightUser, partialAssistant]` onto the chain. The remaining in-flight users dispatch as fresh turns. The model sees:
36
36
 
37
37
  ```
38
38
  [ ...settledMessages, // chain through the last completed turn
@@ -138,10 +138,17 @@ type RecoveryBootResult<TUIM extends UIMessage = UIMessage> = {
138
138
  };
139
139
  ```
140
140
 
141
- - **`chain`** — replaces the seed chain. Defaults to `[...settledMessages, firstInFlightUser, partialAssistant]` when both partial and in-flight users exist, otherwise `settledMessages` alone.
141
+ - **`chain`** — replaces the seed chain. Defaults to `[...settledMessages, firstInFlightUser, partialAssistant]` when there's a partial **and two or more** in-flight users, otherwise `settledMessages` alone.
142
142
  - **`recoveredTurns`** — user messages to dispatch as fresh turns after the chain is restored. Defaults to `inFlightUsers.slice(1)` when the smart default consumed the first user, otherwise `inFlightUsers`.
143
143
  - **`beforeBoot`** — runs after the writer flushes and before the first recovered turn fires. Use for blocking persistence (write the partial to your DB so a later turn can reference it). Errors bubble — wrap your own try/catch if you want to soft-fail.
144
144
 
145
+ <Note>
146
+ The splice needs a follow-up user to answer, so it only applies with two or
147
+ more in-flight users. With exactly one — the plain OOM or crash-mid-answer
148
+ case — the orphan partial is dropped and that single user is re-dispatched as
149
+ a fresh turn, so the interrupted question still gets answered.
150
+ </Note>
151
+
145
152
  ## Examples
146
153
 
147
154
  ### Drop the partial — strict "cancel means discard"
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  title: "Version upgrades"
3
3
  sidebarTitle: "Version upgrades"
4
- description: "Gracefully migrate suspended chat agents to a new deployment using chat.requestUpgrade() and the continuation mechanism."
4
+ description: "Gracefully migrate chat agents to a new deployment using chat.requestUpgrade(), chat.endAndContinue(), and the continuation mechanism."
5
5
  ---
6
6
 
7
7
  Chat agent runs are pinned to the worker version they started on. When you deploy a new version, suspended runs resume on the **old** code. If your deploy includes breaking changes (new tools, changed schemas, updated API contracts), this can cause issues.
8
8
 
9
- `chat.requestUpgrade()` lets the agent opt out of the current run so the transport triggers a new one on the latest version.
9
+ `chat.requestUpgrade()` is the managed upgrade signal for `chat.agent()` and the `chat.createSession()` iterator. Fully hand-rolled custom agents use `chat.endAndContinue()` between turns to immediately hand the Session to a new run.
10
10
 
11
11
  ## How it works
12
12
 
@@ -151,14 +151,34 @@ export const myChat = chat
151
151
 
152
152
  This upgrades on **every** deploy, not just breaking changes. Good for fast-moving projects where you always want the latest code.
153
153
 
154
- ## Other agent types
154
+ ## Custom agents
155
155
 
156
- - **`chat.agent()`** and **`chat.createSession()`** use `chat.requestUpgrade()` as shown above
157
- - **`chat.customAgent()`** — you control the turn loop, so just `return` from `run()` when you want to exit
156
+ Use `chat.requestUpgrade()` with `chat.agent()`. With `chat.createSession()`, call `chat.requestUpgrade()`, then advance the iterator once more so it can exit normally. For an immediate handoff, close the iterator before calling `chat.endAndContinue()`. In a fully hand-rolled `chat.customAgent()` task, detach input listeners, persist the completed turn, write its boundary, then call `chat.endAndContinue()` and return immediately:
157
+
158
+ Close a `chat.createSession()` iterator between reads. If `return()` races a `next()` that is already waiting for input, it waits for that read to settle before the handoff can continue. Input dispatched while the iterator is closing is not yielded as a turn and remains available to the continuation unless you write another turn-complete boundary.
159
+
160
+ ```ts
161
+ // Detach any chat.messages.on() subscriptions you created.
162
+ stop.cleanup();
163
+ await persistMessages(conversation.uiMessages);
164
+ await chat.writeTurnComplete();
165
+ await chat.endAndContinue();
166
+ return;
167
+ ```
168
+
169
+ The continuation uses the same durable Session and receives `.in` records that the old run has not consumed. It starts on the latest deployed task version unless the Session's trigger configuration sets `lockToVersion`.
170
+
171
+ If input has been dispatched to the old run but should be processed by the continuation, detach the old listeners and skip the final `chat.writeTurnComplete()`. A turn-complete boundary acknowledges the latest input dispatched to the old run, so writing one after that dispatch would cause the continuation to resume past the input.
172
+
173
+ <Warning>
174
+ `chat.endAndContinue()` starts the successor but does not stop the calling run. Perform no more Session reads or writes after calling it, and return from the task. If the handoff fails, the promise rejects.
175
+ </Warning>
158
176
 
159
177
  ## Interaction with recovery boot
160
178
 
161
- `chat.requestUpgrade()` is a graceful exit the old run returns cleanly, never writing a partial assistant. The new continuation run boots with an empty `session.out` tail and the upgrade-trigger message on `session.in`. The trigger message dispatches as turn 1 on the new version via the normal continuation-wait path. [`onRecoveryBoot`](/ai-chat/patterns/recovery-boot) does NOT fire on this path — the hook is reserved for mid-stream interruptions (cancel / crash / OOM) where a partial assistant exists on the tail.
179
+ When `chat.requestUpgrade()` is handled before a turn starts, the SDK immediately hands the Session to a new run, which processes the same input on the latest version. When it is requested during a turn, including through `chat.createSession()`, the current turn finishes and the old run exits; the next input starts the continuation run.
180
+
181
+ Both are graceful exits. [`onRecoveryBoot`](/ai-chat/patterns/recovery-boot) does not fire — the hook is reserved for mid-stream interruptions (cancel, crash, or OOM) where a partial assistant exists on the tail.
162
182
 
163
183
  ## See also
164
184
 
@@ -10,7 +10,9 @@ When an AI agent is executing tool calls, users may want to send a message that
10
10
 
11
11
  By default (without `pendingMessages`), a message sent while the agent is responding never interrupts the in-flight response: it's buffered and processed as its own turn once the current turn completes, with multiple messages running sequentially in arrival order.
12
12
 
13
- The `pendingMessages` option enables steering instead, injecting user messages between tool-call steps via the AI SDK's `prepareStep`. Messages that arrive during streaming are queued and injected at the next step boundary. If there are no more step boundaries (single-step response or final text generation), the message becomes the next turn automatically.
13
+ The `pendingMessages` option enables steering instead, injecting user messages between tool-call steps via the AI SDK's `prepareStep`. Messages that arrive during streaming are queued and injected at the next step boundary. A message that is not injected becomes the next turn instead, whether that is because `shouldInject` returned `false` or because there were no more step boundaries (single-step response or final text generation). The backend handles that, so no client-side re-send is involved.
14
+
15
+ Injection is what needs wiring: the `pendingMessages` options only reach `streamText` if you spread `chat.toStreamTextOptions()` (or pass `prepareStep`). Without that, nothing injects, so every mid-turn message is answered as the next turn. Deferral does not depend on it.
14
16
 
15
17
  ## How it works
16
18
 
@@ -20,7 +22,7 @@ The `pendingMessages` option enables steering instead, injecting user messages b
20
22
  4. At the next `prepareStep` boundary (between tool-call steps), `shouldInject` is called
21
23
  5. If it returns `true`, the message is injected into the LLM's context
22
24
  6. A `data-pending-message-injected` stream chunk confirms injection to the frontend
23
- 7. If `prepareStep` never fires (no tool calls), the message becomes the next turn
25
+ 7. If `shouldInject` returns `false`, or `prepareStep` never fires (no tool calls), the message stays queued on the backend and is answered as the next turn
24
26
 
25
27
  ## Backend: chat.agent
26
28
 
@@ -310,7 +312,7 @@ function Chat({ chatId }: { chatId: string }) {
310
312
 
311
313
  ### Message lifecycle
312
314
 
313
- - **Steering messages** are sent via `transport.sendPendingMessage()` immediately. They appear as purple pending bubbles. If injected, they disappear from the overlay and render inline at the injection point. If not injected (no more step boundaries), they auto-send as the next turn when the response finishes.
315
+ - **Steering messages** are sent via `transport.sendPendingMessage()` immediately. They appear as purple pending bubbles. If injected, they disappear from the overlay and render inline at the injection point. If not injected, the backend answers them as the next turn once the response finishes; the client does not need to re-send them.
314
316
 
315
317
  - **Queued messages** stay client-side until the turn completes, then auto-send as the next turn via `sendMessage()`. They can be promoted to steering mid-stream by clicking "Steer instead".
316
318
 
@@ -396,7 +396,7 @@ Options for the `pendingMessages` field. See [Pending Messages](/ai-chat/pending
396
396
 
397
397
  | Option | Type | Required | Description |
398
398
  | -------------- | --------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------- |
399
- | `shouldInject` | `(event: PendingMessagesBatchEvent) => boolean \| Promise<boolean>` | No | Decide whether to inject the batch between tool-call steps. If absent, no injection. |
399
+ | `shouldInject` | `(event: PendingMessagesBatchEvent) => boolean \| Promise<boolean>` | No | Decide whether to inject the batch between tool-call steps. If absent, nothing is injected and the messages are answered as the next turn. Only consulted when `chat.toStreamTextOptions()` (or `prepareStep`) reaches `streamText`; without that nothing is injected and every mid-turn message becomes the next turn. |
400
400
  | `prepare` | `(event: PendingMessagesBatchEvent) => ModelMessage[] \| Promise<ModelMessage[]>` | No | Transform the batch before injection. Default: convert each via `convertToModelMessages`. |
401
401
  | `onReceived` | `(event: PendingMessageReceivedEvent) => void \| Promise<void>` | No | Called when a message arrives during streaming (per-message). |
402
402
  | `onInjected` | `(event: PendingMessagesInjectedEvent) => void \| Promise<void>` | No | Called after a batch is injected via prepareStep. |
@@ -504,13 +504,14 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`.
504
504
  | `chat.createSession(payload, options)` | Create an async iterator for chat turns |
505
505
  | `chat.pipe(source, options?)` | Pipe a stream to the frontend (from anywhere inside a task) |
506
506
  | `chat.pipeAndCapture(source, options?)` | Pipe and capture the response; returns `{ message, status, error }` |
507
- | `chat.writeTurnComplete(options?)` | Signal turn complete; returns `{ lastEventId, sessionInEventId }` resume cursors |
507
+ | `chat.writeTurnComplete(options?)` | Signal turn complete; returns `{ lastEventId, sessionInEventId }` resume cursors. `sessionInEventId` is a lower bound, not the sequence of the record the turn answered |
508
508
  | `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream |
509
- | `chat.messages` | Input stream for incoming messages use `.waitWithIdleTimeout()` |
509
+ | `chat.messages` | Incoming message mailbox; supports non-consuming `.peek()` / `.hasPending()`, single-record `.next()`, `.on()`, and suspend-aware `.waitWithIdleTimeout()` |
510
510
  | `chat.local<T>({ id })` | Create a per-run typed local (see [`chat.local`](/ai-chat/chat-local)) |
511
511
  | `chat.createStartSessionAction(taskId, options?)` | Returns a server action that creates a chat Session + triggers the first run + returns a session-scoped PAT. Idempotent on `(env, externalId)`. |
512
512
  | `chat.waitForHandover(options)` | Wait for a [`chat.headStart`](/ai-chat/fast-starts#handover-with-custom-agents) handover signal in a custom loop. Returns the signal or `null`. `chat.MessageAccumulator` wraps this as `consumeHandover()` / `applyHandover()` |
513
513
  | `chat.requestUpgrade()` | End the current run after this turn so the next message starts on the latest agent version. Server-orchestrated handoff. |
514
+ | `chat.endAndContinue()` | In a hand-rolled custom agent, hand off the Session to a fresh continuation run. Call between turns after detaching input listeners, then return immediately. The promise rejects if the handoff fails. |
514
515
  | `chat.setTurnTimeout(duration)` | Override turn timeout at runtime (e.g. `"2h"`) |
515
516
  | `chat.setTurnTimeoutInSeconds(seconds)` | Override turn timeout at runtime (in seconds) |
516
517
  | `chat.setIdleTimeoutInSeconds(seconds)` | Override idle timeout at runtime |
@@ -546,15 +547,30 @@ Use this when you need [`InferChatUIMessage`](#inferchatuimessage) / typed `data
546
547
 
547
548
  ## `chat.withClientData`
548
549
 
549
- Returns a [`ChatBuilder`](/ai-chat/types#chatbuilder) with a fixed client data schema. All hooks and `run` get typed `clientData` without passing `clientDataSchema` in `.agent()` options.
550
+ Returns a [`ChatBuilder`](/ai-chat/types#chatbuilder) with a fixed client data schema. Managed-agent hooks and `run` get typed `clientData` without passing `clientDataSchema` in `.agent()` options. Custom agents parse `payload.metadata` on the initial payload and later input frames before passing it to user code.
550
551
 
551
552
  ```ts
552
- chat.withClientData<TSchema>({ schema: TSchema }): ChatBuilder<UIMessage, TSchema>;
553
+ chat.withClientData<TSchema extends TaskSchema>(config: {
554
+ schema: TSchema;
555
+ reportErrorAt?: "turn-end" | "arrival";
556
+ onValidationError?: (event: {
557
+ error: unknown;
558
+ payload: ChatTaskWirePayload;
559
+ }) => Promise<void> | void;
560
+ }): ChatBuilder<UIMessage, TSchema>;
553
561
  ```
554
562
 
555
- | Parameter | Type | Description |
556
- | --------- | ------------ | -------------------------------------------------- |
557
- | `schema` | `TaskSchema` | Zod, ArkType, Valibot, or any supported schema lib |
563
+ | Parameter | Type | Default | Description |
564
+ | ------------------- | --------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------ |
565
+ | `schema` | `TaskSchema` | required | Zod, ArkType, Valibot, or any supported schema lib |
566
+ | `reportErrorAt` | `"turn-end" \| "arrival"` | `"turn-end"` | When the client-visible `Invalid client data` error is written for a steering frame that failed validation mid-turn |
567
+ | `onValidationError` | `(event) => void` | — | Called when an input fails validation. Composes with the task-level `onClientDataValidationError` rather than replacing it |
568
+
569
+ `reportErrorAt` governs only the stream-visible error, and only for frames arriving on the steering subscription created by `chat.createSession({ pendingMessages })`. `"turn-end"` holds it until the turn closes, so a bad send cannot truncate an answer the user is already reading; `"arrival"` writes it as soon as validation fails, ending the response in progress. `onValidationError` and the task log fire on arrival in both modes, and the frame is never delivered as a turn either way. A raw `chat.messages.on()` subscription has no turn boundary to defer to, so it always reports through the callback and the task log without writing to the stream.
570
+
571
+ For `chat.customAgent()`, invalid client data is skipped. Async reads emit an error chunk followed by `turn-complete`. A `chat.messages.on()` subscription uses the task's `onClientDataValidationError` callback and task log instead, so an active response is not ended early. Without a schema, metadata is passed through unchanged.
572
+
573
+ Passing options directly to `chat.customAgent()` instead of through the builder uses the flat equivalents: `clientDataSchema`, `clientDataReportErrorAt`, and `onClientDataValidationError`.
558
574
 
559
575
  Full guide: [Typed client data](/ai-chat/types#typed-client-data-with-chatwithclientdata).
560
576
 
@@ -645,7 +661,7 @@ The `onEvent` callback receives a `ChatTransportEvent` (exported from `@trigger.
645
661
  | `message-send-failed` | `messageId?`, `source`, `error`, `status?`, `durationMs`, `partId?`, `bodyBytes?` | A send definitively failed after internal retries. Fires in addition to `useChat`'s `onError`. |
646
662
  | `stream-connected` | `resumed`, `lastEventId?`, `messageId?` | The SSE subscription to the session's output stream started delivering. `resumed: true` when reconnecting from a stored cursor (page reload) rather than following a fresh send. `lastEventId` is the cursor it connected from. |
647
663
  | `first-chunk` | `chunkType?`, `lastEventId?`, `messageId?`, `sinceSendMs?` | The first response chunk of a turn arrived. `sinceSendMs` is the delta from the last turn-producing send — time to first token without any bookkeeping. |
648
- | `turn-completed` | `lastEventId?`, `sessionInEventId?`, `messageId?`, `sinceSendMs?` | The agent's turn-complete control record arrived — the "finished answering" signal. `sinceSendMs` is the full turn latency; `sessionInEventId` is the agent's committed input-stream cursor. |
664
+ | `turn-completed` | `lastEventId?`, `sessionInEventId?`, `messageId?`, `sinceSendMs?` | The agent's turn-complete control record arrived — the "finished answering" signal. `sinceSendMs` is the full turn latency; `sessionInEventId` is the cursor the agent can safely resume its input stream from. Treat it as a lower bound: it is held back behind any message still waiting to be handled, so it can be below the sequence of the record this turn answered. Do not use it to decide whether a turn boundary belongs to your own send. |
649
665
  | `stream-error` | `error`, `status?` | The output stream failed unrecoverably. |
650
666
 
651
667
  `source` identifies the send path: `"submit-message"`, `"regenerate-message"`, `"steer"` (`sendPendingMessage`), `"action"` (`sendAction`), `"stop"` (`stopGeneration`), or `"head-start"`.
@@ -784,7 +800,7 @@ Send a custom action to the agent. Actions wake the agent from suspension and fi
784
800
  transport.sendAction(chatId: string, action: unknown): Promise<ReadableStream<UIMessageChunk>>
785
801
  ```
786
802
 
787
- The action payload is validated against the agent's `actionSchema` on the backend.
803
+ For managed `chat.agent()` tasks, the action payload is validated against the agent's `actionSchema` on the backend. Raw `chat.customAgent()` tasks receive it as `unknown` and must validate it themselves.
788
804
 
789
805
  ```tsx
790
806
  // Undo button
@@ -140,7 +140,7 @@ You can also import `InferChatUIMessage` from `@trigger.dev/sdk/ai` in non-React
140
140
 
141
141
  ## Typed client data with `chat.withClientData`
142
142
 
143
- `chat.withClientData({ schema })` returns a [ChatBuilder](#chatbuilder) that fixes the client data schema. All hooks and `run` receive typed `clientData` without needing `clientDataSchema` in `.agent()` options.
143
+ `chat.withClientData({ schema })` returns a [ChatBuilder](#chatbuilder) that fixes the client data schema. Managed-agent hooks and `run` receive typed `clientData` without needing `clientDataSchema` in `.agent()` options. A `.customAgent()` run receives the parsed schema output in `payload.metadata`, and `chat.createSession()` yields it as `turn.clientData`.
144
144
 
145
145
  ```ts
146
146
  import { chat } from "@trigger.dev/sdk/ai";
@@ -167,6 +167,10 @@ export const myChat = chat
167
167
  });
168
168
  ```
169
169
 
170
+ The schema runs at runtime for both `.agent()` and `.customAgent()`. Custom agents validate the initial payload and later `chat.messages` frames. Invalid frames are not passed to user code. Async reads emit an error chunk followed by `turn-complete`; `chat.messages.on()` reports through `onClientDataValidationError` and the task log so it does not end an active response. Without a schema, metadata is passed through unchanged.
171
+
172
+ `withClientData` also takes `reportErrorAt` and `onValidationError` alongside `schema`. See [chat.withClientData](/ai-chat/reference#chatwithclientdata) for both, and [Validating client data](/ai-chat/custom-agents#validating-client-data) for the custom-agent walkthrough.
173
+
170
174
  ## ChatBuilder
171
175
 
172
176
  Both `chat.withUIMessage()` and `chat.withClientData()` return a **ChatBuilder** — a chainable object that accumulates configuration before creating the agent with `.agent()`.
@@ -4,6 +4,18 @@ sidebarTitle: "Atomic deploys"
4
4
  description: "Use atomic deploys to coordinate changes to your tasks and your application."
5
5
  ---
6
6
 
7
+ <Warning>
8
+ **There's now a simpler way to do this.** [Version skew
9
+ protection](/deployment/version-skew-protection) solves the same problem without a second
10
+ deployment, without gating your app's deploy, and without setting `TRIGGER_VERSION` — and it covers
11
+ staging and preview as well as production. If you use the [Vercel
12
+ integration](/vercel-integration), its **automatic atomic deployments** setting is now deprecated
13
+ in favour of skew protection.
14
+
15
+ The manual workflows on this page still work, and remain the right answer if you specifically want
16
+ your application's deployment held back until your tasks have finished building.
17
+ </Warning>
18
+
7
19
  Atomic deploys in Trigger.dev allow you to synchronize the deployment of your application with a specific version of your tasks. This ensures that your application always uses the correct version of its associated tasks, preventing inconsistencies or errors due to version mismatches.
8
20
 
9
21
  ## How it works
@@ -122,6 +122,12 @@ If you want to set a global version to run all tasks against, you can use the `T
122
122
  TRIGGER_VERSION=20250228.1
123
123
  ```
124
124
 
125
+ <Tip>
126
+ If what you actually want is for each release of your app to run against the tasks built from the
127
+ same commit, you don't need to plumb version numbers around by hand. See [version skew
128
+ protection](/deployment/version-skew-protection).
129
+ </Tip>
130
+
125
131
  ### Child tasks and auto-version locking
126
132
 
127
133
  Trigger and wait functions version lock child task runs to the parent task run version. This ensures the results from child runs match what the parent task is expecting. If you don't wait then version locking doesn't apply.
@@ -155,7 +161,7 @@ Or from the dashboard:
155
161
 
156
162
  ![Trigger.dev dashboard showing the promote button](/deployment/promote-button.png)
157
163
 
158
- To learn more about skipping promotion and how this enables atomic deployments, see our [Atomic deployment](/deployment/atomic-deployment) guide.
164
+ To learn more about skipping promotion and how this enables atomic deployments, see our [Atomic deployment](/deployment/atomic-deployment) guide. To keep your app and tasks in sync without coordinating promotion at all, see [version skew protection](/deployment/version-skew-protection).
159
165
 
160
166
  ## Staging deploys
161
167