Skip to main content

approval-gate

A drop-in approval gate and audit trail for AI agents — works with LangGraph, or with no framework at all.

Before your agent sends an email, deletes a record, or calls an API it shouldn't be calling unsupervised, approval-gate pauses it, shows a human exactly what it's about to do (with any sensitive data flagged), and waits for approve / edit / reject — with a permanent record of what happened.

Status: early / seeking feedback. The core (audit log, PII scanning, approve/edit/reject loop), the framework-agnostic backend abstraction, four approval channels (browser, webhook, email, Slack), and per-action policies are all built and tested. A hosted dashboard is the only thing left on the roadmap — see Roadmap.

This README is the pitch and a quick tour. For a task-oriented walkthrough — which channel to pick, full setup for each, policies, troubleshooting — see USER_GUIDE.md. Running any of the network-facing backends (Email, Slack, Webhook) somewhere real? Read SECURITY.md first — secrets handling and why they need a reverse proxy in front.

agent proposes an action
        │
        ▼
  ┌─────────────────┐      flags emails, phone numbers, card numbers,
  │  scan for PII /  │ ──   API keys, etc. in the proposed action
  │     secrets      │
  └────────┬─────────┘
           ▼
  ┌─────────────────┐      policy decides: auto-approve/reject, or
  │   PAUSE & ASK    │ ──   pause and ask a human (browser, terminal,
  │   a human        │      LangGraph interrupt() -- pluggable)
  └────────┬─────────┘
           ▼
  approve / edit / reject ──→ action runs (or doesn't) ──→ logged forever

Why this exists

LangGraph already gives you interrupt() for human-in-the-loop control. That part's free and built in. What's missing is everything around it: a place to log what was proposed, a way to flag sensitive data before a human sees it (or before it sits in a log file), and a consistent pattern so every tool in your codebase pauses the same way instead of every team hand-rolling it slightly differently.

This is that missing layer. Nothing more.

What this is not: a full observability platform (see Langfuse / LangSmith for that), a vulnerability scanner for MCP servers, or an enterprise compliance suite. If you need those, go get those — this is meant to sit alongside them, not replace them.

Install

pip install approval-gate
# using LangGraph? add the extra:
pip install "approval-gate[langgraph]"
# optional, for richer name/location PII detection beyond the built-in
# regex checks:
pip install presidio-analyzer && python -m spacy download en_core_web_sm

The core package (ApprovalGate, the audit log, PII scanning, BlockingBackend) has no required dependencies — LangGraph is only needed if you use LangGraphBackend.

60-second example

from approval_gate import ApprovalGate

gate = ApprovalGate(db_path="audit.db")

def send_email(to: str, subject: str, body: str) -> str:
    decision = gate.request_approval(
        action_name="send_email",
        args={"to": to, "subject": subject, "body": body},
        risk="medium",
    )
    if not decision.approved:
        return f"BLOCKED: {decision.reason}"

    result = really_send_email(**decision.args)  # decision.args reflects any edits
    gate.log_result(decision.audit_id, result)
    return result

Backends: how the pause actually happens

ApprovalGate doesn't know or care how a human's decision gets back to it — that's the job of a Backend. Pick the one that matches your stack:

  • LangGraphBackend (default) — pauses via LangGraph's native interrupt(). Drive the graph from outside, resuming whenever it pauses:

    from langgraph.types import Command
    
    result = graph.invoke(initial_state, config)
    while "__interrupt__" in result:
        pending = result["__interrupt__"][0].value   # action, args, pii_findings, risk
        decision = ask_a_human_somehow(pending)        # {"decision": "approve"|"reject"|"edit", ...}
        result = graph.invoke(Command(resume=decision), config)
    
  • BlockingBackend — no framework at all. Works from a plain Python tool-calling loop, a script, a notebook — anywhere you can just block the current thread waiting on a decision:

    from approval_gate import ApprovalGate
    from approval_gate.backends import BlockingBackend
    
    def ask_a_human(pending: dict) -> dict:
        print(pending["action"], pending["args"])
        return {"decision": "approve", "by": "amit"}
    
    gate = ApprovalGate(db_path="audit.db", backend=BlockingBackend(ask_a_human))
    
  • WebBackend — a real review inbox in a browser tab instead of a blocking terminal prompt. Zero new dependencies (stdlib http.server); runs locally and polls for pending actions:

    from approval_gate import ApprovalGate
    from approval_gate.backends import WebBackend
    
    backend = WebBackend(port=8642)
    gate = ApprovalGate(db_path="audit.db", backend=backend)
    print(f"Review inbox running at {backend.url}")
    # ... call gate.request_approval(...) from your agent as usual ...
    backend.shutdown()
    

    Multiple pending actions queue up and are all shown at once, each decided independently. Works with any other backend's caller code unchanged — combine it with LangGraph or plain Python the same way as BlockingBackend.

  • WebhookBackend — bring your own system. POSTs the pending action to a URL you configure; your system POSTs the decision back. For teams with their own ticketing/admin tooling who don't want to adopt Slack or email specifically for this:

    from approval_gate.backends import WebhookBackend
    
    backend = WebhookBackend(notify_url="https://internal-tools.example.com/incoming", port=8643)
    gate = ApprovalGate(db_path="audit.db", backend=backend)
    # your system calls back POST {backend.url}/decide with the decision
    
  • EmailBackend — signed one-click approve/reject links, no browser tab or login required. Good for slower-moving approvals:

    from approval_gate.backends import EmailBackend
    
    backend = EmailBackend(
        smtp_host="smtp.example.com", smtp_port=587,
        smtp_user="bot@example.com", smtp_password="...",
        from_addr="approval-gate@example.com", to_addr="reviewer@example.com",
        secret="a-random-string-you-generate-once",
        public_base_url="https://approvals.example.com",
    )
    gate = ApprovalGate(db_path="audit.db", backend=backend)
    

    Links are HMAC-signed so they can't be edited into approving a different action; args aren't editable from an email link, only approve/reject.

  • SlackBackend — interactive Approve/Reject buttons right in a Slack message, resolved without leaving Slack:

    from approval_gate.backends import SlackBackend
    
    backend = SlackBackend(
        bot_token="xoxb-...", signing_secret="...", channel="#approvals", port=8645,
    )
    gate = ApprovalGate(db_path="audit.db", backend=backend)
    

    Every interaction is verified against Slack's own request-signing scheme before anything in the payload is trusted. Needs a public Request URL Slack can reach (a tunnel in development, real ingress in production) — see approval_gate/backends/slack.py's docstring for the one-time Slack app setup.

Writing a new backend means implementing one method, wait_for_decision(pending) -> dict (see approval_gate/backends/base.py). Any backend where the decision arrives asynchronously (a click, a webhook, a reply) can build on _PendingQueueBackend instead of re-solving "wait on a thread, resolve it from an HTTP handler" from scratch — see BACKEND_TEMPLATE.md for the full pattern and a contribution checklist.

Notifications

A review sitting in a browser tab nobody's looking at doesn't help. Pass notifier= to WebBackend to get pinged the instant something needs review:

from approval_gate.backends import WebBackend
from approval_gate.notifiers import SlackNotifier

backend = WebBackend(
    port=8642,
    notifier=SlackNotifier(webhook_url="https://hooks.slack.com/services/..."),
)

SlackNotifier is built in (stdlib urllib, no slack-sdk dependency) and posts the action name, risk level, a flag if sensitive data was detected, and a link straight into the review inbox. Any callable matching (pending: dict, review_url: str) -> None works as a notifier — write your own for email, PagerDuty, a custom webhook, whatever routes to your team. A broken notifier is caught and logged, never allowed to block or fail the approval flow itself.

Policies: not every action needs a human

Pausing for a human on every call doesn't scale past a handful of action types before reviewers start rubber-stamping everything, which defeats the point. Pass policy= to auto-approve, auto-reject, or route actions before a human is ever bothered:

from approval_gate import ApprovalGate
from approval_gate.policy import Rule, RulePolicy

policy = RulePolicy([
    Rule(risk="low", has_pii=False, auto_approve=True, name="auto-low-risk"),
    Rule(action_prefix="delete_", route_to="oncall-reviewer"),
])
gate = ApprovalGate(db_path="audit.db", policy=policy)

Rules are evaluated in order; the first match wins. auto_approve / auto_reject skip the human step entirely — nothing shows up in a review inbox, no notifier fires, but the decision is still written to the audit log with decided_by="policy:<rule-name>" so it stays fully traceable. route_to doesn't auto-decide; it tags the pending payload so the backend/notifier can direct it to the right reviewer, and the action still goes through normal human review.

For anything a declarative rule can't express, pass a plain callable instead — (pending: dict) -> Optional[dict], same escape hatch as Backend/Notifier. A policy must be a pure function of pending: it can re-run on a LangGraph resume-replay, same as everything else before a pause (see the note on LangGraph internals below).

Every example below runs with no API key and no real third-party credentials -- each fakes out only the third-party call (SMTP, Slack's API) while the actual approval-gate mechanics run for real:

git clone <repo>
cd approval-gate
pip install -r requirements.txt
python examples/email_agent_demo.py     # LangGraph, terminal review
python examples/plain_python_demo.py    # no framework, terminal review
python examples/web_inbox_demo.py       # browser review inbox
python examples/webhook_demo.py         # bring-your-own-system channel
python examples/email_demo.py           # signed approve/reject email links
python examples/slack_demo.py           # interactive Slack buttons
python examples/notifier_demo.py        # + Slack ping into the web inbox
python examples/policy_demo.py          # + auto-approve / routing
python examples/seed_inbox_demo.py      # fully populated inbox (6 scenarios at once)
python examples/view_audit_log.py       # see what got logged

How sensitive-data scanning works

Every proposed action's arguments are scanned before being shown to a reviewer or written to the log:

  • Always on, zero setup: regex checks for emails, phone numbers, credit card numbers (Luhn-validated), SSN-shaped numbers, and API-key-shaped strings (OpenAI, AWS, GitHub, Slack, Google patterns).
  • Optional, richer: if presidio-analyzer is installed, it also runs Microsoft Presidio for NLP-based name/location/organization detection.

Findings are masked before they're logged — you get pr***...om, not the raw email address, sitting in your audit database.

A note on how LangGraphBackend is built on LangGraph internals

When a node calling interrupt() is resumed, LangGraph re-runs that node function from the top — so any code before the interrupt() call executes again. ApprovalGate handles this by giving every pending action a deterministic ID (a hash of thread + action + args) and upserting instead of inserting, so a resume-replay updates the same row rather than creating a duplicate. This is unconditional — it happens regardless of which backend you use — but it's specifically LangGraph's replay-on-resume behavior that makes it necessary. If you're extending this code, that's the one piece of LangGraph-specific subtlety to keep in mind.

Roadmap

  • Framework-agnostic core — pluggable Backend interface (LangGraphBackend, BlockingBackend), so approval-gate isn't tied to one agent framework.
  • Local web review UI (WebBackend) — approve/edit/reject from a browser instead of a blocking terminal input() prompt.
  • Notification when something's waiting for review (SlackNotifier, pluggable for anything else — email, PagerDuty, custom webhooks).
  • Per-action policies (RulePolicy) — auto-approve low-risk, always escalate deletes, route by action type to a specific reviewer.
  • Multiple approval channels — browser (WebBackend), bring-your-own-system (WebhookBackend), signed email links (EmailBackend), interactive Slack buttons (SlackBackend). More channels (Teams, PagerDuty, SMS) are a community surface now, not a backlog — see BACKEND_TEMPLATE.md.
  • Hosted dashboard (team accounts, longer-retention Postgres-backed log) — paid tier, self-hosted core stays free and open forever. This is the only thing left.

Contributions welcome — see CONTRIBUTING.md for setup and where things live, and BACKEND_TEMPLATE.md if you want to add another approval channel — that's the highest-value contribution this project can accept right now. Open an issue if you want to discuss an approach before sending a PR.

License

MIT. Use it, modify it, ship it in commercial products. If it's useful to you, a GitHub star helps other people find it — that's the entire marketing budget for this project.

Metadata

Release files for approval-gate 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for approval-gate 0.1.0
File Size Uploaded Provenance
approval_gate-0.1.0.tar.gz 54.8 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for approval-gate 0.1.0
File Interpreter ABI Platform
approval_gate-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 100.1 kB

Release files / approval_gate-0.1.0.tar.gz

Download URL approval_gate-0.1.0.tar.gz
Size 54.8 kB
Tags Source
SHA-256 checksum
How to use checksums
11c7df0d44ccfb0b21c88e4f46d7d6b615da4d6ab5f918a660dc5b37443c6ed7
BLAKE2b-256 checksum
How to use checksums
0d4c739c355dbba800f40402c5739972407714e348e03fe6f437c91cb382b794
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.5

Release files / approval_gate-0.1.0-py3-none-any.whl

Download URL approval_gate-0.1.0-py3-none-any.whl
Size 45.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d273f0646a4699aeb44a3ad33e551f71a2a362ea9660a10d65a256e59a8ae282
BLAKE2b-256 checksum
How to use checksums
30bfa6727de56822443fec70ecd27a83558f63b8c040e6fea55a3b8a5f2d35f1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.5

Security for approval-gate 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page