Skip to content

Approval Workflows

Any permission on a SecurityContext can be routed through an approval backend before the action actually runs — this is how you put a human (or another agent, or a webhook, or anything else) in the loop for sensitive operations like DELETE or EXECUTE, without hard-coding that logic into every tool.

How it fits together

Set require_approval_for on the context, and pass an approval backend:

from langchain_mero_tools import SecurityContext, PathEntry, Permission, CLIApproval

ctx = SecurityContext(
    paths=[
        PathEntry(
            path="./workspace",
            slug="root",
            allowed_permission=Permission.READ | Permission.WRITE | Permission.DELETE,
        ),
    ],
    require_approval_for=Permission.WRITE | Permission.DELETE,
    approval=CLIApproval(),   # blocking terminal y/N prompt
)

When guard() (see Core Concepts) sees that the permission being requested is in require_approval_for, it builds an ApprovalRequest and calls ctx.approval.request(req). If that returns True, the action proceeds; if False, it raises ApprovalDeniedError, which the tool turns into a "Denied: ..." string.

If require_approval_for is set but approval is None, the action is denied — approval requirements fail safe, not fail open.

ApprovalRequest

Every backend receives the same request object:

@dataclass
class ApprovalRequest:
    action: str              # e.g. "WRITE", "DELETE", "EXECUTE"
    detail: str              # e.g. the path or command being acted on
    requester: str = "agent" # the SecurityContext's name
    context: dict = field(default_factory=dict)  # extra structured info

The four built-in backends

Backend Use case
CLIApproval() Local dev — blocking input() prompt in the terminal.
InterruptApproval() LangGraph interrupt() — pauses the graph, resumes via Command(resume=...) from your own UI/API. See LangGraph Integration.
CallbackApproval(fn) Wrap anything callable — a webhook, a Slack bot, a database poll, a custom UI.
AutoApprove() / AutoDeny() Tests, fully-trusted internal contexts, or an explicit fail-safe default.

All four implement the same tiny interface — request(req: ApprovalRequest) -> bool — so they're fully interchangeable.

CLIApproval

from langchain_mero_tools import CLIApproval

approval = CLIApproval(auto_deny_on_eof=True)  # default

Prints a prompt like:

[approval required] 'worker_1' wants to WRITE: workspace/report.txt
Allow? [y/N]:

and blocks on input(). Only y/yes (case-insensitive) returns True. If stdin hits EOF (e.g. running non-interactively), it auto-denies by default; pass auto_deny_on_eof=False if you'd rather the underlying EOFError propagate.

InterruptApproval

from langchain_mero_tools import InterruptApproval

approval = InterruptApproval()

Requires langgraph installed (pip install -e ".[langgraph]", see Installation). Calls LangGraph's interrupt() with a structured payload describing the request, pausing the graph run entirely — no action is taken until your own application resumes it with a decision. Full walkthrough, including a complete working example: LangGraph Integration.

CallbackApproval

from langchain_mero_tools import CallbackApproval

def ask_slack(req):
    return slack_bot.ask(channel="#approvals", question=f"{req.requester}: {req.action} {req.detail}")

approval = CallbackApproval(ask_slack)

Wraps any Callable[[ApprovalRequest], bool] — the simplest way to plug in a custom synchronous decision source (a webhook call, a database poll loop, anything).

AutoApprove / AutoDeny

from langchain_mero_tools import AutoApprove, AutoDeny

test_ctx = SecurityContext(..., require_approval_for=Permission.DELETE, approval=AutoApprove())
locked_ctx = SecurityContext(..., require_approval_for=Permission.DELETE, approval=AutoDeny())

AutoApprove() is useful in tests or fully-trusted internal contexts where you want the shape of an approval-gated workflow without a human in the loop. AutoDeny() is useful as an explicit, self-documenting fail-safe default — clearer than leaving approval=None when you specifically want "never allow this, ever" to be visible in the config.

Writing your own backend

The interface is one method — anything with a matching request method works, no base class required (it's a typing.Protocol):

from langchain_mero_tools import ApprovalRequest

class MySlackApproval:
    def request(self, req: ApprovalRequest) -> bool:
        return my_slack_bot.ask(req.requester, req.action, req.detail)

ctx = SecurityContext(..., approval=MySlackApproval())

Chaining backends with ChainedApproval

ChainedApproval tries backends in order and returns the first definitive result. A backend signals "not my call, ask the next one" by raising NotImplementedError instead of returning False — this is exactly how manager/worker delegation is built: try the manager's own auto-decision first, fall back to a human if the manager can't decide.

from langchain_mero_tools import ChainedApproval, CLIApproval, AutoDeny

approval = ChainedApproval([
    some_fast_automated_check,   # may raise NotImplementedError to defer
    CLIApproval(),                # falls back to a human
])

If every backend in the chain defers (raises NotImplementedError), ChainedApproval raises ApprovalTimeoutError rather than silently returning False — a chain that can't resolve a request is a configuration problem worth surfacing loudly, not swallowing.

See also

  • Manager/Worker Delegation — the main real-world use of ChainedApproval, where a manager agent can approve on behalf of workers within its own scope.
  • LangGraph Integration — full working example of InterruptApproval pausing and resuming a graph.
  • Core Concepts — how approval fits into the guard() flow every tool call goes through.