diff --git a/docs/content/docs/v5/api-reference/workflow-api/start.mdx b/docs/content/docs/v5/api-reference/workflow-api/start.mdx
index 556e803189..9447e6978b 100644
--- a/docs/content/docs/v5/api-reference/workflow-api/start.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-api/start.mdx
@@ -56,7 +56,7 @@ Learn more about [`WorkflowReadableStreamOptions`](/docs/api-reference/workflow-
* 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).
* This is different from calling workflow functions directly, which is the typical pattern in Next.js applications.
* The function returns immediately after enqueuing the workflow - it doesn't wait for the workflow to complete.
-* 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).
+* Pass a deterministic `hook.token` to atomically prevent duplicate runs from starting. See [Prevent duplicate starts](#prevent-duplicate-starts) and [Idempotency](/docs/foundations/idempotency#run-idempotency).
* All arguments must be [serializable](/docs/foundations/serialization).
* 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.
* `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 [`setAttributes`](/docs/api-reference/workflow/set-attributes) option of the same name.
@@ -67,6 +67,52 @@ If `start()` throws `'start' received an invalid workflow function. Ensure the W
## Examples
+### Prevent duplicate starts
+
+Use a hook token as an idempotency key. The World reserves the token while it admits the run, before the workflow body executes:
+
+```typescript lineNumbers
+import { start } from "workflow/api";
+import { HookConflictError, WorkflowStartError } from "workflow/errors";
+import { processOrder } from "./workflows/process-order";
+
+export async function POST(request: Request) {
+ const { orderId } = await request.json();
+
+ try {
+ const run = await start(processOrder, [orderId], {
+ hook: { // [!code highlight]
+ token: `order:${orderId}`, // [!code highlight]
+ experimental_minRetention: "30 days", // [!code highlight]
+ }, // [!code highlight]
+ });
+ return Response.json({ runId: run.runId, duplicate: false });
+ } catch (error) {
+ if (HookConflictError.is(error)) {
+ return Response.json({
+ runId: error.conflictingRunId,
+ duplicate: true,
+ });
+ }
+
+ if (WorkflowStartError.is(error)) {
+ console.error(`Could not confirm start candidate ${error.runId}`);
+ }
+ throw error;
+ }
+}
+```
+
+Exactly one run can own the token. A duplicate throws [`HookConflictError`](/docs/api-reference/workflow-errors/hook-conflict-error) with the owner's run ID, without creating or executing another run.
+
+`experimental_minRetention` accepts the same duration strings, millisecond numbers, and absolute `Date` values as [`sleep()`](/docs/api-reference/workflow/sleep). A duration starts when `start()` is called. The token remains unavailable until both the run has ended and that time has passed.
+
+You do not need to call `createHook()` inside the workflow when the token is only an idempotency key. If the workflow also receives data through a Hook, create one with the same token. Its `experimental_minRetention` may extend the original time, but cannot shorten it.
+
+If the SDK cannot confirm whether the candidate was queued or admitted, `start()` throws [`WorkflowStartError`](/docs/api-reference/workflow-errors/workflow-start-error). Its `runId` identifies that candidate, and its `stage` identifies the failed operation. Retrying the logical request with the same token cannot admit two owners.
+
+Worlds that do not support atomic start Hooks reject this option before sending the workflow message.
+
### With Arguments
```typescript
diff --git a/docs/content/docs/v5/api-reference/workflow-errors/hook-conflict-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/hook-conflict-error.mdx
index 90467b1864..48e01a96e9 100644
--- a/docs/content/docs/v5/api-reference/workflow-errors/hook-conflict-error.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-errors/hook-conflict-error.mdx
@@ -1,6 +1,6 @@
---
title: HookConflictError
-description: Thrown when creating a hook with a token that is already in use by another workflow run.
+description: Thrown when creating or atomically starting with a Hook token that is already in use by another workflow run.
type: reference
summary: Catch HookConflictError when a hook token is already claimed by another active workflow run.
related:
@@ -9,7 +9,9 @@ related:
- /docs/errors/hook-conflict
---
-`HookConflictError` is thrown when creating a hook with a token that is already in use by another active workflow run. Hook tokens must be unique across all running workflows — see the [hook-conflict](/docs/errors/hook-conflict) error guide for resolution strategies.
+`HookConflictError` is thrown when creating a Hook or calling `start({ hook })` with a token that another workflow run owns. See the [hook-conflict](/docs/errors/hook-conflict) error guide for resolution strategies.
+
+Atomic `start({ hook })` conflicts always include `conflictingRunId`. It remains optional on the general error type because old persisted Hook conflicts may not contain the owner's run ID.
```typescript lineNumbers
import { HookConflictError } from "workflow/errors"
diff --git a/docs/content/docs/v5/api-reference/workflow-errors/index.mdx b/docs/content/docs/v5/api-reference/workflow-errors/index.mdx
index bd4c061ada..761e8fc861 100644
--- a/docs/content/docs/v5/api-reference/workflow-errors/index.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-errors/index.mdx
@@ -51,6 +51,9 @@ All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow
Thrown when the workflow runtime encounters an execution error, such as serialization failures or timeouts.
+
+ Thrown when an atomic-start candidate's queue or admission outcome cannot be confirmed.
+
Thrown when a workflow run has expired and can no longer be operated on.
diff --git a/docs/content/docs/v5/api-reference/workflow-errors/meta.json b/docs/content/docs/v5/api-reference/workflow-errors/meta.json
index a84eddd1ad..87606787d5 100644
--- a/docs/content/docs/v5/api-reference/workflow-errors/meta.json
+++ b/docs/content/docs/v5/api-reference/workflow-errors/meta.json
@@ -11,6 +11,7 @@
"workflow-run-cancelled-error",
"workflow-run-not-completed-error",
"workflow-runtime-error",
+ "workflow-start-error",
"workflow-world-error",
"throttle-error",
"entity-conflict-error",
diff --git a/docs/content/docs/v5/api-reference/workflow-errors/workflow-start-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/workflow-start-error.mdx
new file mode 100644
index 0000000000..d26defcea8
--- /dev/null
+++ b/docs/content/docs/v5/api-reference/workflow-errors/workflow-start-error.mdx
@@ -0,0 +1,48 @@
+---
+title: WorkflowStartError
+description: Thrown when start with a Hook cannot confirm whether a workflow candidate was queued or admitted.
+type: reference
+summary: Use WorkflowStartError to identify an atomic-start candidate whose outcome could not be confirmed.
+related:
+ - /docs/api-reference/workflow-api/start
+ - /docs/foundations/idempotency
+---
+
+`WorkflowStartError` is thrown when [`start()`](/docs/api-reference/workflow-api/start#prevent-duplicate-starts) uses a Hook token but cannot confirm whether its candidate was queued or admitted.
+
+```typescript lineNumbers
+import { start } from "workflow/api";
+import { WorkflowStartError } from "workflow/errors";
+import { processOrder } from "./workflows/process-order";
+declare const orderId: string; // @setup
+
+try {
+ await start(processOrder, [orderId], {
+ hook: { token: `order:${orderId}` },
+ });
+} catch (error) {
+ if (WorkflowStartError.is(error)) {
+ console.error(error.runId, error.stage);
+ }
+}
+```
+
+The candidate might never become a run. Keep `runId` for inspection and retry the logical request with the same Hook token.
+
+## API Signature
+
+### Properties
+
+
+
+`stage` is `"queue"` when the SDK could not confirm queueing and `"admission"` when the workflow message was queued but the SDK could not confirm admission.
+
+### Static Methods
+
+#### `WorkflowStartError.is(value)`
+
+Type-safe check for `WorkflowStartError` instances across module boundaries and VM contexts.
diff --git a/docs/content/docs/v5/api-reference/workflow-errors/workflow-world-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/workflow-world-error.mdx
index ac12e75ef3..ad7fbf36ae 100644
--- a/docs/content/docs/v5/api-reference/workflow-errors/workflow-world-error.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-errors/workflow-world-error.mdx
@@ -74,6 +74,7 @@ if (WorkflowWorldError.is(error)) {
The following error types extend `WorkflowWorldError`:
- [`EntityConflictError`](/docs/api-reference/workflow-errors/entity-conflict-error) — operation conflicts with entity state
+- [`WorkflowStartError`](/docs/api-reference/workflow-errors/workflow-start-error) — atomic-start queue or admission outcome could not be confirmed
- [`RunExpiredError`](/docs/api-reference/workflow-errors/run-expired-error) — run has expired
- [`TooEarlyError`](/docs/api-reference/workflow-errors/too-early-error) — request made before system is ready
- [`ThrottleError`](/docs/api-reference/workflow-errors/throttle-error) — request was rate-limited
diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/world/index.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/world/index.mdx
index aa9d3f4f5e..cbabcf19be 100644
--- a/docs/content/docs/v5/api-reference/workflow-runtime/world/index.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-runtime/world/index.mdx
@@ -43,6 +43,18 @@ const world = await getWorld(); // [!code highlight]
The World SDK is the low-level foundation that higher-level functions like [`getRun()`](/docs/api-reference/workflow-api/get-run) and [`start()`](/docs/api-reference/workflow-api/start) are built on. Use it when you need capabilities beyond what those functions provide.
+## Capabilities
+
+`world.capabilities` declares optional behavior implemented by the complete World, including its storage and queue runtime. The SDK returns the same object from deployment health checks so `start()` can verify both the calling and target deployments before using a feature.
+
+A World may advertise `atomicStartHook: { active: true }` only when `events.create()` atomically admits a `run_created` or resilient `run_started` event containing `startHook`:
+
+- Reserve the token and create the run as one operation.
+- If another run owns the token, throw `HookConflictError` with its `conflictingRunId` and create no run or events for the candidate.
+- Keep each candidate `runId`'s decision immutable while a queued copy can still arrive. Repeating admission for that candidate must return the same accepted run or the same conflict, even if the token becomes available between attempts.
+
+This contract lets the direct `start()` request and its queued copy safely race without executing a candidate after the caller was told it lost.
+
## Data Hydration
Step input/output data is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format. To display this data in your UI, use the hydration utilities from `workflow/observability`:
diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx
index 6637882677..63acf71413 100644
--- a/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx
@@ -64,6 +64,8 @@ await world.events.create(runId, { // [!code highlight]
**Returns:** `EventResult` — The created event and the affected entity (run/step/hook)
+Worlds that advertise `capabilities.atomicStartHook.active` must apply the [atomic start Hook contract](/docs/api-reference/workflow-runtime/world#capabilities) when `run_created` or resilient `run_started` includes `startHook`.
+
### events.get()
Retrieve a single event by run ID and event ID.
diff --git a/docs/content/docs/v5/foundations/idempotency.mdx b/docs/content/docs/v5/foundations/idempotency.mdx
index ed51c86faa..6fac3e352d 100644
--- a/docs/content/docs/v5/foundations/idempotency.mdx
+++ b/docs/content/docs/v5/foundations/idempotency.mdx
@@ -57,11 +57,43 @@ Why this works:
Step idempotency protects side effects **inside** a workflow run. Run idempotency answers a different question: if the same API request is sent twice, should it create one workflow run or two?
-Because [hooks](/docs/foundations/hooks) already ensure globally unique active tokens, Workflow can use the same mechanism to coordinate duplicate requests while a run is active.
+Use a deterministic [Hook](/docs/foundations/hooks) token from your domain, such as an order ID, invoice ID, import ID, or request ID.
-Use a hook token as the idempotency key for an active workflow run. Hook tokens are globally unique while they are active: if another run tries to create a hook with the same token, the runtime records a conflict, `hook.getConflict()` resolves with a `Run` handle for the run that owns the token, and the hook rejects with [`HookConflictError`](/docs/errors/hook-conflict) when the workflow awaits or iterates its payload.
+### Atomic admission
-The token should come from your domain, such as an order ID, invoice ID, import ID, or request ID. Create the hook near the beginning of the workflow and check `await hook.getConflict()` before doing duplicate-sensitive work that depends on owning the active token. Calling `createHook()` alone does not register the hook — awaiting `getConflict()` suspends the workflow to commit the registration.
+Pass the token to `start()`. The World reserves it while admitting the run, so a duplicate is rejected before another run or any duplicate-sensitive work is created:
+
+```typescript lineNumbers
+import { start } from "workflow/api";
+import { HookConflictError } from "workflow/errors";
+import { processOrder } from "./workflows/process-order";
+
+export async function POST(request: Request) {
+ const { orderId } = await request.json();
+
+ try {
+ const run = await start(processOrder, [orderId], {
+ hook: {
+ token: `order:${orderId}`, // [!code highlight]
+ experimental_minRetention: "30 days", // [!code highlight]
+ },
+ });
+ return Response.json({ runId: run.runId, duplicate: false });
+ } catch (error) {
+ if (!HookConflictError.is(error)) throw error;
+ return Response.json({
+ runId: error.conflictingRunId,
+ duplicate: true,
+ });
+ }
+}
+```
+
+The workflow does not need to call `createHook()` unless it also receives data through that Hook. See [`start()`](/docs/api-reference/workflow-api/start#prevent-duplicate-starts) for retention and uncertain-start behavior.
+
+### Workflow-side fallback
+
+If a World does not support atomic start Hooks, create a Hook near the beginning of the workflow and check `await hook.getConflict()` before duplicate-sensitive work. Calling `createHook()` alone does not register the Hook; awaiting `getConflict()` suspends the workflow to commit its registration.
```typescript lineNumbers
import { createHook } from "workflow";
@@ -146,10 +178,10 @@ export async function POST(request: Request) {
```
-This avoids creating a new run only after the first run has registered its hook. Because `start()` returns before the run body executes and calls `createHook()`, two concurrent requests can both observe "no hook yet" and each call `start()`. The race is resolved inside the workflow body, where the losing run observes `getConflict()` resolving with the active owner and returns without doing duplicate-sensitive work — and the route detects it by comparing the resumed hook's `runId` against the run it just started, without waiting for either run to finish. A native API for atomically starting a run and registering a hook is in the works. Until then, model recovery inside the workflow by checking `hook.getConflict()`.
+This fallback can create duplicate runs during the gap between `start()` and Hook registration. The losing workflow returns before duplicate-sensitive work, but it was still created, queued, and started. Use atomic admission when the World supports it.
-This coordinates active runs by default: the token becomes available when its workflow ends. Set `experimental_minRetention` to keep it unavailable to late duplicates. After the workflow ends, the Hook can still be found with `getHookByToken()` until retention ends, but it cannot be resumed. See [`createHook()` minimum retention](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) for examples and supported values.
+In either pattern, the token becomes available when its workflow ends by default. Set `experimental_minRetention` to keep it unavailable to late duplicates. See [`createHook()` minimum retention](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) for examples and supported values.
### Conflict-handling strategies