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:
- The engine emits a
TASK_AWAITING_APPROVALevent and inserts an approval row keyed by the task’stask_id. - The workflow pauses on the same machinery as
pause(). The worker releases its slot. - An approver decides through the CLI, HTTP API, or the agent harness UI.
- On approve, the engine emits
TASK_APPROVEDand the task body runs. On reject, it emitsTASK_REJECTEDand raisesApprovalRejectedat 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:
- If a row exists and is decided, the engine reuses the verdict — neither the predicate nor the approval prompt fire again. The task body runs exactly once (or doesn’t run at all, for a rejection).
- If a row exists and is pending, the workflow waits again.
- If no row exists yet, the predicate runs and a fresh row is created.
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
- No parallel approval-gated calls in a single execution.
asyncio.gather(approve_a(), approve_b())where both gate will only surface the first approval. - No timeouts on approval. Approvals pause forever until acted on. If a deadline matters, set a task
timeout(@task.with_options(timeout=...)); the timeout fires while the workflow is paused on approval and cancels the task call. - Single approver. No N-of-M policies, no role-scoped approver lists.
Next steps
- Build → Workflow control → Pause and resume — the underlying mechanism this feature builds on.
- Tools and structured output — how tools are declared, how schemas are generated, and how the agent loop calls them.
- Sub-agents — delegate to other agents; sub-agent tool calls inherit each sub-agent’s
approval_modeindependently.