Skip to content

Approval Required#

approval_required() is a built-in tool middleware that gates tool execution on human approval. When the agent tries to call a tool decorated with this middleware, the user is prompted to approve or deny the call before it runs.

This is useful for tools that perform irreversible, expensive, or sensitive actions — sending emails, modifying databases, executing payments, or deleting resources.

Quick start#

import asyncio

from ag2 import Agent, tool
from ag2.config import OpenAIConfig
from ag2.middleware import approval_required

@tool(
    middleware=[approval_required()],
)
def delete_account(user_id: str) -> str:
    """Deletes a user account by ID permanently."""
    return f"Account {user_id} deleted."

agent = Agent(
    "assistant",
    config=OpenAIConfig("gpt-4o-mini"),
    tools=[delete_account],
    hitl_hook=lambda event: input(event.content),
)

async def main() -> None:
    reply = await agent.ask("Delete the account for user abc-123.")
    print(await reply.content())

asyncio.run(main())

When the agent calls delete_account, the user sees:

Agent tries to call tool:
`delete_account`, {"user_id": "abc-123"}
Please approve or deny this request.
Y/N?

Typing y lets the tool run. Any other input denies it — the agent receives the denied message and can adjust.

With allow_always=True (the default), typing always approves this call and every later call of the same tool in this agent's conversation. The answer belongs to the tool that was asked about, identified by its implementation (the Python function, or the MCP server and its tool name), not by its name alone: another tool with the same name is still asked about, even behind the same approval_required() hook. The answer survives a restart as long as the conversation's variables do. It does not carry across sub-task delegation in either direction: a sub-task asks again, and its "always" does not reach the parent.

Note

approval_required() relies on the agent's hitl_hook to collect user input. With no hitl_hook configured there is nobody to ask, so context.input() raises HumanInputNotProvidedError and the turn ends — the gated tool does not run, and the model is not handed a tool failure it could route around. Read more about Human in the Loop to learn how to configure a HITL hook.

An agent served over AG-UI is the exception: with no hook, the call is put to the connected client as an interrupt instead of ending the turn, and the client approves or refuses it. See Gating a tool call.

Waiting for the answer#

By default approval_required() waits as long as the human takes. An approval is a question for a person, so the deadline is yours to set:

1
2
3
4
5
6
@tool(
    middleware=[approval_required(timeout=120)],
)
def delete_account(user_id: str) -> str:
    """Deletes a user account by ID permanently."""
    return f"Account {user_id} deleted."

When the timeout expires the turn ends with HumanInputTimeoutError and the gated tool does not run — an unanswered approval is not an approval. A late answer arriving after the deadline does not release it either.

timeout used to be 30 and used to do nothing

Before AG2 fixed the human-input channel, timeout never fired: the hook ran inline, so the clock only started once it had already returned. It now binds, and the default changed from 30 to None (wait indefinitely) so that turning the parameter on does not silently start failing turns whenever a human takes over half a minute to read a prompt.

Inspecting the configuration#

approval_required() returns an ApprovalRequired instance, also exported from ag2.middleware. It reports its settings, so you can log or assert on how a tool was gated:

1
2
3
4
from ag2.middleware import approval_required

approval_required(timeout=5, allow_always=False).describe().config
# {'message': '...', 'denied_message': 'User denied...', 'timeout': 5, 'allow_always': False}

Approval state (which tools the user answered "always" for) lives in context.variables, not on the instance, so it never appears in the description. See Describing middleware.

Only your human writes it

The "always" answer is stored under a reserved ag: variable key. When the agent is served over a protocol that syncs variables with a peer — A2A, NLIP, A2UI, AG-UI — reserved keys are stripped from the payload in both directions, so a remote caller can neither pre-approve a gated tool nor read what your human approved. See Variables over a protocol connection.

Customizing the prompt#

Override the message parameter to tailor the approval prompt:

1
2
3
4
5
6
7
8
9
@tool(
    middleware=[approval_required(
        message="⚠️ The agent wants to run `{tool_name}` with {tool_arguments}. Allow? (y/n)",
        denied_message="Operation blocked by user.",
    )],
)
def send_email(to: str, subject: str, body: str) -> str:
    """Send an email to the given address."""
    return f"Email sent to {to}."