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.

import { createHook } from "workflow"

export async function hookWorkflow() {
  "use workflow";
  // `using` automatically disposes the hook when it goes out of scope
  using hook = createHook();
  const result = await hook; // Suspends the workflow until the hook is resumed
}

API signature

Parameters

NameTypeDescription
optionsHookOptionsConfiguration options for the hook.

HookOptions

NameTypeDescription
tokenstringUnique 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_minRetentionnumber | 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_forceboolean**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.
metadataSerializableAdditional 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.
isWebhookbooleanWhether 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

NameTypeDescription
tokenstringThe 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() => voidDisposes 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:

import { createHook } from "workflow"

export async function approvalWorkflow() {
  "use workflow";

  using hook = createHook<{ approved: boolean; comment: string }>();
  console.log("Send approval to token:", hook.token);

  const result = await hook;

  if (result.approved) {
    console.log("Approved with comment:", result.comment);
  }
}

Customizing tokens

Tokens are used to identify a specific hook. You can customize the token to be more specific to a use case.

import { createHook } from "workflow";

export async function slackBotWorkflow(channelId: string) {
  "use workflow";

  // Token constructed from channel ID
  using hook = createHook<SlackMessage>({
    token: `slack_messages:${channelId}`,
  });

  for await (const message of hook) {
    if (message.text === "/stop") {
      break;
    }
    await processMessage(message);
  }
}

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:

import { createHook } from "workflow";

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

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

  using hook = createHook({
    token: `order:${orderId}`
  });

  const conflict = await hook.getConflict();
  if (conflict) {
    // Another active workflow run already owns this token.
    return { dedupedTo: conflict.runId };
  }

  await chargeOrder(orderId);
}

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 throws HookNotFoundError until 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 HookConflictError end the run, may already have started that step.

In either case, await hook.getConflict() before calling the step:

import { createHook } from "workflow";

declare function requestApproval(token: string): Promise<void>; // @setup

async function approvalWorkflow() {
  "use workflow";

  using hook = createHook<{ approved: boolean }>();
  await hook.getConflict();

  // The hook is registered, so an approver that resumes it immediately
  // finds it.
  await requestApproval(hook.token);

  const { approved } = await hook;
  return approved;
}

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:

import { createHook } from "workflow";

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

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

  const hook = createHook({
    token: `order:${orderId}`,
    experimental_minRetention: "30d",
  });

  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(): 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:

import { createHook } from "workflow";

declare function processMessage(message: SlackMessage): Promise<void>; // @setup
type SlackMessage = { text: string }; // @setup

export async function slackChannelWorkflow(channelId: string) {
  "use workflow";

  // Whichever run for this channel started most recently owns the token.
  const hook = createHook<SlackMessage>({
    token: `slack_messages:${channelId}`,
    experimental_force: true,
  });

  for await (const message of hook) {
    await processMessage(message);
  }
}

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 await rejects with HookForceClaimedError, which names the run that took the token. Payloads it received before the takeover stay with it; a for await...of loop 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 inside resumeHook().
  • 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_minRetention is 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.

import { createHook } from "workflow"

export async function collectHookWorkflow() {
  "use workflow";

  using hook = createHook<{ message: string; done?: boolean }>();

  const payloads = [];
  for await (const payload of hook) {
    payloads.push(payload);

    if (payload.done) break;
  }

  return payloads;
}

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.

import { createHook } from "workflow"

export async function handoffWorkflow(channelId: string) {
  "use workflow";

  const hook = createHook<{ message: string; handoff?: boolean }>({
    token: `channel:${channelId}`
  });

  for await (const payload of hook) {
    console.log("Received:", payload.message);

    if (payload.handoff) {
      hook.dispose(); // Release the token for another workflow
      break;
    }
  }

  // Continue with other work while another workflow uses the token
}

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:

import { createHook } from "workflow"

export async function scopedHookWorkflow(channelId: string) {
  "use workflow";

  {
    using hook = createHook<{ message: string }>({
      token: `channel:${channelId}`
    });

    const payload = await hook;
    console.log("Received:", payload.message);
  } // hook is automatically disposed here

  // Token is now available for other workflows to use
  console.log("Hook disposed, continuing with other work...");
}

This is equivalent to manually calling dispose() but ensures the hook is always cleaned up, even if an error occurs.