workflow 5.0.0-beta.3 → 5.0.0-beta.31

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 (176) hide show
  1. package/dist/api-workflow.d.ts +1 -1
  2. package/dist/api-workflow.d.ts.map +1 -1
  3. package/dist/api-workflow.js +1 -1
  4. package/dist/api.d.ts +5 -1
  5. package/dist/api.d.ts.map +1 -1
  6. package/dist/api.js +12 -2
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +6 -1
  10. package/dist/internal/builtins.d.ts +17 -0
  11. package/dist/internal/builtins.d.ts.map +1 -1
  12. package/dist/internal/builtins.js +65 -1
  13. package/dist/observability.d.ts +1 -1
  14. package/dist/observability.js +2 -2
  15. package/dist/runtime.d.ts +2 -1
  16. package/dist/runtime.d.ts.map +1 -1
  17. package/dist/runtime.js +5 -2
  18. package/docs/ai/chat-session-modeling.mdx +7 -3
  19. package/docs/ai/defining-tools.mdx +2 -2
  20. package/docs/ai/index.mdx +28 -23
  21. package/docs/ai/message-queueing.mdx +10 -10
  22. package/docs/ai/resumable-streams.mdx +9 -1
  23. package/docs/api-reference/index.mdx +24 -0
  24. package/docs/api-reference/meta.json +8 -0
  25. package/docs/api-reference/vitest/index.mdx +28 -7
  26. package/docs/api-reference/workflow/create-hook.mdx +38 -0
  27. package/docs/api-reference/workflow/create-webhook.mdx +1 -0
  28. package/docs/api-reference/workflow/fetch.mdx +5 -0
  29. package/docs/api-reference/workflow/index.mdx +3 -0
  30. package/docs/api-reference/workflow/set-attributes.mdx +65 -0
  31. package/docs/api-reference/workflow-ai/durable-agent.mdx +7 -45
  32. package/docs/api-reference/workflow-ai/index.mdx +2 -2
  33. package/docs/api-reference/workflow-ai/workflow-chat-transport.mdx +44 -1
  34. package/docs/api-reference/workflow-api/get-hook-by-token.mdx +7 -0
  35. package/docs/api-reference/workflow-api/get-run.mdx +25 -0
  36. package/docs/api-reference/workflow-api/index.mdx +6 -8
  37. package/docs/api-reference/workflow-api/resume-hook.mdx +57 -0
  38. package/docs/api-reference/workflow-api/start.mdx +13 -5
  39. package/docs/api-reference/workflow-astro/index.mdx +18 -0
  40. package/docs/api-reference/workflow-astro/meta.json +4 -0
  41. package/docs/api-reference/workflow-astro/workflow.mdx +45 -0
  42. package/docs/api-reference/workflow-errors/hook-conflict-error.mdx +60 -0
  43. package/docs/api-reference/workflow-errors/index.mdx +85 -0
  44. package/docs/api-reference/workflow-errors/meta.json +5 -0
  45. package/docs/api-reference/workflow-errors/run-not-supported-error.mdx +58 -0
  46. package/docs/api-reference/workflow-errors/workflow-error.mdx +52 -0
  47. package/docs/api-reference/workflow-errors/workflow-run-failed-error.mdx +16 -6
  48. package/docs/api-reference/workflow-errors/workflow-run-not-completed-error.mdx +58 -0
  49. package/docs/api-reference/workflow-errors/workflow-runtime-error.mdx +58 -0
  50. package/docs/api-reference/workflow-nest/configure-workflow-controller.mdx +33 -0
  51. package/docs/api-reference/workflow-nest/index.mdx +31 -0
  52. package/docs/api-reference/workflow-nest/meta.json +9 -0
  53. package/docs/api-reference/workflow-nest/nest-local-builder.mdx +64 -0
  54. package/docs/api-reference/workflow-nest/workflow-controller.mdx +40 -0
  55. package/docs/api-reference/workflow-nest/workflow-module.mdx +74 -0
  56. package/docs/api-reference/workflow-next/with-workflow.mdx +56 -2
  57. package/docs/api-reference/workflow-nitro/index.mdx +60 -0
  58. package/docs/api-reference/workflow-nuxt/index.mdx +48 -0
  59. package/docs/api-reference/workflow-observability/hydrate-data.mdx +35 -0
  60. package/docs/api-reference/workflow-observability/hydrate-resource-io.mdx +62 -0
  61. package/docs/api-reference/workflow-observability/index.mdx +64 -0
  62. package/docs/api-reference/workflow-observability/meta.json +11 -0
  63. package/docs/api-reference/workflow-observability/observability-revivers.mdx +50 -0
  64. package/docs/api-reference/workflow-observability/parse-class-name.mdx +41 -0
  65. package/docs/api-reference/workflow-observability/parse-step-name.mdx +40 -0
  66. package/docs/api-reference/workflow-observability/parse-workflow-name.mdx +55 -0
  67. package/docs/api-reference/workflow-runtime/create-world.mdx +39 -0
  68. package/docs/api-reference/workflow-runtime/get-world-handlers.mdx +44 -0
  69. package/docs/api-reference/{workflow-api → workflow-runtime}/get-world.mdx +7 -10
  70. package/docs/api-reference/workflow-runtime/health-check.mdx +50 -0
  71. package/docs/api-reference/workflow-runtime/index.mdx +43 -0
  72. package/docs/api-reference/workflow-runtime/meta.json +12 -0
  73. package/docs/api-reference/workflow-runtime/set-world.mdx +49 -0
  74. package/docs/api-reference/workflow-runtime/workflow-entrypoint.mdx +42 -0
  75. package/docs/api-reference/{workflow-api → workflow-runtime}/world/index.mdx +5 -8
  76. package/docs/api-reference/workflow-runtime/world/meta.json +4 -0
  77. package/docs/api-reference/{workflow-api → workflow-runtime}/world/queue.mdx +4 -2
  78. package/docs/api-reference/{workflow-api → workflow-runtime}/world/storage.mdx +11 -4
  79. package/docs/api-reference/{workflow-api → workflow-runtime}/world/streams.mdx +2 -2
  80. package/docs/api-reference/workflow-serde/index.mdx +0 -1
  81. package/docs/api-reference/workflow-serde/workflow-deserialize.mdx +1 -2
  82. package/docs/api-reference/workflow-serde/workflow-serialize.mdx +1 -2
  83. package/docs/api-reference/workflow-sveltekit/index.mdx +18 -0
  84. package/docs/api-reference/workflow-sveltekit/meta.json +4 -0
  85. package/docs/api-reference/workflow-sveltekit/workflow-plugin.mdx +42 -0
  86. package/docs/api-reference/workflow-vite/index.mdx +18 -0
  87. package/docs/api-reference/workflow-vite/meta.json +4 -0
  88. package/docs/api-reference/workflow-vite/workflow.mdx +48 -0
  89. package/docs/changelog/attributes-mvp.mdx +380 -0
  90. package/docs/changelog/eager-processing.mdx +269 -0
  91. package/docs/changelog/index.mdx +2 -1
  92. package/docs/changelog/lazy-event-creation.md +127 -0
  93. package/docs/changelog/meta.json +8 -1
  94. package/docs/changelog/resilient-start.mdx +31 -283
  95. package/docs/changelog/step-message-ownership.mdx +360 -0
  96. package/docs/changelog/turbo-mode.md +87 -0
  97. package/docs/configuration/build-and-diagnostics.mdx +51 -0
  98. package/docs/configuration/cli-and-web-ui.mdx +154 -0
  99. package/docs/configuration/framework-options.mdx +165 -0
  100. package/docs/configuration/index.mdx +32 -0
  101. package/docs/configuration/meta.json +12 -0
  102. package/docs/configuration/runtime-tuning.mdx +156 -0
  103. package/docs/configuration/worlds.mdx +228 -0
  104. package/docs/cookbook/advanced/child-workflows.mdx +199 -256
  105. package/docs/cookbook/advanced/meta.json +1 -1
  106. package/docs/cookbook/advanced/publishing-libraries.mdx +13 -12
  107. package/docs/cookbook/advanced/serializable-steps.mdx +8 -4
  108. package/docs/cookbook/advanced/upgrading-workflows.mdx +195 -0
  109. package/docs/cookbook/agent-patterns/agent-cancellation.mdx +31 -76
  110. package/docs/cookbook/agent-patterns/durable-agent.mdx +11 -143
  111. package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +7 -3
  112. package/docs/cookbook/common-patterns/idempotency.mdx +36 -52
  113. package/docs/cookbook/common-patterns/rate-limiting.mdx +1 -1
  114. package/docs/cookbook/common-patterns/saga.mdx +2 -2
  115. package/docs/cookbook/common-patterns/scheduling.mdx +8 -0
  116. package/docs/cookbook/common-patterns/timeouts.mdx +2 -1
  117. package/docs/cookbook/common-patterns/workflow-composition.mdx +15 -16
  118. package/docs/cookbook/index.mdx +3 -3
  119. package/docs/cookbook/integrations/ai-sdk.mdx +60 -28
  120. package/docs/cookbook/integrations/chat-sdk.mdx +13 -0
  121. package/docs/cookbook/integrations/sandbox.mdx +13 -0
  122. package/docs/deploying/building-a-world.mdx +1 -1
  123. package/docs/deploying/index.mdx +1 -0
  124. package/docs/deploying/world/local-world.mdx +23 -6
  125. package/docs/deploying/world/postgres-world.mdx +79 -13
  126. package/docs/deploying/world/vercel-world.mdx +47 -12
  127. package/docs/errors/abort-signal-timeout-in-workflow.mdx +80 -0
  128. package/docs/errors/corrupted-event-log.mdx +5 -5
  129. package/docs/errors/hook-conflict.mdx +56 -4
  130. package/docs/errors/index.mdx +1 -35
  131. package/docs/errors/replay-divergence.mdx +27 -0
  132. package/docs/errors/runtime-decryption-failed.mdx +77 -0
  133. package/docs/errors/step-executed-multiple-times.mdx +23 -0
  134. package/docs/errors/step-not-registered.mdx +1 -1
  135. package/docs/foundations/cancellation.mdx +459 -0
  136. package/docs/foundations/errors-and-retries.mdx +7 -3
  137. package/docs/foundations/hooks.mdx +29 -0
  138. package/docs/foundations/idempotency.mdx +236 -11
  139. package/docs/foundations/index.mdx +1 -23
  140. package/docs/foundations/meta.json +3 -1
  141. package/docs/foundations/serialization.mdx +77 -41
  142. package/docs/foundations/starting-workflows.mdx +5 -1
  143. package/docs/foundations/streaming.mdx +14 -23
  144. package/docs/foundations/versioning.mdx +263 -0
  145. package/docs/getting-started/astro.mdx +6 -0
  146. package/docs/getting-started/index.mdx +6 -7
  147. package/docs/getting-started/meta.json +1 -0
  148. package/docs/getting-started/nestjs.mdx +9 -0
  149. package/docs/getting-started/next.mdx +5 -3
  150. package/docs/getting-started/nitro.mdx +22 -0
  151. package/docs/getting-started/sveltekit.mdx +6 -0
  152. package/docs/getting-started/tanstack-start.mdx +241 -0
  153. package/docs/how-it-works/cancellation.mdx +287 -0
  154. package/docs/how-it-works/code-transform.mdx +2 -2
  155. package/docs/how-it-works/encryption.mdx +2 -2
  156. package/docs/how-it-works/event-sourcing.mdx +2 -2
  157. package/docs/how-it-works/meta.json +2 -1
  158. package/docs/internal/index.mdx +21 -0
  159. package/docs/internal/meta.json +10 -0
  160. package/docs/internal/nitro-native-build.mdx +38 -0
  161. package/docs/internal/nitro-web-ui.mdx +24 -0
  162. package/docs/internal/serializable-abort-controller.mdx +148 -0
  163. package/docs/meta.json +1 -1
  164. package/docs/migration-guides/migrating-from-aws-step-functions.mdx +7 -12
  165. package/docs/migration-guides/migrating-from-inngest.mdx +7 -17
  166. package/docs/migration-guides/migrating-from-temporal.mdx +6 -11
  167. package/docs/migration-guides/migrating-from-trigger-dev.mdx +8 -17
  168. package/docs/observability/attributes.mdx +81 -0
  169. package/docs/observability/index.mdx +18 -1
  170. package/docs/observability/meta.json +1 -1
  171. package/docs/observability/tracing.mdx +119 -0
  172. package/docs/testing/index.mdx +2 -2
  173. package/package.json +14 -13
  174. package/docs/api-reference/workflow-api/world/meta.json +0 -4
  175. package/docs/api-reference/workflow-api/world/observability.mdx +0 -164
  176. package/docs/cookbook/advanced/distributed-abort-controller.mdx +0 -318
@@ -1,45 +1,25 @@
1
1
  ---
2
2
  title: Idempotency
3
- description: Ensure external side effects happen exactly once, even when steps are retried or workflows are replayed.
3
+ description: Make step retries safe and coordinate duplicate workflow starts with hook tokens.
4
4
  type: guide
5
- summary: Use step IDs as idempotency keys for external APIs like Stripe so that retries and replays don't create duplicate charges.
5
+ summary: Use step IDs for retry-safe external calls, and use deterministic hook tokens when duplicate requests must route to one active workflow.
6
6
  ---
7
7
 
8
- Workflow steps can be retried (on failure) and replayed (on cold start). If a step calls an external API that isn't idempotent, retries could create duplicate charges, send duplicate emails, or double-process records. Use idempotency keys to make these operations safe.
8
+ Use idempotency when a retry or duplicate request should not repeat the underlying work. In Workflow, there are two common patterns: use the step ID for retry-safe external calls, and use hook tokens to coordinate duplicate workflow starts.
9
9
 
10
10
  ## When to use this
11
11
 
12
- - Charging a payment (Stripe, PayPal)
13
- - Sending transactional emails or SMS
14
- - Creating records in external systems where duplicates are harmful
15
- - Any step that has side effects in systems you don't control
12
+ - A step charges a payment, sends an email, enqueues work, or creates an external record.
13
+ - A route may receive duplicate requests that should map to one active workflow run.
16
14
 
17
- ## Pattern: Step ID as idempotency key
15
+ ## Step idempotency
18
16
 
19
17
  Every step has a unique, deterministic `stepId` available via `getStepMetadata()`. Pass this as the idempotency key to external APIs:
20
18
 
21
19
  ```typescript
22
20
  import { getStepMetadata } from "workflow";
23
21
 
24
- declare function createCharge(customerId: string, amount: number): Promise<{ id: string }>; // @setup
25
- declare function sendReceipt(customerId: string, chargeId: string): Promise<void>; // @setup
26
-
27
- export async function chargeCustomer(customerId: string, amount: number) {
28
- "use workflow";
29
-
30
- const charge = await createCharge(customerId, amount);
31
- await sendReceipt(customerId, charge.id);
32
-
33
- return { customerId, chargeId: charge.id, status: "completed" };
34
- }
35
- ```
36
-
37
- ### Step function with idempotency key
38
-
39
- ```typescript
40
- import { getStepMetadata } from "workflow";
41
-
42
- async function createCharge(
22
+ export async function createCharge(
43
23
  customerId: string,
44
24
  amount: number
45
25
  ): Promise<{ id: string }> {
@@ -69,39 +49,43 @@ async function createCharge(
69
49
 
70
50
  return charge.json();
71
51
  }
72
-
73
- async function sendReceipt(customerId: string, chargeId: string): Promise<void> {
74
- "use step";
75
-
76
- const { stepId } = getStepMetadata();
77
-
78
- await fetch("https://api.example.com/receipts", {
79
- method: "POST",
80
- headers: { "Idempotency-Key": stepId },
81
- body: JSON.stringify({ customerId, chargeId }),
82
- });
83
- }
84
52
  ```
85
53
 
86
- ## Race condition caveats
54
+ See [Step Idempotency](/docs/foundations/idempotency#step-idempotency) for why `stepId` is stable across retries and how to think about external API conflicts.
87
55
 
88
- Workflow does not currently provide distributed locking or true exactly-once delivery across concurrent runs. If two workflow runs could process the same entity concurrently:
56
+ ## Run idempotency
89
57
 
90
- - **Rely on the external API's idempotency** (like Stripe's `Idempotency-Key`) rather than checking a local flag.
91
- - **Don't use check-then-act patterns** like "read a flag, then write if not set" -- another run could read the same flag between your read and write.
58
+ For duplicate workflow-start requests, derive a hook token from your domain key. You can avoid obvious duplicate starts by checking whether an active hook already owns that token before calling `start()`:
92
59
 
93
- If your external API doesn't support idempotency keys natively, consider adding a deduplication layer (e.g., a database unique constraint on the operation ID).
60
+ ```typescript
61
+ import { getHookByToken, start } from "workflow/api";
62
+ import { HookNotFoundError } from "workflow/errors";
63
+ import { processOrder } from "./workflows/process-order";
64
+
65
+ export async function POST(request: Request) {
66
+ const { orderId } = await request.json();
67
+ const token = `order:${orderId}`;
68
+
69
+ try {
70
+ const hook = await getHookByToken(token); // [!code highlight]
71
+ return Response.json({ runId: hook.runId, reused: true });
72
+ } catch (error) {
73
+ if (!HookNotFoundError.is(error)) throw error;
74
+ }
94
75
 
95
- ## Tips
76
+ const run = await start(processOrder, [orderId]); // [!code highlight]
77
+ return Response.json({ runId: run.runId, reused: false });
78
+ }
79
+ ```
96
80
 
97
- - **`stepId` is deterministic.** It's the same value across retries and replays of the same step, making it a reliable idempotency key.
98
- - **Always provide idempotency keys for non-idempotent external calls.** Even if you think a step won't be retried, cold-start replay will re-execute it.
99
- - **Handle 409/conflict as success.** If an external API returns "already processed," treat that as a successful result, not an error.
100
- - **Make your own APIs idempotent** where possible. Accept an idempotency key and return the cached result on duplicate requests.
81
+ The workflow should create the deterministic hook and check `await hook.getConflict()` before duplicate-sensitive work — awaiting `getConflict()` suspends the workflow to commit the hook registration and resolves with the conflicting run when another active run already owns the token (or `null` once the hook is registered). See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for the full pattern, including how to steer an active run with `resumeHook()` and how to handle the current race between `start()` and hook registration.
101
82
 
102
83
  ## Key APIs
103
84
 
104
- - [`"use workflow"`](/docs/api-reference/workflow/use-workflow) -- declares the orchestrator function
105
- - [`"use step"`](/docs/api-reference/workflow/use-step) -- declares step functions with full Node.js access
106
- - [`getStepMetadata()`](/docs/api-reference/step/get-step-metadata) -- provides the deterministic `stepId` for idempotency keys
85
+ - [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) -- declares the orchestrator function
86
+ - [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) -- declares step functions with full Node.js access
87
+ - [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) -- provides the deterministic `stepId` for idempotency keys
88
+ - [`createHook()`](/docs/api-reference/workflow/create-hook) -- creates a hook with an optional deterministic token
89
+ - [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) -- finds the active hook for a token
90
+ - [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) -- resumes the active hook when the duplicate request carries data
107
91
  - [`start()`](/docs/api-reference/workflow-api/start) -- starts a new workflow run
@@ -224,5 +224,5 @@ export async function downloadWithRetry(url: string) {
224
224
  - [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions that run with full Node.js access
225
225
  - [`RetryableError`](/docs/api-reference/workflow/retryable-error) -- signals the runtime to retry after a delay
226
226
  - [`FatalError`](/docs/api-reference/workflow/fatal-error) -- signals a permanent failure, skipping retries
227
- - [`getStepMetadata()`](/docs/api-reference/step/get-step-metadata) -- provides the current attempt number and step ID
227
+ - [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) -- provides the current attempt number and step ID
228
228
  - [`sleep()`](/docs/api-reference/workflow/sleep) -- durable pause for circuit breaker cooldowns
@@ -241,7 +241,7 @@ export async function subscriptionUpgradeSaga(accountId: string, seats: number)
241
241
 
242
242
  ## Key APIs
243
243
 
244
- - [`"use workflow"`](/docs/api-reference/workflow/use-workflow) -- declares the orchestrator function
245
- - [`"use step"`](/docs/api-reference/workflow/use-step) -- declares step functions with full Node.js access
244
+ - [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) -- declares the orchestrator function
245
+ - [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) -- declares step functions with full Node.js access
246
246
  - [`FatalError`](/docs/api-reference/workflow/fatal-error) -- non-retryable error that triggers compensation
247
247
  - [`getWritable()`](/docs/api-reference/workflow/get-writable) -- streams data from workflows for real-time UI updates
@@ -7,6 +7,10 @@ summary: Schedule future actions with durable sleep that survives cold starts, a
7
7
 
8
8
  Workflow's `sleep()` is durable — it survives cold starts, restarts, and deployments. Combined with `defineHook()` and `Promise.race()`, it becomes the foundation for interruptible scheduled workflows like drip campaigns, reminders, and timed sequences.
9
9
 
10
+ <Callout type="info">
11
+ Scheduled workflows are still pinned to the deployment that started them. If you are building recurring or indefinitely running schedules that should adopt newer code over time, see [Versioning](/docs/foundations/versioning) for the explicit `deploymentId: "latest"` continuation pattern.
12
+ </Callout>
13
+
10
14
  ## When to use this
11
15
 
12
16
  - Sending emails on a schedule (drip campaigns, onboarding sequences, reminders)
@@ -102,6 +106,10 @@ export async function POST(req: Request) {
102
106
  3. **Race** — `Promise.race([sleep(...), hook])` blocks until either the timer fires or the hook is resumed, whichever comes first.
103
107
  4. **Fresh hooks per window** — after a sleep completes normally, the previous hook instance is consumed. A new `.create()` call registers a fresh hook for the next sleep window, reusing the same token.
104
108
 
109
+ <Callout type="info">
110
+ Deterministic hook tokens can also serve as the idempotency point for scheduled runs. If duplicate schedule starts would send duplicate campaigns or reminders, create a hook with a token derived from the campaign key near the beginning of the workflow and route retries through that hook. If two scheduled starts race, the duplicate run can detect the conflict early with `await hook.getConflict()`, which resolves with the active owner so the duplicate can defer to it. See [Idempotency](/docs/foundations/idempotency).
111
+ </Callout>
112
+
105
113
  ## Adapting to your use case
106
114
 
107
115
  - **Change durations** — replace `"2d"` with any duration string (`"1h"`, `"7d"`, `"30m"`) or a `Date` object for absolute times.
@@ -80,7 +80,7 @@ export async function waitForApproval(requestId: string) {
80
80
  4. **Throw to fail the workflow** — inside a workflow function, throwing an `Error` exits the run with that error. Use `FatalError` inside steps; throw plain errors inside workflows.
81
81
 
82
82
  <Callout type="warn">
83
- **The losing operation keeps running.** `Promise.race` doesn't cancel — when the sleep wins, the underlying step (or model call, or HTTP request) continues to completion in the background. This is fine for idempotent reads but matters when the operation has side effects or costs money. For hard cancellation across processes, see [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller).
83
+ **The losing operation keeps running.** `Promise.race` doesn't cancel — when the sleep wins, the underlying step (or model call, or HTTP request) continues to completion in the background. This is fine for idempotent reads but matters when the operation has side effects or costs money. Pass an `AbortSignal` into the step to cancel it cooperatively, and use idempotency keys for non-idempotent side effects. See the [Cancellation Guide](/docs/foundations/cancellation) and [Idempotency](/docs/foundations/idempotency) for patterns.
84
84
  </Callout>
85
85
 
86
86
  ## Adapting to your use case
@@ -96,4 +96,5 @@ export async function waitForApproval(requestId: string) {
96
96
  - [`sleep()`](/docs/api-reference/workflow/sleep) — durable wait (survives restarts, zero compute cost)
97
97
  - [`createWebhook()`](/docs/api-reference/workflow/create-webhook) — create a webhook URL the workflow can race against
98
98
  - [`defineHook()`](/docs/api-reference/workflow/define-hook) — typed hook for in-process cancellation
99
+ - [Idempotency](/docs/foundations/idempotency) — protect side effects that may keep running after a timeout
99
100
  - [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) — race operations against deadlines
@@ -5,11 +5,12 @@ type: guide
5
5
  summary: Compose workflows two ways — direct await flattens the child into the parent's event log, while background spawn via start() runs the child as an independent run.
6
6
  related:
7
7
  - /cookbook/advanced/child-workflows
8
+ - /cookbook/common-patterns/idempotency
8
9
  - /docs/api-reference/workflow-api/start
9
10
  - /docs/api-reference/workflow-api/get-run
10
11
  ---
11
12
 
12
- Workflows can call other workflows. Choose between two composition modes depending on whether the parent needs the child's result inline (direct await) or wants to fire the child off as an independent run (background spawn). For massive fan-out with polling and partial-failure handling, see [Child Workflows](/cookbook/advanced/child-workflows).
13
+ Workflows can call other workflows. Choose between two composition modes depending on whether the parent needs the child's result inline (direct await) or wants to fire the child off as an independent run (background spawn). For massive fan-out with hook-based waiting and partial-failure handling, see [Child Workflows](/cookbook/advanced/child-workflows).
13
14
 
14
15
  ## When to use this
15
16
 
@@ -53,7 +54,7 @@ The parent waits for the child to finish before continuing. Both functions share
53
54
 
54
55
  ### Background spawn via `start()`
55
56
 
56
- To run a child workflow independently without blocking the parent, call [`start()`](/docs/api-reference/workflow-api/start) from a step. This launches the child as a separate workflow run with its own `runId`.
57
+ To run a child workflow independently without blocking the parent, call [`start()`](/docs/api-reference/workflow-api/start) from the parent workflow. This launches the child as a separate workflow run with its own `runId`.
57
58
 
58
59
  ```typescript lineNumbers
59
60
  import { start } from "workflow/api";
@@ -62,37 +63,34 @@ declare function generateReport(reportId: string): Promise<void>; // @setup
62
63
  declare function fulfillOrder(orderId: string): Promise<{ id: string }>; // @setup
63
64
  declare function sendConfirmation(orderId: string): Promise<void>; // @setup
64
65
 
65
- async function triggerReportGeneration(reportId: string) {
66
- "use step"; // [!code highlight]
67
-
68
- const run = await start(generateReport, [reportId]); // [!code highlight]
69
- return run.runId;
70
- }
71
-
72
66
  export async function processOrder(orderId: string) {
73
67
  "use workflow";
74
68
 
75
69
  const order = await fulfillOrder(orderId);
76
70
 
77
- const reportRunId = await triggerReportGeneration(orderId); // [!code highlight]
71
+ const reportRun = await start(generateReport, [orderId]); // [!code highlight]
78
72
 
79
73
  await sendConfirmation(orderId);
80
74
 
81
- return { orderId, reportRunId };
75
+ return { orderId, reportRunId: reportRun.runId };
82
76
  }
83
77
  ```
84
78
 
85
79
  The parent continues immediately after `start()` returns. The child runs independently and can be monitored separately using the returned `runId` (e.g., via [`getRun()`](/docs/api-reference/workflow-api/get-run)).
86
80
 
87
81
  <Callout type="info">
88
- If you want the child workflow to run on the latest deployment rather than the current one, pass [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest) in the `start()` options. This is currently a Vercel-specific feature. Be aware that the child workflow's function name, file path, argument types, and return type must remain compatible across deployments renaming the function or changing its location will change the workflow ID, and modifying expected inputs or outputs can cause serialization failures.
82
+ Each background spawn creates a separate run. If duplicate requests must route to one active child workflow, have the child create a deterministic hook token from the business key and use that hook as the idempotency point. If concurrent starts race, the losing child can detect the conflict early with `await hook.getConflict()`, which resolves with the active owner so the child can point callers at it. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
83
+ </Callout>
84
+
85
+ <Callout type="info">
86
+ If you want the child workflow to run on the latest deployment rather than the current one, pass [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest) in the `start()` options. See [Versioning](/docs/foundations/versioning) for the full model. This is currently a Vercel-specific feature, and other Worlds may map the concept to their own deployment runtimes. Be aware that the child workflow's function name, file path, argument types, and return type must remain compatible across deployments — renaming the function or changing its location will change the workflow ID, and modifying expected inputs or outputs can cause serialization failures.
89
87
  </Callout>
90
88
 
91
89
  ## How it works
92
90
 
93
91
  1. **Direct await flattens.** When a workflow function awaits another workflow function, the child's `"use workflow"` directive is treated as inline — the child's steps emit into the parent's event log and share the parent's run ID.
94
92
  2. **`start()` mints a new run.** The child gets its own `runId`, its own event log, and its own retry boundary. The parent only sees the `runId` returned by `start()`.
95
- 3. **`start()` must be called from a step.** Calling `start()` directly from a workflow function is not allowed — wrap it in a `"use step"` function. This keeps the spawn deterministic across replays.
93
+ 3. **`start()` can run inside workflows.** In v5, `start()` is step-backed, so it can be called directly from a workflow function and still records a deterministic step boundary in the event log.
96
94
 
97
95
  ## Choosing between the two modes
98
96
 
@@ -106,9 +104,9 @@ If you want the child workflow to run on the latest deployment rather than the c
106
104
 
107
105
  ## Adapting to your use case
108
106
 
109
- - **Spawn many children at once** — call `start()` in a loop inside a step. For more advanced fan-out (chunking, polling, partial-failure handling), graduate to the [Child Workflows](/cookbook/advanced/child-workflows) recipe.
110
- - **Wait for a background child to finish** — combine `start()` with `getRun()` polling. The [Child Workflows](/cookbook/advanced/child-workflows) page covers the full polling loop.
111
- - **Pass results back from background children** — the spawn step returns the `runId`; later, a poll step uses `getRun(runId).returnValue` to fetch the final result.
107
+ - **Spawn many children at once** — call `start()` in a loop from the workflow. For more advanced fan-out (chunking, hook-based waiting, partial-failure handling), graduate to the [Child Workflows](/cookbook/advanced/child-workflows) recipe.
108
+ - **Wait for a background child to finish** — combine `start()` with a completion hook the child resumes when done. The [Child Workflows](/cookbook/advanced/child-workflows) page covers the recommended `startAndWait()` pattern.
109
+ - **Pass results back from background children** — the wrapped child resumes the parent's hook in `finally` with `{ status, value | error }`; the parent awaits the hook instead of polling `getRun().status`.
112
110
 
113
111
  ## Key APIs
114
112
 
@@ -116,3 +114,4 @@ If you want the child workflow to run on the latest deployment rather than the c
116
114
  - [`"use step"`](/docs/foundations/workflows-and-steps) — marks functions with full Node.js access
117
115
  - [`start()`](/docs/api-reference/workflow-api/start) — spawn a child workflow as a separate run
118
116
  - [`getRun()`](/docs/api-reference/workflow-api/get-run) — retrieve a workflow run's status and return value
117
+ - [Idempotency](/docs/foundations/idempotency) — deduplicate step side effects and workflow starts
@@ -8,7 +8,7 @@ A curated collection of workflow patterns with clean, copy-paste code examples f
8
8
 
9
9
  ## Agent Patterns
10
10
 
11
- - [**Durable Agent**](/cookbook/agent-patterns/durable-agent) — Replace a stateless AI agent with one that survives crashes and retries tool calls
11
+ - [**WorkflowAgent**](/cookbook/agent-patterns/durable-agent) — Build durable, resumable AI agents with AI SDK's WorkflowAgent
12
12
  - [**Human-in-the-Loop**](/cookbook/agent-patterns/human-in-the-loop) — Pause an agent for human approval, then resume based on the decision
13
13
  - [**Agent Cancellation**](/cookbook/agent-patterns/agent-cancellation) — Stop a running agent immediately via `run.cancel()` or gracefully via a hook + `Promise.race`
14
14
 
@@ -21,7 +21,7 @@ A curated collection of workflow patterns with clean, copy-paste code examples f
21
21
  - [**Rate Limiting**](/cookbook/common-patterns/rate-limiting) — Handle 429 responses and transient failures with RetryableError and backoff
22
22
  - [**Scheduling**](/cookbook/common-patterns/scheduling) — Use durable sleep to schedule actions minutes, hours, or weeks ahead
23
23
  - [**Timeouts**](/cookbook/common-patterns/timeouts) — Add deadlines to slow steps, hooks, and webhooks by racing them against a durable sleep
24
- - [**Idempotency**](/cookbook/common-patterns/idempotency) — Ensure side effects happen exactly once, even when steps retry
24
+ - [**Idempotency**](/cookbook/common-patterns/idempotency) — Ensure side effects and duplicate starts are safe to retry
25
25
  - [**Webhooks**](/cookbook/common-patterns/webhooks) — Receive HTTP callbacks from external services and process them durably
26
26
 
27
27
  ## Integrations
@@ -33,6 +33,6 @@ A curated collection of workflow patterns with clean, copy-paste code examples f
33
33
  ## Advanced
34
34
 
35
35
  - [**Child Workflows**](/cookbook/advanced/child-workflows) — Spawn and orchestrate child workflows from a parent
36
- - [**Distributed Abort Controller**](/cookbook/advanced/distributed-abort-controller) — Build a cross-process abort controller using workflow streams and hooks
36
+ - [**Upgrading Workflows**](/cookbook/advanced/upgrading-workflows) — Identify a clean upgrade point in a long-running workflow and spawn a fresh run on the latest deployment carrying state forward
37
37
  - [**Serializable Steps**](/cookbook/advanced/serializable-steps) — Wrap non-serializable third-party objects so they cross the workflow boundary
38
38
  - [**Publishing Libraries**](/cookbook/advanced/publishing-libraries) — Ship npm packages that export reusable workflow functions
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  title: AI SDK
3
- description: Use AI SDK's streamText directly inside durable workflows for lower-level control over model calls and tool execution.
3
+ description: Use AI SDK's streamText directly inside durable workflows when you need the raw AI SDK API or a per-turn durability boundary.
4
4
  type: guide
5
- summary: Use streamText() inside a workflow for full control over model options, stop conditions, and output schemas while tools remain durable steps.
5
+ summary: Use streamText() inside a workflow when the durability boundary is an entire user turn, or when you need AI SDK APIs not exposed by WorkflowAgent. Individual tool calls and LLM calls inside a turn are not separately durable.
6
6
  related:
7
7
  - /docs/ai
8
8
  - /docs/ai/chat-session-modeling
@@ -11,27 +11,32 @@ related:
11
11
  - /docs/api-reference/workflow-ai/durable-agent
12
12
  ---
13
13
 
14
- [AI SDK](https://ai-sdk.dev/) is Vercel's framework-agnostic TypeScript toolkit for building AI-powered apps and agents — unified provider access, streaming, tool calling, structured output, and UI hooks. Workflow SDK complements it by making those calls durable: the model request, the tool loop, and the multi-turn conversation all survive restarts and timeouts.
14
+ [AI SDK](https://ai-sdk.dev/) is Vercel's framework-agnostic TypeScript toolkit for building AI-powered apps and agents — unified provider access, streaming, tool calling, structured output, and UI hooks. Workflow SDK complements it by making the multi-turn loop durable: the conversation state, hooks, and per-turn responses survive restarts and timeouts. Note that in this pattern the durability boundary is the entire turn — individual tool calls inside a turn are **not** durable on their own (see [Pitfalls](#tools-are-not-individually-durable) below).
15
15
 
16
16
  For the full AI SDK reference (providers, `streamText`, `generateObject`, `useChat`, tool calling, etc.) see the [AI SDK docs](https://ai-sdk.dev/docs). This page covers the Workflow-specific integration points.
17
17
 
18
18
  <Callout type="info">
19
- For most agent use cases, prefer [`DurableAgent`](/cookbook/agent-patterns/durable-agent) which wraps [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) and manages the tool loop automatically. This page covers using `streamText()` directly when you need lower-level control.
19
+ For most agent use cases, prefer AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent), which implements the same agent loop as [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text), manages tool calling automatically, and runs tools at workflow scope — each tool can be marked `"use step"` for per-call durability and retries, or stay at workflow level to use primitives like `sleep()` and hooks. Use this page's raw `streamText()` pattern when you want the exact AI SDK API (for example `toUIMessageStream()`, `onChunk`, or `generateText`), or when the durability boundary should be an entire user turn in one step — accepting that tool calls inside that turn are not individually durable.
20
20
  </Callout>
21
21
 
22
22
  ## When to use streamText directly
23
23
 
24
- Use [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) instead of `DurableAgent` when you need:
24
+ Use [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) instead of `WorkflowAgent` when you need:
25
25
 
26
- * **Custom stop conditions** — [`stopWhen`](https://ai-sdk.dev/docs/ai-sdk-core/agents#stop-conditions), [`prepareStep`](https://ai-sdk.dev/docs/ai-sdk-core/agents#prepare-step), or [`onStepFinish`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text#on-step-finish) callbacks
27
- * **Structured output** — [`Output.object()`](https://ai-sdk.dev/docs/ai-sdk-core/generating-structured-data) or `Output.array()` alongside tool calling
28
- * **Step-level callbacks** — `onStepFinish` for logging, metrics, or branching logic
29
- * **Provider options** — per-step model switching, reasoning budgets, or custom [provider options](https://ai-sdk.dev/docs/ai-sdk-core/provider-options)
26
+ * **The raw AI SDK API** — `streamText().toUIMessageStream()`, `onChunk`, `smoothStream`, or other options that map directly to the [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) return value rather than `WorkflowAgent.stream()`
27
+ * **Per-turn durability** — wrap the entire agent response (model + tools) in a single `"use step"` function so one user turn is the atomic retry unit; useful when you want all tool calls inside a turn to re-execute together
28
+ * **Custom multi-turn orchestration** — manual hook loops, per-turn stream slicing (`sliceUntilFinish`), or other workflow patterns shown below that don't map cleanly to `WorkflowAgent`
29
+
30
+ `WorkflowAgent` already supports `stopWhen`, `prepareStep`, lifecycle callbacks, structured output (`output`), per-step model switching, and [provider options](https://ai-sdk.dev/docs/ai-sdk-core/provider-options). See the [`WorkflowAgent` docs](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent).
30
31
 
31
32
  ## Multi-turn pattern
32
33
 
33
34
  One workflow run = one full conversation. The workflow suspends between turns on a hook and resumes when the next user message arrives. Conversation state, tool history, and intermediate computation all live inside the run.
34
35
 
36
+ <Callout type="info">
37
+ Because the conversation is one workflow run, it stays on the deployment that started it. If each turn should run on the latest deployment while preserving selected state or streams, see [Versioning](/docs/foundations/versioning) for the child-run continuation pattern.
38
+ </Callout>
39
+
35
40
  <Tabs items={['Workflow', 'API Route', 'Client']}>
36
41
 
37
42
  <Tab value="Workflow">
@@ -48,14 +53,15 @@ export const turnHook = defineHook({ // [!code highlight]
48
53
  schema: z.object({ message: z.string() }),
49
54
  });
50
55
 
56
+ // `streamText` runs tool executes inside `runTurn` (a step), so tool calls
57
+ // are not individually durable — the entire turn retries together. See
58
+ // "Tools are not individually durable" below. Make side-effectful tools idempotent.
51
59
  async function lookupOrder({ orderId }: { orderId: string }) {
52
- "use step";
53
60
  const res = await fetch(`https://api.store.com/orders/${orderId}`);
54
61
  return res.json();
55
62
  }
56
63
 
57
64
  async function processRefund({ orderId, reason }: { orderId: string; reason: string }) {
58
- "use step";
59
65
  const res = await fetch("https://api.store.com/refunds", {
60
66
  method: "POST",
61
67
  body: JSON.stringify({ orderId, reason }),
@@ -124,6 +130,10 @@ export async function supportWorkflow(initialMessages: ModelMessage[]) {
124
130
 
125
131
  One endpoint handles first turn, follow-ups, and the `/done` exit. The client sends `runId` in the body to distinguish first vs follow-up.
126
132
 
133
+ <Callout type="info">
134
+ The first turn calls `start()` and then returns the `runId`. If your client or platform can retry that first request before it receives and stores the `runId`, use an atomic conversation or request key before starting the workflow. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
135
+ </Callout>
136
+
127
137
  ```typescript title="app/api/support/route.ts" lineNumbers
128
138
  import type { UIMessage, UIMessageChunk } from "ai";
129
139
  import { convertToModelMessages, createUIMessageStreamResponse } from "ai";
@@ -240,7 +250,7 @@ Store the `runId` in a ref and pass it in the body of every follow-up. `Workflow
240
250
  "use client";
241
251
 
242
252
  import { useChat } from "@ai-sdk/react";
243
- import { WorkflowChatTransport } from "@workflow/ai";
253
+ import { WorkflowChatTransport } from "@ai-sdk/workflow";
244
254
  import { useMemo, useRef, useState } from "react";
245
255
 
246
256
  export function SupportChat() {
@@ -290,16 +300,32 @@ export function SupportChat() {
290
300
  ## How it works
291
301
 
292
302
  1. **One workflow = one conversation.** The workflow loops on a hook, keeping `allMessages`, tool history, and state alive across turns.
293
- 2. **Hook is created once.** `turnHook.create({ token: workflowRunId })` outside the loop calling it twice with the same token throws `HookConflictError`.
294
- 3. **`preventClose: true`** on `pipeTo` keeps the durable writable open so the next turn can write to it.
295
- 4. **`sliceUntilFinish`** in the API reads chunks until `type === "finish"`, then closes the HTTP response. The source reader is released not cancelled — so the workflow stream keeps flowing.
296
- 5. **`startIndex: tailIndex + 1`** gives each follow-up response only the new chunks, avoiding replay of previous turns.
297
- 6. **`/done`** resumes the hook so the workflow exits cleanly, then returns a synthetic `start` + `finish` so `useChat` transitions out of "streaming".
303
+ 2. **`runTurn` is the durability boundary.** Each turn is one step. The model request and all tool calls inside it run as plain inline functions within that step. If anything throws mid-turn, the whole `runTurn` retries — individual tool calls are not separately durable. See [Pitfalls](#tools-are-not-individually-durable).
304
+ 3. **Hook is created once.** `turnHook.create({ token: workflowRunId })` outside the loop calling it twice with the same token throws `HookConflictError`.
305
+ 4. **`preventClose: true`** on `pipeTo` keeps the durable writable open so the next turn can write to it.
306
+ 5. **`sliceUntilFinish`** in the API reads chunks until `type === "finish"`, then closes the HTTP response. The source reader is released — not cancelled — so the workflow stream keeps flowing.
307
+ 6. **`startIndex: tailIndex + 1`** gives each follow-up response only the new chunks, avoiding replay of previous turns.
308
+ 7. **`/done`** resumes the hook so the workflow exits cleanly, then returns a synthetic `start` + `finish` so `useChat` transitions out of "streaming".
298
309
 
299
310
  ## Pitfalls
300
311
 
301
312
  Non-obvious correctness details worth knowing before adapting this pattern.
302
313
 
314
+ ### Tools are not individually durable
315
+
316
+ `streamText()` is invoked from inside `runTurn` (a `"use step"` function), and the AI SDK calls each tool by directly invoking its `execute` function in that same step. Even if a tool body has its own `"use step"` directive, that directive is a [no-op when called from another step](/docs/foundations/workflows-and-steps#step-functions) — the function just runs inline.
317
+
318
+ The consequences:
319
+
320
+ - The atomic retry unit is the entire `runTurn`, not the individual tool call.
321
+ - If `processRefund` succeeds and then the model call (or a later tool) throws, the whole turn retries, and `processRefund` will run again.
322
+ - Tool calls do not appear as separate entries in the event log or observability dashboard.
323
+
324
+ **Mitigations:**
325
+
326
+ - Make side-effectful tool implementations idempotent — dedupe server-side on a stable key (e.g. `orderId`, an `Idempotency-Key` header, etc.).
327
+ - Or use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent), which runs tools at workflow scope — each tool can be marked `"use step"` to become its own durable, retryable step, or stay at workflow level to use primitives like `sleep()` and hooks.
328
+
303
329
  ### Snapshot `tailIndex` *before* resuming the hook
304
330
 
305
331
  {/* @skip-typecheck - fragment referencing variables from the surrounding multi-turn pattern */}
@@ -328,33 +354,39 @@ In `sliceUntilFinish`, use `reader.releaseLock()` in the `finally` block rather
328
354
 
329
355
  Clients can send a `runId` from a long-gone workflow (localStorage, back button, server restart). Wrap the follow-up path in a try/catch for `not found` / `expired` and fall through to the first-turn code path to start a fresh workflow.
330
356
 
331
- ## streamText vs DurableAgent
357
+ ### Make the first turn idempotent when needed
358
+
359
+ This example stores the `runId` after the first response. For strict one-session-per-thread behavior, use a deterministic hook token derived from the thread ID or conversation ID and route retries through the active hook. See [Idempotency](/docs/foundations/idempotency).
360
+
361
+ ## streamText vs WorkflowAgent
332
362
 
333
- | | `streamText()` | `DurableAgent` |
363
+ | | `streamText()` (this pattern) | `WorkflowAgent` |
334
364
  |---|---|---|
335
- | **Tool loop** | AI SDK handles via `stopWhen` | DurableAgent handles internally |
336
- | **LLM call durability** | Re-executes on replay | Each LLM call is a durable step |
337
- | **Stop conditions** | `stopWhen`, `prepareStep` | `prepareStep` only |
338
- | **Structured output** | `Output.object()`, `Output.array()` | Not available |
339
- | **Step callbacks** | `onStepFinish`, `onChunk` | Not available |
340
- | **Setup** | Manual stream piping | Automatic |
365
+ | **Tool loop** | AI SDK handles via `stopWhen` | Handles internally (AI SDK–compatible options) |
366
+ | **LLM call durability** | Re-executes with the parent turn | Each LLM call is a durable step |
367
+ | **Tool call durability** | Not individually durable — re-executes with the parent turn | Per tool — mark `"use step"` for a durable, retryable step, or keep at workflow level for `sleep()` / hooks |
368
+ | **Stop conditions** | `stopWhen`, `prepareStep` | `stopWhen`, `prepareStep` |
369
+ | **Structured output** | `Output.object()`, `Output.array()` | `output` (`Output.object()`, `Output.text()`) |
370
+ | **Step callbacks** | `onStepFinish`, `onChunk`, etc. | `onStepFinish`, `onFinish`, `onError`, `onAbort` (`onChunk` not available) |
371
+ | **Setup** | Manual stream piping and turn slicing | Automatic |
341
372
 
342
- Use `DurableAgent` for most agent use cases. Use `streamText` when you need the additional control.
373
+ Use `WorkflowAgent` for most agent use cases. Use `streamText` when you need the raw AI SDK surface or a per-turn durability boundary.
343
374
 
344
375
  ## Key APIs
345
376
 
346
377
  **AI SDK** ([docs](https://ai-sdk.dev/docs))
347
378
 
348
379
  * [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) — core streaming function; `toUIMessageStream()` pipes into the durable writable
349
- * [`tool()` / tool calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) — tools wrap `"use step"` functions so each tool call is replayed from the log, not re-executed
380
+ * [`tool()` / tool calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) — tools are plain async functions invoked by `streamText` inside the turn step; they are **not** individually durable in this pattern (see [Pitfalls](#tools-are-not-individually-durable))
350
381
  * [`stepCountIs()` / `stopWhen`](https://ai-sdk.dev/docs/ai-sdk-core/agents#stop-conditions) — bound the agent loop inside each turn
351
382
  * [`convertToModelMessages()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/convert-to-model-messages) / [`createUIMessageStreamResponse()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/create-ui-message-stream-response) — UI ↔ model message conversion at the API boundary
352
383
  * [`useChat()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat) — React hook that consumes the UI message stream on the client
353
384
 
354
385
  **Workflow SDK**
355
386
 
356
- * [`"use step"`](/docs/api-reference/workflow/use-step) — makes tool executions durable
387
+ * [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — applied to `runTurn` to make each turn a durable, retryable unit
357
388
  * [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspension point for follow-up messages
358
389
  * [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable stream output
359
390
  * [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.getReadable({ startIndex })` for slicing per-turn streams
360
391
  * [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport) — passes `runId` between turns
392
+ * [Idempotency](/docs/foundations/idempotency) — protect duplicate-sensitive first turns and side effects
@@ -20,6 +20,10 @@ Workflow SDK complements it by making bot **sessions** durable. Each conversatio
20
20
  - Survives deploys, cold starts, and crashes — the session picks up from the last step on replay
21
21
  - Receives follow-up messages via hooks, so the bot stays responsive while the workflow is still running
22
22
 
23
+ <Callout type="info">
24
+ One thread mapped to one workflow run also means the thread stays on the deployment that started it. For channels where each message should use newer code, see [Versioning](/docs/foundations/versioning) for explicit child-run and handoff patterns using `deploymentId: "latest"`.
25
+ </Callout>
26
+
23
27
  The rest of this page covers the integration pattern. For a full Slack + Next.js + Redis walkthrough, see the [Durable chat sessions guide](https://chat-sdk.dev/docs/guides/durable-chat-sessions-nextjs) on chat-sdk.dev.
24
28
 
25
29
  ## How It Fits Together
@@ -172,6 +176,10 @@ export type ChatTurnPayload = {
172
176
 
173
177
  Handlers live outside the workflow file so adapter dependencies don't leak in. They decide whether to start a new workflow or resume an existing one, then store the `runId` in thread state:
174
178
 
179
+ <Callout type="info">
180
+ If the platform can deliver the same first message concurrently, use a deterministic hook token derived from the thread ID so duplicate handlers route to the active chat session hook. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
181
+ </Callout>
182
+
175
183
  ```typescript title="lib/chat-session-handlers.ts" lineNumbers
176
184
  import type { Message, Thread } from "chat";
177
185
  import { getRun, resumeHook, start } from "workflow/api";
@@ -285,6 +293,10 @@ Adapter packages (`@chat-adapter/slack`, `@chat-adapter/telegram`, etc.) depend
285
293
 
286
294
  A workflow run ends but its `runId` is still cached in thread state. The next message calls `resumeHook` on a dead run and throws `not found` / `expired`. Gate on `getRun(runId).exists` before resuming, or catch the error and fall through to `startSession`. Either way the user's message must not be dropped.
287
295
 
296
+ ### Make first-message routing atomic
297
+
298
+ Thread state is also the idempotency boundary for starting sessions. Back it with a state adapter or database operation that can atomically claim the thread before `startSession()` runs when duplicate sessions would be harmful.
299
+
288
300
  ### Keep the hook outside the loop
289
301
 
290
302
  One `chatTurnHook.create({ token: workflowRunId })` per workflow run, reused every iteration. Creating a new hook with the same token throws `HookConflictError`. This is the same rule as the [AI SDK](/docs/cookbook/integrations/ai-sdk) and [Sandbox](/docs/cookbook/integrations/sandbox) session patterns.
@@ -301,3 +313,4 @@ Slack wants a 200 within 3 seconds. The webhook handler returns immediately afte
301
313
  - [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.exists` before resuming, to detect stale `runId`s.
302
314
  - [`defineHook()`](/docs/api-reference/workflow/define-hook) — per-turn suspension point inside the workflow.
303
315
  - [`registerSingleton()`](https://chat-sdk.dev/docs/api/chat) — makes the bot resolvable from inside step functions.
316
+ - [Idempotency](/docs/foundations/idempotency) — protect duplicate-sensitive first messages and side effects.
@@ -21,6 +21,10 @@ A sandbox alone gets you an isolated VM. A workflow around it gets you a **durab
21
21
  - **Beyond the 5-hour hard cap.** Every Vercel Sandbox has a maximum lifetime. The workflow tracks that deadline and proactively snapshots + recreates *before* the cap, so the logical session outlives any one VM. Effectively unbounded session duration on top of time-bounded infrastructure.
22
22
  - **Automatic cleanup.** `try/finally` in the workflow guarantees the VM is stopped on failure or destroy.
23
23
 
24
+ <Callout type="info">
25
+ An effectively unbounded sandbox session is still one workflow run, so it stays on the deployment that started it. If the controller or agent code should upgrade over time, use an explicit version boundary and pass the serialized state or stream handles forward. See [Versioning](/docs/foundations/versioning).
26
+ </Callout>
27
+
24
28
  ## Use Case: Coding Agents
25
29
 
26
30
  This is the pattern [Open Agents](https://open-agents.dev/) uses to spawn coding agents that run "infinitely in the cloud." Each agent session gets its own sandbox — full filesystem, network, and runtime access — and the durable workflow keeps the agent loop resumable across restarts, auto-hibernates when the user walks away, and reconnects instantly when they return.
@@ -296,6 +300,10 @@ export async function sandboxSessionWorkflow() {
296
300
 
297
301
  Two endpoints. `/start` accepts an optional `{ runId }` — if the run still exists, it replays the event log from index 0 so a returning client fully rehydrates. `/command` resumes the hook and returns immediately; command output lands on the `/start` stream.
298
302
 
303
+ <Callout type="info">
304
+ This example starts a fresh sandbox session when no `runId` is provided. If your product needs one sandbox session per user, project, or task, use a deterministic hook token derived from that session key and route retries through the active hook. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
305
+ </Callout>
306
+
299
307
  ```typescript title="app/api/sandbox/start/route.ts" lineNumbers
300
308
  import { start, getRun } from "workflow/api";
301
309
  import { sandboxSessionWorkflow } from "@/workflows/sandbox-session";
@@ -501,6 +509,10 @@ Stream closure must happen inside a `"use step"` function. Calling `writable.clo
501
509
 
502
510
  Clients can hold `runId`s from long-gone workflow runs (localStorage, back button, server restart). Gate the reconnect path on `run.exists` and fall through to starting fresh. On `hook.resume`, catch `not found` / `expired` and return 410 so the client clears its state.
503
511
 
512
+ ### Decide whether `/start` should be idempotent
513
+
514
+ The sample treats a missing or stale `runId` as a request for a new session. For one-session-per-resource behavior, use a durable resource key, such as `projectId` or `taskId`, to claim or retrieve the run before starting a new one.
515
+
504
516
  ### Keep the hook outside the loop
505
517
 
506
518
  Each iteration's `hook.then(...)` attaches a listener to the same hook instance. Creating a new hook per iteration with the same token throws `HookConflictError`. One hook, one token (`workflowRunId`), reused every iteration.
@@ -514,3 +526,4 @@ Each iteration's `hook.then(...)` attaches a listener to the same hook instance.
514
526
  - [`sleep()`](/docs/api-reference/workflow/sleep) — durable timer that powers both idle hibernation and proactive refresh
515
527
  - [`getRun()`](/docs/api-reference/workflow-api/get-run) — look up a run and replay its event log for reconnection
516
528
  - [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable NDJSON event stream
529
+ - [Idempotency](/docs/foundations/idempotency) — choose when `/start` should reuse an existing run
@@ -93,7 +93,7 @@ interface Storage {
93
93
 
94
94
  **Run Creation:** For `run_created` events, the `runId` parameter may be a client-provided string or `null`. When `null`, your World generates and returns a new `runId`.
95
95
 
96
- **Hook Tokens:** Hook tokens must be unique. If a `hook_created` event conflicts with an existing token, return a `hook_conflict` event instead.
96
+ **Hook Tokens:** Hook tokens must be unique. If a `hook_created` event conflicts with an existing token, return a `hook_conflict` event instead and include the active hook owner's run ID as `eventData.conflictingRunId`.
97
97
 
98
98
  **Automatic Hook Disposal:** When a workflow reaches a terminal state (`completed`, `failed`, or `cancelled`), automatically dispose of all associated hooks to release tokens for reuse.
99
99
 
@@ -4,6 +4,7 @@ icon: Rocket
4
4
  description: Deploy workflows locally, on Vercel, or anywhere using pluggable World adapters.
5
5
  type: overview
6
6
  summary: Learn how to deploy workflows to different environments using World adapters.
7
+ manualCards: true
7
8
  related:
8
9
  - /docs/deploying/world/local-world
9
10
  - /docs/deploying/world/postgres-world