AI Agent Human Approval: Binding Decisions to Exact Actions
An approve button authorizes whatever the system happens to run afterwards, unless the approval is tied to the exact tool, arguments, target version and actor the reviewer saw. This post covers that binding, then two uncomfortable facts: reviewers mostly click yes, and a reviewer cannot approve what the card does not show.
Reviewers click yes
Anthropic's engineering post on Claude Code auto mode says that asking for approval by default "leads to approval fatigue, where people stop paying close attention to what they're approving", and that "in practice users accept 93% of them anyway." That is one product's coding agent, and your reviewers are different people. Still, plan for it. A binding that perfectly records what was approved protects you from tampering and drift after the click. It cannot make the click informed.
Three design choices follow. Ask for approval rarely and only for actions with a real downside, so each prompt carries signal. Make the card complete, so the one reviewer who reads it has everything. And enforce hard limits in code that no click can override, such as the recipient domain, the amount ceiling, or tools switched off by policy. Scheduled jobs can follow this pattern: each stores which write tools and which destinations it may use unattended, so the allowance is a narrow, reviewed grant and not a standing "always approve".
If the card cannot show it, do not ask
A preview that hides the body of an email or half of an argument list turns approval into a ceremony. Know the limits of the surface that renders the card. Slack's section block, for example, accepts at most 3,000 characters of text (Slack section block), so a long email body cannot fit one block, and a client may fold a long card behind a "Show more" link. If an action's preview exceeds the limit for its tool, refuse to create the approval and tell the model the content is too long to show in full, so it must shorten it first. Expand the card by default where you can. For sends that read a remote draft, put the full draft into the hashed content too, so the click is checked against everything, including the part that did not fit the card.
Bind the click to a digest of the full content
The approval record should be immutable and carry the tool and its version, canonical arguments, tenant and acting user, target resource versions, policy version, reviewer, decision time, expiry and a one-time execution claim. Keep the canonical fields for inspection and a digest for a cheap equality check, built on the server from deterministic serialization.
{
"approval_id": "apr_7f2",
"tool": "send_refund_email",
"tool_version": "3",
"canonical_arguments": { "case_id": "case_88", "recipient": "buyer@example.com", "refund_amount": "125.00", "currency": "USD" },
"action_digest": "sha256:stored-server-side",
"tenant_id": "tenant_19",
"acting_principal": "user_51",
"target_versions": { "case_88": "17" },
"policy_version": "refund-policy-12",
"status": "approved",
"expires_at": "2026-08-24T09:30:00Z",
"claimed_at": null,
"operation_id": null
}
Compute the digest as SHA-256 over canonical JSON (sorted keys, compact separators) of the arguments and, for some tools, the resolved preview text. Store it server-side. On the click, rebuild the preview from the current state and compare it with the stored one. For an email send, a draft edited after the card was posted gives a different digest, and the user sees that the message changed, nothing was sent, and a new card is needed. Re-check policy at click time too, since an admin may have switched the tool off while the card waited.
Make the transition single-use
Use a compare-and-set from pending to the decision state so two reviewers clicking at once give one accepted decision. Approving should be an update that only succeeds on a row that is still pending and unexpired, and a unique database constraint should allow one live approval per conversation thread, so a model cannot stack up several cards. Then claim the approval once before executing, pass a stable operation ID to the downstream API as its idempotency key, and execute outside the transaction. Clear the stored arguments of expired approvals.
For customer-visible writes, a stricter gate approves only on the person's own next message in the thread, and a reply counts only if it contains an approval word plus filler and no question or condition. "Yes please send it" passes. "Yes but add a thanks line" and "can you send it to legal first?" do not, and the model cannot ask and approve in the same turn.
Unknown outcomes stay unknown
A timeout after the downstream call leaves you not knowing whether it ran. Never move the record back to approved. Keep it claimed, query the destination with the operation ID, and mark it executed or outcome unknown. The conservative version is a periodic sweep that marks an approval stuck in the running state past its window as failed, with a message that the outcome is uncertain after an interruption and that it was not retried. A person can then check the destination, which beats a second email or a second charge.
Framework gaps to test
Frameworks give you pause and resume. The OpenAI Agents SDK human-in-the-loop flow can interrupt a tool call, serialize state and resume on a decision, and LangGraph interrupts do durable pause and resume, with a resumed node starting again from the beginning of the interrupted node, so keep side effects after the gate. Both leave the authorization record to you. Practitioners have reported a case where malformed arguments reached an approval predicate as an empty object and a serialized approval that was not honored on resume when actor context was carried. Treat these as reasons to test the full resume path against your real persistence, and to fail closed when arguments cannot be parsed. Store the approval in an application-controlled table with its own uniqueness constraints, not only inside serialized agent state. OWASP's guidance on excessive agency adds that review does not replace narrow tools and scoped credentials.
Tests
- Change the recipient, amount, tool version, tenant or actor after approval, and expect a different digest and no execution.
- Edit the draft or target record between card and click, and expect a refusal with a new card.
- Race two reviewers and two executing workers, and expect one winner each.
- Expire and reject, then try to claim, and expect failure.
- Kill the worker after the downstream call, and expect reconciliation by operation ID, not a second send.
- Make a preview larger than the card limit, and expect the approval request to fail.
Production invariant: no side-effecting tool starts without exactly one unexpired, digest-matching, claimed authorization when policy requires review. Alert on any violation.
References
- Anthropic: Claude Code auto mode
- OpenAI Agents SDK human in the loop
- LangGraph interrupts
- OWASP LLM06:2025 Excessive Agency
- OpenAI Agents SDK issue 3863
- OpenAI Agents SDK issue 4244
- Slack Block Kit: section block: text is limited to 3,000 characters.