windmill-labs/windmill · error · RuntimeError

task_script("{path}") can only be called inside a @workflow

Error message

task_script("{path}") can only be called inside a @workflow

What it means

task_script(path) returns a wrapper that, when invoked, enqueues a Windmill script as a step — but only if a @workflow-decorated context exists in the current thread-local _workflow_ctx. When called outside a workflow, the wrapper deliberately raises this RuntimeError instead of silently running the script as a plain sub-call. It is a guard against using workflow-only task primitives in ordinary scripts.

Source

Thrown at python-client/wmill/wmill/client.py:3337

):
    """Create a task that dispatches to a separate Windmill script.

    Usage::

        extract = task_script("f/data/extract", timeout=600)

        @workflow
        async def main():
            data = await extract(url="https://...")
    """
    name = path.rsplit("/", 1)[-1]
    _opts = {k: v for k, v in {"timeout": timeout, "tag": tag, "cache_ttl": cache_ttl, "priority": priority, "concurrent_limit": concurrency_limit, "concurrency_key": concurrency_key, "concurrency_time_window_s": concurrency_time_window_s}.items() if v is not None} or None

    def wrapper(**kwargs):
        ctx = _workflow_ctx.get(None)
        if ctx is not None:
            return ctx._next_step(name, path, dispatch_type="script", _task_options=_opts, **kwargs)
        raise RuntimeError(f'task_script("{path}") can only be called inside a @workflow')

    wrapper.__name__ = name
    wrapper._is_task = True
    wrapper._task_path = path
    return wrapper


def task_flow(
    path: str,
    *,
    timeout: Optional[int] = None,
    tag: Optional[str] = None,
    cache_ttl: Optional[int] = None,
    priority: Optional[int] = None,
    concurrency_limit: Optional[int] = None,
    concurrency_key: Optional[str] = None,
    concurrency_time_window_s: Optional[int] = None,
):

View on GitHub (pinned to e474e8803c)

Solutions

  1. Wrap the calling code in @wmill.workflow and run it as a flow so _workflow_ctx is populated
  2. Replace the task_script call with a direct sub-run API (e.g. wmill.run_script or resume APIs) if you actually want to launch a script outside a flow
  3. If the code must work in both contexts, check ctx first: ctx = _workflow_ctx.get(None); branch to direct execution when ctx is None

Example fix

// before
def process():
    return task_script("/u/scripts/transform")(payload)
// after
import wmill
@wmill.workflow
def process():
    return task_script("/u/scripts/transform")(payload)
Defensive patterns

Strategy: validation

Validate before calling

import wmill
from wmill.client import _workflow_ctx
if _workflow_ctx.get(None) is None:
    raise RuntimeError("task_script requires a @workflow context")
# or: only call tasks inside @wmill.workflow functions

Type guard

def in_workflow() -> bool:
    from wmill.client import _workflow_ctx
    return _workflow_ctx.get(None) is not None

Try / catch

try:
    result = my_task(x)
except RuntimeError as e:
    if "can only be called inside a @workflow" in str(e):
        result = run_directly(x)  # fallback outside flows
    else:
        raise

Prevention

When it happens

Trigger: Calling a function produced by wmill.task_script(...) inside a plain Python script, a Job/standalone run, a worker context, or any code path that is not executed under the @workflow (wmill.workflow) decorator — i.e. _workflow_ctx.get() returned None.

Common situations: Moving task-heavy code into a plain script for testing without realizing task_script only resolves within a workflow; importing a shared module that calls a @task at module level; refactoring a flow step into a standalone script and forgetting that step wiring (ctx._next_step) requires the workflow runtime.

Related errors


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