Frontier Engineering
← Tool Calling & Function Execution

Human-in-the-Loop Approval Gate

Also known as: confirmation gate, dry run, tool permissioning

Classify tools by reversibility and require explicit human approval before the model takes an action it cannot take back.

9 min read

The problem

An agent with write access will eventually do something wrong: refund the wrong order, email the wrong list, delete the wrong records, push the wrong branch. The model does not distinguish a reversible action from an irreversible one, and the tool layer usually does not either — `send_email` and `search_orders` are dispatched by the same code path. The cost of a mistaken read is a wasted token; the cost of a mistaken write can be unrecoverable.

The pattern

Classify every tool by blast radius: read-only, reversible write, or irreversible. Read-only executes freely. Reversible writes execute but are logged with an undo handle. Irreversible actions suspend the run, surface a rendered preview of exactly what will happen, and wait for an explicit human decision before proceeding. The agent loop must therefore be able to pause and resume across a human's response time.

Use when

  • Tools touch money, external communication, production data, or anything a customer sees
  • Regulation or policy requires an auditable human decision on specific actions
  • You are rolling out an agent to real users and want a graduated trust ramp
  • The action is rare enough that approval latency does not dominate the experience

Avoid when

  • High-volume automation where a human cannot possibly review each action — use sampling and post-hoc review
  • Every action is genuinely reversible, where an undo log is cheaper and less intrusive
  • Approval fatigue is likely — a gate people click through without reading is worse than no gate

Tradeoffs

  • GainIrreversible mistakes become near-impossible without a human signing off

    CostThe run must survive suspension and resumption, which forces durable state

  • GainApproval records are an audit trail regulators and incident reviews actually want

    CostHuman latency enters the critical path, sometimes measured in hours

  • GainA graduated ramp lets you auto-approve categories as confidence is earned

    CostOver-gating trains users to approve reflexively, which silently removes the protection


The question is not whether to trust the model. It is which specific actions are cheap to get wrong. Classify by consequence, and the design follows.

The preview is rendered from the arguments that will execute — never from the model's summary of its intent.

Classify by reversibility, not by risk vibes

Attach a policy to the tool definition itself so the dispatcher enforces it centrally and no tool can be added without a decision being made. Three tiers cover nearly everything, and the classification is a property of the tool, not of the moment.

class Policy(StrEnum):
    AUTO = "auto"           # read-only: execute freely
    UNDOABLE = "undoable"   # write, but we can reverse it; log an undo handle
    APPROVE = "approve"     # irreversible or externally visible: human first


TOOLS = {
    "search_orders":  Tool(fn=search_orders,  policy=Policy.AUTO),
    "draft_reply":    Tool(fn=draft_reply,    policy=Policy.AUTO),
    "update_address": Tool(fn=update_address, policy=Policy.UNDOABLE),
    "issue_refund":   Tool(fn=issue_refund,   policy=Policy.APPROVE),
    "send_email":     Tool(fn=send_email,     policy=Policy.APPROVE),
    "delete_account": Tool(fn=delete_account, policy=Policy.APPROVE),
}

Suspend, preview, resume

When a gated call comes up, the loop stops and persists its state — messages so far, the pending call, the run id. A human sees a rendered preview built from the actual arguments, not a generic prompt, and approves, edits, or rejects. Rejection is not an error: it returns to the model as an observation explaining that the human declined and why, so the agent can propose an alternative rather than crash.

async def run_step(run: Run, call: ToolCall) -> ToolResult | Suspension:
    tool = TOOLS[call.name]

    if tool.policy is Policy.APPROVE:
        decision = await approvals.lookup(run.id, call.id)
        if decision is None:
            await runs.persist(run)                      # survive process restart
            await approvals.request(
                run_id=run.id,
                call_id=call.id,
                preview=tool.render_preview(call.arguments),  # human-readable
                requested_by=run.user_id,
            )
            return Suspension(reason="awaiting_approval", call_id=call.id)

        if decision.rejected:
            # Back into the loop as an observation, not an exception.
            return ToolResult(id=call.id, content=json.dumps({
                "error": "rejected_by_human",
                "reason": decision.note or "No reason given.",
                "hint": "Propose a different action or ask the user what they want instead.",
            }))

        call = call.with_arguments(decision.edited_arguments or call.arguments)

    result = await tool.fn(**json.loads(call.arguments))

    if tool.policy is Policy.UNDOABLE:
        await undo_log.record(run.id, call.name, result.undo_handle)

    return ToolResult(id=call.id, content=result.content)
warning

Render the preview from the arguments that will actually execute, never from the model's own natural-language summary of what it intends to do. Those two can differ, and the gap between them is precisely the failure the gate exists to catch.

Dry run as the middle path

For tools where full approval is too heavy, a dry-run parameter gives the model a way to check its own work. The model calls with `dry_run=true`, gets back exactly what would change, and either proceeds or corrects. This catches argument errors without human involvement — and it makes the eventual approval preview trivial to generate, since the dry run already produced it.

  • Policy lives on the tool definition; the dispatcher enforces it centrally
  • Persist run state before suspending — approvals can take hours and processes restart
  • Rejection returns as an observation with a reason, so the agent can adapt
  • Let approvers edit arguments, not just approve or reject; near-misses are common
  • Log every approval decision with the rendered preview for audit
tip

Watch your approval rate. A gate approved 99.9% of the time is training people to stop reading, and its protection is largely illusory — either narrow the gate's scope or move that tool to auto with post-hoc sampling.