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 with warrant.approval_threshold(). The wire field is min_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_gates API is unchanged: it evaluates the gate map only. Capability failures still return True when a gate fires. Use approval_requirement for denied.
  • Malformed or unknown gate encodings raise (Python) or return status="required" with error set (WASM, no fabricated kind). 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_gated result 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 (or ApprovalRequired / approval_required)
  • Partial multi-sig → look for got/need or required/received (or InsufficientApprovals)

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