Human approval that binds to the exact arguments

A "yes" to pay(10, alice) must not run pay(9999, mallory), and one "yes" should cover one action. How to bind an approval to what was shown.

The gap

Human-in-the-loop flows usually work like this: the agent proposes a call, the run pauses, a person approves, and the run continues from saved state. Two things can go wrong:

Bind the "yes" to the call, and spend it once

import call4agents as c4a
c4a.init()                     # the approval secret lives in .c4a/secret

@c4a.effect(approval=True)
def pay(amount: int, to: str) -> str:
    return payments.charge(amount=amount, to=to)

with c4a.run("order-42"):
    c4a.approve(pay, amount=10, to="alice")   # what the person SAW
    pay(amount=10, to="alice")                # runs once
    pay(amount=9999, to="mallory")            # ApprovalRequired: not what was approved
    c4a.deny(pay, amount=10, to="alice")      # a later "no" is enforced

The approval is an HMAC over the tool name and the exact arguments, scoped to the run, made with a local secret. Each approval can be spent by one action. Many callers racing on one "yes": exactly one runs.

What it does not protect against: someone who can read the secret file, and a person approving the wrong thing.

When the approval comes back through a UI: tickets (experimental)

If the "yes" travels through a web UI, a queue or a saved state, you also want to know that it answers a pause your server really made. c4a.pause records the pause and returns a ticket that holds no arguments, only a random id and a signature. c4a.decide reads the call from the record, so nothing the client sends back can change what runs. A ticket the record did not issue, or one that was changed, expired or already decided, is refused, and the decision is stored with who made it.

with c4a.run("order-42"):
    ticket = c4a.pause(pay, amount=10, to="alice")   # show the ticket with the call
c4a.decide(ticket, "approve", by="sam")              # later, from any process on the same record

These are the cases discussed in pydantic-ai #6452 (where an approval comes from) and #6968 (the approver sees other arguments than the ones that run). Since version 0.5 there are bridges for Pydantic AI, the OpenAI Agents SDK and LangGraph, and the checks run against each real framework: the approved call runs once, a denied call does not run, and a call changed by the client or in the saved state after the pause does not run. (The OpenAI Agents SDK already refuses changed arguments under the same call id by itself; the ticket also catches a call replaced with a new id.) Experimental: tested with fake models, and no real user has tried it yet.

pip install call4agents
call4agents quickstart   # offline, no API keys: 2 emails without the kit, 1 with it

Related guides