Skip to content

19 min read

An approval attaches to a payload, not to a tool

On this page

An agent that can send mail, move money or cancel a subscription will occasionally do one of those things to the wrong party, and a share of those actions cannot be taken back afterwards. The arrangement almost every team reaches for is to let the agent run unattended until it reaches a consequential action, stop it there, and ask a person. That arrangement is worth its cost only if the thing the person agreed to is the thing that runs, and the cheapest way to build it quietly breaks that correspondence.

Human in the loop — HITL in most vendor documentation — is the arrangement in which an agent running on its own stops before a chosen action and waits for a person to say whether it may go ahead. The stopping point is an approval gate: a check placed in front of one specific action that suspends the run, sends a request to whoever is on duty, and lets the run continue only when a decision comes back. What the person is deciding about is the payload, meaning the tool the agent selected together with the exact arguments it resolved for that call — send_email addressed to one named recipient, carrying this subject and this draft, rather than the general capacity to send mail.

Gates exist because the two simpler arrangements are both unaffordable at the consequential end of an agent's toolset. An agent that acts freely will occasionally mail the wrong person or refund the wrong invoice, at whatever rate its inputs and its own errors produce, and a share of those actions cannot be undone afterwards. An agent that acts only once a person has read every step is a slow way of doing the work by hand. A gate spends human attention selectively, so that cheap and reversible actions run unattended at machine speed while a small consequential set waits for someone with the context to authorise it, at a cost paid in latency and in operator time.

Most readers have met one from the other side: a coding assistant that asks before running a shell command, a deploy bot posting approve this release? into a channel, a support agent that drafts a refund and waits. The agent frameworks expose the same shape directly, LangGraph as an interrupt that suspends a graph mid-run and the OpenAI Agents SDK as a needs_approval flag on a tool, and each of them supplies a place to stop, a message to a person, and a way to resume. What they leave open are the design decisions: which actions stop, what the person is shown, what the approval covers, and what happens when nobody answers. This chapter is about the third of those, and about what a wrong answer to it costs.

What the operator actually agreed to

A gate is cheapest to build as a flag. When the operator clicks approve, write a row naming the run, the tool, who approved and when, and have the check in front of the tool ask whether such a row exists. What that record grants is a capability to a tool for the lifetime of a run. That is not what the operator was looking at when they clicked. They were looking at a recipient, a subject line and a draft, and their decision was a judgement about that specific combination of values reaching that specific address. A record that keeps only the tool name discards everything the decision was about and keeps the part the operator never evaluated.

Both the person who clicks and the reviewer who signed the design off believe that flag is the defence, and the belief is a reasonable one to hold. The row is written by a real human decision about a real payload, nothing about it is forged, and no code path bypasses it. It fails anyway, for a reason that has nothing to do with anybody being careless. The flag is a quantity that stays constant while the dangerous property varies. A tool name is fixed the moment the model chooses the tool; the arguments are resolved separately, later, out of state that other systems are changing; and a check keyed on the name cannot see the only difference that matters. send_email is invariant under exactly the change that turns an approved send into an unapproved one.

Written out as a chain, the failure is four links and each one is checkable on its own. The approval record's key is the tool name. The tool name is fixed at planning time, when the model emits its choice. The recipient is not fixed then — it is resolved at execution time by a call to whatever system holds the current answer. So any path that re-enters the dispatch code and resolves the arguments a second time produces a different call under the same key, and a check that asks has this tool been approved in this run answers yes to it. Nothing in that chain requires an unusual failure. It requires only that the resolution step run twice.

text
tool-scoped flag     approved(run, tool)
payload binding      approved(run, tool, args)

    between planning and execution, args may change; tool does not

    approved(run, tool)         is invariant under a change of args
    approved(run, tool, args)   is not

    a gate is exactly as strong as the part of the call it is keyed on

The last line is the general form, and it is the part worth carrying out of this chapter. Every check of this kind is a function of some portion of the thing being checked, and its strength is bounded by how much of that thing it reads. Keying on the tool name reads the portion that a re-resolution cannot alter, so it certifies something true and useless.

An approval is more usefully modelled as a signature over the resolved call: the tool name together with the exact arguments that will be passed, bound to the run it belongs to. If any of those arguments differ at execution time, the signature does not verify and the gate fires again. Implementing that is a matter of canonicalising the arguments and hashing them, which is mechanical enough to fit in a screen of code.

Signature there is an analogy, and it is worth saying where it stops. A cryptographic signature binds an identity to a message so that a third party holding nothing but a public key can verify it, and that is not what the digest below does. The digest is a comparison the same system performs against its own store, so it detects a change in the arguments and says nothing about who wrote the record or whether they were entitled to write it. Anyone who can insert a row into the approval store can mint an approval for anything. Change-detection is the whole of what it offers, and the rest of this chapter is careful not to ask it for more.

Canonicalising has to happen before the hash, and the reason is that two argument dictionaries carrying the same values in a different key order are the same decision and have to produce the same key. A hash is a function of bytes, so left alone it reports two spellings of one decision as two different decisions, which turns every harmless re-serialisation into a spurious second card. sort_keys=True fixes the order of the keys and separators=(",", ":") fixes the whitespace between them, and together they make the string a function of the values alone rather than of whichever library happened to write it. ensure_ascii=False does the same job for a recipient name outside the Latin alphabet, which would otherwise hash differently depending on whether it had been escaped.

python
import hashlib
import json
from dataclasses import dataclass

def payload_digest(run_id: str, tool: str, args: dict) -> str:
    canonical = json.dumps(
        {"run_id": run_id, "tool": tool, "args": args},
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,
    )
    return hashlib.sha256(canonical.encode()).hexdigest()

@dataclass(frozen=True)
class Approval:
    digest: str          # binds run + tool + exact arguments
    approved_by: str
    approved_at: str
    consumed_by: str | None = None   # execution attempt that used it

class ApprovalRequired(Exception):
    def __init__(self, digest: str, tool: str, args: dict):
        self.digest, self.tool, self.args = digest, tool, args

def gate(store, run_id: str, tool: str, args: dict) -> Approval:
    digest = payload_digest(run_id, tool, args)
    approval = store.get(digest)
    if approval is None:
        raise ApprovalRequired(digest, tool, args)
    return approval

Walk one retry through that function by hand, on the smallest case that shows the whole mechanism. An agent drafts a renewal notice, resolves the account's primary contact from a customer relationship management system, and calls gate with {"to": "alice@northwind.example", "subject": "...", "body_id": "draft_88f1"}. payload_digest folds the run id, the tool name and those arguments into one canonical string and hashes it, store.get misses, ApprovalRequired is raised, a card is rendered, and the operator's click writes an Approval under that digest. The send then times out. The loop re-plans, the contact lookup runs a second time, and the value that comes back is procurement@northwind.example. The canonical string now differs in one field. A hash of a string that differs in one field differs everywhere, so store.get returns None, and the loop stops at a second card that reads the recipient has changed since you approved this. The route that would otherwise have put confidential terms in front of a shared mailbox becomes a second click for the same operator, forty seconds later.

The hash is doing one narrow job, which is making sameness checkable. You could compare argument dictionaries directly and get the same behaviour; the digest is convenient because it is a single short string you can carry through a queue, put in a URL, write in an audit row, and compare cheaply. What matters is that the comparison is over the values, and that it happens at the moment of execution rather than at the moment of planning.

The three routes that walk around a tool-scoped flag

The timeout-and-retry path is the easiest of these to construct on purpose, and it is not the only one. Each of the three has a different reason for slipping past a check that asks "has this tool been approved", and each is closed by the same payload binding.

Route to the actionWhy a tool-scoped flag lets it throughWhat payload binding does instead
Retry after a failureThe flag is set for the run; the retry re-enters the same code path with re-resolved argumentsRe-hashes the new arguments, misses, and stops for a fresh decision
Parallel branch in one turnOne turn emits three tool_use blocks for the same tool; the operator sees and approves oneEach block carries its own digest; two of the three are unapproved
Side effect before returnThe tool sent the mail and then the connection dropped; the loop treats the step as not doneThe digest matches, so the approval is reusable — and its id becomes the idempotency key, the token that lets the far end recognise a repeat and act once

Those rows are worth reading as differences rather than as a list, because they are not three levels of severity. They are three ways for the invariant part of a call to survive a route while the varying part changes underneath it, and the middle column is where each row's reason lives. A retry re-resolves the arguments inside the same code path. A parallel branch produces several distinct calls that share one tool name. A completed side effect leaves the arguments identical and moves the uncertainty out of the payload and into the world. One binding closes all three, and it closes them for a single reason: it moves the check off the part that survives the route and onto the part that varies.

The parallel case deserves a moment because it is the one that survives code review. A model that decides to notify three stakeholders emits three tool_use blocks in a single assistant turn, and a gate that pauses the loop, shows the operator "the agent wants to send email", and resumes on approve has granted three sends for one decision. The dispatch code that fans those calls out concurrently is usually the same code that was written before any gate existed, so the gate sits above the fan-out rather than inside it. Binding to the payload forces the check down to the individual call, where the arguments live, and it also forces a product decision you would otherwise never make: three digests means three cards, unless you deliberately build a batch card that shows all three payloads and records one decision against all three digests. Both are defensible; silently approving three because one was shown is not.

That last point is worth separating from the requirement it sits under, because the two are routinely collapsed. The requirement is that a person authorises each effect. The design decision is whether three effects are authorised by three cards or by one card that displays three payloads and records one decision against all three digests. Payload binding does not make that choice for you, and it is not meant to. What it does is make the choice unavoidable, since the third option — one card, three effects, and no record of which payloads were on screen — stops being expressible once the check reads the arguments.

The retry row hides a subtlety about what counts as the same payload. If the retry re-resolves the recipient and gets alice@northwind.example again — the ordinary case, since most retries happen against unchanged state — the digest matches, the stored approval is found, and the loop proceeds without asking anyone anything. That is the behaviour you want, and it is why the check has to be a comparison rather than a one-shot consumption flag that a first attempt burns. A gate that invalidates its own approval on the first execution attempt turns every transient 503 into a human interruption, which at the volumes in the next chapter is how a correct mechanism becomes an unusable one.

The third row is the one that changes shape rather than being closed. When a tool produces its effect before returning — a payment capture that succeeds and then times out on the response, a webhook the receiver has already processed, a file write that completed before the process was killed — the honest position is that you do not know whether the action happened. Re-approval is the wrong response to that, because the operator has no more information than you do and will approve, producing a second charge. What you want is for the retry of an identical payload to be safe, which means the effect has to be deduplicated at the far end.

python
def execute(store, run_id: str, tool: str, args: dict):
    approval = gate(store, run_id, tool, args)
    # one approval authorises one effect: reuse it as the idempotency key
    return TOOLS[tool](**args, idempotency_key=approval.digest)

Stripe, Adyen and most payment APIs will collapse two requests carrying the same Idempotency-Key into one charge and return the original response for the second, typically within a 24-hour window. An SMTP gateway will not, so for mail the equivalent is a Message-ID you generate yourself plus a sent-log keyed on the digest, checked before the connection is opened. The general rule is that the approval authorises exactly one effect, and the digest is the identifier of that effect everywhere it can be recognised.

That reuse is an identity rather than a convenience, and it is worth stating with its limits attached. Because one approval authorises exactly one effect, the identifier of the approval is already a correct identifier of the effect, and no second piece of bookkeeping has to be invented to let the far end recognise a repeat. What the identity licenses is precisely that and no more: a retry carrying an identical payload is safe without a new human decision. What it leaves over is everything the far end declines to do for you. A payment API that collapses two requests under one key honours the identity; a mail gateway does not, which is why mail needs a message identifier and a sent-log of its own to stand in for it. And it says nothing whatever about a second effect that is not identical, since a different amount or a different recipient hashes to a different digest and is a different decision. That residual is not a rounding error. It is the whole class of double effects your dependencies have chosen not to deduplicate, and it is yours to close.

The order of the four steps

Where the gate sits relative to argument resolution is the part that is easy to get backwards, because the natural place to put a pause is where the plan becomes visible, and that is too early. An approval taken before the contact lookup would have shown the operator send the renewal notice to the primary contact, which is a sentence they cannot check, and would have verified happily against either address.

Sequence: Agent loop → Resolver (CRM) (primary_contact(northwind)); Resolver (CRM) → Agent loop (alice@northwind.example); Agent loop → Gate (check digest(to=alice@…)); Gate → Operator (card with resolved payload); Operator → Gate (approve 14:02:11); Agent loop → Mail gateway (send(idempotency_key=digest)); Mail gateway → Agent loop (504 after 30s); Agent loop → Resolver (CRM) (primary_contact(northwind) [retry]); Resolver (CRM) → Agent loop (procurement@northwind.example); Agent loop → Gate (check digest(to=procurement@…)); Gate → Operator (MISS — new card, recipient changed).

Reading the diagram from the bottom up gives the invariant: every arrow into the mail gateway is preceded by a digest check over the arguments on that arrow. There is no path into the effect that does not pass the check, including the path that arrives from an error handler. The practical consequence for the code in the previous topic's hand-built loop is that the gate belongs in the dispatch step, next to the argument unpacking, and not in the planning branch where it would be easier to write.

Four steps, and their order is the whole of the design. Resolve the arguments. Compute the digest over them and look it up. Take a human decision if there is no match. Execute against the arguments that were hashed. Move the check before the resolution and it covers placeholders rather than values. Move it after the execution and it is an audit record rather than a gate. Leave resolution and execution on either side of the check but allow a second resolution to run between them, and you are back at the failure this chapter opened on. The order is not a convention that could have gone another way: each step's input is the previous step's output, and there is exactly one arrangement in which the values being checked are the values that run.

The ordering also settles an argument that comes up whenever a team tries to gate a multi-step plan as a unit. Approving a plan of six steps at the point the plan is produced binds a digest over six sets of arguments, four of which contain placeholders that later steps will fill in from tool output. Those placeholders are exactly the values an operator would want to check, so a plan-level approval taken early is a decision about structure with the substance left blank. Plans can still be approved as plans, and the fourth chapter is about doing that well, but a plan approval does not remove the need for a payload check in front of the irreversible step; it changes what the operator is asked at each of them.

There is one more field worth putting in the approval record, which is a copy of what the operator was shown. Storing the rendered card alongside the digest — the bytes, not a reference to a template that will change — costs a few kilobytes per approval and answers the only question an incident review reliably asks, which is what the person could see when they decided. A digest proves that the executed arguments matched the approved ones; it says nothing about whether the recipient was displayed in full or truncated to alice@nort…, and those two cards produce very different defences of the same click.

Authorisation, idempotency and mutable references

Payload binding is a mechanism for making an approval mean what the operator thought it meant. It is not authorisation, and conflating the two produces a system where anyone who can see a card can grant any capability. The check above asks whether this exact call was approved and says nothing about whether d.ferreira is permitted to approve a $40,000 credit note; that question belongs to the same policy layer that decides tool scopes, and it is answered before the card is ever rendered to a particular person.

It is also not idempotency, though it supplies a convenient key for it. A digest that matches will happily authorise a second identical charge if the far end has no deduplication, and a system whose only protection against double execution is that the payload changed is protected by accident.

The narrower limitation is that a digest is only as meaningful as the arguments it covers. If body_id: "draft_88f1" points at a mutable row and the draft's text is rewritten between approval and send, the digest is unchanged and the operator has approved content that no longer exists. Anything the decision depended on has to be inside the hash or immutable behind the reference, which in practice means content-addressed drafts or a version field in the arguments. Teams discover this the first time a template edit changes the meaning of a thousand approved-but-not-yet-sent messages.

That limitation is this chapter's own failure arriving one level further down, and seeing it as the same shape is more useful than filing it as a separate hazard. A digest is a statement about the arguments and never about the world behind a reference. The draft identifier is the constant; the text it points at is the dangerous property; and a check keyed on the constant is invariant under exactly the change that matters. The remedy is the remedy at both levels: put the varying thing inside the thing you hash, or take away the reference's ability to vary. Content-addressed drafts do the second, a version field in the arguments does the first, and anything short of one of those leaves a check that verifies while the decision it stands for has quietly expired.

What payload binding buys, then, is a correspondence: the thing the operator agreed to and the thing that executes are the same thing, and the system can demonstrate it at the moment the demonstration is worth having. Everything else in this chapter follows from choosing which portion of the call that demonstration covers. Cover the tool name and it holds under every change worth stopping for. Cover the resolved arguments and it breaks under exactly those changes, which is what a check is for.

None of this decides which actions deserve a card in the first place. The gate in the run above was on send_email because someone had reasoned that mail leaves the building; the same agent could dispatch crm.update_contact and calendar.create_event without stopping, and the team had never written down why. That question — where friction goes, and how to defend the actions you left ungated — is the design decision the rest of this depends on, and it fails in a direction that a correct gate implementation cannot rescue.

Human in the loop — read the current version at https://daviddominguez.dev/learn/en/human-in-the-loop/an-approval-attaches-to-a-payload-not-to-a-tool

Back to top