createHook
Creates a low-level hook primitive that can be used to resume a workflow run with arbitrary payloads.
Hooks allow external systems to send data to a paused workflow without the HTTP-specific constraints of webhooks. They're identified by a token and can receive any serializable payload.
A hook token routes a payload to the right hook; it does not authorize the sender. Generated tokens are hard to guess but are not secrets, and custom tokens are usually easy to reconstruct. Authorize callers in the route that calls resumeHook(). See Hook and webhook security.
API signature
Parameters
| Name | Type | Description |
|---|---|---|
options | HookOptions | Configuration options for the hook. |
HookOptions
| Name | Type | Description |
|---|---|---|
token | string | Unique token that is used to associate with the hook.
When specifying an explicit token, the token should be constructed
with information that the dispatching side can reliably reconstruct
the token with the information it has available.
Deterministic tokens are intended for use with createHook() and
server-side resumeHook() only. For webhooks (createWebhook()), an
explicit token is not accepted, one is always generated for you.
A generated token is not trivial to guess but is not a security
contract, so authenticate webhook requests themselves rather than
relying on URL secrecy:
https://workflow-sdk.dev/docs/foundations/hooks#token-design
If provided, the token must be a non-empty string; passing an empty
string throws. If not provided (or undefined), a token is generated
for you. |
experimental_minRetention | number | StringValue | Date | **Experimental.** Keeps this Hook's token unavailable for at least the
configured time after createHook() runs.
Accepts the same values as sleep(): a duration string, a number of
milliseconds, or an absolute Date. Relative durations start when
createHook() runs, not when the workflow ends.
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 run
has ended and the configured time has passed.
After the run ends, the Hook can still be found with getHookByToken()
until retention ends, but it cannot be resumed.
Calling dispose() (including through using) releases the token
immediately.
createHook() throws if the configured World does not support this
experimental option. |
experimental_force | boolean | **Experimental.** Take the token over if another run currently holds it.
Without this, a token that another active run already registered makes
the hook reject with HookConflictError. With it, this run always ends
up owning the token: the other run's hook is disposed and its awaiters
reject with HookForceClaimedError, that run is woken so it can react,
and every resumeHook(token) from then on reaches this run — including
one that was already in flight when the takeover happened. Payloads the
previous owner received before the takeover stay with it.
Any number of runs forcing the same token converge on one owner; each
run that loses it gets HookForceClaimedError, and none can get stuck.
A finished run holding the token under experimental_minRetention is
taken over silently. A run can also take over a token held by its own
earlier hook.
A token is only taken from a run whose runtime understands being taken
from (started at spec version 8 or later). For an older run the World
declines and the hook rejects with the ordinary HookConflictError,
as if this option had not been set.
Requires an explicit token (a generated token can never conflict) and
is not available on createWebhook(). createHook() throws if the
configured World does not support this experimental option. |
metadata | Serializable | Additional user-defined data to include with the hook payload.
Read it back outside the workflow with getHookByToken(), where
hook.metadata is a Promise: await hook.metadata. |
isWebhook | boolean | Whether this hook can be resumed via the public webhook endpoint.
When true, the hook can be triggered by sending an HTTP request to the
public workflow webhook URL. This is automatically set when using
createWebhook().
When false (the default), the hook can only be resumed server-side
via resumeHook(). |
Returns
Hook<T>Hook
| Name | Type | Description |
|---|---|---|
token | string | The token used to identify this hook. |
getConflict | () => Promise<Run<unknown> | null> | Returns the Run already using this token, or null when this Hook
registers successfully.
Calling createHook() alone does not register the hook: registration
only happens when the workflow suspends. Awaiting getConflict()
suspends the workflow to commit the hook registration without waiting for
payload data.
If it returns a run, this Hook was not created. Awaiting the Hook instead
rejects with HookConflictError. |
dispose | () => void | Disposes the hook, releasing its token for reuse by other workflows.
After calling dispose(), the hook will no longer receive any events.
This is useful when you want to explicitly release a hook token before
the workflow completes, allowing another workflow to register a hook
with the same token. |
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 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.
Examples
Basic usage
When creating a hook, you can specify a payload type for automatic type safety:
Customizing tokens
Tokens are used to identify a specific hook. You can customize the token to be more specific to a use case.
Detecting token conflicts
Use hook.getConflict() when the workflow needs to claim a hook token before doing other work, but does not need a payload yet:
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.
Registering a hook before a step uses it
A hook's registration is committed alongside everything else the workflow started before it suspended, not ahead of it. When a workflow creates a hook and calls a step without awaiting anything in between, the step can start running before the hook is registered, and it can run even if the registration turns out to conflict. That matters in two cases:
- The step hands the token to something that may call
resumeHook()right away, which throwsHookNotFoundErroruntil the hook exists. - The hook guards against duplicate runs. A run that only learns of the conflict after calling the step, for example by awaiting the hook and letting
HookConflictErrorend the run, may already have started that step.
In either case, await hook.getConflict() before calling the step:
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 for these strategies in context.
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.
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:
experimental_minRetention accepts the same values as 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() can still find the Hook until retention ends, but the Hook cannot be resumed.
using auto-disposes the Hook at scope exit, which releases the token immediately and defeats experimental_minRetention. Declare retained Hooks with const and let the runtime clean them up when the run ends.
This option is experimental. Worlds can limit how long tokens are retained; see World configuration for each World's limit. If the configured World does not support minimum retention, the workflow fails when registering the Hook. createWebhook() does not accept this option.
Take over a token another run holds
By default, a token that another active run already registered makes the new Hook reject with HookConflictError. Set experimental_force when the newest run should own the token instead, for example when a fresh deployment or a restarted conversation must replace a run that is still waiting:
With experimental_force, this run always ends up owning the token:
- The previous owner's Hook is disposed, recorded in that run's event log, and the previous owner is woken. If it was awaiting the Hook, that
awaitrejects withHookForceClaimedError, which names the run that took the token. Payloads it received before the takeover stay with it; afor await...ofloop drains them before it throws. - Every
resumeHook()for the token from then on reaches this run, including a call that was already in flight when the takeover happened. Callers never see the token move; a delivery aimed at the previous owner is redirected to this run insideresumeHook(). - Any number of runs forcing the same token at the same time converge on a single owner. The takeovers form a chain: each run that loses the token gets
HookForceClaimedError, exactly one run ends up owning it, and none of them can get stuck. Which run wins among simultaneous claimers is not defined; if the order matters, start them in order. - A finished run that still holds the token under
experimental_minRetentionis taken over silently, since there is nothing left to wake. A run can also take over a token held by its own earlier Hook.
The takeover is durable. If either run's compute fails partway through, the next request for the token completes it, so the token never ends up held by nobody or by both runs. The previous owner's wake is durable too: if the new owner's compute fails between registering the Hook and waking the previous owner, the new owner's next invocation republishes the wake, whatever else the new owner has recorded since (a step it started alongside the Hook, for example). Every invocation of the new owner within 24 hours of the takeover republishes it under the same idempotency key, which collapses the repeats into one wake; a repeat that does get through only replays the previous owner, which finds nothing new.
A token can only be taken from a run whose runtime understands being taken from. Runs started at a Workflow spec version below 8, which includes every run started by an older SDK release, a Python SDK run, or a deployment with WORKFLOW_SEALED_LOG=0, would never learn that their Hook was disposed. The World declines to take their token and the forced Hook rejects with the ordinary HookConflictError instead, exactly as if experimental_force had not been set. Finished runs holding a retained token are taken over at any version.
hook.getConflict() on a forced Hook resolves with null once the takeover succeeds: the token is this run's by construction. If the World declines the takeover because the current owner predates spec version 8, getConflict() behaves as it does for an ordinary conflict and resolves with that owner's Run. Read hook.claimedFrom on the value returned by getHookByToken() to find out which run, if any, the token was taken from.
This option is experimental. It requires an explicit token (a generated token can never conflict) and is not accepted by createWebhook(). If the configured World does not support force-claiming at all, the workflow fails when registering the Hook; a World that declines a specific takeover because the current owner cannot be woken answers with HookConflictError instead.
Senders on an older SDK release are not redirected. A resumeHook() from a deployment that predates this option and that looked the token up inside the short handoff window gets an error (EntityConflictError) instead of following the token; the payload is refused, never delivered to the wrong run, and a retry resolves the new owner. Upgrade the sending deployment for the transparent redirect.
Webhook requests are not redirected either. resumeWebhook() streams the request body once, and buffering a copy of every webhook body on the chance that its token is being taken over at that moment would be a cost paid by everyone who never uses this option. A webhook delivery that lands inside the handoff window fails with a retryable error naming the takeover; nothing is delivered anywhere, and the sender's retry reaches the new owner. A forced createHook() can take over a token that a webhook holds, but the token then belongs to a Hook that is not a webhook, so resumeWebhook() answers every later request for it as not found, exactly as it does for any createHook() token, and never redirects one into it.
Waiting for multiple payloads
You can also wait for multiple payloads by using the for await...of syntax.
Disposing hooks early
You can dispose a hook early to release its token for reuse by another workflow. This is useful for handoff patterns where one workflow needs to transfer a hook token to another workflow while still running.
After calling dispose(), the hook will no longer receive events and its token becomes available for other workflows to use.
Automatic disposal with using
Hooks implement the TC39 Explicit Resource Management proposal, allowing automatic disposal with the using keyword:
This is equivalent to manually calling dispose() but ensures the hook is always cleaned up, even if an error occurs.
Related functions
defineHook(): Type-safe hook helperresumeHook(): Resume a hook with a payloadcreateWebhook(): Higher-level HTTP webhook abstraction- Idempotency: Deduplicate step side effects and workflow starts