Human Approvals
Cryptographically verified human-in-the-loop authorization for AI agent tool calls.
Define who must approve, how many, and which calls in the warrant. Collect SignedApproval signatures and retry. There is no unsigned path.
Examples on this page use the explicit low-level API (Warrant.mint_builder(), enforce_tool_call) rather than the mint_sync/guard helpers from the README quick start. The concepts are the same: gates and approvers are fields on the warrant, and the framework adapters below enforce them identically.
Quick Start
from tenuo import SigningKey, Warrant, BoundWarrant, enforce_tool_call, cli_prompt
control_key = SigningKey.generate()
agent_key = SigningKey.generate()
approver_key = SigningKey.generate()
# 1. Warrant: capabilities, gates, approvers, threshold
warrant = (Warrant.mint_builder()
.capability("transfer")
.approval_gates({
"transfer": {"args": None, "message": "A human must confirm this transfer."},
})
.required_approvers([approver_key.public_key])
.min_approvals(1)
.holder(agent_key.public_key)
.ttl(3600)
.mint(control_key)
)
# 2. Enforce: handler prompts and signs on gate fire
result = enforce_tool_call(
tool_name="transfer",
tool_args={"amount": 50_000, "to": "alice"},
bound_warrant=BoundWarrant(warrant, agent_key),
trusted_roots=[control_key.public_key],
approval_handler=cli_prompt(approver_key=approver_key),
)
Three things to configure on the warrant:
| Method | Purpose |
|---|---|
.approval_gates({...}) |
Which tool calls trigger approval |
.required_approvers([...]) |
Who may sign |
.min_approvals(n) |
How many valid signatures (m-of-n) |
Naming: use
.min_approvals()when minting. On issued warrants, read the threshold withwarrant.approval_threshold(). The wire field ismin_approvals.
Two ways to supply approvals at runtime:
| Mechanism | When |
|---|---|
approval_handler=... |
Prompt or call your approval UI when a gate fires |
approvals=[signed, ...] |
Pre-collected SignedApproval objects (retry / out-of-band) |
Gates are evaluated per call. Listing required_approvers alone does not require approval unless a gate fires for that (tool, args).
Do not treat every tool listed in tenuo.approval_gates as unconditionally gated. A tool can be listed with a per-argument Exempt or Constraint gate; only some calls require approval. Inspect the signed extension with the typed API below instead of parsing the raw CBOR keys.
Approval Gates
from tenuo_core import Exact
warrant = (Warrant.mint_builder()
.capability("search")
.capability("restart_service")
.approval_gates({
"restart_service": {"environment": Exact("production")}, # prod only
# whole-tool + display text:
# "transfer": {"args": None, "message": "A human must confirm this transfer."}
})
.required_approvers([approver_key.public_key])
.min_approvals(1)
.holder(agent_key.public_key)
.ttl(3600)
.mint(control_key)
)
An optional message on a gate is display text only. It is resolved once when
the gate fires and copied onto every adapter (ApprovalRequired, FastAPI 409
message, MCP denial_reason, Temporal ApplicationError, authorizer JSON,
A2A, control-plane request). It is not part of request_hash or receipts.
Empty strings are omitted; values longer than 200 characters are truncated.
There is no argument interpolation.
Because the message is not a security property, approval UIs should treat it as
context, not authority. A delegated child can replace display text, so approvers
and automated workflows should make decisions from the signed request fields
(tool, arguments, request_hash, warrant_id, approver threshold) rather
than from the wording alone.
MCP clients still retry by attaching signatures in _meta.tenuo.approvals;
that instruction is documented here rather than rewritten into the denial
reason.
Inspecting gates (typed preflight)
Adapters that need to know whether a concrete call requires approval should
use Warrant.approval_requirement(tool, args) (or the module-level
approval_requirement). It returns a typed result from the same
Constraint.matches implementation the authorizer uses:
| Status | Meaning |
|---|---|
not_gated |
Tool is granted and either absent from the gate map, or a constrained gate does not match these arguments |
exempt |
A per-argument Exempt gate is present and this call matches the exemption |
required |
The gate fires (kind is whole_tool or argument) |
denied |
The warrant does not grant this call (missing tool or capability miss). Do not collect approval. |
To inspect the structure without arguments, use
Warrant.inspect_approval_gate(tool):
kind |
Meaning |
|---|---|
none |
Tool is not in the gate map |
whole_tool |
Every invocation of this tool is gated |
conditional |
Per-argument All / Constraint / Exempt gates (arguments lists the names) |
from tenuo import Range, Warrant
warrant = (Warrant.mint_builder()
.capability("write_approval", amount=Range(0, 1000))
.approval_gates({
"write_approval": {"amount": {"exempt": Range(0, 500)}},
})
.required_approvers([approver_key.public_key])
.min_approvals(1)
.holder(agent_key.public_key)
.ttl(3600)
.mint(control_key)
)
assert warrant.inspect_approval_gate("write_approval").kind == "conditional"
# 456.50 is inside 0..500 — capability passes, exemption matches, no approval.
req = warrant.approval_requirement("write_approval", {"amount": 456.50})
assert req.status == "exempt"
assert not req.requires_approval()
# 650 is inside 0..1000 but outside the exemption — approval required.
req = warrant.approval_requirement("write_approval", {"amount": 650})
assert req.status == "required"
assert req.kind == "argument"
# 1200 fails the capability Range(0, 1000). Preflight reports `denied`.
req = warrant.approval_requirement("write_approval", {"amount": 1200})
assert req.status == "denied"
assert not req.requires_approval()
evaluate_approval_gates(warrant, tool, args) is the gate-map-only boolean
(split-view safe: it does not run capability checks). Prefer
approval_requirement so an exemption or a deny is not confused with “this
tool is not gated.”
Compatibility
- The boolean
evaluate_approval_gatesAPI is unchanged: it evaluates the gate map only. Capability failures still returnTruewhen a gate fires. Useapproval_requirementfordenied. - Malformed or unknown gate encodings raise (Python) or return
status="required"witherrorset (WASM, no fabricatedkind). Treat that as fail-closed — never as ungated. - This preflight does not replace the authorizer. PoP, expiry, and
collected approvals remain authorizer-only. An SDK-side
exempt/not_gatedresult must not allow a call the authorizer would deny.
Rust: warrant.approval_requirement(tool, args) and
warrant.inspect_approval_gate(tool). TypeScript / WASM:
approvalRequirement(warrantB64, tool, args),
inspectApprovalGate(warrantB64, tool), and evaluateApprovalGates.
M-of-N Multi-Sig
warrant = (Warrant.mint_builder()
.capability("deploy_prod")
.approval_gates({"deploy_prod": None})
.required_approvers([alice.public_key, bob.public_key, carol.public_key])
.min_approvals(2) # any 2-of-3
.holder(agent_key.public_key)
.ttl(3600)
.mint(control_key)
)
Retry Flow
Call without approvals
→ gate fires
→ caller gets approval_required (or insufficient_approvals if partial)
→ collect SignedApproval(s) bound to request_hash
→ retry same call with approvals attached
→ threshold met → proceed
Sign with sign_approval(request, approver_key) or a built-in handler (cli_prompt, etc.). Default approval TTL: handler ttl_seconds → 300s.
Branch on the signal, not the message:
- First attempt → look for
request_hash(orApprovalRequired/approval_required) - Partial multi-sig → look for
got/needorrequired/received(orInsufficientApprovals)
Wire Format (retry payloads)
Each SignedApproval is CBOR bytes. Adapters differ in how they wrap the list:
| Integration | Where | Encoding |
|---|---|---|
| MCP | params._meta.tenuo.approvals |
JSON array of base64(CBOR) strings, no outer wrapper |
| FastAPI / A2A | X-Tenuo-Approvals header (A2A also accepts x-tenuo-approvals param) |
base64(JSON array of base64(CBOR) strings) |
| Temporal | x-tenuo-approvals activity header |
JSON array of base64(CBOR) strings, no outer base64 wrapper |
import base64, json
from tenuo.approval import sign_approval
signed = sign_approval(request, approver_key)
# MCP / Temporal: array of base64 CBOR blobs
approvals_wire = [base64.b64encode(signed.to_bytes()).decode("ascii")]
# FastAPI / A2A: outer base64 JSON wrapper
header_value = base64.b64encode(json.dumps(approvals_wire).encode()).decode()
See integration guides: MCP, FastAPI, A2A, Temporal.
Signals by Integration
| Integration | First call (gate, no approvals) | Partial multi-sig | Retry with |
|---|---|---|---|
| In-process | tenuo.approval.ApprovalRequired |
tenuo.exceptions.InsufficientApprovals |
Same call + approvals=[...] or approval_handler |
| MCP | JSON-RPC -32002 + request_hash |
JSON-RPC -32002 + got / need |
_meta.tenuo.approvals |
| FastAPI | HTTP 409 error: "approval_required" + request_hash |
HTTP 409 error: "insufficient_approvals" + got / need |
X-Tenuo-Approvals header |
| A2A | JSON-RPC -32019 + request_hash |
JSON-RPC -32020 + required / received |
X-Tenuo-Approvals header or x-tenuo-approvals param |
| Temporal | ApplicationError.type == "approval_required" |
ApplicationError.type == "insufficient_approvals" |
x-tenuo-approvals header or set_activity_approvals() |
Scope denials use different codes; see each integration guide.
Field names: Python .details use required / received. HTTP and MCP retry payloads use got / need. A2A error data uses required / received.
Framework Integration
Gates and approvers live on the warrant. Pass approval_handler (or pre-built approvals) to the adapter.
LangChain
from tenuo.langchain import guard
from tenuo.approval import cli_prompt
tools = guard(
[search, transfer_funds],
bound_warrant,
approval_handler=cli_prompt(approver_key=approver_key),
)
LangGraph
from tenuo.langgraph import TenuoMiddleware
middleware = TenuoMiddleware(
approval_handler=cli_prompt(approver_key=approver_key),
)
CrewAI / AutoGen / OpenAI / Google ADK
guard = (GuardBuilder()
.allow("transfer_funds", amount=Range(0, 100_000))
.with_warrant(warrant, agent_key)
.on_approval(cli_prompt(approver_key=approver_key))
.build())
See LangChain, CrewAI, OpenAI, AutoGen, Google ADK, MCP, FastAPI, A2A, Temporal.
Temporal
plugin = TenuoTemporalPlugin(
TenuoPluginConfig(
key_resolver=resolver,
trusted_roots=[control_key.public_key],
approval_handler=cli_prompt(approver_key=approver_key),
)
)
Built-in Handlers
| Handler | Use Case |
|---|---|
cli_prompt(approver_key=key) |
Local dev - terminal prompt |
auto_approve(approver_key=key) |
Tests - signs automatically |
auto_deny(reason=...) |
Dry-run - always raises |
All signing handlers require the approver’s SigningKey (held by the human or approval service, not the agent).
Custom Handlers
from tenuo.approval import sign_approval, ApprovalDenied
def slack_approval(request):
reaction = wait_for_slack_reaction(request, timeout=300)
if reaction != "thumbsup":
raise ApprovalDenied(request, reason="denied in Slack")
return sign_approval(request, approver_key, external_id=reaction.user, ttl_seconds=60)
Async handlers are supported.
Tenuo Cloud provides managed approval routing (approvals delivered to Slack and mobile, with SSO-backed approver identity) so you don’t have to build and operate custom handlers. Cloud customers: see the Cloud approvals API reference (sign-in required). Not a customer yet? Try Tenuo Cloud or book a demo.
Exceptions
| Exception | Module | When |
|---|---|---|
ApprovalRequired |
tenuo.approval |
Gate fired; no approval_handler / approvals (enforce_tool_call) |
ApprovalGateTriggered |
tenuo.exceptions |
Gate fired; direct Authorizer / MCP PEP path |
InsufficientApprovals |
tenuo.exceptions |
Approvals supplied but below threshold |
ApprovalDenied / ApprovalTimeout |
tenuo.approval |
Handler denied or timed out |
ApprovalVerificationError |
tenuo.approval |
Bad signature, hash mismatch, expired, untrusted key |
InvalidApproval / ApprovalExpired |
tenuo.exceptions |
Invalid or expired signed approval on wire paths |
from tenuo.approval import ApprovalRequired, ApprovalDenied, cli_prompt
from tenuo.exceptions import InsufficientApprovals, ApprovalGateTriggered
Cryptographic Model
Every approval binds to (warrant_id, tool, args, holder) via SHA-256 (compute_request_hash). SignedApproval = Ed25519 over ApprovalPayload. Rust verifies signature, hash, expiry (30s clock tolerance), approver trust, deduplication, and threshold.
See Also
- Enforcement Architecture - Where approvals sit in the pipeline
- MCP Approval Gates - Remote PEP retry flow
- FastAPI - HTTP 409 approval responses
- Wire format §16 -
SignedApprovalbytes - Cloud approvals API reference - Managed approval routing for Tenuo Cloud customers (sign-in required)