Tool approval

Gate agent tool calls behind human review by marking tools with @task.with_options(requires_approval=...). The workflow pauses on the gated call, an operator decides via the CLI or HTTP API, and the agent continues or sees an error message it can adapt to.

Not every tool call should run unattended. An agent that can send email, run a shell command, or push a deployment usually needs a human in the loop for those actions. Flux exposes one universal control for this: requires_approval as an option on @task.with_options(...). The same primitive works for agent tools and for regular tasks; the agent harness just hooks into it so the model sees a meaningful error when a call is rejected.

Marking a tool for approval

requires_approval=True on a task makes that task pause for approval every time it’s called. To gate a single agent tool, wrap it with with_options:

from flux import ExecutionContext, workflow
from flux.tasks.ai import agent
from flux.tasks.ai.tools.system_tools import system_tools


def _gate_shell(tools):
    """Return the same tool list with `shell` requiring approval."""
    gated = []
    for tool in tools:
        func = tool.func if hasattr(tool, "func") else tool
        if getattr(func, "__name__", "") == "shell" and hasattr(tool, "with_options"):
            gated.append(tool.with_options(requires_approval=True))
        else:
            gated.append(tool)
    return gated


@workflow
async def tool_approval_demo(ctx: ExecutionContext):
    raw = ctx.input or {}
    task_description = raw.get("task", "List the files in the current directory")

    tools = _gate_shell(system_tools("./workspace", timeout=10))

    assistant = await agent(
        "You are a helpful assistant with access to system tools. "
        "Use them to complete the user's request.",
        model="ollama/llama3.2",
        tools=tools,
        stream=False,
    )

    return await assistant(task_description)

The pattern is the same as system_tools ships them — read access without friction, an approval checkpoint before any shell execution.

Conditional approval

requires_approval also accepts a callable that takes the same arguments as the task and returns bool (or an awaitable bool). The predicate runs before the task body and decides per-call whether to gate:

from flux import task


@task.with_options(
    requires_approval=lambda amount, customer: amount > 100,
)
async def issue_refund(amount: float, customer: str) -> None:
    ...

Refunds at or below 100 go through unattended; anything larger pauses for approval. The predicate is evaluated exactly once per task call. If it raises, the task call fails — exceptions in the predicate are not silently mapped to “approve” or “reject.”

How the pause works

When a gated task is called:

  1. The engine emits a TASK_AWAITING_APPROVAL event and inserts an approval row keyed by the task’s task_id.
  2. The workflow pauses on the same machinery as pause(). The worker releases its slot.
  3. An approver decides through the CLI, HTTP API, or the agent harness UI.
  4. On approve, the engine emits TASK_APPROVED and the task body runs. On reject, it emits TASK_REJECTED and raises ApprovalRejected at the call site.

The approval row is durable. On replay, the engine looks it up by task_id before re-evaluating the predicate, so a non-deterministic predicate (time, random, external state) can’t flip the verdict between runs.

Acting as the approver

CLI

List pending approvals:

flux execution approvals

Useful filters: --execution <id>, --workflow <ns>/<name>, --task <name>, --age 1h, --status all|approved|rejected|cancelled, --json.

Approve a specific task call:

flux execution approve <execution_id> <task_call_id> --reason "lgtm"

Reject it — the agent sees an error and can adapt:

flux execution reject <execution_id> <task_call_id> --reason "looks suspicious"

flux execution show <execution_id> lists the execution’s pending approvals on stderr, so callers piping stdout to json.loads still work. The same is true of flux workflow status <workflow> <execution_id>, which appends a Blocked on N approval(s) line on stderr when the execution is paused on one or more gates.

HTTP API

POST /executions/{execution_id}/approvals/{task_call_id}/approve
Content-Type: application/json

{"reason": "lgtm"}
POST /executions/{execution_id}/approvals/{task_call_id}/reject
Content-Type: application/json

{"reason": "looks suspicious"}

200 returns the post-decision row. 409 returns {"error": "already_decided", "current_status": ..., "decided_at": ...} — useful when multiple approvers might race on the same gate; only the first decision is recorded, and the response does not leak the winning approver’s identity.

Listing endpoints:

GET /approvals[?status=&execution_id=&workflow_namespace=&workflow_name=&task_name=&age_min=&limit=&offset=]
GET /executions/{execution_id}/approvals
GET /executions/{execution_id}/approvals/{task_call_id}

Permissions

Deciding an approval requires the workflow:{namespace}:{name}:task:{task_name}:approve verb. The built-in operator role has it via the wildcard workflow:*:*:task:*:approve. A user with workflow:*:*:read can see a pending approval but cannot decide it without the approve verb.

What the agent sees after a rejection

ApprovalRejected is caught by the agent’s tool executor and turned into a normal tool-error response that the model receives:

Error: Approval rejected for task shell by alice@oidc: looks suspicious

The model treats it as a failed tool call and continues its loop. Depending on max_tool_calls and how the model handles it, it may try a different approach, surface a clarifying question, or produce a final answer explaining what it could not do. The workflow does not raise — rejection is a normal outcome in the agent’s context.

Bypassing approval: autonomous mode

approval_mode="autonomous" on agent(...) runs every tool in the agent’s batch as a non-gated with_options(requires_approval=False) variant. The engine-level approval gate is skipped for those tool invocations only — your task definitions are untouched.

assistant = await agent(
    "You are a helpful assistant.",
    model="ollama/llama3.2",
    tools=tools,                  # tools may still carry requires_approval
    approval_mode="autonomous",
)

"default" and "autonomous" are the two accepted values. "default" respects whatever is on each task; "autonomous" overrides them for the duration of the agent’s tool calls. This is the right mode for CI pipelines, batch workflows, or any context where human review is not an option and the operator has accepted the risk.

System prompt annotation

When approval-gated tools are present and approval_mode is not "autonomous", Flux adds a section to the system prompt listing which tools require approval. This tells the model to expect a pause before those calls complete, which helps it handle rejection without getting confused.

The injected text looks like this:

## Tool Approval

Some tools require human approval before execution. When you call
these tools, execution will pause until a human approves or rejects
the call. If rejected, you will receive an error — adapt your
approach accordingly.

Tools requiring approval: shell

This is generated automatically by build_tools_preamble in flux.tasks.ai.tool_executor. You do not write it.

Replay safety

The approval row is the durable record. On replay or worker reclaim:

The one narrow window is a worker crash between predicate evaluation and the next event flush; in that case the predicate can re-run on reclaim. This is the same restart-race contract that applies to ordinary task bodies — design predicates to be cheap and side-effect-free.

A rejection emits TASK_FAILED with the ApprovalRejected exception persisted via the task’s configured output storage. On replay, the event log short-circuits the call and re-raises the same exception, so the workflow body sees identical behavior on the original run and every replay.

Cancellation

If a workflow is cancelled while paused on approval, all pending approval rows for that execution transition to cancelled. They do not emit TASK_REJECTED — rejection implies an approver acted; cancellation does not.

Limitations

Next steps