workflow 5.0.0-beta.14 → 5.0.0-beta.16

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 (91) hide show
  1. package/docs/api-reference/index.mdx +24 -0
  2. package/docs/api-reference/meta.json +8 -0
  3. package/docs/api-reference/vitest/index.mdx +1 -7
  4. package/docs/api-reference/workflow/create-hook.mdx +38 -0
  5. package/docs/api-reference/workflow/create-webhook.mdx +1 -0
  6. package/docs/api-reference/workflow/experimental-set-attributes.mdx +3 -1
  7. package/docs/api-reference/workflow/fetch.mdx +5 -0
  8. package/docs/api-reference/workflow-api/get-hook-by-token.mdx +7 -0
  9. package/docs/api-reference/workflow-api/get-run.mdx +6 -0
  10. package/docs/api-reference/workflow-api/index.mdx +6 -8
  11. package/docs/api-reference/workflow-api/resume-hook.mdx +57 -0
  12. package/docs/api-reference/workflow-api/start.mdx +6 -1
  13. package/docs/api-reference/workflow-astro/index.mdx +18 -0
  14. package/docs/api-reference/workflow-astro/meta.json +4 -0
  15. package/docs/api-reference/workflow-astro/workflow.mdx +45 -0
  16. package/docs/api-reference/workflow-errors/hook-conflict-error.mdx +60 -0
  17. package/docs/api-reference/workflow-errors/index.mdx +85 -0
  18. package/docs/api-reference/workflow-errors/meta.json +5 -0
  19. package/docs/api-reference/workflow-errors/run-not-supported-error.mdx +58 -0
  20. package/docs/api-reference/workflow-errors/workflow-error.mdx +52 -0
  21. package/docs/api-reference/workflow-errors/workflow-run-not-completed-error.mdx +58 -0
  22. package/docs/api-reference/workflow-errors/workflow-runtime-error.mdx +58 -0
  23. package/docs/api-reference/workflow-nest/configure-workflow-controller.mdx +33 -0
  24. package/docs/api-reference/workflow-nest/index.mdx +31 -0
  25. package/docs/api-reference/workflow-nest/meta.json +9 -0
  26. package/docs/api-reference/workflow-nest/nest-local-builder.mdx +64 -0
  27. package/docs/api-reference/workflow-nest/workflow-controller.mdx +40 -0
  28. package/docs/api-reference/workflow-nest/workflow-module.mdx +74 -0
  29. package/docs/api-reference/workflow-nitro/index.mdx +59 -0
  30. package/docs/api-reference/workflow-nuxt/index.mdx +47 -0
  31. package/docs/api-reference/workflow-observability/hydrate-data.mdx +35 -0
  32. package/docs/api-reference/workflow-observability/hydrate-resource-io.mdx +62 -0
  33. package/docs/api-reference/workflow-observability/index.mdx +64 -0
  34. package/docs/api-reference/workflow-observability/meta.json +11 -0
  35. package/docs/api-reference/workflow-observability/observability-revivers.mdx +50 -0
  36. package/docs/api-reference/workflow-observability/parse-class-name.mdx +41 -0
  37. package/docs/api-reference/workflow-observability/parse-step-name.mdx +40 -0
  38. package/docs/api-reference/workflow-observability/parse-workflow-name.mdx +55 -0
  39. package/docs/api-reference/workflow-runtime/create-world.mdx +39 -0
  40. package/docs/api-reference/workflow-runtime/get-world-handlers.mdx +44 -0
  41. package/docs/api-reference/{workflow-api → workflow-runtime}/get-world.mdx +7 -10
  42. package/docs/api-reference/workflow-runtime/health-check.mdx +50 -0
  43. package/docs/api-reference/workflow-runtime/index.mdx +43 -0
  44. package/docs/api-reference/workflow-runtime/meta.json +12 -0
  45. package/docs/api-reference/workflow-runtime/set-world.mdx +49 -0
  46. package/docs/api-reference/workflow-runtime/workflow-entrypoint.mdx +42 -0
  47. package/docs/api-reference/{workflow-api → workflow-runtime}/world/index.mdx +5 -8
  48. package/docs/api-reference/workflow-runtime/world/meta.json +4 -0
  49. package/docs/api-reference/{workflow-api → workflow-runtime}/world/queue.mdx +2 -2
  50. package/docs/api-reference/{workflow-api → workflow-runtime}/world/storage.mdx +11 -4
  51. package/docs/api-reference/{workflow-api → workflow-runtime}/world/streams.mdx +2 -2
  52. package/docs/api-reference/workflow-serde/index.mdx +0 -1
  53. package/docs/api-reference/workflow-serde/workflow-deserialize.mdx +1 -2
  54. package/docs/api-reference/workflow-serde/workflow-serialize.mdx +1 -2
  55. package/docs/api-reference/workflow-sveltekit/index.mdx +18 -0
  56. package/docs/api-reference/workflow-sveltekit/meta.json +4 -0
  57. package/docs/api-reference/workflow-sveltekit/workflow-plugin.mdx +42 -0
  58. package/docs/api-reference/workflow-vite/index.mdx +18 -0
  59. package/docs/api-reference/workflow-vite/meta.json +4 -0
  60. package/docs/api-reference/workflow-vite/workflow.mdx +48 -0
  61. package/docs/changelog/attributes-mvp.mdx +15 -3
  62. package/docs/changelog/eager-processing.mdx +26 -6
  63. package/docs/changelog/index.mdx +1 -1
  64. package/docs/cookbook/advanced/child-workflows.mdx +3 -1
  65. package/docs/cookbook/advanced/publishing-libraries.mdx +13 -12
  66. package/docs/cookbook/advanced/serializable-steps.mdx +3 -3
  67. package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +2 -2
  68. package/docs/cookbook/common-patterns/idempotency.mdx +36 -52
  69. package/docs/cookbook/common-patterns/rate-limiting.mdx +1 -1
  70. package/docs/cookbook/common-patterns/saga.mdx +2 -2
  71. package/docs/cookbook/common-patterns/scheduling.mdx +4 -0
  72. package/docs/cookbook/common-patterns/timeouts.mdx +2 -1
  73. package/docs/cookbook/common-patterns/workflow-composition.mdx +6 -0
  74. package/docs/cookbook/index.mdx +1 -1
  75. package/docs/cookbook/integrations/ai-sdk.mdx +10 -1
  76. package/docs/cookbook/integrations/chat-sdk.mdx +9 -0
  77. package/docs/cookbook/integrations/sandbox.mdx +9 -0
  78. package/docs/errors/step-not-registered.mdx +1 -1
  79. package/docs/foundations/cancellation.mdx +1 -2
  80. package/docs/foundations/hooks.mdx +29 -0
  81. package/docs/foundations/idempotency.mdx +236 -11
  82. package/docs/foundations/streaming.mdx +1 -1
  83. package/docs/foundations/versioning.mdx +1 -1
  84. package/docs/how-it-works/encryption.mdx +2 -2
  85. package/docs/how-it-works/event-sourcing.mdx +2 -2
  86. package/docs/migration-guides/migrating-from-temporal.mdx +1 -1
  87. package/docs/observability/attributes.mdx +26 -5
  88. package/docs/testing/index.mdx +2 -2
  89. package/package.json +10 -10
  90. package/docs/api-reference/workflow-api/world/meta.json +0 -4
  91. package/docs/api-reference/workflow-api/world/observability.mdx +0 -164
@@ -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
@@ -5,6 +5,7 @@ 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
  ---
@@ -77,6 +78,10 @@ export async function processOrder(orderId: string) {
77
78
 
78
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)).
79
80
 
81
+ <Callout type="info">
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
+
80
85
  <Callout type="info">
81
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.
82
87
  </Callout>
@@ -109,3 +114,4 @@ If you want the child workflow to run on the latest deployment rather than the c
109
114
  - [`"use step"`](/docs/foundations/workflows-and-steps) — marks functions with full Node.js access
110
115
  - [`start()`](/docs/api-reference/workflow-api/start) — spawn a child workflow as a separate run
111
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
@@ -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
@@ -130,6 +130,10 @@ export async function supportWorkflow(initialMessages: ModelMessage[]) {
130
130
 
131
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.
132
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
+
133
137
  ```typescript title="app/api/support/route.ts" lineNumbers
134
138
  import type { UIMessage, UIMessageChunk } from "ai";
135
139
  import { convertToModelMessages, createUIMessageStreamResponse } from "ai";
@@ -350,6 +354,10 @@ In `sliceUntilFinish`, use `reader.releaseLock()` in the `finally` block rather
350
354
 
351
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.
352
356
 
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
+
353
361
  ## streamText vs DurableAgent
354
362
 
355
363
  | | `streamText()` (this pattern) | `DurableAgent` |
@@ -376,8 +384,9 @@ Use `DurableAgent` for most agent use cases. Use `streamText` when you need the
376
384
 
377
385
  **Workflow SDK**
378
386
 
379
- * [`"use step"`](/docs/api-reference/workflow/use-step) — applied to `runTurn` to make each turn a durable, retryable unit
387
+ * [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — applied to `runTurn` to make each turn a durable, retryable unit
380
388
  * [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspension point for follow-up messages
381
389
  * [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable stream output
382
390
  * [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.getReadable({ startIndex })` for slicing per-turn streams
383
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
@@ -176,6 +176,10 @@ export type ChatTurnPayload = {
176
176
 
177
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:
178
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
+
179
183
  ```typescript title="lib/chat-session-handlers.ts" lineNumbers
180
184
  import type { Message, Thread } from "chat";
181
185
  import { getRun, resumeHook, start } from "workflow/api";
@@ -289,6 +293,10 @@ Adapter packages (`@chat-adapter/slack`, `@chat-adapter/telegram`, etc.) depend
289
293
 
290
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.
291
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
+
292
300
  ### Keep the hook outside the loop
293
301
 
294
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.
@@ -305,3 +313,4 @@ Slack wants a 200 within 3 seconds. The webhook handler returns immediately afte
305
313
  - [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.exists` before resuming, to detect stale `runId`s.
306
314
  - [`defineHook()`](/docs/api-reference/workflow/define-hook) — per-turn suspension point inside the workflow.
307
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.
@@ -300,6 +300,10 @@ export async function sandboxSessionWorkflow() {
300
300
 
301
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.
302
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
+
303
307
  ```typescript title="app/api/sandbox/start/route.ts" lineNumbers
304
308
  import { start, getRun } from "workflow/api";
305
309
  import { sandboxSessionWorkflow } from "@/workflows/sandbox-session";
@@ -505,6 +509,10 @@ Stream closure must happen inside a `"use step"` function. Calling `writable.clo
505
509
 
506
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.
507
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
+
508
516
  ### Keep the hook outside the loop
509
517
 
510
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.
@@ -518,3 +526,4 @@ Each iteration's `hook.then(...)` attaches a listener to the same hook instance.
518
526
  - [`sleep()`](/docs/api-reference/workflow/sleep) — durable timer that powers both idle hibernation and proactive refresh
519
527
  - [`getRun()`](/docs/api-reference/workflow-api/get-run) — look up a run and replay its event log for reconnection
520
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
@@ -4,7 +4,7 @@ description: A step function is not registered in the current deployment.
4
4
  type: troubleshooting
5
5
  summary: Resolve step not registered errors caused by build issues.
6
6
  prerequisites:
7
- - /docs/foundations/steps
7
+ - /docs/foundations/workflows-and-steps
8
8
  related:
9
9
  - /docs/errors/workflow-not-registered
10
10
  - /docs/api-reference/workflow-errors/step-not-registered-error
@@ -6,7 +6,6 @@ summary: Cancel in-flight work with AbortSignal or stop entire workflow runs.
6
6
  prerequisites:
7
7
  - /docs/foundations/workflows-and-steps
8
8
  related:
9
- - /docs/foundations/common-patterns
10
9
  - /docs/foundations/hooks
11
10
  - /docs/how-it-works/cancellation
12
11
  ---
@@ -455,6 +454,6 @@ This is safe even if both steps have already completed — aborting a finished o
455
454
 
456
455
  - [How Cancellation Works](/docs/how-it-works/cancellation) — Hook and stream backing, serialization internals
457
456
  - [Serialization](/docs/foundations/serialization) — Understanding serializable types
458
- - [Common Patterns](/docs/foundations/common-patterns) — Timeout and race patterns
457
+ - [Cookbook](/v5/cookbook) — Timeout, race, and other reliability patterns
459
458
  - [Hooks](/docs/foundations/hooks) — Pausing workflows for external events
460
459
  - [Errors and Retries](/docs/foundations/errors-and-retries) — Handling step failures
@@ -85,6 +85,35 @@ The key points:
85
85
  - You need the hook's `token` to resume it
86
86
  - The workflow will resume execution right where it left off
87
87
 
88
+ ### Checking for Token Conflicts
89
+
90
+ Sometimes you need to know that a hook token has been claimed, but you do not want to wait for external data yet. Await `hook.getConflict()` for that:
91
+
92
+ ```typescript lineNumbers
93
+ import { createHook } from "workflow";
94
+
95
+ declare function processOrder(orderId: string): Promise<void>; // @setup
96
+
97
+ export async function orderWorkflow(orderId: string) {
98
+ "use workflow";
99
+
100
+ using hook = createHook({
101
+ token: `order:${orderId}`
102
+ });
103
+
104
+ const conflict = await hook.getConflict(); // [!code highlight]
105
+ if (conflict) { // [!code highlight]
106
+ // Another active run already owns this token.
107
+ return { dedupedTo: conflict.runId };
108
+ }
109
+
110
+ // The hook token is registered and reserved here.
111
+ await processOrder(orderId);
112
+ }
113
+ ```
114
+
115
+ Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with a `Run` handle for the run that owns the token if another active hook already claimed it (see [`HookConflictError`](/docs/errors/hook-conflict)). For `hook_conflict` events persisted by older worlds that did not record the owning run's ID, `getConflict()` rejects with `HookConflictError` instead of resolving with an incomplete handle. The conflicting run's accessors are durable steps, so the workflow can inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` — see [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies.
116
+
88
117
  ### Custom Tokens for Deterministic Hooks
89
118
 
90
119
  By default, hooks generate a random token. However, you often want to use a **custom token** that external systems can reconstruct. This is especially useful for long-running workflows where the same workflow instance should handle multiple events.