Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
bd33c10
feat(core): add hook token retention contract
NathanColosimo Jul 9, 2026
f7a5865
refactor(core): constrain hook retention options
NathanColosimo Jul 10, 2026
4fe2918
fix(core): preserve boolean hook visibility options
NathanColosimo Jul 10, 2026
0cd7401
revert(core): preserve HookOptions interface
NathanColosimo Jul 10, 2026
8d54bac
docs(core): clarify retained conflict ownership
NathanColosimo Jul 10, 2026
eda3a40
docs(core): retain newest-wins conflict pattern
NathanColosimo Jul 10, 2026
58a5a4b
docs(core): simplify hook retention guidance
NathanColosimo Jul 10, 2026
efa6404
docs(core): explain retained token cleanup
NathanColosimo Jul 10, 2026
1771b8d
docs(core): simplify idempotency guidance
NathanColosimo Jul 10, 2026
cdc543e
docs(core): clarify retained token results
NathanColosimo Jul 10, 2026
08fe520
refactor(core): rename hook token expiration option
NathanColosimo Jul 10, 2026
f8e6c6c
chore(core): name hook expiration changeset
NathanColosimo Jul 10, 2026
7549626
Merge remote-tracking branch 'origin/main' into codex/hook-retention-…
NathanColosimo Jul 10, 2026
2b67594
docs(core): simplify Hook expiration language
NathanColosimo Jul 10, 2026
03cb847
docs(core): clarify Hook expiration deadline
NathanColosimo Jul 10, 2026
ad5442f
docs(core): remove Hook deadline caveat
NathanColosimo Jul 10, 2026
9d60f99
refactor(core): align Hook expiration field names
NathanColosimo Jul 10, 2026
e40c3f5
Merge remote-tracking branch 'origin/main' into codex/hook-retention-…
NathanColosimo Jul 10, 2026
0deba85
docs(core): narrow Hook expiration documentation
NathanColosimo Jul 10, 2026
8b75665
Merge remote-tracking branch 'origin/main' into codex/hook-retention-…
NathanColosimo Jul 10, 2026
1ea2bd3
Merge remote-tracking branch 'origin/main' into codex/hook-retention-…
NathanColosimo Jul 13, 2026
ecd2c61
docs(core): clarify hook expiration availability
NathanColosimo Jul 13, 2026
4311f0a
Merge branch 'main' into codex/hook-retention-core
VaguelySerious Jul 14, 2026
7eee23b
Update packages/core/src/workflow/hook.ts
NathanColosimo Jul 14, 2026
e65f1d2
docs(core): clarify Hook token expiration behavior
NathanColosimo Jul 14, 2026
8d9923b
docs(core): explain active Hook expiration behavior
NathanColosimo Jul 14, 2026
d6ebc67
feat(world): advertise hook ttl capability
NathanColosimo Jul 15, 2026
be2b20f
Merge remote-tracking branch 'origin/main' into codex/hook-retention-…
NathanColosimo Jul 16, 2026
e560b35
fix(core): validate hook ttl capability after main merge
NathanColosimo Jul 16, 2026
d45f8b0
refactor(core): rename hook expiry to minimum retention
NathanColosimo Jul 16, 2026
cef4190
Merge remote-tracking branch 'origin/main' into codex/hook-retention-…
NathanColosimo Jul 16, 2026
e26ca7c
docs: keep hook retention guidance on v5
NathanColosimo Jul 16, 2026
6731caa
docs: define retained run availability
NathanColosimo Jul 16, 2026
02eceb9
fix(core): validate Hook retention at creation
NathanColosimo Jul 16, 2026
8090ef9
feat(core): define retained Hook lookup semantics
NathanColosimo Jul 16, 2026
b98df86
refactor(core): simplify hook retention checks
NathanColosimo Jul 16, 2026
0db7b3f
feat(world-local): support Hook token expiration
NathanColosimo Jul 13, 2026
2d20344
fix(world-local): make hook recovery atomic
NathanColosimo Jul 14, 2026
22c7f40
refactor(world-local): align Hook minimum retention
NathanColosimo Jul 16, 2026
d9fa89c
fix(world-local): preserve Hook creation order
NathanColosimo Jul 16, 2026
b257f46
fix(world-local): expose retained Hooks consistently
NathanColosimo Jul 16, 2026
a30a3d7
refactor(world-local): simplify retained hook storage
NathanColosimo Jul 16, 2026
f37fa91
fix(world-local): allow stale lock recovery
NathanColosimo Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/hook-min-retention.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@workflow/core': minor
'@workflow/world': minor
'@workflow/world-vercel': minor
---

Add `experimental_minRetention` for keeping a Hook token unavailable after its run ends, and require supporting Worlds to advertise the `hookRetention` capability.
5 changes: 5 additions & 0 deletions .changeset/local-hook-min-retention.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/world-local': minor
---

Keep Hook tokens reserved through their configured minimum retention.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ related:

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.

When `experimental_minRetention` is set, this function continues to return the Hook after its workflow ends until retention ends. That Hook cannot be resumed. Use `getRun(hook.runId)` to inspect the finished run.

<Callout type="warn">
`getHookByToken` is a runtime function that must be called from outside a workflow function.
</Callout>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Resumes a workflow run by sending a payload to a hook identified by its token.

It creates a `hook_received` event and re-triggers the workflow to continue execution.

A Hook kept by `experimental_minRetention` after its workflow ends cannot be resumed. `resumeHook()` throws `HookNotFoundError` in that case.

<Callout type="warn">
`resumeHook` is a runtime function that must be called from outside a workflow function.
</Callout>
Expand Down
44 changes: 42 additions & 2 deletions docs/content/docs/v5/api-reference/workflow/create-hook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export default Hook;`}

The returned `Hook` object also implements `AsyncIterable<T>`, which allows you to iterate over incoming payloads using `for await...of` syntax.

Use `hook.getConflict()` to check whether the hook token is already claimed by another active hook, without waiting for hook payload data. 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 registration, then resolves with `null` once `hook_created` is recorded, or with the conflicting [`Run`](/docs/api-reference/workflow-api/get-run) if another active hook already owns the same token.
Use `hook.getConflict()` to check whether the hook token is already claimed by another hook, including one kept reserved after its run ends, without waiting for hook payload data. 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 registration, then resolves with `null` once `hook_created` is recorded, or with the conflicting [`Run`](/docs/api-reference/workflow-api/get-run).

## Examples

Expand Down Expand Up @@ -143,12 +143,52 @@ async function processOrder(orderId: string) {

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`.

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.
On a conflict, the resolved value is a `Run` handle for the run that 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.

<Callout type="info">
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).
</Callout>

### Keep a Token Unavailable After the Run Ends

By default, another Hook can use the token after its workflow ends. Set `experimental_minRetention` to keep the token unavailable for at least a specific time after `createHook()` runs:

```typescript lineNumbers
import { createHook } from "workflow";

declare function processOwnedOrder(orderId: string): Promise<void>; // @setup

export async function processOrder(orderId: string) {
"use workflow";

// Do not use `using` here. It calls dispose() and releases the token.
const hook = createHook({ // [!code highlight]
token: `order:${orderId}`, // [!code highlight]
experimental_minRetention: "30d", // [!code highlight]
}); // [!code highlight]

const conflict = await hook.getConflict();
if (conflict) {
return { status: "duplicate" as const, runId: conflict.runId };
}

await processOwnedOrder(orderId);
return { status: "processed" as const };
}
```

`experimental_minRetention` accepts the same values as [`sleep()`](/docs/api-reference/workflow/sleep): a duration string such as `"30d"`, a number of milliseconds, or an absolute `Date`. Durations start when `createHook()` runs.

The Hook remains active until the workflow ends, even if the configured time passes first. Another Hook can use the token only after both the workflow has ended and the configured time has passed. For example, `"30d"` keeps the token unavailable for 29 more days if the workflow ends after 1 day. A workflow that runs for more than 30 days releases the token when it ends.

After the workflow ends, [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) can still find the Hook until retention ends, but the Hook cannot be resumed.

`hook.dispose()` releases the token immediately. Because `using` calls `dispose()`, do not use it when the token should remain unavailable after the run ends.

<Callout type="warn">
This option is experimental. If the configured World does not support it, the workflow fails when registering the Hook. `createWebhook()` does not accept this option.
</Callout>

### Waiting for Multiple Payloads

You can also wait for multiple payloads by using the `for await...of` syntax.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,6 @@ The workflow should create the deterministic hook and check `await hook.getConfl
- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) -- declares step functions with full Node.js access
- [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) -- provides the deterministic `stepId` for idempotency keys
- [`createHook()`](/docs/api-reference/workflow/create-hook) -- creates a hook with an optional deterministic token
- [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) -- finds the active hook for a token
- [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) -- finds the Hook that owns a token
- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) -- resumes the active hook when the duplicate request carries data
- [`start()`](/docs/api-reference/workflow-api/start) -- starts a new workflow run
2 changes: 1 addition & 1 deletion docs/content/docs/v5/foundations/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export async function orderWorkflow(orderId: string) {
}
```

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.
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 (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.

### Custom Tokens for Deterministic Hooks

Expand Down
23 changes: 14 additions & 9 deletions docs/content/docs/v5/foundations/idempotency.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export async function processOrder(orderId: string): Promise<OrderResult> {
}
```

The runtime creates the hook atomically. At most one active hook can own `order:${orderId}`, so duplicate workflow runs converge on one active owner. A duplicate run observes `getConflict()` resolving with the owner's `Run` and returns before it reaches `chargeOrder()`. The conflicting run's accessors (`status`, `returnValue`, `cancel()`, …) are durable steps, so the duplicate run can do more than report the owner — see [conflict-handling strategies](#conflict-handling-strategies) below.
The runtime creates the hook atomically. At most one hook can own `order:${orderId}`, so duplicate workflow runs converge on one owner. A duplicate run observes `getConflict()` resolving with the owner's `Run` and returns before it reaches `chargeOrder()`. The conflicting run's accessors (`status`, `returnValue`, `cancel()`, …) are durable steps, so the duplicate run can do more than report the owner — see [conflict-handling strategies](#conflict-handling-strategies) below.

Outside the workflow, try to resume the hook first. If the hook is not registered yet, start the workflow and retry the resume until the new run creates the hook:

Expand Down Expand Up @@ -149,7 +149,7 @@ 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()`.
</Callout>

This is active-run coordination. When the workflow completes and disposes the hook, the token can be used again. If a duplicate request after completion must return the original result instead of starting fresh work, persist that completed result under the same domain key.
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.

### Conflict-handling strategies

Expand Down Expand Up @@ -182,7 +182,7 @@ export async function processOrder(orderId: string) {
}
```

**Inspect the owner before deciding.** Branch on the owner's live state:
**Inspect the owner before deciding.** Branch on the owner's state:

```typescript lineNumbers
import { createHook } from "workflow";
Expand All @@ -200,17 +200,14 @@ export async function processOrder(orderId: string) {
const conflict = await request.getConflict();
if (conflict) {
const status = await conflict.status; // [!code highlight]
if (status === "running") {
return { status: "duplicate" as const, runId: conflict.runId };
}
// Owner already reached a terminal state; its hook will be released.
return { status: "duplicate" as const, ownerStatus: status, runId: conflict.runId };
}

return await processOwnedOrder(orderId);
}
```

**Signal the owner instead of doing the work.** The duplicate run knows the token, so it can deliver this run's input to the owner's hook from a step:
**Signal the owner instead of doing the work.** A conflict can refer to a finished run when `experimental_minRetention` is set, so check its status before sending data to its Hook:

```typescript lineNumbers
import { createHook } from "workflow";
Expand All @@ -231,6 +228,10 @@ export async function processOrder(orderId: string, confirmed: boolean) {

const conflict = await request.getConflict();
if (conflict) {
const status = await conflict.status;
if (status !== "pending" && status !== "running") {
return { status: "duplicate" as const, runId: conflict.runId };
}
await forwardToOwner(token, { confirmed }); // [!code highlight]
return { status: "forwarded" as const, runId: conflict.runId };
}
Expand All @@ -239,7 +240,7 @@ export async function processOrder(orderId: string, confirmed: boolean) {
}
```

**Supersede the owner.** Newest-wins: cancel the active run, then claim the released token. Cancellation disposes the owner's hooks; the retry loop covers the window where that disposal has not propagated yet:
**Supersede the owner.** Without minimum retention, cancel the active run, then claim the released token. The retry loop covers the window where cancellation cleanup has not propagated yet:

```typescript lineNumbers
import { createHook } from "workflow";
Expand Down Expand Up @@ -272,6 +273,10 @@ export async function processOrderNewestWins(orderId: string) {
}
```

<Callout type="warn">
This pattern does not work with `experimental_minRetention`: cancelling the old run does not make its token available early.
</Callout>

If duplicate requests should only reuse the active run without sending data, use [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) as an advisory pre-check before calling `start()`. The workflow should still check `hook.getConflict()`, because the lookup and `start()` are not atomic.

Because this pattern uses hooks for idempotency, duplicate requests can also inject additional data and steer the existing run. The route example above uses `resumeHook()` for that: if the hook already exists, the duplicate request resumes the active workflow; if the hook is not registered yet, the route starts the workflow and retries `resumeHook()` so the payload is not dropped.
Expand Down
12 changes: 6 additions & 6 deletions docs/content/docs/v5/how-it-works/event-sourcing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -121,15 +121,15 @@ flowchart TD

**Hook states:**

- `active`: Ready to receive payloads (hook exists in storage)
- `disposed`: No longer accepting payloads (hook is deleted from storage)
- `active`: Ready to receive payloads
- `disposed`: No longer accepting payloads
- `conflicted`: Hook creation failed because the token is already in use by another workflow

Unlike other entities, hooks don't have a `status` field—the states above are conceptual. An "active" hook is one that exists in storage, while "disposed" means the hook has been deleted. When a `hook_disposed` event is created, the hook record is removed rather than updated.
Unlike other entities, hooks don't have a `status` field—the states above are conceptual. When a `hook_disposed` event is created, the hook record is removed rather than updated.

While a hook is active, its token is reserved and cannot be used by other workflows. If a workflow attempts to create a hook with a token that is already in use by another active hook, a `hook_conflict` event is recorded instead of `hook_created`. Current worlds include the token and the run ID that currently owns it, though older persisted events or world implementations may only include the token. This causes `hook.getConflict()` to resolve with the conflicting run and the hook's payload promise to reject with a `HookConflictError`, which you can detect with `HookConflictError.is(error)`. See the [hook-conflict error](/docs/errors/hook-conflict) documentation for more details.
While a hook is active, its token is reserved and cannot be used by other workflows. If a workflow attempts to create a hook with a token reserved by another run — either by an active hook or by `experimental_minRetention` after its run ended — a `hook_conflict` event is recorded instead of `hook_created`. Current worlds include the token and the run ID that owns it, though older persisted events or world implementations may only include the token. This causes `hook.getConflict()` to resolve with the conflicting run and the hook's payload promise to reject with a `HookConflictError`, which you can detect with `HookConflictError.is(error)`. See the [hook-conflict error](/docs/errors/hook-conflict) documentation for more details.

When a hook is disposed (either explicitly or when its workflow completes), the token is released and can be claimed by future workflows. Hooks are automatically disposed when a workflow reaches a terminal state (`completed`, `failed`, or `cancelled`). The `hook_disposed` event is only needed for explicit disposal before workflow completion.
When a workflow ends, its Hooks can no longer be resumed. They are normally removed and their tokens become available again. With `experimental_minRetention`, a Hook remains readable and its token remains unavailable until retention ends. A `hook_disposed` event removes the Hook and makes its token available immediately.

See [Hooks & Webhooks](/docs/foundations/hooks) for more on how hooks and webhooks work.

Expand Down Expand Up @@ -188,7 +188,7 @@ Events are categorized by the entity type they affect. Each event contains metad
| Event | Description |
|-------|-------------|
| `hook_created` | Creates a new hook in `active` state. Contains the hook token and optional metadata. |
| `hook_conflict` | Records that hook creation failed because the token is already in use by another active hook. Contains the token and, for current worlds, the active hook owner's run ID. The hook is not created: `hook.getConflict()` resolves with the conflicting run, and awaiting the hook payload rejects with a `HookConflictError`. |
| `hook_conflict` | Records that hook creation failed because another run owns the token. Contains the token and, for current worlds, the owner's run ID. The hook is not created: `hook.getConflict()` resolves with the conflicting run, and awaiting the hook payload rejects with a `HookConflictError`. |
| `hook_received` | Records that a payload was delivered to the hook. The hook remains `active` and can receive more payloads. |
| `hook_disposed` | Deletes the hook from storage (conceptually transitioning to `disposed` state). The token is released for reuse by future workflows. |

Expand Down
Loading
Loading