workflow 5.0.0-beta.15 → 5.0.0-beta.17

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 (36) hide show
  1. package/docs/api-reference/vitest/index.mdx +1 -1
  2. package/docs/api-reference/workflow/create-hook.mdx +7 -1
  3. package/docs/api-reference/workflow/fetch.mdx +5 -0
  4. package/docs/api-reference/workflow-api/get-hook-by-token.mdx +7 -0
  5. package/docs/api-reference/workflow-api/get-run.mdx +6 -0
  6. package/docs/api-reference/workflow-api/resume-hook.mdx +57 -0
  7. package/docs/api-reference/workflow-api/start.mdx +4 -1
  8. package/docs/api-reference/workflow-errors/index.mdx +85 -0
  9. package/docs/api-reference/workflow-next/with-workflow.mdx +2 -2
  10. package/docs/api-reference/workflow-runtime/world/storage.mdx +6 -0
  11. package/docs/changelog/index.mdx +1 -1
  12. package/docs/cookbook/advanced/child-workflows.mdx +3 -1
  13. package/docs/cookbook/advanced/publishing-libraries.mdx +13 -12
  14. package/docs/cookbook/advanced/serializable-steps.mdx +3 -3
  15. package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +2 -2
  16. package/docs/cookbook/common-patterns/idempotency.mdx +36 -52
  17. package/docs/cookbook/common-patterns/rate-limiting.mdx +1 -1
  18. package/docs/cookbook/common-patterns/saga.mdx +2 -2
  19. package/docs/cookbook/common-patterns/scheduling.mdx +4 -0
  20. package/docs/cookbook/common-patterns/timeouts.mdx +2 -1
  21. package/docs/cookbook/common-patterns/workflow-composition.mdx +6 -0
  22. package/docs/cookbook/index.mdx +1 -1
  23. package/docs/cookbook/integrations/ai-sdk.mdx +10 -1
  24. package/docs/cookbook/integrations/chat-sdk.mdx +9 -0
  25. package/docs/cookbook/integrations/sandbox.mdx +9 -0
  26. package/docs/errors/step-not-registered.mdx +1 -1
  27. package/docs/foundations/cancellation.mdx +1 -2
  28. package/docs/foundations/hooks.mdx +1 -1
  29. package/docs/foundations/idempotency.mdx +236 -11
  30. package/docs/migration-guides/migrating-from-temporal.mdx +1 -1
  31. package/docs/observability/attributes.mdx +14 -0
  32. package/docs/observability/index.mdx +3 -0
  33. package/docs/observability/meta.json +4 -1
  34. package/docs/observability/tracing.mdx +106 -0
  35. package/docs/testing/index.mdx +2 -2
  36. package/package.json +12 -12
@@ -69,7 +69,7 @@ export async function setup() {
69
69
 
70
70
  ### `setupWorkflowTests()`
71
71
 
72
- Sets up an in-process workflow runtime in each test worker. Imports pre-built bundles, creates a [Local World](/docs/worlds/local) instance with direct handlers, and sets it as the global world. Clears all workflow data on each invocation for full test isolation.
72
+ Sets up an in-process workflow runtime in each test worker. Imports pre-built bundles, creates a [Local World](/worlds/local) instance with direct handlers, and sets it as the global world. Clears all workflow data on each invocation for full test isolation.
73
73
 
74
74
  Called automatically by the `workflow()` plugin in `setupFiles`. Use directly only for [manual setup](/docs/testing#manual-setup).
75
75
 
@@ -8,6 +8,7 @@ prerequisites:
8
8
  related:
9
9
  - /docs/api-reference/workflow/define-hook
10
10
  - /docs/api-reference/workflow/create-webhook
11
+ - /docs/foundations/idempotency
11
12
  ---
12
13
 
13
14
  Creates a low-level hook primitive that can be used to resume a workflow run with arbitrary payloads.
@@ -142,7 +143,11 @@ async function processOrder(orderId: string) {
142
143
 
143
144
  Because `createHook()` alone does not suspend the workflow, awaiting `hook.getConflict()` is what actually suspends the run and commits the hook registration. It only waits for registration — to receive payload data from a future `resumeHook()` call, await the hook itself or iterate it with `for await...of`.
144
145
 
145
- On a conflict, the resolved value is a `Run` handle for the run that currently owns the token, with durable step-backed accessors. The duplicate run can decide in code how to handle it: return or log `conflict.runId`, inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` and continue in the current run. See [Idempotency](/docs/foundations/idempotency) for these strategies in context.
146
+ On a conflict, the resolved value is a `Run` handle for the run that currently owns the token, with durable step-backed accessors. The duplicate run can decide in code how to handle it: return or log `conflict.runId`, inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` and continue in the current run. See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies in context.
147
+
148
+ <Callout type="info">
149
+ Custom hook tokens are the recommended way to coordinate active workflow runs. Use a deterministic token from your domain, such as an order ID or conversation ID, create the hook near the beginning of the workflow, and check `await hook.getConflict()` before work that depends on owning the token. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
150
+ </Callout>
146
151
 
147
152
  ### Waiting for Multiple Payloads
148
153
 
@@ -227,3 +232,4 @@ This is equivalent to manually calling `dispose()` but ensures the hook is alway
227
232
  - [`defineHook()`](/docs/api-reference/workflow/define-hook) - Type-safe hook helper
228
233
  - [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) - Resume a hook with a payload
229
234
  - [`createWebhook()`](/docs/api-reference/workflow/create-webhook) - Higher-level HTTP webhook abstraction
235
+ - [Idempotency](/docs/foundations/idempotency) - Deduplicate step side effects and workflow starts
@@ -7,12 +7,17 @@ prerequisites:
7
7
  - /docs/foundations/workflows-and-steps
8
8
  related:
9
9
  - /docs/errors/fetch-in-workflow
10
+ - /docs/foundations/idempotency
10
11
  ---
11
12
 
12
13
  Makes HTTP requests from within a workflow. This is a special step function that wraps the standard `fetch` API, automatically handling serialization and providing retry semantics.
13
14
 
14
15
  This is useful when you need to call external APIs or services from within your workflow.
15
16
 
17
+ <Callout type="warn">
18
+ Because workflow `fetch()` has retry semantics, use idempotency keys when the request mutates an external system, such as creating a charge, sending an email, or enqueueing work. See [Idempotency](/docs/foundations/idempotency).
19
+ </Callout>
20
+
16
21
  <Callout>
17
22
  `fetch` is a *special* type of step function provided and should be called directly inside workflow functions.
18
23
  </Callout>
@@ -5,6 +5,8 @@ type: reference
5
5
  summary: Use getHookByToken to look up a hook's metadata and associated workflow run before resuming it.
6
6
  prerequisites:
7
7
  - /docs/foundations/hooks
8
+ related:
9
+ - /docs/foundations/idempotency
8
10
  ---
9
11
 
10
12
  Retrieves a hook by its unique token, returning the associated workflow run information and any metadata that was set when the hook was created. This function is useful for inspecting hook details before deciding whether to resume a workflow.
@@ -13,6 +15,10 @@ Retrieves a hook by its unique token, returning the associated workflow run info
13
15
  `getHookByToken` is a runtime function that must be called from outside a workflow function.
14
16
  </Callout>
15
17
 
18
+ <Callout type="info">
19
+ Looking up a deterministic hook token is useful in hook-based idempotency flows, but it is only an advisory check. If no hook exists yet, another request can still start the same workflow before your `start()` call registers its hook. Use the lookup to avoid obvious duplicate starts, and handle the race inside the workflow by checking `await hook.getConflict()` before duplicate-sensitive work — on a conflict it resolves with the run that owns the token, so the duplicate can route the caller to the active owner. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
20
+ </Callout>
21
+
16
22
  ```typescript lineNumbers
17
23
  import { getHookByToken } from "workflow/api";
18
24
 
@@ -178,3 +184,4 @@ export async function POST(request: Request) {
178
184
  - [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) - Resume a hook with a payload.
179
185
  - [`createHook()`](/docs/api-reference/workflow/create-hook) - Create a hook in a workflow.
180
186
  - [`defineHook()`](/docs/api-reference/workflow/define-hook) - Type-safe hook helper.
187
+ - [Idempotency](/docs/foundations/idempotency) - Deduplicate step side effects and workflow starts.
@@ -5,12 +5,18 @@ type: reference
5
5
  summary: Use getRun to check a workflow run's status and metadata without blocking on completion.
6
6
  prerequisites:
7
7
  - /docs/foundations/starting-workflows
8
+ related:
9
+ - /docs/foundations/idempotency
8
10
  ---
9
11
 
10
12
  Retrieves the workflow run metadata and status information for a given run ID. This function provides immediate access to workflow run details without waiting for completion, making it ideal for status checking and monitoring.
11
13
 
12
14
  Use this function when you need to check workflow status, get timing information, or access workflow metadata without blocking on workflow completion.
13
15
 
16
+ <Callout type="info">
17
+ `getRun()` retrieves a run when you already have its `runId`. It does not look up runs by a business key. For retried requests that should route to one active workflow, use a deterministic hook token and [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token). After a hook conflict, `HookConflictError.conflictingRunId` can be passed to `getRun()` to inspect, stream, or return the active owner. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
18
+ </Callout>
19
+
14
20
  ```typescript lineNumbers
15
21
  import { getRun } from "workflow/api";
16
22
 
@@ -7,6 +7,7 @@ prerequisites:
7
7
  - /docs/foundations/hooks
8
8
  related:
9
9
  - /docs/api-reference/workflow-api/resume-webhook
10
+ - /docs/foundations/idempotency
10
11
  ---
11
12
 
12
13
  Resumes a workflow run by sending a payload to a hook identified by its token.
@@ -155,8 +156,64 @@ export async function POST(request: Request) {
155
156
  }
156
157
  ```
157
158
 
159
+ ### Resume or Start
160
+
161
+ A common endpoint shape is "resume or start": one route that resumes the active workflow run for a business key if one exists, or starts a new run otherwise. This comes up when the workflow uses a deterministic hook token as its idempotency key — for example, one active run per order or conversation.
162
+
163
+ `resumeHook()` is the resume half of that flow. Try it first; if it throws `HookNotFoundError`, no active run owns the token yet, so start the workflow. One subtlety: `start()` returns before the new run executes and registers its hook, so you cannot resume immediately after starting. Retry the resume until the hook is registered — if you drop the payload and only start the workflow, the data from this request is lost.
164
+
165
+ ```typescript lineNumbers
166
+ import { resumeHook, start } from "workflow/api";
167
+ import { HookNotFoundError } from "workflow/errors";
168
+ import { processOrder } from "./workflows/process-order";
169
+
170
+ type OrderRequest = { confirmed: boolean };
171
+
172
+ async function resumeWithRetry(token: string, payload: OrderRequest) {
173
+ for (let attempt = 0; attempt < 5; attempt++) {
174
+ try {
175
+ return await resumeHook(token, payload); // [!code highlight]
176
+ } catch (error) {
177
+ if (!HookNotFoundError.is(error)) throw error;
178
+ await new Promise((resolve) => setTimeout(resolve, 100));
179
+ }
180
+ }
181
+
182
+ throw new Error("Workflow did not register its hook in time");
183
+ }
184
+
185
+ export async function POST(request: Request) {
186
+ const { orderId, confirmed } = await request.json();
187
+ const token = `order:${orderId}`;
188
+ const payload = { confirmed };
189
+
190
+ try {
191
+ // An active run already owns this token: resume it.
192
+ const hook = await resumeHook(token, payload); // [!code highlight]
193
+ return Response.json({ runId: hook.runId, reused: true });
194
+ } catch (error) {
195
+ if (!HookNotFoundError.is(error)) throw error;
196
+ }
197
+
198
+ // No hook yet: start a new run, then retry the resume so this
199
+ // request's payload still reaches the workflow.
200
+ const run = await start(processOrder, [orderId]); // [!code highlight]
201
+ const resumed = await resumeWithRetry(token, payload);
202
+
203
+ // A concurrent request can win the race between `start()` and hook
204
+ // registration; the resume always reaches the actual active owner.
205
+ return Response.json({
206
+ runId: resumed.runId,
207
+ reused: resumed.runId !== run.runId,
208
+ });
209
+ }
210
+ ```
211
+
212
+ See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for the full pattern, including how the workflow claims the token with `hook.getConflict()` and how concurrent starts converge on one active owner.
213
+
158
214
  ## Related Functions
159
215
 
160
216
  - [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) - Get hook details before resuming.
161
217
  - [`createHook()`](/docs/api-reference/workflow/create-hook) - Create a hook in a workflow.
162
218
  - [`defineHook()`](/docs/api-reference/workflow/define-hook) - Type-safe hook helper.
219
+ - [Idempotency](/docs/foundations/idempotency) - Deduplicate step side effects and workflow starts.
@@ -5,6 +5,8 @@ type: reference
5
5
  summary: Use start to programmatically enqueue a new workflow run.
6
6
  prerequisites:
7
7
  - /docs/foundations/starting-workflows
8
+ related:
9
+ - /docs/foundations/idempotency
8
10
  ---
9
11
 
10
12
  Start/enqueue a new workflow run.
@@ -54,9 +56,10 @@ Learn more about [`WorkflowReadableStreamOptions`](/docs/api-reference/workflow-
54
56
  * In v5, `start()` can also be called directly from a workflow function to spawn a child run or continue work in a new run. See [Workflow Composition](/cookbook/common-patterns/workflow-composition) and [Versioning](/docs/foundations/versioning).
55
57
  * This is different from calling workflow functions directly, which is the typical pattern in Next.js applications.
56
58
  * The function returns immediately after enqueuing the workflow - it doesn't wait for the workflow to complete.
59
+ * Each call to `start()` creates a new workflow run. If retried requests must route to one active workflow, have the workflow create a deterministic hook token and use [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) to reuse an already-registered active hook. The lookup is not atomic with `start()`, so concurrent callers can still create extra runs before the hook is registered; handle that race inside the workflow by checking `await hook.getConflict()` before duplicate-sensitive work — on a conflict it resolves with the run that owns the token, so the duplicate can return the active owner to the caller. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Idempotency](/docs/foundations/idempotency#run-idempotency).
57
60
  * All arguments must be [serializable](/docs/foundations/serialization).
58
61
  * When `deploymentId` is provided, the argument types and return type become `unknown` since there is no guarantee the workflow function's types will be consistent across different deployments.
59
- * `attributes` seeds plaintext run metadata as part of creation and requires a World implementing spec version 4 or later.
62
+ * `attributes` seeds plaintext run metadata as part of creation and requires a World implementing spec version 4 or later. Keys that start with `$` are reserved for framework and library code; framework-level callers can pass `allowReservedAttributes: true` to seed reserved keys, with the same semantics as the [`experimental_setAttributes`](/docs/api-reference/workflow/experimental-set-attributes) option of the same name.
60
63
 
61
64
  <Callout type="info">
62
65
  If `start()` throws `'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive.`, the passed function was not transformed as a workflow. The two most common causes are a missing `"use workflow"` directive or missing framework integration. See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function).
@@ -0,0 +1,85 @@
1
+ ---
2
+ title: "workflow/errors"
3
+ description: Semantic error types thrown by the Workflow SDK and its storage backends.
4
+ type: overview
5
+ summary: Explore the error classes exported from workflow/errors for handling workflow failures.
6
+ related:
7
+ - /docs/foundations/errors-and-retries
8
+ ---
9
+
10
+ API reference for the error classes exported from the `workflow/errors` package.
11
+
12
+ All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow-error), so you can catch any SDK error with a single `instanceof` check, or narrow to a specific class for fine-grained handling.
13
+
14
+ ## Base Classes
15
+
16
+ <Cards>
17
+ <Card href="/docs/api-reference/workflow-errors/workflow-error" title="WorkflowError">
18
+ Base class for all workflow error types.
19
+ </Card>
20
+ <Card href="/docs/api-reference/workflow-errors/workflow-world-error" title="WorkflowWorldError">
21
+ Base error for failures from workflow storage backends.
22
+ </Card>
23
+ </Cards>
24
+
25
+ ## Registration Errors
26
+
27
+ <Cards>
28
+ <Card href="/docs/api-reference/workflow-errors/workflow-not-registered-error" title="WorkflowNotRegisteredError">
29
+ Thrown when a workflow function is not registered in the current deployment.
30
+ </Card>
31
+ <Card href="/docs/api-reference/workflow-errors/step-not-registered-error" title="StepNotRegisteredError">
32
+ Thrown when a step function is not registered in the current deployment.
33
+ </Card>
34
+ </Cards>
35
+
36
+ ## Run Errors
37
+
38
+ <Cards>
39
+ <Card href="/docs/api-reference/workflow-errors/workflow-run-not-found-error" title="WorkflowRunNotFoundError">
40
+ Thrown when operating on a workflow run that does not exist.
41
+ </Card>
42
+ <Card href="/docs/api-reference/workflow-errors/workflow-run-failed-error" title="WorkflowRunFailedError">
43
+ Thrown when awaiting the return value of a failed workflow run.
44
+ </Card>
45
+ <Card href="/docs/api-reference/workflow-errors/workflow-run-cancelled-error" title="WorkflowRunCancelledError">
46
+ Thrown when awaiting the return value of a cancelled workflow run.
47
+ </Card>
48
+ <Card href="/docs/api-reference/workflow-errors/workflow-run-not-completed-error" title="WorkflowRunNotCompletedError">
49
+ Thrown when requesting the result of a workflow run that has not completed yet.
50
+ </Card>
51
+ <Card href="/docs/api-reference/workflow-errors/workflow-runtime-error" title="WorkflowRuntimeError">
52
+ Thrown when the workflow runtime encounters an execution error, such as serialization failures or timeouts.
53
+ </Card>
54
+ <Card href="/docs/api-reference/workflow-errors/run-expired-error" title="RunExpiredError">
55
+ Thrown when a workflow run has expired and can no longer be operated on.
56
+ </Card>
57
+ <Card href="/docs/api-reference/workflow-errors/run-not-supported-error" title="RunNotSupportedError">
58
+ Thrown when a workflow run requires a newer workflow spec version than the installed SDK supports.
59
+ </Card>
60
+ </Cards>
61
+
62
+ ## Hook Errors
63
+
64
+ <Cards>
65
+ <Card href="/docs/api-reference/workflow-errors/hook-not-found-error" title="HookNotFoundError">
66
+ Thrown when resuming a hook that does not exist.
67
+ </Card>
68
+ <Card href="/docs/api-reference/workflow-errors/hook-conflict-error" title="HookConflictError">
69
+ Thrown when creating a hook with a token that is already in use by another workflow run.
70
+ </Card>
71
+ </Cards>
72
+
73
+ ## Backend Errors
74
+
75
+ <Cards>
76
+ <Card href="/docs/api-reference/workflow-errors/throttle-error" title="ThrottleError">
77
+ Thrown when a request is rate-limited by the workflow backend.
78
+ </Card>
79
+ <Card href="/docs/api-reference/workflow-errors/entity-conflict-error" title="EntityConflictError">
80
+ Thrown when a storage operation conflicts with the current entity state.
81
+ </Card>
82
+ <Card href="/docs/api-reference/workflow-errors/too-early-error" title="TooEarlyError">
83
+ Thrown when a request is made before the system is ready to process it.
84
+ </Card>
85
+ </Cards>
@@ -31,8 +31,8 @@ export default withWorkflow(nextConfig, workflowConfig); // [!code highlight]
31
31
  If a package in `serverExternalPackages` contains workflow code (`"use step"`,
32
32
  `"use workflow"`, or serialization classes), `withWorkflow()` automatically
33
33
  removes it from `serverExternalPackages` for the current build and prints a
34
- warning. This ensures the package still gets transformed by the Workflow
35
- compiler. Remove that package from `serverExternalPackages` in your
34
+ warning. Workflow still compiles the package so its directives are transformed.
35
+ Remove that package from `serverExternalPackages` in your
36
36
  `next.config` to silence the warning.
37
37
  </Callout>
38
38
 
@@ -259,6 +259,12 @@ const hook = await world.hooks.get(hookId); // [!code highlight]
259
259
 
260
260
  Look up a hook by its token. Useful in webhook resume flows where you receive a token in the callback URL.
261
261
 
262
+ <Callout type="info">
263
+ For runtime application code, prefer [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token). Use `world.hooks.getByToken()` when you are working directly with the World storage interface for custom tooling, admin views, or low-level integrations.
264
+
265
+ Hook-token lookup is the low-level form of the recommended idempotency flow: if a hook is already registered for your business key, reuse the hook's `runId` or resume that hook instead of starting another run. If no hook exists yet, start a workflow that creates the deterministic hook near the beginning and checks `await hook.getConflict()` to detect whether another run claimed the token first — on a conflict it resolves with the run that owns the token. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
266
+ </Callout>
267
+
262
268
  ```typescript lineNumbers
263
269
  const hook = await world.hooks.getByToken(token); // [!code highlight]
264
270
  ```
@@ -13,4 +13,4 @@ Stay up to date with the latest changes to Workflow SDK.
13
13
  ## 2026
14
14
 
15
15
  - [Eager processing of steps and incremental event replay](/docs/changelog/eager-processing) - March 2026
16
- - [Serializable AbortController and AbortSignal](/docs/changelog/serializable-abort-controller) — March 12, 2026
16
+ - Serializable AbortController and AbortSignal — March 12, 2026
@@ -3,6 +3,8 @@ title: Child Workflows
3
3
  description: Spawn child workflows from a parent and wait for completion via hook resume.
4
4
  type: guide
5
5
  summary: Orchestrate independent child workflows from a parent using start(), defineHook(), and startAndWait() — the child resumes the parent's hook when done instead of polling getRun().status.
6
+ related:
7
+ - /docs/api-reference/workflow-api/start
6
8
  ---
7
9
 
8
10
  Use child workflows when a single workflow needs to orchestrate many independent units of work. Each child runs as its own workflow with a separate event log, retry boundary, and failure scope -- if one child fails, it doesn't take down the parent or siblings.
@@ -145,7 +147,7 @@ Polling with `getRun().status` in a `sleep()` loop works, but hook resume is pre
145
147
  - **Zero compute while waiting** — the parent suspends on the hook instead of waking every poll interval
146
148
  - **Immediate wake-up** — the parent resumes as soon as the child finishes, not on the next poll tick
147
149
  - **Typed payloads** — the child sends `{ status, value | error }` directly; no separate `returnValue` fetch step
148
- - **No worker-pool pressure** — `Run#returnValue` polling inside steps can hold worker slots while waiting for children (see [Eager Processing](/changelog/eager-processing))
150
+ - **No worker-pool pressure** — `Run#returnValue` polling inside steps can hold worker slots while waiting for children (see [Eager Processing](/docs/changelog/eager-processing))
149
151
 
150
152
  When a parent calls a child workflow inline with `await` (flattened into the same run), the same wrapper and hook handshake still works — pass the token and `await processDocumentWithCompletion(...)` inside `startAndWait()` instead of calling `start()`.
151
153
 
@@ -270,14 +270,14 @@ Declare `workflow` as an **optional** peer so consumers without the runtime aren
270
270
 
271
271
  ### Runtime detection
272
272
 
273
- Wrap a dynamic `import("workflow")` in try/catch. If either the module isn't installed *or* `getWorkflowMetadata()` throws (call site isn't inside a workflow run), fall through to the standalone path.
273
+ Wrap a dynamic `import("workflow")` in try/catch. If either the module isn't installed *or* `getStepMetadata()` throws (call site isn't inside a workflow step), fall through to the standalone path.
274
274
 
275
275
  ```typescript lineNumbers
276
- async function getWorkflowRunId(): Promise<string | null> { // [!code highlight]
276
+ async function getWorkflowStepId(): Promise<string | null> { // [!code highlight]
277
277
  try {
278
278
  const wf = await import("workflow");
279
- const { workflowRunId } = wf.getWorkflowMetadata();
280
- return workflowRunId;
279
+ const { stepId } = wf.getStepMetadata();
280
+ return stepId;
281
281
  } catch {
282
282
  return null;
283
283
  }
@@ -286,13 +286,14 @@ async function getWorkflowRunId(): Promise<string | null> { // [!code highlight]
286
286
 
287
287
  ### A concrete use case: replay-safe idempotency keys
288
288
 
289
- A payments utility that uses the workflow run ID as a Stripe idempotency key when available, and a fresh UUID otherwise:
289
+ A payments utility that uses the current workflow step ID as a Stripe idempotency key when available, and a fresh UUID otherwise:
290
290
 
291
- {/* @skip-typecheck - depends on getWorkflowRunId defined in the previous block */}
292
291
  ```typescript lineNumbers
292
+ declare function getWorkflowStepId(): Promise<string | null>; // @setup (defined in the previous block)
293
+
293
294
  export async function processPayment(amount: number, currency: string) {
294
- const runId = await getWorkflowRunId();
295
- const idempotencyKey = runId ?? crypto.randomUUID(); // [!code highlight]
295
+ const stepId = await getWorkflowStepId();
296
+ const idempotencyKey = stepId ? `payment:${stepId}` : crypto.randomUUID(); // [!code highlight]
296
297
 
297
298
  const res = await fetch("https://api.stripe.com/v1/charges", {
298
299
  method: "POST",
@@ -306,7 +307,7 @@ export async function processPayment(amount: number, currency: string) {
306
307
  }
307
308
  ```
308
309
 
309
- When called from inside a workflow run, the step wrapping this utility gets a stable idempotency key across replays — Stripe dedupes retries for free. When called from a plain Node.js process, it behaves like any other function and a fresh UUID is generated.
310
+ When called from inside a workflow step, the utility gets a stable idempotency key for that step across retries — Stripe dedupes retries for free. When called from a plain Node.js process, it behaves like any other function and a fresh UUID is generated. For more patterns, see [Idempotency](/docs/foundations/idempotency).
310
311
 
311
312
  ### In production
312
313
 
@@ -330,7 +331,7 @@ Before publishing a workflow library:
330
331
 
331
332
  ## Key APIs
332
333
 
333
- - [`"use workflow"`](/docs/api-reference/workflow/use-workflow) — declares the orchestrator function
334
- - [`"use step"`](/docs/api-reference/workflow/use-step) — marks functions for durable execution
335
- - [`start`](/docs/api-reference/workflow/start) — starts a workflow run
334
+ - [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) — declares the orchestrator function
335
+ - [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — marks functions for durable execution
336
+ - [`start`](/docs/api-reference/workflow-api/start) — starts a workflow run
336
337
  - [`getWorkflowMetadata`](/docs/api-reference/workflow/get-workflow-metadata) — runtime detection and run ID access
@@ -6,7 +6,7 @@ summary: Return a callback from a step to defer construction of a non-owned clas
6
6
  related:
7
7
  - /docs/foundations/serialization
8
8
  - /docs/foundations/serialization#custom-class-serialization
9
- - /docs/api-reference/workflow/use-step
9
+ - /docs/foundations/workflows-and-steps#step-functions
10
10
  ---
11
11
 
12
12
  <Callout>
@@ -141,7 +141,7 @@ async function uploadFile(
141
141
 
142
142
  ## Key APIs
143
143
 
144
- - [`"use step"`](/docs/api-reference/workflow/use-step) — marks a function for extraction and serialization
145
- - [`"use workflow"`](/docs/api-reference/workflow/use-workflow) — declares the orchestrator function
144
+ - [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — marks a function for extraction and serialization
145
+ - [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) — declares the orchestrator function
146
146
  - [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) — accepts a model factory for durable AI agent streaming
147
147
  - [Custom class serialization](/docs/foundations/serialization#custom-class-serialization) — the companion pattern for classes you own (`WORKFLOW_SERIALIZE` / `WORKFLOW_DESERIALIZE`)
@@ -247,8 +247,8 @@ const approvalResult = messages
247
247
 
248
248
  ## Key APIs
249
249
 
250
- - [`"use workflow"`](/docs/api-reference/workflow/use-workflow) — declares the orchestrator function
251
- - [`"use step"`](/docs/api-reference/workflow/use-step) — declares step functions with retries
250
+ - [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) — declares the orchestrator function
251
+ - [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — declares step functions with retries
252
252
  - [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook with schema validation
253
253
  - [`sleep()`](/docs/api-reference/workflow/sleep) — durable timeout for approval expiry
254
254
  - [`getWritable()`](/docs/api-reference/workflow/get-writable) — stream custom data parts from steps
@@ -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
@@ -106,6 +106,10 @@ export async function POST(req: Request) {
106
106
  3. **Race** — `Promise.race([sleep(...), hook])` blocks until either the timer fires or the hook is resumed, whichever comes first.
107
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.
108
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
+
109
113
  ## Adapting to your use case
110
114
 
111
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. Pass an `AbortSignal` into the step to cancel it cooperatively see the [Cancellation Guide](/docs/foundations/cancellation) for patterns.
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