n4nAI

AutoGen human-in-the-loop tutorial for agent approvals

Step-by-step autogen human in the loop tutorial for agent approvals: build guarded tools, configure UserProxyAgent, and run sign-off flows with code.

n4n Team3 min read691 words

Audio narration

Coming soon — every post will get a voice note here.

This autogen human in the loop tutorial agent approvals walks through building a guarded AutoGen system where an agent proposes an action and a human must explicitly approve before any side effect occurs. We’ll use pyautogen’s UserProxyAgent and a wrapped tool to enforce sign-off, then show how to tighten the loop with human_input_mode. The pattern is minimal, runnable, and maps directly to refund, email, or database-mutation workflows.

Prerequisites

  • Python 3.10 or newer
  • pip install pyautogen==0.2.32 (recent 0.2.x works)
  • An OpenAI-compatible LLM endpoint. Export OPENAI_API_KEY, and optionally OPENAI_BASE_URL if you use a gateway.
python -m venv .venv && source .venv/bin/activate
pip install pyautogen==0.2.32
export OPENAI_API_KEY=sk-...
# export OPENAI_BASE_URL=https://your-endpoint/v1

The approval problem

Agents that can call tools will eventually try to do something irreversible—refunds, emails, DB deletes. You need a checkpoint. AutoGen’s stable 0.2 line does not ship a built-in “approve this tool call” flag, but the primitive is simple: make the tool itself block on human input, or force the UserProxyAgent to surface the proposal to a human before execution.

Step 1: Write a guarded tool

Define the side-effecting function. Inside, prompt for confirmation synchronously. This keeps approval logic next to the risky operation.

def process_refund(amount: float, user_id: str) -> str:
    """Issue a refund. Blocks for human approval."""
    confirm = input(f"Approve refund of ${amount:.2f} to {user_id}? [y/N]: ").strip().lower()
    if confirm != "y":
        return "Refund cancelled by human."
    # Stub for actual payment API call
    return f"Refunded ${amount:.2f} to {user_id}."

The function signature and docstring matter: AutoGen’s assistant uses the docstring to build the JSON schema for tool calling. Keep the docstring explicit about side effects.

Step 2: Configure the agents

Create an AssistantAgent that knows about the tool and a UserProxyAgent that maps the function name to our guarded implementation.

import os
from autogen import AssistantAgent, UserProxyAgent

llm_config = {
    "model": "gpt-4o-mini",
    "api_key": os.environ["OPENAI_API_KEY"],
    "base_url": os.environ.get("OPENAI_BASE_URL"),
}

assistant = AssistantAgent(
    name="support_agent",
    llm_config=llm_config,
    system_message=(
        "You handle customer requests. When a refund is justified, "
        "call process_refund with amount and user_id. Do not invent user ids."
    ),
)

user_proxy = UserProxyAgent(
    name="executor",
    human_input_mode="NEVER",  # approval handled inside the tool
    code_execution_config=False,
    function_map={"process_refund": process_refund},
)

human_input_mode="NEVER" means the proxy won’t interject on every message; the only pause is inside process_refund.

Step 3: Run the chat

Kick off the conversation with a customer request.

user_proxy.initiate_chat(
    assistant,
    message="Customer #42 says the item arrived broken. Refund $30.",
)

Expected output (first checkpoint)

The assistant emits a function call. Your terminal blocks on the input prompt:

support_agent (to executor):
call process_refund({"amount": 30, "user_id": "42"})
Approve refund of $30.00 to 42? [y/N]: 

Type y and you’ll see:

Refunded $30.00 to 42.

Type anything else and the tool returns Refund cancelled by human. The assistant receives that string and can reply to the customer accordingly.

Step 4: Strict mode with human_input_mode

If you want a human to see the raw assistant proposal before any tool mapping, set human_input_mode="ALWAYS". The proxy prints each assistant message and waits for you to press enter or type feedback.

user_proxy = UserProxyAgent(
    name="executor",
    human_input_mode="ALWAYS",
    code_execution_config=False,
    function_map={"process_refund": process_refund},
)

Now every assistant turn pauses:

support_agent (to executor):
I'll refund $30 to customer 42 via process_refund.
Please give feedback (or press enter to continue): 

This is heavier but useful when the assistant might call multiple tools and you want to veto before any run.

Step 5: Logging approvals

In production, input() isn’t auditable. Replace it with a function that writes to your approval log and pulls from a secure admin console. Sketch:

import json, time

def approve_action(action: dict) -> bool:
    record = {"action": action, "ts": time.time(), "decision": None}
    decision = input(f"Approve {json.dumps(action)}? [y/N]: ")
    record["decision"] = decision == "y"
    # TODO: persist record to your audit store
    return record["decision"]

Wire this into process_refund and you have a compliance trail. Fail closed: if the approval service times out, return False.

Step 6: Guarding multiple tools

Real agents call more than one action. Register a second guarded tool the same way.

def send_email(to: str, subject: str, body: str) -> str:
    """Send an email. Blocks for human approval."""
    confirm = input(f"Send email to {to}? [y/N]: ").strip().lower()
    if confirm != "y":
        return "Email cancelled."
    # SMTP send stub
    return f"Sent email to {to}"

user_proxy = UserProxyAgent(
    name="executor",
    human_input_mode="NEVER",
    code_execution_config=False,
    function_map={
        "process_refund": process_refund,
        "send_email": send_email,
    },
)

The assistant can call either; each blocks independently. Keep the docstrings distinct so the model doesn’t confuse them.

Full transcript example

With both tools registered and human_input_mode="NEVER", a typical run looks like:

support_agent (to executor):
call process_refund({"amount": 30, "user_id": "42"})
Approve refund of $30.00 to 42? [y/N]: y
Refunded $30.00 to 42.

support_agent (to executor):
call send_email({"to": "42@example.com", "subject": "Refund processed", "body": "Your $30 refund is done."})
Send email to 42@example.com? [y/N]: n
Email cancelled.

The assistant then tells the customer the refund is complete but email notification was skipped.

Why not just use human_input_mode=“ALWAYS”?

ALWAYS mode creates a prompt per assistant turn. For a 10-step plan that’s 10 interruptions, and humans start rubber-stamping. Embedding approval in the tool confines the pause to the exact side effect, which scales better. Use ALWAYS only when you need to review reasoning before any execution.

Production routing note

If you run these loops for hours, provider flakiness will bite. Pointing AutoGen at n4n.ai’s OpenAI-compatible endpoint gives you automatic fallback when a provider is rate-limited or degraded, while per-token metering keeps the approval-chain costs visible.

Common pitfalls

  • Docstring drift: If the assistant’s view of the tool diverges from code, it will call with wrong args. Keep docstrings tight.
  • Blocking the event loop: input() blocks the main thread. For web services, use an async approval queue instead.
  • Silent approvals: Never default to “yes” on timeout in a guarded tool. Fail closed.
  • Missing function_map: If the name in function_map doesn’t match the assistant’s call exactly, AutoGen raises at execution.

Where to extend

Add a list_pending_approvals tool so a supervisor agent can batch-sign. Or wrap UserProxyAgent in a GroupChat with a dedicated auditor agent that reviews the transcript. The core pattern from this autogen human in the loop tutorial agent approvals stays the same: the side effect waits for a human, every time.

Tagsautogenhuman-in-the-loopagentsapprovals

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All autogen human-in-the-loop workflows posts →