windmill-labs/windmill · error · Error

waitForApproval can only be called inside a workflow()

Error message

waitForApproval can only be called inside a workflow()

What it means

waitForApproval delegates to the active workflow context (_workflowCtx or globalThis.__wmill_wf_ctx). If neither is set, there is no flow step to suspend and later resume, so the client throws this error (typescript-client/client.ts:2334). Approvals are inherently flow-scoped: outside a workflow there is nothing to pause or to receive the approval on.

Source

Thrown at typescript-client/client.ts:2334

 *
 * Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that
 * resume exactly this approval — route them through your own channel. Without a
 * key the steps are named `approval`, `approval_2`, ...
 *
 * @example
 * const urls = await step("urls", () => getApprovalUrls("manager"));
 * await step("notify", () => sendEmail(urls.resume, urls.cancel));
 * const { value, approver } = await waitForApproval({ key: "manager", timeout: 3600 });
 */
export function waitForApproval(options?: {
  timeout?: number;
  form?: object;
  selfApproval?: boolean;
  key?: string;
}): PromiseLike<{ value: any; approver: string; approved: boolean }> {
  const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx");
  if (!ctx) {
    throw new Error("waitForApproval can only be called inside a workflow()");
  }
  return ctx._waitForApproval(options);
}

/**
 * Resume/cancel/approval-page URLs bound to one `waitForApproval` step.
 *
 * Unlike `getResumeUrls()`, which signs a random nonce, these address the very
 * `resume_job` record the step's built-in approval buttons use, so they are
 * stable across replays and safe to embed in a custom notification.
 *
 * `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique
 * within a workflow; reusing one throws rather than silently renaming it. The URL
 * only resumes while that step is awaiting approval; used at any other moment it is
 * rejected rather than banking a row a different approval would consume. Send it
 * ahead of time — approvers just cannot act before the workflow reaches the step.
 *
 * `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's

View on GitHub (pinned to e474e8803c)

Solutions

  1. Call waitForApproval only within a workflow() step; restructure standalone scripts to skip or mock approval.
  2. If logic is shared, branch on context presence before calling waitForApproval.
  3. Dedupe dependencies so a single copy of the Windmill client is bundled and the context global matches.
  4. For tests, install a stub context via setWorkflowCtx that returns a canned result.

Example fix

// before
export async function main() {
  await waitForApproval({ key: 'ok' }); // standalone -> throws
}
// after
export async function main() {
  const ctx = _workflowCtx ?? Reflect.get(globalThis, '__wmill_wf_ctx');
  if (!ctx) return { skipped: 'approval requires a workflow' };
  return await waitForApproval({ key: 'ok' });
}
Defensive patterns

Strategy: type-guard

Validate before calling

const approvalAvailable = () =>
  Boolean(_workflowCtx ?? Reflect.get(globalThis, '__wmill_wf_ctx'));

Type guard

function hasWorkflowCtx(): ctx is WorkflowCtx {
  return (_workflowCtx ?? Reflect.get(globalThis, '__wmill_wf_ctx')) != null;
}

Try / catch

try {
  return await waitForApproval({ key: 'ok' });
} catch (e) {
  if (e.message === 'waitForApproval can only be called inside a workflow()') {
    return { skipped: 'approval requires a workflow' };
  } else throw e;
}

Prevention

When it happens

Trigger: Calling waitForApproval in a standalone script, a plain function, or inside workflow-adjacent code that never registered the context; the global __wmill_wf_ctx removed by a sandbox or duplicated client bundles.

Common situations: Reusing approval code from a flow inside a scheduled standalone script; running the module where the worker did not set the global context; two bundled copies of the client so the set context is not the one read.

Related errors


AI-assisted analysis of windmill-labs/windmill@e474e8803c (2026-09-03). Data as JSON: /api/errors/32f29e651ed8ebf7. Report an issue: GitHub.