Chio/Docs
LOGIN · JOIN

LearnAnatomy of a Governed Call

Human-in-the-Loop

A policy can require a human signature before a governed action proceeds.

A capability grant can require human approval. The threshold is stored as a RequireApprovalAbove constraint on the grant, and the approval itself is a separate signed object bound to that one request. Calls below the threshold proceed under the grant; calls at or above it stop until an approver signs. The calls on either side of the pause are ordinary governed calls, so each still produces a receipt.


Three components

HITL has three components:

  1. Policy-declared approval gate. A HITL policy, rules.human_in_loop in a HushSpec, compiles to a RequireApprovalAbove constraint on the matching tool grants. The dedicated ApprovalGuard reads that constraint through ApprovalGuard::evaluate and decides whether a matching request needs a human before it can proceed. It does not implement the Guard trait, so it sits beside the guard pipeline rather than inside it.
  2. A pending record the caller can read. When the guard returns HitlVerdict::Pending, it persists an ApprovalRequest in the approval store and dispatches it to the configured channels. The caller gets a 202-style answer rather than a result, and the request stays readable at GET /approvals/pending until it resolves.
  3. Approval callback with a signed token. A human reviews the request through a configured channel or by polling the approval store. Their decision is encoded as a GovernedApprovalToken signed with the approver's Ed25519 key. resume_with_decision verifies the token against the stored request, rejects a replay, records the resolution, and returns ApprovalOutcome::Approved or ApprovalOutcome::Denied.

State machine

A governed tool call that touches an approval constraint walks the following states:

rendering
The outcomes ApprovalGuard::evaluate returns and the two ways a pending request ends. HumanInLoopTimeoutAction has no escalation and no automatic approval.
sourcecrates/kernel/chio-kernel/src/approval.rs:432-451crates/guards/chio-policy/src/models/rules.rs:344-357at fe56570

The priority rule across guards is: any Deny dominates any PendingApproval. If a structural guard such as forbidden-path denies the request, the pipeline does not ask for human approval. Approval is only solicited when the non-approval guards all allow.


Pending verdict

The kernel's Verdict enum carries a third protocol outcome alongside Allow and Deny. The variant is a bare marker: it keeps Verdict's Copy semantics, and the associated ApprovalRequest payload is returned separately through the HITL API (see HitlVerdict below):

crates/kernel/chio-kernel/src/runtime.rs30-38rust
pub enum Verdict {
    /// The action is allowed.
    Allow,
    /// The action is denied.
    Deny,
    /// The action is suspended pending a human decision. Look up the
    /// associated `ApprovalRequest` via the HITL API.
    PendingApproval,
}

A generic Guard cannot drive this flow. The sequential guard loop treats a Guard that returns PendingApproval as an unsupported state and fails closed (denies). Suspension is produced only by the dedicated ApprovalGuard, which does not implement the Guard trait and is called directly rather than registered in a pipeline. Its result type carries the full request:

crates/kernel/chio-kernel/src/approval.rsrust
pub enum HitlVerdict {
    /// Guard passes -- no approval required.
    Allow,
    /// Guard denies without an approval path (e.g. fail-closed).
    Deny { reason: String },
    /// Approval is required. Kernel should persist the request and
    /// return a 202-style response to the caller.
    Pending {
        request: Box<ApprovalRequest>,
        verdict: Verdict,
    },
    /// Approval was supplied with the request and passed verification.
    Approved { token: Box<ApprovalToken> },
}

Receipt decisions

The signed receipt Decision enum in crates/core/chio-core-types/src/receipt/decision.rs has four variants. HITL does not add Decision variants. The approval flow reuses these four plus receipt metadata to distinguish states:

crates/core/chio-core-types/src/receipt/decision.rsrust
pub enum Decision {
    /// The tool call was allowed and executed.
    Allow,
    /// The tool call was denied.
    Deny {
        /// Human-readable reason for the denial.
        reason: String,
        /// The guard or validation step that triggered the denial.
        guard: String,
    },
    /// The tool call was interrupted by explicit cancellation.
    Cancelled {
        /// Human-readable reason for the cancellation.
        reason: String,
    },
    /// The tool call did not reach a complete terminal result.
    Incomplete {
        /// Human-readable reason for the incomplete terminal state.
        reason: String,
    },
}

Mapping approval lifecycle states onto these four:

  • Suspended awaiting approval: Incomplete, whose reason says why the call did not reach a terminal result.
  • Approved and executed: Allow.
  • Denied by a human, or at the deadline under on_timeout: deny: Deny. Its guard field names the step that denied, and its reason carries the refusal text.
  • Cancelled before resolution: Cancelled.

Nothing in the approval path writes a reserved guard name of its own. The strings a Chio deny receipt carries come from the denying step: kernel, policy.deny, session_roots, delivery_contract, finding_status, or the runtime name of the guard that returned Deny.

PendingApproval itself is a runtime-only Verdict variant in crates/kernel/chio-kernel/src/runtime.rs. It does not appear on a signed receipt; the signed Decision for a suspended call is Incomplete with metadata pointing at the approval request.


Approval request

When a guard returns PendingApproval, it constructs an ApprovalRequest that the kernel will persist and route to channels. The request contains the information for a human decision and for kernel validation of the returned token:

crates/kernel/chio-kernel/src/approval.rs44-109rust
pub struct ApprovalRequest {
    /// Unique request identifier. Caller-stable so the approval store
    /// can be keyed on this value. Callers should supply a UUIDv7.
    pub approval_id: String,

    /// The policy / grant identifier that triggered the approval.
    pub policy_id: String,

    /// The calling agent's identifier.
    pub subject_id: AgentId,

    /// Capability token ID bound to this request.
    pub capability_id: String,

    /// Public key of the capability subject this approval is bound to.
    /// A presented approval token must carry the same subject.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub subject_public_key: Option<PublicKey>,

    /// Server hosting the target tool.
    pub tool_server: ServerId,

    /// Tool being invoked.
    pub tool_name: String,

    /// Short action verb for human summaries (e.g. `invoke`, `charge`).
    pub action: String,

    /// SHA-256 hex digest of the canonical JSON of the tool arguments
    /// / governed intent. Used to bind an approval token to this exact
    /// parameter set; a mutated argument payload will not satisfy the
    /// same approval.
    pub parameter_hash: String,

    /// Unix seconds after which the request auto-denies (or escalates,
    /// per `timeout_action` in the grant).
    pub expires_at: u64,

    /// Hint for channels about where the human can respond (e.g. the
    /// URL of the dashboard or a Slack permalink). `None` means
    /// "dispatcher will fill this in after sending".
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub callback_hint: Option<String>,

    /// Unix seconds when the request was created.
    pub created_at: u64,

    /// Short human-readable summary for dashboards.
    pub summary: String,

    /// Original governed intent, when one is bound. Required for
    /// threshold-based approvals so the approver sees the financial
    /// envelope they are signing off on.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub governed_intent: Option<GovernedTransactionIntent>,

    /// Public keys allowed to approve this request. The kernel fails
    /// closed when the set is empty or when the presented approver is
    /// not in the set.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub trusted_approvers: Vec<PublicKey>,

    /// Guards that triggered the approval requirement.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub triggered_by: Vec<String>,
}

Supported approval triggers

ApprovalGuard::evaluate raises approval through three paths (needs_approval = threshold_hit || tier_hit || always_hit). Two are grant-level constraints on Constraint (see crates/core/chio-core-types/src/capability/scope.rs); the third is a request attribute:

TriggerFires When
RequireApprovalAbove { threshold_units: u64 }The governed intent's max_amount.units meets or exceeds the threshold, measured in minor currency units. A grant with the constraint but no governed intent fails closed.
MinimumAutonomyTier(Autonomous)When the request carries a governed intent, an Autonomous tier requirement is treated as "requires human approval"; Direct and Delegated pass through.
force_approval (request attribute)A flag on ApprovalContext forces a pending outcome regardless of constraints, letting host integrations and test harnesses enter the HITL flow directly.

There is no way to route an ordinary guard into this flow. The generic Guard pipeline fails closed on a PendingApproval verdict, so a content-review or secret-leak guard that denies stays a deny: it cannot be reconfigured to suspend for a human.

One signature resolves one request. The kernel reads a single GovernedApprovalToken and does not count signatures against a quorum. The HushSpec extension slot chio.human_in_loop.approvers = { n, of: [...], timeout_seconds } (ChioApproverSet) declares an n-of-M shape that the kernel carries verbatim for chio-bridge consumers rather than interpreting: a bridge that wants n-of-M enforces it before it presents a token. To restrict who may sign inside the kernel, set trusted_approvers on the request; an empty set fails closed.

Missing intent is not a free pass

If RequireApprovalAbove is configured but the incoming request does not carry a governed intent, the kernel fails closed. You cannot bypass the threshold by omitting the intent.

To require approval for every call matching a tool family rather than for calls above an amount, list the tool-name globs under require_confirmation. They compile to a RequireApprovalAbove threshold of 0 on the matching grants, which fires on every invocation.


Approval flow

Example: a support agent wants to issue a $450 refund, and the grant carries RequireApprovalAbove { threshold_units: 200 }.

rendering
One approval, from the guard that suspends the call to the resume that resolves it. The host owns the pause and the retry; the kernel owns the token check.
sourcecrates/kernel/chio-kernel/src/approval.rs:519-743crates/products/chio-api-protect/src/proxy/router.rs:3-17at fe56570
  1. The agent submits a tool call with a governed intent whose max_amount.units = 450.
  2. The structural guards pass, so nothing has denied the call. ApprovalGuard::evaluate sees the threshold fire and returns HitlVerdict::Pending.
  3. The guard persists the request in the approval store and dispatches it to any configured channels (a webhook, or none: a programmatic approver can poll GET /approvals/pending instead). Dispatch is fire and forget: a channel that fails leaves the request pending and readable. The caller receives HitlVerdict::Pending carrying the request, whose approval_id and expires_at name the record and its deadline.
  4. A human reviews the summary and the governed_intent through a channel. They choose approve or deny.
  5. The channel sends a signed GovernedApprovalToken to POST /approvals/{id}/respond. The kernel checks the replay store, then the request id binding, the intent hash binding, the trusted-approver set, the subject binding, the time window, and the signature. It also refuses when the HTTP outcome disagrees with the decision inside the signed token.
  6. On approve: resume_with_decision returns ApprovalOutcome::Approved. The host resubmits the call with the token attached; the guard takes the HitlVerdict::Approved path, and the call runs capability validation and the other guards again, because either may have changed during the wait.
  7. On deny: resume_with_decision returns ApprovalOutcome::Denied and the call does not proceed. The resolution record keeps the approver and the outcome.

Approval token

The human's decision is encoded in an GovernedApprovalToken (already defined in chio-core-types::capability and re-used here):

crates/core/chio-core-types/src/capability/governance.rsrust
pub struct GovernedApprovalToken {
    pub id: String,
    pub approver: PublicKey,
    pub subject: PublicKey,
    pub governed_intent_hash: String,
    pub request_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub threshold_proposal_hash: Option<String>,
    pub issued_at: u64,
    pub expires_at: u64,
    pub decision: GovernedApprovalDecision,
    /// Signing algorithm. Absent means Ed25519 (the default).
    ///
    /// Informational: verification dispatches off the algorithm encoded in
    /// [`GovernedApprovalToken::signature`] and [`GovernedApprovalToken::approver`].
    #[serde(default, skip_serializing_if = "is_default_optional_algorithm")]
    pub algorithm: Option<SigningAlgorithm>,
    pub signature: Signature,
}

ApprovalToken::verify_against checks the token against the stored request before the signature: request_id must equal the stored approval_id, governed_intent_hash must equal the request's parameter_hash, the approver must match and must appear in trusted_approvers, the subject must match the capability subject, and now must fall inside [issued_at, expires_at). Each failure returns KernelError::ApprovalRejected with the check that failed.

Replay protection

Approval tokens are single-use. The kernel combines four mechanisms to enforce this:

  • Request binding: a token for request A cannot be replayed against request B.
  • Time bounds: outside [issued_at, expires_at) the token is invalid.
  • Lifetime cap: the kernel rejects tokens with a lifetime longer than MAX_APPROVAL_TTL_SECS (one hour) to bound the replay-store window.
  • Consumption store: the approval store records each consumed (token_id, parameter_hash) pair, and resume_with_decision asks is_consumed before it verifies anything else. A token presented a second time is rejected as already consumed.

Timeout policies

Every pending approval has a deadline. The request carries it as expires_at, and the policy sets the window with timeout_seconds. What a deployment does when the deadline passes is declared by on_timeout, whose type HumanInLoopTimeoutAction admits two values and no others:

on_timeoutWhat the policy declares
denyThe #[default] variant: a lapsed deadline is a refusal.
deferA lapsed deadline is not a refusal; the request stays open for a later human decision.

There is no escalation value and no automatic-approval value. The approval store keeps the deadline rather than acting on it: ApprovalFilter carries not_expired_at, so a reader of GET /approvals/pending chooses whether lapsed requests come back.

Deny is the fail-closed default

on_timeout defaults to deny. Leave it there unless your workflow requires requests to remain open past the deadline. Do not treat a lapsed deadline as an implicit allow.

Python sketch

The chio_sdk package (PyPI chio-sdk-python) wraps the sidecar's approval endpoints. A host holds a tool call pending approval, an operator lists the queue, and a decision resolves it. The client is async:

approvals.pypython
import asyncio
import os

from chio_sdk import ChioClient

async def main() -> None:
    capability_id = os.environ["CHIO_CAPABILITY_ID"]
    agent_public_key_hex = os.environ["CHIO_AGENT_PUBKEY"]

    async with ChioClient("http://127.0.0.1:9090") as chio:
        # Host side: hold a tool call pending human approval.
        # The parameter hash is derived from the canonical JSON of tool_args.
        approval_id = await chio.submit_for_approval(
            capability_id=capability_id,
            tool_server="payment-server",
            tool_name="issue_refund",
            tool_args={"customer_id": "cust-9012", "amount": 450, "currency": "USD"},
            requested_by=agent_public_key_hex,
            summary="Refund $450 for order #8834",
        )

        # Operator side: review what is waiting.
        for pending in await chio.list_pending_approvals():
            print(pending.approval_id, pending.summary, pending.expires_at)

        # Resolve it. operator-respond has the sidecar sign the
        # GovernedApprovalToken with its own key; use the signed /respond
        # route directly when an external approver keypair is required.
        await chio.respond_approval(
            approval_id,
            "approve",  # or "deny"; also accepts an ApprovalVerdict value
            reason="Verified against the order record.",
        )

asyncio.run(main())

TypeScript sketch

The TypeScript SDK (@chio-protocol/sdk)'s ChioClient speaks MCP, withStaticBearer then initialize, with tool calls on the returned session. Approval is a REST surface rather than an MCP tool, so drive it against the sidecar directly:

approvals.tstypescript
const sidecar = "http://127.0.0.1:9090";

// Operator side: list what is waiting.
const { approvals } = await fetch(`${sidecar}/approvals/pending`)
  .then((r) => r.json());

for (const req of approvals) {
  console.log(req.approval_id, req.summary, req.expires_at);
}

// Resolve one. operator-respond has the sidecar sign the
// GovernedApprovalToken with its own key; POST /approvals/{id}/respond
// takes a token signed by an external approver instead.
await fetch(`${sidecar}/approvals/${approvalId}/operator-respond`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ outcome: "approved", reason: "verified" }),
});

The calling agent does not block on a pending approval. The host suspends its own workflow (a Temporal signal, a LangGraph interrupt, a Prefect pause) and resumes once an operator resolves the request through one of these endpoints.


Approval channels

A channel presents a request to a human and returns a decision. Chio ships two channel implementations and lets you add your own by implementing the ApprovalChannel trait. A channel is a fire-and-forget sink: if delivery fails, the request stays in the approval store and remains reachable through GET /approvals/pending.

ChannelWhat It Does
WebhookChannelBlocking HTTP POST of the ApprovalRequest to a configured URL. Production integrations wire this into their own dashboard or ticketing system.
RecordingChannelCaptures every dispatch in an in-memory ring so tests and host adapters can assert that a dispatch fired without standing up an HTTP listener.

There is no separate poll channel. A programmatic approver that polls GET /approvals/pending needs no channel at all: the request already lives in the approval store, so the approval store is sufficient.


Configuration

HITL is configured in a HushSpec policy under rules.human_in_loop. The block compiles to a RequireApprovalAbove constraint on the matching tool grants:

policy.yamlyaml
rules:
  human_in_loop:
    enabled: true
    # Tool-name globs that always need approval (compile to threshold 0).
    require_confirmation: ["write_*", "run_command"]
    # Monetary gate, in minor units. Governed calls whose max_amount is at
    # or above this value require approval; below it they pass through.
    approve_above: 15000
    approve_above_currency: "USD"
    # Deadline for a pending approval.
    timeout_seconds: 900
    # deny (default, fail-closed) or defer.
    on_timeout: deny

HumanInLoopRule fixes the block. Every field it accepts:

KeyTypeRequired
enabledbooldefaulted
require_confirmationVec<String>defaulted
approve_aboveOption<u64>optional
approve_above_currencyOption<String>optional
timeout_secondsOption<u64>optional
on_timeoutHumanInLoopTimeoutActiondefaulted

require_confirmation holds globs that collapse to a threshold of 0. There is no per-grant approver list, per-approver contact block, or escalation-tier config: the trusted-approver set reaches the kernel out of band on the ApprovalContext, and quorum and escalation are outside the policy schema.


Batch approval

Per-call approval creates friction for repetitive operations. Batch approval lets a human pre-approve a class of calls for a bounded window. A BatchApproval (paired with a BatchApprovalStore trait) declares a server pattern, tool pattern, per-call and total monetary ceilings (max_amount_per_call / max_total_amount), a max call count, and a validity window (not_before / not_after), and tracks its own consumption (used_calls, used_total_units, revoked). BatchApprovalStore::find_matching takes a subject, server, tool, optional amount, and the current time and returns the batch that covers them, if one does; record_usage moves the counters and revoke ends a batch early. A host wires those calls into its own approval path. chio-store-sqlite carries the durable implementation.

bash
# Examples of batch approval scopes:

"Approve all search calls for the next hour"
  server_pattern: "search-server"
  tool_pattern: "*"
  max_calls: None
  not_after: now + 3600

"Approve up to 20 database reads in the next 30 minutes"
  server_pattern: "db-server"
  tool_pattern: "read_*"
  max_calls: Some(20)
  not_after: now + 1800

"Approve payments under $100 for 4 hours, max $500 total"
  server_pattern: "payment-server"
  tool_pattern: "charge"
  max_amount_per_call: { units: 100, currency: USD }
  max_total_amount:    { units: 500, currency: USD }
  not_after: now + 14400

Each batch carries a batch_id, so a batch-approved call can be tied back to the blanket approval that covered it.


Receipts in the approval chain

The approval store is the record of the decision, and the receipt log is the record of the calls around it. A suspended call has not reached a terminal result, so a host that signs a receipt for it signs Decision::Incomplete with a reason. The resubmitted call that carries the token is an ordinary governed call and takes the ordinary Allow or Deny.

ResolvedApproval is the row that survives the decision. It is retained for audit and for the single-use replay check, and carries approval_id, outcome, resolved_at, approver_hex, and token_id.


Auditing approval activity

The Receipt Query API exposes filters that make it easy to audit HITL activity. Common queries:

bash
# Calls that reached no terminal result, since a Unix timestamp.
# --since / --until take Unix seconds. The read fails closed unless you
# pass --admin-all (all tenants) or --tenant <id>. Output is JSON Lines:
# one receipt per line.
chio receipt list --outcome incomplete --since 1713200400 --admin-all

# Denied calls. The guard field in each receipt body names the step that
# denied, so denials group by that field.
chio receipt list --outcome deny --admin-all

# Allowed calls, for approver analysis. There is no stats subcommand;
# pipe the JSON Lines output through jq.
chio receipt list --outcome allow --admin-all \
    | jq '.metadata'

Omitting both flags is the one query shape that never reaches the store. The command refuses with --tenant <id> or --admin-all is required for local receipt reads and exits 1.


Security properties

  • Fail closed on every rejected check. An empty trusted_approvers set, an approver outside it, a token bound to another request or another parameter set, a lifetime over MAX_APPROVAL_TTL_SECS, a bad signature, or an expired token each stop the call. A constraint with no governed intent to compare against stops it too.
  • A failed dispatch does not lose the request. Channels are fire-and-forget sinks. When dispatch fails the guard logs it and the request stays in the store, still readable at GET /approvals/pending.
  • Non-repudiation. The decision is signed with the approver's key, and ResolvedApproval keeps the approver hex, the token id, the outcome, and the resolution time.
  • The approver reviews a summary, not the arguments. ApprovalRequest carries the summary, the governed_intent, and the parameter_hash. The raw tool arguments are not fields on it, so a channel cannot render what it was never sent.

Adopting HITL in an existing deployment

A rollout sequence, narrowest gate first:

  1. Start with RequireApprovalAbove on a single high-value tool. Pick a threshold that will trigger approvals for only the top few calls per day.
  2. Wire one channel first, a WebhookChannel into your dashboard or ticketing system, or start with polling GET /approvals/pending and no channel. Add delivery channels incrementally; each adds another path for review.
  3. Keep on_timeout: deny (the fail-closed default) for the first month. Verify the team is meeting the SLA before considering on_timeout: defer.
  4. Add batch approval once the team is confident with per-call review. Batch policies are harder to reason about; do not reach for them first.
  5. Once batch approval is settled, broaden coverage with require_confirmation globs for the tool families you want gated. They compile to a threshold of 0, so every matching call is gated.

Monitor approval outcomes

Track pending requests and denial rates. A rise in denials can indicate a low threshold, unsuitable agent actions, or summaries that lack the information approvers need.

Summary

  • HITL adds a third verdict to the guard pipeline: PendingApproval, alongside Allow and Deny. Deny dominates; approval is solicited only when non-approval guards allow.
  • The request is suspended in a signed Decision::Incomplete receipt whose metadata carries the approval request id and deadline, and routed to human-facing channels.
  • The human's decision is a signed GovernedApprovalToken bound to the request, intent, approver, agent, and a time window, with replay protection.
  • On approve, the resubmitted call re-runs capability validation and the other guards before it proceeds. On deny, resume_with_decision returns ApprovalOutcome::Denied and the call does not run. At the deadline, on_timeout decides.
  • One signature resolves one request. Quorum shapes declared in a HushSpec extension are carried to bridge consumers, not enforced by the kernel.