Approval Post Hook

Demonstrates the post-hook reading the resolved approval record from run_output.metadata["approval"] after a paused run resumes via DB resolution.

approval_post_hook.py
"""
Approval Post Hook
=============================

Demonstrates the post-hook reading the resolved approval record from
run_output.metadata["approval"] after a paused run resumes via DB resolution.

Use case: audit/observability hooks that need to know WHO resolved the
approval and WHEN, not just whether the tool was allowed to run.
"""

import os
import time

from agno.agent import Agent
from agno.approval import approval
from agno.db.sqlite import SqliteDb
from agno.models.openai import OpenAIResponses
from agno.run.agent import RunOutput
from agno.tools import tool

DB_FILE = "tmp/approval_post_hook.db"


@approval
@tool(requires_confirmation=True)
def delete_user_data(user_id: str) -> str:
    """Permanently delete all data for a user. This is irreversible.

    Args:
        user_id (str): The user ID whose data should be deleted.
    """
    return f"All data for user {user_id} has been permanently deleted."


def audit_resolved_approval(run_output: RunOutput) -> None:
    """Post-hook: log audit trail using the resolved approval record."""
    if not run_output.metadata:
        return

    approval_record = run_output.metadata.get("approval")
    if approval_record is None:
        return

    print("[audit-hook] tool gated by approval:")
    print(f"  approval_id: {approval_record['id']}")
    print(f"  status:      {approval_record['status']}")
    print(f"  resolved_by: {approval_record.get('resolved_by')}")
    print(f"  resolved_at: {approval_record.get('resolved_at')}")


if __name__ == "__main__":
    if os.path.exists(DB_FILE):
        os.remove(DB_FILE)
    os.makedirs("tmp", exist_ok=True)

    db = SqliteDb(
        db_file=DB_FILE, session_table="agent_sessions", approvals_table="approvals"
    )
    agent = Agent(
        name="Admin Agent",
        model=OpenAIResponses(id="gpt-5-mini"),
        tools=[delete_user_data],
        post_hooks=[audit_resolved_approval],
        db=db,
    )

    print("--- Step 1: Running agent (expects pause) ---")
    run = agent.run("Delete all data for user U-12345")
    assert run.is_paused, f"Expected paused, got {run.status}"
    print(f"Paused. Run ID: {run.run_id}")

    print("\n--- Step 2: Resolving approval in DB (admin/API path) ---")
    pending, _ = db.get_approvals(run_id=run.run_id, status="pending")
    assert len(pending) == 1
    approval_id = pending[0]["id"]
    resolved = db.update_approval(
        approval_id,
        expected_status="pending",
        status="approved",
        resolved_by="admin@example.com",
        resolved_at=int(time.time()),
    )
    assert resolved is not None
    print(f"  Resolved by: {resolved['resolved_by']}")

    print("\n--- Step 3: Continuing run (post-hook should see resolution) ---")
    # Calling continue_run with run_id and NO requirements triggers the admin/API
    # resolution path: check_and_apply_approval_resolution reads the resolved
    # record from the DB and attaches it to run_response.metadata["approval"].
    run = agent.continue_run(run_id=run.run_id)
    assert not run.is_paused, f"Expected run to complete, got {run.status}"

    print("\n--- Step 4: Verifying metadata exposed on RunOutput ---")
    assert run.metadata is not None, "Expected metadata to be populated"
    assert "approval" in run.metadata, "Expected metadata['approval'] to be set"
    assert run.metadata["approval"]["resolved_by"] == "admin@example.com"
    print(f"  metadata['approval'] status: {run.metadata['approval']['status']}")
    print(
        f"  metadata['approval'] resolved_by: {run.metadata['approval']['resolved_by']}"
    )

    print("\n--- All checks passed! ---")

These examples simulate approval decisions with fixed confirmations, rejections, user input, or external results. The deployment, email, payment, and scan demonstrations use placeholder actions. The Hacker News examples make real HTTP requests after confirmation. In an application, obtain the authorized user's decision before updating a requirement or approval record.

There are two continuation paths:

  • Explicit requirements: update the paused run's requirements and pass them to continue_run() or acontinue_run(). This supplies the decision directly; a pending database approval record does not gate that path. Keep its audit record in sync.
  • Database resolution: resolve the approval record first, then continue by run ID without supplying requirements. Required approvals are checked before execution. See Approval Post Hook for this pattern and its resolved approval metadata.

With @approval(type="audit"), records describe HITL resolution, including rejection and externally supplied results. A record does not establish that the tool body executed or that a real person supplied the decision.

Run the Example

Set up your virtual environment

uv venv --python 3.12
source .venv/bin/activate

Use a disposable example directory

This script deletes tmp/approval_post_hook.db if it exists, including its saved sessions and approvals. Save and run it in a separate disposable directory. If changing the database path, use a new file dedicated to this example.

Install dependencies

uv pip install -U agno openai sqlalchemy

Export your OpenAI API key

export OPENAI_API_KEY="your_openai_api_key_here"

Run the example

Save the code above as approval_post_hook.py, then run:

python approval_post_hook.py

Full source: cookbook/02_agents/11_approvals/approval_post_hook.py