Context and Tools
Agents and Tool Use

Add a Human Approval Step Before an Agent Takes Action

Add a Human Approval Step Before an Agent Takes Action
This article explores how to make AI agents safer by adding a mandatory human approval step before any side-effecting tool is executed. By examining practical Python examples, explicit allow-lists for dangerous tools, and common failure modes like retries and approval fatigue, it provides a reliable pattern for keeping human oversight in the agent loop.

The first time an agent I wrote sent a real email without asking me, I felt a very specific kind of cold. The message itself was harmless—a short status update to a colleague—but the fact that the code had been capable of doing it without a human checkpoint stayed with me for days. I rewrote the loop that afternoon and added a single approval gate. Every side-effecting tool has gone through that gate ever since.

This post is about that gate. We will take the minimal tool-using agent from the previous two articles and insert a human approval step before any tool that can change the outside world is allowed to run. The goal is not to make the agent smarter. The goal is to make the agent safer in the only way that has consistently worked for me: keep a person in the loop for irreversible actions.

As always:

Build the smallest useful version, then test what breaks.

What “Human Approval” Actually Means Here

In this article, human approval is a deliberate pause in the agent loop. When the model requests a tool that has side effects, the code stops, shows a clear summary of the proposed action, and waits for an explicit yes or no from a person. Only after that yes does the tool execute. A no (or a timeout) aborts the action and tells the model the tool was denied.

We are not building a sophisticated policy engine or a multi-user review system. We are building the smallest useful checkpoint that prevents an agent from acting while no one is watching.

Which Tools Need Approval?

Not every tool requires a human. Purely informational tools (looking up a number, reading a public page, calculating a sum) can usually run automatically. Tools that send email, write to a database, charge a card, post to a chat channel, or delete data should not.

A documentary-style photograph of a developer's hand hovering over a keyboard in front of a laptop screen displaying code, capturing a moment of cautious decision-making in a realistic office setting.

I keep a simple Python set:

Python

SIDE_EFFECT_TOOLS = {
    "send_email",
    "create_ticket",
    "update_record",
    "post_message",
    # add more as the agent grows
}

Any tool whose name appears in that set must pass through the approval gate. Everything else runs without interruption. The set is deliberately explicit; I prefer to maintain a short allow-list of dangerous tools rather than try to infer side effects automatically.

Extending the Minimal Agent

We will reuse the same four-stage loop (Decide → Call → Observe → Respond) and insert the approval check inside the Call stage. For demonstration we will keep the original add_numbers tool (safe, no approval) and add a second tool that pretends to send an email (side-effecting, requires approval).

Python

import os
from dotenv import load_dotenv
from anthropic import Anthropic

load_dotenv()
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))

TOOLS = [
    {
        "name": "add_numbers",
        "description": "Add two integers and return their sum.",
        "input_schema": {
            "type": "object",
            "properties": {
                "a": {"type": "integer"},
                "b": {"type": "integer"}
            },
            "required": ["a", "b"]
        }
    },
    {
        "name": "send_email",
        "description": "Send a short email to a recipient. Use only when the user explicitly asks to notify someone.",
        "input_schema": {
            "type": "object",
            "properties": {
                "to": {"type": "string", "description": "Email address of the recipient"},
                "subject": {"type": "string"},
                "body": {"type": "string"}
            },
            "required": ["to", "subject", "body"]
        }
    }
]

def add_numbers(a: int, b: int) -> str:
    return str(a + b)

def send_email(to: str, subject: str, body: str) -> str:
    # In a real system this would call an email API.
    # Here we only simulate the side effect.
    print(f"\n[SIMULATED EMAIL SENT]")
    print(f"To: {to}")
    print(f"Subject: {subject}")
    print(f"Body: {body}\n")
    return f"Email successfully sent to {to}"

TOOL_MAP = {
    "add_numbers": add_numbers,
    "send_email": send_email
}

SIDE_EFFECT_TOOLS = {"send_email"}

The Approval Function

The approval step itself is intentionally boring. It prints a clear summary and blocks until the human types a response.

Python

def ask_for_approval(tool_name: str, tool_input: dict) -> bool:
    """Return True only if a human explicitly approves the action."""
    print("\n" + "=" * 60)
    print("HUMAN APPROVAL REQUIRED")
    print("=" * 60)
    print(f"Tool:  {tool_name}")
    print(f"Input: {tool_input}")
    print("-" * 60)
    answer = input("Type 'yes' to approve, anything else to deny: ").strip().lower()
    return answer == "yes"

In a production system you would replace the input() call with a Slack button, an email confirmation link, a ticket in a review queue, or any other channel that forces a human decision. The important part is that the agent cannot proceed until that decision arrives.

Inserting the Gate into the Call Stage

Here is the updated Call stage. Everything else in the loop stays the same.

Python

def run_agent(user_message: str, max_iterations: int = 6) -> str:
    messages = [{"role": "user", "content": user_message}]

    for iteration in range(1, max_iterations + 1):
        print(f"\n=== Iteration {iteration} ===")

        # Stage 1: Decide
        response = client.messages.create(
            model="claude-sonnet-5",  # verify current ID in official docs
            max_tokens=512,
            tools=TOOLS,
            messages=messages
        )
        messages.append({"role": "assistant", "content": response.content})
        print(f"Stop reason: {response.stop_reason}")

        if response.stop_reason != "tool_use":
            return "".join(
                block.text for block in response.content if block.type == "text"
            )

        # Stage 2: Call (with approval gate)
        tool_results = []
        for block in response.content:
            if block.type != "tool_use":
                continue

            tool_name = block.name
            tool_input = block.input
            tool_use_id = block.id

            print(f"Model requested: {tool_name}({tool_input})")

            # Approval check for side-effecting tools
            if tool_name in SIDE_EFFECT_TOOLS:
                approved = ask_for_approval(tool_name, tool_input)
                if not approved:
                    result = (
                        f"Action denied by human reviewer. "
                        f"The tool '{tool_name}' was not executed."
                    )
                    print(f"Denied: {result}")
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": tool_use_id,
                        "content": result
                    })
                    continue  # move to the next tool_use block if any

            # Execute the tool (safe tools, or approved side-effect tools)
            try:
                if tool_name not in TOOL_MAP:
                    result = f"Error: unknown tool '{tool_name}'"
                else:
                    result = TOOL_MAP[tool_name](**tool_input)
            except Exception as e:
                result = f"Error executing tool: {type(e).__name__}: {e}"

            print(f"Result: {result}")
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": tool_use_id,
                "content": result
            })

        # Stage 3: Observe
        messages.append({"role": "user", "content": tool_results})

    return "Stopped: max iterations reached."

When the model requests add_numbers, the tool runs immediately. When it requests send_email, the process pauses and waits for a human.

What the Model Sees When Approval Is Denied

If the human types anything other than “yes”, the tool result returned to the model is a clear denial message. A well-behaved model will usually acknowledge the denial and either ask the user for different instructions or stop. A poorly prompted model may try to call the same tool again; that is one of the failure modes we test below.

The denial string is deliberately explicit. Vague messages such as “something went wrong” encourage the model to retry. Clear messages such as “Action denied by human reviewer” give the model a chance to change course.

Three Failure Modes Worth Testing

1. The Model Ignores a Denial and Retries

Ask the agent to send an email, deny the first request, and watch what happens on the next iteration. If the model immediately requests the same tool again, the system prompt or the tool description needs stronger language about respecting denials. Logging every denial makes this pattern visible.

2. Approval Fatigue

If an agent asks for approval on every minor action, humans start clicking “yes” without reading. Keep the SIDE_EFFECT_TOOLS set small and only include actions that truly need a person. Approval is a scarce resource; spend it carefully.

3. Timeout or Abandoned Approval

In a real system the approval request may sit unanswered. Decide in advance what should happen: treat silence as denial, escalate to another reviewer, or abort the entire agent run. Leaving the decision undefined is how agents end up stuck in limbo.

I keep a short personal rule: any tool that can send a message to another human or change persistent state must appear in SIDE_EFFECT_TOOLS. The rule is blunt on purpose. Blunt rules are easier to audit than clever ones.

A cluttered developer desk with a laptop showing system logs and a coffee cup, under natural light.

Practical Habits That Keep the Gate Useful

  • Make the approval prompt show the exact tool name and the exact arguments. Never ask a human to approve an opaque “action.”

  • Log every approval and every denial with a timestamp and the tool input.

  • Prefer denying by default. Silence or an unclear answer should never be interpreted as consent.

  • Review the SIDE_EFFECT_TOOLS set whenever you add a new tool. It is easy to forget.

What This Gate Does Not Solve

A human approval step is necessary but not sufficient. It does not:

  • Prevent a compromised or tricked human from approving a bad action

  • Protect against prompt-injection attacks that try to hide the real tool arguments

  • Replace proper authentication, authorization, or audit logging

  • Scale to high-volume automated workflows that cannot wait for a person

Those concerns belong to later posts on production readiness and security. The gate we built today is the smallest useful version that stops an agent from acting while no one is looking.

Verification Checklist

  1. Ask the agent a pure calculation question and confirm add_numbers runs without any approval prompt.

  2. Ask the agent to send an email and confirm the approval prompt appears with the correct arguments.

  3. Type “yes” and confirm the simulated email is “sent” and the model receives a success result.

  4. Run the same request again, type “no,” and confirm the model receives a clear denial and does not execute the tool.

  5. After a denial, check the next model turn and confirm it does not silently retry the same action without a new user request.

If all five steps behave as expected, the approval gate is working.

Update Note

The Messages API shapes used for tool use remain stable. Model identifiers change; always verify the current recommended ID in the official Anthropic documentation. In production systems the input() based approval shown here should be replaced by a durable review channel (ticket, chat button, email confirmation, etc.). The logic of the gate itself stays the same.

Looking Ahead

The next post examines the ways an agent can still misbehave even with an approval gate: infinite loops, duplicate actions, and unsafe tool calls that slip through. We will add concrete guards for each of those cases so the same four-stage loop becomes harder to push into a bad state.

Keep the approval script. When you later add tools that touch real inboxes or databases, you will already have a place to put the human checkpoint.

Build the smallest useful version, then test what breaks.

— Caleb Fletcher

Boise, Idaho

Updated · 2026-10-16 17:10
Feedback

No feedback yet — submit the first.

Submit feedback
© 2026 Context & Tools. All rights reserved. data-driven, published weekly ♥