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'sView on GitHub (pinned to e474e8803c)
Solutions
- Call waitForApproval only within a workflow() step; restructure standalone scripts to skip or mock approval.
- If logic is shared, branch on context presence before calling waitForApproval.
- Dedupe dependencies so a single copy of the Windmill client is bundled and the context global matches.
- 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
- Call waitForApproval only inside workflow() steps.
- Branch on context presence before calling in shared code.
- Dedupe the client dependency so the context global is a single instance.
- Install a stub context via setWorkflowCtx for tests.
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
- You can't use this function in a standalone script or flow s
- WAC step key "${options.key}" is already used in this workfl
- taskScript("${path}") can only be called inside a workflow()
- taskFlow("${path}") can only be called inside a workflow()
- You can't use 'request_interactive_slack_approval' function
AI-assisted analysis of windmill-labs/windmill@e474e8803c (2026-09-03).
Data as JSON: /api/errors/32f29e651ed8ebf7.
Report an issue: GitHub.