workflow 5.0.0-beta.50 → 5.0.0-beta.52

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.
package/docs/ai/index.mdx CHANGED
@@ -57,55 +57,49 @@ cd workflow-examples/flight-booking-app
57
57
 
58
58
  <Step>
59
59
 
60
- ### Set up API keys
60
+ ### Configure model access
61
61
 
62
- To connect to an LLM, set up an API key. You can use Vercel Gateway, which works with all providers at zero markup, or configure a custom provider.
63
- <Tabs items={['Gateway', 'Custom Provider']}>
62
+ <Tabs items={['AI Gateway', 'Provider package']}>
64
63
 
65
- <Tab value="Gateway">
64
+ <Tab value="AI Gateway">
66
65
 
67
- Get a Gateway API key from the [Vercel Gateway](https://vercel.com/docs/ai-gateway/authentication) page.
66
+ AI SDK uses [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) as its default global provider, so plain `"provider/model"` strings need no provider-specific package. Vercel deployments authenticate with OIDC automatically. For local development, link the project and pull a short-lived OIDC token:
68
67
 
69
- Then add it to your `.env.local` file:
70
-
71
- ```bash title=".env.local" lineNumbers
72
- GATEWAY_API_KEY=...
68
+ ```bash
69
+ vercel link
70
+ vercel env pull .env.local
73
71
  ```
74
72
 
73
+ You can alternatively set `AI_GATEWAY_API_KEY` from the [AI Gateway authentication](https://vercel.com/docs/ai-gateway/authentication) page.
74
+
75
75
  </Tab>
76
76
 
77
- <Tab value="Custom Provider">
77
+ <Tab value="Provider package">
78
78
 
79
- This is an example of how to use the OpenAI provider for AI SDK. For details on other providers and more details, see the [AI SDK provider guide](https://ai-sdk.dev/providers/ai-sdk-providers).
79
+ `WorkflowAgent` accepts any AI SDK provider. To use OpenAI, install its provider package:
80
80
 
81
81
  ```package-install
82
82
  npm i @ai-sdk/openai
83
83
  ```
84
84
 
85
- Set your OpenAI API key in your environment variables:
85
+ Set the provider's API key:
86
86
 
87
87
  ```bash title=".env.local" lineNumbers
88
88
  OPENAI_API_KEY=...
89
89
  ```
90
90
 
91
- Then modify your API endpoint to use the OpenAI provider:
91
+ Then construct the model with the provider package:
92
92
 
93
- {/* @skip-typecheck: incomplete code sample */}
94
- ```typescript title="app/api/chat/route.ts" lineNumbers
95
- // ...
96
- import { openai } from "@ai-sdk/openai"; // [!code highlight]
93
+ ```typescript
94
+ import { openai } from "@ai-sdk/openai";
97
95
 
98
- export async function POST(req: Request) {
99
- // ...
100
- const agent = new Agent({
101
- // This uses the OPENAI_API_KEY environment variable by default, but you
102
- // can also pass { apiKey: string } as an option.
103
- model: openai("gpt-5.1"), // [!code highlight]
104
- // ...
105
- });
96
+ const model = openai("gpt-5.6-sol");
106
97
  ```
107
98
 
99
+ See the [AI SDK provider guide](https://ai-sdk.dev/providers/ai-sdk-providers) for Anthropic, Google, Amazon Bedrock, and other providers.
100
+
108
101
  </Tab>
102
+
109
103
  </Tabs>
110
104
  </Step>
111
105
 
@@ -131,7 +125,7 @@ import { convertToModelMessages, createUIMessageStreamResponse } from "ai";
131
125
  export async function POST(req: Request) {
132
126
  const { messages }: { messages: UIMessage[] } = await req.json();
133
127
  const agent = new ToolLoopAgent({ // [!code highlight]
134
- model: "bedrock/claude-4-5-haiku-20251001-v1",
128
+ model: "spacexai/grok-4.6",
135
129
  instructions: FLIGHT_ASSISTANT_PROMPT,
136
130
  tools: flightBookingTools,
137
131
  });
@@ -257,12 +251,10 @@ export default withWorkflow(nextConfig);
257
251
 
258
252
  Move the agent logic into a separate function, which will serve as our workflow definition.
259
253
 
260
- {/* @skip-typecheck: Shows two mutually exclusive model options */}
261
254
  ```typescript title="workflows/chat/workflow.ts" lineNumbers
262
255
  import { WorkflowAgent, type ModelCallStreamPart } from "@ai-sdk/workflow"; // [!code highlight]
263
256
  import { getWritable } from "workflow"; // [!code highlight]
264
- import { tools } from "@/ai/tools";
265
- import { openai } from "@ai-sdk/openai";
257
+ import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from "@/ai/tools";
266
258
  import { convertToModelMessages, type UIMessage } from "ai";
267
259
 
268
260
  export async function chatWorkflow(messages: UIMessage[]) {
@@ -271,13 +263,8 @@ export async function chatWorkflow(messages: UIMessage[]) {
271
263
  const writable = getWritable<ModelCallStreamPart>(); // [!code highlight]
272
264
 
273
265
  const agent = new WorkflowAgent({ // [!code highlight]
274
-
275
- // If using AI Gateway, specify the model name as a string:
276
- model: "bedrock/claude-4-5-haiku-20251001-v1", // [!code highlight]
277
-
278
- // ELSE if using a custom provider, pass the provider call as an argument:
279
- model: openai("gpt-5.1"), // [!code highlight]
280
-
266
+ // Plain model strings use Vercel AI Gateway.
267
+ model: "spacexai/grok-4.6", // [!code highlight]
281
268
  instructions: FLIGHT_ASSISTANT_PROMPT,
282
269
  tools: flightBookingTools,
283
270
  });
@@ -291,6 +278,10 @@ export async function chatWorkflow(messages: UIMessage[]) {
291
278
  }
292
279
  ```
293
280
 
281
+ <Callout type="info">
282
+ `WorkflowAgent` accepts any AI SDK provider. Import the provider and pass its model instance, for example `model: openai("gpt-5.6-sol")` from `@ai-sdk/openai`. The rest of the integration is unchanged.
283
+ </Callout>
284
+
294
285
  Key changes:
295
286
 
296
287
  - Add the `"use workflow"` directive to mark our Agent as a workflow function
@@ -7,7 +7,6 @@ prerequisites:
7
7
  - /docs/ai
8
8
  related:
9
9
  - /docs/ai/chat-session-modeling
10
- - /docs/api-reference/workflow-ai/durable-agent
11
10
  - /docs/api-reference/workflow/define-hook
12
11
  ---
13
12
 
@@ -29,27 +28,24 @@ If you need basic multi-turn conversations where messages arrive between turns,
29
28
 
30
29
  ## The `prepareStep` callback
31
30
 
32
- The `prepareStep` callback runs before each step in the agent loop. It receives the current state and can modify the messages sent to the model:
31
+ The `prepareStep` callback runs before each step in the agent loop. Use WorkflowAgent's exported types rather than redeclaring its normalized provider-prompt contract:
33
32
 
34
33
  ```typescript lineNumbers
35
- import type { ModelMessage, LanguageModel } from "ai";
36
-
37
- interface PrepareStepInfo {
38
- model: string | (() => Promise<LanguageModel>); // Current model
39
- stepNumber: number; // 0-indexed step count
40
- steps: StepResult[]; // Previous step results
41
- messages: ModelMessage[]; // Messages to be sent
42
- }
43
-
44
- interface PrepareStepResult {
45
- model?: string | (() => Promise<LanguageModel>); // Override model
46
- messages?: ModelMessage[]; // Override messages
47
- }
34
+ import type {
35
+ PrepareStepInfo,
36
+ PrepareStepResult,
37
+ } from "@ai-sdk/workflow";
38
+
39
+ const prepareStep = (
40
+ { messages }: PrepareStepInfo
41
+ ): PrepareStepResult => ({ messages });
48
42
  ```
49
43
 
50
- ## Injecting queued messages
44
+ `PrepareStepInfo.messages` is a normalized `LanguageModelV4Prompt`, not the application-level `ModelMessage[]` accepted by `WorkflowAgent.stream()`.
51
45
 
52
- Once you have a [multi-turn workflow](/docs/ai/chat-session-modeling#multi-turn-workflows), you can combine a message queue with `prepareStep` to inject messages that arrive during processing:
46
+ ## Queueing messages during and between turns
47
+
48
+ Use one async Hook consumer and one FIFO. `prepareStep` atomically drains messages that arrived during a model turn; messages that arrive after the final model step become input to the next turn. Each Hook payload therefore has exactly one ownership path.
53
49
 
54
50
  ```typescript title="workflows/chat/index.ts" lineNumbers
55
51
  import { WorkflowAgent, type ModelCallStreamPart } from "@ai-sdk/workflow";
@@ -63,112 +59,77 @@ export async function chat(initialMessages: ModelMessage[]) {
63
59
 
64
60
  const { workflowRunId: runId } = getWorkflowMetadata();
65
61
  const writable = getWritable<ModelCallStreamPart>();
62
+ let messages: ModelMessage[] = [...initialMessages];
66
63
  const messageQueue: Array<{ role: "user"; content: string }> = []; // [!code highlight]
64
+ let stopped = false;
65
+ let notifyMessage: (() => void) | undefined;
67
66
 
68
67
  const agent = new WorkflowAgent({
69
- model: "bedrock/claude-haiku-4-5-20251001-v1",
68
+ model: "spacexai/grok-4.6",
70
69
  instructions: FLIGHT_ASSISTANT_PROMPT,
71
70
  tools: flightBookingTools,
72
71
  });
73
72
 
74
- // Listen for messages in background (non-blocking) // [!code highlight]
75
73
  const hook = chatMessageHook.create({ token: runId }); // [!code highlight]
76
- hook.then(({ message }) => { // [!code highlight]
77
- messageQueue.push({ role: "user", content: message }); // [!code highlight]
78
- }); // [!code highlight]
79
-
80
- await agent.stream({
81
- messages: initialMessages,
82
- writable,
83
- prepareStep: ({ messages: currentMessages }) => { // [!code highlight]
84
- // Inject any queued messages before the next LLM call // [!code highlight]
85
- if (messageQueue.length > 0) { // [!code highlight]
86
- const newMessages = messageQueue.splice(0); // Drain queue // [!code highlight]
87
- return { // [!code highlight]
88
- messages: [ // [!code highlight]
89
- ...currentMessages, // [!code highlight]
90
- ...newMessages.map((m) => ({ // [!code highlight]
91
- role: m.role, // [!code highlight]
92
- content: [{ type: "text" as const, text: m.content }], // [!code highlight]
93
- })), // [!code highlight]
94
- ], // [!code highlight]
95
- }; // [!code highlight]
74
+ // This is the only code path that consumes Hook payloads. // [!code highlight]
75
+ const consumeMessages = (async () => { // [!code highlight]
76
+ for await (const { message } of hook) { // [!code highlight]
77
+ if (message === "/done") { // [!code highlight]
78
+ stopped = true; // [!code highlight]
79
+ notifyMessage?.(); // [!code highlight]
80
+ break; // [!code highlight]
96
81
  } // [!code highlight]
97
- return {}; // [!code highlight]
98
- }, // [!code highlight]
99
- });
100
- }
101
- ```
102
-
103
- Messages sent via `chatMessageHook.resume()` accumulate in the queue and get injected before the next step, whether that's a tool call or another LLM request.
104
-
105
- <Callout type="info">
106
- The `prepareStep` callback receives messages in `ModelMessage[]` format (with content arrays), which is the internal format used by the AI SDK.
107
- </Callout>
108
-
109
- ## Combining with multi-turn sessions
110
-
111
- You can also combine message queueing with the standard multi-turn pattern:
112
-
113
- ```typescript title="workflows/chat/index.ts" lineNumbers
114
- import { WorkflowAgent, type ModelCallStreamPart } from "@ai-sdk/workflow";
115
- import { getWritable, getWorkflowMetadata } from "workflow";
116
- import { chatMessageHook } from "./hooks/chat-message";
117
- import type { ModelMessage } from "ai";
118
-
119
- export async function chat(initialMessages: ModelMessage[]) {
120
- "use workflow";
121
-
122
- const { workflowRunId: runId } = getWorkflowMetadata();
123
- const writable = getWritable<ModelCallStreamPart>();
124
- const messages: ModelMessage[] = [...initialMessages];
125
- const messageQueue: Array<{ role: "user"; content: string }> = [];
126
-
127
- const agent = new WorkflowAgent({ /* ... */ });
128
- const hook = chatMessageHook.create({ token: runId });
129
-
130
- while (true) {
131
- // Set up non-blocking listener for mid-turn messages // [!code highlight]
132
- let pendingMessage: string | null = null; // [!code highlight]
133
- hook.then(({ message }) => { // [!code highlight]
134
- if (message === "/done") return; // [!code highlight]
135
82
  messageQueue.push({ role: "user", content: message }); // [!code highlight]
136
- pendingMessage = message; // [!code highlight]
137
- }); // [!code highlight]
138
-
83
+ notifyMessage?.(); // [!code highlight]
84
+ notifyMessage = undefined; // [!code highlight]
85
+ } // [!code highlight]
86
+ })(); // [!code highlight]
87
+
88
+ const waitForMessage = async () => {
89
+ while (messageQueue.length === 0 && !stopped) {
90
+ await new Promise<void>((resolve) => {
91
+ notifyMessage = resolve;
92
+ });
93
+ }
94
+ };
95
+
96
+ while (!stopped) {
139
97
  const result = await agent.stream({
140
98
  messages,
141
99
  writable,
142
100
  preventClose: true,
101
+ sendFinish: false,
143
102
  prepareStep: ({ messages: currentMessages }) => {
144
- // Inject queued messages during turn // [!code highlight]
145
- if (messageQueue.length > 0) {
146
- const newMessages = messageQueue.splice(0);
147
- return {
148
- messages: [
149
- ...currentMessages,
150
- ...newMessages.map((m) => ({
151
- role: m.role,
152
- content: [{ type: "text" as const, text: m.content }],
153
- })),
154
- ],
155
- };
156
- }
157
- return {};
103
+ const queued = messageQueue.splice(0); // Atomic drain // [!code highlight]
104
+ if (queued.length === 0) return {};
105
+ return {
106
+ messages: [
107
+ ...currentMessages,
108
+ ...queued.map(({ role, content }) => ({
109
+ role,
110
+ content: [{ type: "text" as const, text: content }],
111
+ })),
112
+ ],
113
+ };
158
114
  },
159
115
  });
116
+ messages = result.messages;
160
117
 
161
- messages.push(...result.messages.slice(messages.length));
162
-
163
- // Wait for next message (either queued during turn or new) // [!code highlight]
164
- const { message: followUp } = pendingMessage ? { message: pendingMessage } : await hook; // [!code highlight]
165
- if (followUp === "/done") break;
118
+ if (stopped) break;
119
+ await waitForMessage(); // [!code highlight]
120
+ if (stopped) break;
166
121
 
167
- messages.push({ role: "user", content: followUp });
122
+ // Anything not consumed by prepareStep arrived after the final model step.
123
+ messages = [...messages, ...messageQueue.splice(0)]; // [!code highlight]
168
124
  }
125
+
126
+ await consumeMessages;
127
+ return { messages };
169
128
  }
170
129
  ```
171
130
 
131
+ Messages sent via `chatMessageHook.resume()` accumulate until either `prepareStep` or the between-turn branch drains the FIFO. Send `/done` to stop the consumer and let the workflow return.
132
+
172
133
  ## Related documentation
173
134
 
174
135
  - [Chat Session Modeling](/docs/ai/chat-session-modeling) - Single-turn vs multi-turn patterns
@@ -51,7 +51,7 @@ All the functions and primitives that come with Workflow SDK by package.
51
51
  Serialization symbols for custom class serialization in workflows.
52
52
  </Card>
53
53
  <Card title="@workflow/ai" href="/docs/api-reference/workflow-ai">
54
- Helpers for integrating AI SDK for building AI-powered workflows.
54
+ Deprecated AI integration APIs kept for existing applications. Use `@ai-sdk/workflow` for new agents.
55
55
  </Card>
56
56
  <Card title="@workflow/vitest" href="/docs/api-reference/vitest">
57
57
  Vitest plugin and test helpers for integration testing workflows in-process.
@@ -199,7 +199,7 @@ async function weatherAgentWorkflow(userQuery: string) {
199
199
  "use workflow";
200
200
 
201
201
  const agent = new DurableAgent({
202
- model: "anthropic/claude-haiku-4.5",
202
+ model: "spacexai/grok-4.6",
203
203
  tools: {
204
204
  getWeather: {
205
205
  description: "Get current weather for a location",
@@ -244,7 +244,7 @@ async function multiToolAgentWorkflow(userQuery: string) {
244
244
  "use workflow";
245
245
 
246
246
  const agent = new DurableAgent({
247
- model: "anthropic/claude-haiku-4.5",
247
+ model: "spacexai/grok-4.6",
248
248
  tools: {
249
249
  getWeather: {
250
250
  description: "Get weather for a location",
@@ -289,7 +289,7 @@ async function multiTurnAgentWorkflow() {
289
289
  "use workflow";
290
290
 
291
291
  const agent = new DurableAgent({
292
- model: "anthropic/claude-haiku-4.5",
292
+ model: "spacexai/grok-4.6",
293
293
  tools: {
294
294
  searchProducts: {
295
295
  description: "Search for products",
@@ -368,7 +368,7 @@ async function agentWithLibraryFeaturesWorkflow(userRequest: string) {
368
368
  "use workflow";
369
369
 
370
370
  const agent = new DurableAgent({
371
- model: "anthropic/claude-haiku-4.5",
371
+ model: "spacexai/grok-4.6",
372
372
  tools: {
373
373
  scheduleTask: {
374
374
  description: "Pause the workflow for the specified number of seconds",
@@ -405,7 +405,7 @@ async function agentWithPrepareStep(userMessage: string) {
405
405
  "use workflow";
406
406
 
407
407
  const agent = new DurableAgent({
408
- model: "openai/gpt-4.1-mini", // Default model
408
+ model: "spacexai/grok-4.6", // Default model
409
409
  instructions: "You are a helpful assistant.",
410
410
  });
411
411
 
@@ -459,7 +459,7 @@ async function agentWithMessageQueue(initialMessage: string) {
459
459
  });
460
460
 
461
461
  const agent = new DurableAgent({
462
- model: "anthropic/claude-haiku-4.5",
462
+ model: "spacexai/grok-4.6",
463
463
  instructions: "You are a helpful assistant.",
464
464
  });
465
465
 
@@ -500,7 +500,7 @@ async function agentWithGenerationSettings() {
500
500
 
501
501
  // Set default generation settings in constructor
502
502
  const agent = new DurableAgent({
503
- model: "anthropic/claude-haiku-4.5",
503
+ model: "spacexai/grok-4.6",
504
504
  temperature: 0.7,
505
505
  maxOutputTokens: 2000,
506
506
  topP: 0.9,
@@ -548,7 +548,7 @@ async function multiStepAgent() {
548
548
  "use workflow";
549
549
 
550
550
  const agent = new DurableAgent({
551
- model: "anthropic/claude-haiku-4.5",
551
+ model: "spacexai/grok-4.6",
552
552
  tools: {
553
553
  searchWeb: {
554
554
  description: "Search the web for information",
@@ -588,7 +588,7 @@ async function agentWithCallbacks() {
588
588
  "use workflow";
589
589
 
590
590
  const agent = new DurableAgent({
591
- model: "anthropic/claude-haiku-4.5",
591
+ model: "spacexai/grok-4.6",
592
592
  });
593
593
 
594
594
  await agent.stream({
@@ -630,7 +630,7 @@ async function agentWithStructuredOutput() {
630
630
  "use workflow";
631
631
 
632
632
  const agent = new DurableAgent({
633
- model: "anthropic/claude-haiku-4.5",
633
+ model: "spacexai/grok-4.6",
634
634
  });
635
635
 
636
636
  const result = await agent.stream({
@@ -665,7 +665,7 @@ async function agentWithToolChoice() {
665
665
  "use workflow";
666
666
 
667
667
  const agent = new DurableAgent({
668
- model: "anthropic/claude-haiku-4.5",
668
+ model: "spacexai/grok-4.6",
669
669
  tools: {
670
670
  calculator: {
671
671
  description: "Perform calculations",
@@ -733,7 +733,7 @@ async function agentWithContext(userId: string) {
733
733
  "use workflow";
734
734
 
735
735
  const agent = new DurableAgent({
736
- model: "anthropic/claude-haiku-4.5",
736
+ model: "spacexai/grok-4.6",
737
737
  tools: {
738
738
  getUserData: {
739
739
  description: "Get user data",
@@ -771,7 +771,7 @@ async function agentWithUIMessages(userMessage: string) {
771
771
  "use workflow";
772
772
 
773
773
  const agent = new DurableAgent({
774
- model: "anthropic/claude-haiku-4.5",
774
+ model: "spacexai/grok-4.6",
775
775
  instructions: "You are a helpful assistant.",
776
776
  });
777
777
 
@@ -819,7 +819,7 @@ async function agentWithToolInspection(userMessage: string) {
819
819
  "use workflow";
820
820
 
821
821
  const agent = new DurableAgent({
822
- model: "anthropic/claude-haiku-4.5",
822
+ model: "spacexai/grok-4.6",
823
823
  tools: {
824
824
  checkOrderStatus: {
825
825
  description: "Check order status",
@@ -874,7 +874,7 @@ async function agentWithTimeout(userMessage: string) {
874
874
  "use workflow";
875
875
 
876
876
  const agent = new DurableAgent({
877
- model: "anthropic/claude-haiku-4.5",
877
+ model: "spacexai/grok-4.6",
878
878
  });
879
879
 
880
880
  await agent.stream({
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  title: "@workflow/ai"
3
- description: Helpers for building AI-powered workflows with the AI SDK.
3
+ description: Deprecated AI integration APIs kept for existing Workflow applications.
4
4
  type: overview
5
- summary: Explore helpers for integrating AI SDK to build durable AI-powered workflows.
5
+ summary: Migrate legacy @workflow/ai APIs to AI SDK's WorkflowAgent and WorkflowChatTransport.
6
6
  related:
7
7
  - /docs/ai
8
8
  ---
9
9
 
10
- The `@workflow/ai` package provides helpers for integrating AI SDK into AI-powered workflows.
10
+ The `@workflow/ai` package is deprecated in Workflow 5 and remains documented for existing applications. Build new agents with [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) and [`WorkflowChatTransport`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#resumable-streaming-with-workflowchattransport) from `@ai-sdk/workflow`.
11
11
 
12
12
  ## Classes
13
13
 
@@ -66,7 +66,7 @@ The contract:
66
66
 
67
67
  **Per-chunk continuation.** Each chunk's follow-on work starts the moment **that chunk** commits, not when the whole fold does: a chunk's step-execution queue messages publish right off its own commit (publish-after-create holds per step), and only the chunk carrying the inline pairs gates the replay's continuation: trailing chunks' commits and publishes are joined before the invocation can acknowledge its message, so the durability contract ("every create durable before ack") is unchanged.
68
68
 
69
- **Pre-claimed inline pairs.** When the fold engages and has company for them (at least two inline steps, or one plus other batchable events), the steps the runtime is about to execute inline join the batch as adjacent `[step_created, step_started]` pairs: the created row carrying the input, the started row a bare ownership-stamped claim the World folds into a born-running create. The inline bodies start straight off the pair chunk's commit (in parallel with the queue publishes and any trailing chunks) with no per-step claim POST at all, and a pair that loses its atomic create-claim to a concurrent delivery skips its body exactly as a lost lazy claim does. A lone inline step with nothing else to batch keeps the optimistic lazy-start path, whose claim overlaps the body.
69
+ **Pre-claimed inline pairs.** When the fold engages with at least two inline steps, the steps the runtime is about to execute inline join the batch as adjacent `[step_created, step_started]` pairs: the created row carrying the input, the started row a bare ownership-stamped claim the World folds into a born-running create. The pairs commit in a chunk of their own, ahead of the plain `step_created` and `wait_created` chunks, so the write the inline bodies wait for carries only two rows per inline step (a small transaction that commits faster than a full 32-event chunk) while the plain creates commit concurrently beside it. The inline bodies start straight off the pair chunk's commit (in parallel with the queue publishes and the sibling chunks) with no per-step claim POST at all, and a pair that loses its atomic create-claim to a concurrent delivery skips its body exactly as a lost lazy claim does. A lone inline step keeps the optimistic lazy-start path (one row, whose claim overlaps the body) even when eager creates batch beside it: the pairs share no round trip with those creates, so only two or more inline steps make a pair chunk worth the trade. A plain partition of exactly one `step_created` or `wait_created` beside the pairs is written through the ordinary single path rather than a one-row batch, and its queue message still waits for that write.
70
70
 
71
71
  Per-event `409`s are tolerated the same way the single path tolerates `EntityConflictError` (a concurrent delivery already created the entity); any other per-event failure fails the suspension write the way a single-path rejection would. A batch carrying a `step_started` (that is, any batch with inline pairs) is **not** retried in-process on a transport blip: a pair's `409` cannot be told apart from the caller's own earlier attempt having committed it, so recovery goes through queue redelivery instead, where the step's ownership stamp routes it back to the same invocation.
72
72
 
@@ -314,6 +314,11 @@ These variables are primarily for tests, debugging, or unusual deployments.
314
314
  - Group-commit window for the *leading* chunk of an idle stream. `0` sends it at once; a positive value holds it up to that many milliseconds to collect a group. This opt-in setting trades first-chunk latency for larger batches and can benefit slow-but-steady producers. Chunks arriving while a request is already in flight always coalesce into the next group regardless of this setting.
315
315
  - Also available as `streamFlushIntervalMs` on Worlds that expose it (the env var, when set, takes precedence over the World option).
316
316
 
317
+ ### `WORKFLOW_STEP_STREAM_DRAIN_TIMEOUT_MS`
318
+
319
+ - Default: `30000` (30 seconds)
320
+ - Maximum time a step waits for writes queued by a released workflow stream writer to be acknowledged by the Workflow server before recording `step_completed`. A timeout fails the step instead of exposing a completion while released-writer data remains client-side. A writer intentionally kept locked does not trigger this durability wait.
321
+
317
322
  ### `WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS`
318
323
 
319
324
  - Default: `1000`
@@ -286,7 +286,7 @@ Platform-provided values such as `VERCEL_DEPLOYMENT_ID`, `VERCEL_PROJECT_ID`, an
286
286
  - Factory option: none
287
287
  - CLI flag: none
288
288
  - Default: `http`
289
- - Experimental stream-write transport capability. Set to exactly `ws` to advertise support for `workflow-stream-ws/v1` when it becomes available. The server authoritatively accepts or declines an upgrade; a decline uses HTTP directly. Stream reads remain HTTP and demand-driven.
289
+ - Experimental stream-write transport capability. Set to exactly `ws` to attempt `workflow-stream-ws/v1`. The server authoritatively accepts or declines each upgrade; a decline uses HTTP directly for that writer lifetime. Stream reads remain HTTP and demand-driven.
290
290
  - This is not tenant rollout policy or a package-version check. HTTP remains the compatibility path. `/websockets/v1` is independent of REST v2/v4 and persisted workflow `specVersion` values.
291
291
 
292
292
  ### `WORKFLOW_DISABLE_ANALYTICS_READS`