You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(chat): hand run() a streamText that already has the managed options
Spreading chat.toStreamTextOptions() is the integration point for six things:
the managed prompt and its cache control, the resolved model, the prompt's
sampling config, telemetry, the skill tools, and the prepareStep that delivers
steering, compaction and injected context. Forgetting the spread drops all six
in silence, and spread order decides whether passing your own tools or
prepareStep clobbers the managed ones.
run() now receives a streamText with those options applied, so the managed
state cannot be lost by omission and the merge happens inside rather than at
the call site: tools go into the helper so skills survive, a caller system
becomes the base the prompt and injections append to, and a caller prepareStep
composes after the managed one instead of replacing it.
The signature is borrowed with typeof import("ai").streamText rather than
restated, so it resolves to whichever of ai v5/v6/v7 the user installed. The
runtime value rides the existing ESM/CJS shim that already isolates value
imports from ai.
PROTOTYPE. Typechecks and passes the suite on ai@6.0.116 and ai@7.0.66, but
adds a public registry option, does not settle what happens when caller and
managed system are both structured, and has no test for the composed
prepareStep.
`run()` now receives a `streamText` with your agent's managed options already applied, so they cannot be lost by leaving out the spread:
6
+
7
+
```ts
8
+
run: async ({ messages, signal, streamText }) =>
9
+
streamText({ model, messages, abortSignal: signal });
10
+
```
11
+
12
+
Spreading `chat.toStreamTextOptions()` still works and is equivalent. The difference is what happens when your options collide with the managed ones. Passing `tools` after the spread replaces the skill tools, and passing your own `prepareStep` replaces the managed one, which silently switches off steering, compaction and injected context. The managed `streamText` merges tools and composes `prepareStep` instead, so neither can be turned off by accident.
13
+
14
+
`system` can be set at the call site, on `chat.agent({ system })`, or through `chat.prompt.set()`, but only in one of them: setting it in two places throws, because no single shape merges two system values across every supported AI SDK version, and dropping one silently is the failure this seam exists to prevent. Injected instructions append to whichever one is in play.
15
+
16
+
`chat.agent()` also takes `registry`, `cacheControl` and `systemProviderOptions` now, so a managed prompt's model and its cache breakpoint no longer have to be passed at the call site.
17
+
18
+
`onAction` receives the same `streamText`, so a response produced from an action, a regenerate especially, answers with the agent's own system prompt and tools. Built with the `streamText` imported from `ai` it answered with none, and the reply still looked fine, which is what made the difference easy to miss.
19
+
20
+
`chat.headStart` and `chat.startHeadStart` hand their `run` the same thing, carrying the four options the handover protocol depends on. There it matters more: re-setting `messages`, `stopWhen` or `abortSignal` after a spread breaks the handover rather than degrading a feature, and nothing caught it. On the managed one those keys are a type error.
`onAction` can return a `StreamTextResult`, `string`, or `UIMessage` to produce a response. The returned stream is auto-piped to the frontend just like a normal turn, but the rest of the turn machinery (`onTurnStart`, `onTurnComplete`, etc.) still does not fire.
58
58
59
+
Build it with the `streamText` from `onAction`'s own argument, the same one `run()` receives. It carries the agent's system prompt, skill tools, resolved model and telemetry, so a regenerated answer is produced under the same configuration as every other turn. The `streamText` imported from `ai` carries none of that, and the reply still looks fine, which is what makes the difference easy to miss.
chat.history.slice(0, -1); // drop the last assistant
63
65
returnstreamText({
@@ -81,7 +83,7 @@ An action is not a turn, so `onTurnComplete` never fires, and that is where an a
81
83
**Your own store** (`hydrateMessages` registered): the runtime deliberately does not write, because your store is the source of truth. A history mutation and a returned response both live only in the running worker until you persist them, and a continuation rehydrates from your store, not from what the worker had in memory. `chat.pipeAndCapture` hands you the same assistant message the runtime would have captured:
awaitdb.deleteLastExchange(chatId); // the rollback is yours to persist
@@ -107,7 +109,7 @@ Returning the stream instead of piping it yourself still works and still reaches
107
109
If you have a [human-in-the-loop](/ai-chat/patterns/human-in-the-loop) tool waiting on `addToolOutput`, you usually want to refuse competing actions like `regenerate` until the answer arrives. [`chat.history.getPendingToolCalls()`](/ai-chat/backend#chat-history) gives you exactly that signal:
108
110
109
111
```ts
110
-
onAction: async ({ action, messages, signal }) => {
**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
+
exportconst 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
93
import { chat } from"@trigger.dev/sdk/ai";
61
-
import { streamText } from"ai";
62
94
import { anthropic } from"@ai-sdk/anthropic";
63
95
importtype { ModelMessage } from"ai";
64
96
65
97
exportconst agentChat =chat.agent({
66
98
id: "agent-chat",
67
-
run: async ({ messages }) => {
99
+
run: async ({ messages, streamText }) => {
68
100
// Don't return anything — chat.pipe is called inside
@@ -200,12 +232,12 @@ See [Tools](/ai-chat/tools) for `toModelOutput` across turns, per-turn dynamic t
200
232
201
233
### Using prompts
202
234
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.
235
+
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.
`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:**
273
+
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
274
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. |
275
+
`chat.toStreamTextOptions()` remains available for the same job, and is the only option in a [custom agent](#custom-agents) or a `chat.headStart` route, where there is no `run` argument to take it from. 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
276
252
277
<Tip>
253
278
See [Prompts](/ai/prompts) for the full guide — defining templates, variable schemas, dashboard
@@ -273,7 +298,7 @@ The `run` function receives three abort signals:
returnstreamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
733
758
},
734
759
});
@@ -746,7 +771,7 @@ export const myChat = chat
746
771
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
772
748
773
```ts
749
-
run: async ({ messages, clientData, signal }) => {
0 commit comments