Catch your LangChain agent's risky actions before they run, and route them to a human for approval — in 3 lines.
Project description
actionguard
Catch your LangChain agent's risky actions before they run, and route them to a human for approval — in 3 lines.
from actionguard import guard, ApprovalPolicy
from langchain_core.tools import tool
@guard(policy=ApprovalPolicy(amount_over={"arg": "amount", "threshold": 100}))
@tool
def refund_customer(amount: float, customer_id: str) -> str:
"""Issue a refund."""
...
When your agent calls refund_customer(amount=4000, ...), execution pauses, a human is asked to approve or deny, and the refund only runs if approved. Denied calls return a clear message to the agent instead of doing anything. Every decision is written to an append-only audit log.
Why
AI agents are great until one does something it can't take back — double-charging a card, deleting production data, emailing the wrong customer, issuing a duplicate refund. Automated checks can't catch every irreversible action, and you don't want to babysit the agent for the 1% of calls that actually matter. actionguard lets you keep the agent autonomous for routine work while putting a human in the loop for exactly the calls you decide are dangerous.
Install
pip install actionguard
The only required dependency is langchain-core. Slack support adds requests:
pip install "actionguard[slack]"
Quickstart (5 minutes)
No LLM or API key needed — this simulates the agent deciding to refund, so you can watch the call get halted at your terminal:
from langchain_core.tools import tool
from actionguard import guard
# With no policy given, actionguard uses the safe default: every call needs approval.
@guard()
@tool
def refund_customer(amount: float, customer_id: str) -> str:
"""Issue a refund to a customer's card."""
return f"✅ Refunded ${amount:.2f} to {customer_id}"
if __name__ == "__main__":
print("Agent: issuing the refund now...")
print(refund_customer.invoke({"amount": 4000.0, "customer_id": "cus_123"}))
print("Agent: did that go through? Trying again to be safe...")
# The dangerous duplicate — deny it at the prompt!
print(refund_customer.invoke({"amount": 4000.0, "customer_id": "cus_123"}))
You'll see a prompt like this and the call will wait for your answer:
──────────────────────────────────────────────────────────────
🛑 actionguard — approval required
──────────────────────────────────────────────────────────────
Tool: refund_customer
About: Issue a refund to a customer's card.
Args:
amount = 4000.0
customer_id = 'cus_123'
──────────────────────────────────────────────────────────────
Approve this action? [y/N]
Deny it, and the agent receives a DENIED: ... message instead of issuing a duplicate refund.
To guard tools at the agent level, use guard_tools and hand the result to your agent:
from actionguard import guard_tools
guarded = guard_tools(my_tools, policy=my_policy) # returns a new list
agent = create_agent(llm, guarded) # the agent sees identical tools
Not just LangChain — guard any function
guard wraps any Python callable, not only LangChain tools. This is the one thing a framework's built-in approval hook can't do — it works on a plain function, a CrewAI/LlamaIndex tool, or anything callable. Because a plain function has no agent loop to hand a message to, a denied call raises ApprovalDenied (or set on_denied="return" to get the denial string back):
from actionguard import guard, ApprovalPolicy, ApprovalDenied
@guard(policy=ApprovalPolicy(require_always=True))
def delete_user(user_id: str) -> None:
"""Permanently delete a user account."""
...
try:
delete_user("u_42") # pauses for approval; runs only if you say yes
except ApprovalDenied as denied:
print("blocked:", denied.action.pretty())
It preserves the function's name, docstring, and signature (so other tools' introspection still works), and supports async def too. Same policies, channels, and audit log as the LangChain path.
Policies — decide what needs approval
An ApprovalPolicy looks at a tool call's arguments and answers one question: does a human need to approve this? Rules combine with OR — if any rule fires, the call is held.
from actionguard import ApprovalPolicy
# Threshold: only refunds over $100 need approval
ApprovalPolicy(amount_over={"arg": "amount", "threshold": 100})
# Regex: only touch production customers needs approval
ApprovalPolicy(match_args={"customer_id": r"^prod-"})
# Arbitrary predicate over the args dict
ApprovalPolicy(require_if=lambda args: args["amount"] > 100 and args["currency"] == "USD")
# Always require approval (e.g. for a delete tool)
ApprovalPolicy(require_always=True)
# Safe default: ApprovalPolicy() with no rules requires approval for EVERY call.
ApprovalPolicy()
Approval channels — decide who approves and how
The channel is where the human's yes/no comes from. CLIChannel is the default and needs zero setup.
from actionguard import guard, CLIChannel, SlackChannel
# CLI (default) — a blocking terminal prompt. Works in any script or notebook.
guard(my_tool, channel=CLIChannel())
# Slack (v0) — posts the action to an incoming webhook and waits for a decision.
guard(my_tool, channel=SlackChannel(
webhook_url="https://hooks.slack.com/services/...",
poll_fn=my_decision_poller, # how actionguard learns the human answered
timeout=300, # seconds to wait
on_timeout="deny", # safe default if nobody answers
))
Write your own by subclassing ApprovalChannel and implementing one method:
from actionguard import ApprovalChannel
from actionguard.core import Action, Decision
class EmailChannel(ApprovalChannel):
name = "email"
def request_approval(self, action: Action) -> Decision:
# ...notify a human, block until they answer...
return Decision(approved=True, source=self.name)
Note on Slack: incoming webhooks are one-way, so the v0 Slack channel posts the action and then waits for your
poll_fnto report the decision (poll a DB, a queue, a file — whatever your Slack app writes to). Native Block Kit buttons wired to a request URL are on the roadmap. CLI works out of the box with nothing to set up.
Audit log
Every intercepted call writes exactly one JSON line to actionguard_audit.jsonl (configurable). This is the trust surface — what the agent tried, whether policy held it, what the human decided, and what happened:
{"timestamp": "2026-06-01T12:00:00+00:00", "tool": "refund_customer", "args": {"amount": 4000.0, "customer_id": "cus_123"}, "needed_approval": true, "approved": false, "decision_source": "cli", "decision_comment": null, "executed": false, "result": null, "error": null}
Point it wherever you like:
from actionguard import guard, AuditLog
guard(my_tool, audit=AuditLog("logs/approvals.jsonl"))
# or just a path:
guard(my_tool, audit="logs/approvals.jsonl")
The audit log records tool arguments verbatim and is written to disk with your default file permissions. If your tools take secrets or PII as arguments, point the log at a restricted location (or disable it with
AuditLog(enabled=False)). Automatic secret redaction is on the roadmap, not in v0.
Scope — what this is (and is not, yet)
actionguard v0 is a focused human-in-the-loop approval gate for irreversible actions. That's it. It is not a security sandbox and makes no security guarantees beyond "this call won't run until a human says yes."
It deliberately does not (yet) do: sandboxing, SSRF protection, secret redaction, code/AST verification, durable/resumable approval state, retries, or planning. Those are real and useful — they're just not v0. See ROADMAP.md for what's intentionally cut and what's coming.
If you need a full guardrails platform today, that's not this. If you want to stop your agent from double-refunding a customer in the next five minutes, you're in the right place.
Contributing
Issues and PRs welcome. To set up a dev environment:
pip install -e ".[dev]"
pytest
ruff check . && black --check .
Keep changes small and aligned with the scope above — actionguard's whole value is being the smallest thing that solves the irreversible-action problem.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file actionguard-0.2.1.tar.gz.
File metadata
- Download URL: actionguard-0.2.1.tar.gz
- Upload date:
- Size: 329.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.9
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
89efbb22787f877dc6929a7e9f66451fc0ed7086209e63a28826e402fca0129e
|
|
| MD5 |
6ce72b3204a71184c6fe92bbb8330e45
|
|
| BLAKE2b-256 |
c8ae6fb4962a49f76a9f4f1da697d8aad0cc0820fffb14c71adb2540361d89f1
|
File details
Details for the file actionguard-0.2.1-py3-none-any.whl.
File metadata
- Download URL: actionguard-0.2.1-py3-none-any.whl
- Upload date:
- Size: 25.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.9
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5a0a2dba4019963dc3d62f45c78c2a3ce7878547e608a6cff2afeeb4bdc69a1a
|
|
| MD5 |
909f6fd19ee8ffde6377c5169f8bb13b
|
|
| BLAKE2b-256 |
1df0bbaa7c009dc51ba41ef3cac2ab9e89842f9fd5ccaea7800d25dfbc69fd27
|