Chio/Docs

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; the approval is a second signature that the receipt chain records alongside the agent's signature. Calls below the threshold proceed under the grant; calls at or above it require a human signature.

Alpha

The HITL protocol is alpha. The kernel verdict, receipt decision variants, and approval token shape are stable enough to build on, but the approval channel catalog is still expanding and some SDK helpers are being filled in. Treat policies and interfaces on this page as the target shape; expect minor changes before general availability.

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, evaluated ahead of the generic guard pipeline through the kernel's evaluate_tool_call_with_hitl entry point, reads that constraint and decides whether a matching request needs a human before it can proceed.
  2. In-flight suspension receipt. When the approval guard returns pending, the kernel signs a receipt with Decision::Incomplete whose metadata marks the call as awaiting approval, names the approval request id, and records the deadline. The agent receives a"pending approval" response; the trust control plane log records an intermediate state.
  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. The kernel validates the token, re-runs capability and guard checks, then emits a final Allow or Deny receipt.

State machine

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

Chio approval state machinepending branchesReceived: a ToolCallRequest arrives at the kernelReceivedtool call inPendingApproval: the kernel suspended the call and signed an IncompleteAwaitingApproval receiptPendingApprovalguard returned pendingApproved: human signed a GovernedApprovalToken with decision=approvedApprovedtoken: approvedHumanDenied: human signed a GovernedApprovalToken with decision=deniedHumanDeniedtoken: deniedTimedOut: deadline elapsed with no token receivedTimedOutdeadline elapsedAllow: tool dispatched, ApprovedAndExecuted receipt signedAllowdispatchedDeny: Deny receipt signed, tool not dispatchedDenyfail-closedEscalated: advanced to the next approval tier with its own approvers and timeoutEscalatednext tierAutoApprovedAdvisory: kernel self-signed; receipt flagged for later reviewAutoApprovedAdvisoryreview_required=trueapproval guard firesguards pass, no approvala guard deniesapprovedenytimeoutre-validate okre-eval failspolicy = denypolicy = escalatepolicy = auto_approve_advisorypriority: Deny > PendingApprovalsolid = signed-token transition · dashed = policy / fallback
This flow applies when a tool call matches a require_approval constraint. Deny takes priority over PendingApproval, and the kernel requests approval only after the other guards allow the call.

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):

chio-kernel/src/runtime.rs
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 runs through its own kernel entry point, evaluate_tool_call_with_hitl. Its result type carries the full request:

chio-kernel/src/approval.rs
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 persists the request and returns 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 chio-core-types/src/receipt.rs has four variants. HITL does not add Decision variants. The approval flow reuses these four plus receipt metadata to distinguish states:

chio-core-types/src/receipt.rs
pub enum Decision {
    /// The tool call was allowed and executed.
    Allow,
    /// The tool call was denied.
    Deny { reason: String, guard: String },
    /// The tool call was interrupted by explicit cancellation.
    Cancelled { reason: String },
    /// The tool call did not reach a complete terminal result.
    Incomplete { reason: String },
}

Mapping approval lifecycle states onto these four:

  • Suspended awaiting approval: Incomplete with a reason identifying the approval request, and an approval-request id plus deadline carried in receipt metadata. The agent-facing label "IncompleteAwaitingApproval" is a metadata convention, not a new enum variant.
  • Approved and executed: Allow, with approver identity, token id, and the referenced approval-request id carried in receipt metadata.
  • Denied by a human: Deny with guard = "human-approval"; approver identity and optional reason live in metadata.
  • Timed out with on_timeout = deny: Deny with guard = "approval-timeout".
  • Cancelled before resolution: Cancelled.

PendingApproval itself is a runtime-only Verdict variant in 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:

chio-kernel/src/approval.rs
pub struct ApprovalRequest {
    /// Unique approval id (UUIDv7). Keys the approval store.
    pub approval_id: String,
    /// Policy / grant id that triggered the approval.
    pub policy_id: String,
    /// 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. A presented approval token
    /// must carry the same subject.
    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 of the canonical JSON of the tool arguments / intent.
    pub parameter_hash: String,
    /// Unix seconds after which the request is resolved per `on_timeout`
    /// (deny by default).
    pub expires_at: u64,
    /// Hint for channels about where the human can respond.
    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.
    pub governed_intent: Option<GovernedTransactionIntent>,
    /// Public keys allowed to approve this request. Empty set fails closed.
    pub trusted_approvers: Vec<PublicKey>,
    /// Guards that triggered the approval requirement.
    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 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.

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 simply omitting the intent.

Planned trigger families (not yet shipped)

The following constraint variants have been discussed as future extensions but are not part of the current Constraint enum. Do not rely on them in policy files:
  • RequireApprovalAlways: unconditional human approval for every invocation under a grant.
  • RequireApprovalFirstN: human approval for the first N invocations only.
  • RequireApprovalForActions: human approval when a tool's declared action category matches a listed category (see Action Categories below).
  • RequireApprovalAboveTier: human approval when the governed autonomy tier is at or above a named level.
These variants are still at the design stage. Policy authors who need approval for every matching call today should list the relevant tool-name globs under require_confirmation, which compile to a RequireApprovalAbove threshold of 0 on the matching grants.

Action categories (planned)

Design-stage

Action categories are part of the planned RequireApprovalForActions trigger described above. They are not yet implemented. This section sketches the intended vocabulary so policy authors can weigh in on the taxonomy before it lands.

The target model is that tools self-declare an action category in their manifest, and a future constraint names the categories that require approval. Candidate categories:

  • Financial: payments, transfers, trades
  • Communication: outbound email, SMS, Slack posts
  • Infrastructure: deploys, scale operations, deletes
  • DataMutation: writes, deletes, updates on managed data
  • SensitiveDataAccess: PII, credentials, keys
  • Custom("name"): declare your own label

Approval flow

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

rendering…
Suspended by the approval guard, routed to a channel, resumed by a signed token.
  1. The agent submits a tool call with a governed intent whose max_amount.units = 450.
  2. The kernel runs guards. Structural guards pass. The approval guard sees the threshold fire and returns PendingApproval(request).
  3. The kernel persists the request in the approval store, dispatches it to any configured channels (a webhook, or none: a programmatic approver can poll GET /approvals/pending instead), and signs an Incomplete receipt whose metadata marks the call as awaiting approval and carries the approval-request id and deadline. The agent receives a "pending approval" response naming the approval request id and the deadline.
  4. A human reviews the summary, intent, and (optionally) arguments through a channel. They choose approve or deny.
  5. The channel sends a signed GovernedApprovalToken to POST /approvals/{id}/respond. The kernel validates the signature, the request id binding, the intent hash binding, the approver whitelist, and the expiry.
  6. On approve: the kernel re-runs capability validation (the grant may have been revoked during the wait) and non-approval guards (state may have changed), then dispatches to the tool server and emits an Allow receipt. Metadata carries the approver public key, the approval token id, and a reference back to the earlier suspension receipt.
  7. On deny: the kernel emits a Deny receipt with guard = "human-approval", carrying the approver's public key and optional reason in metadata.

Approval token

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

approval_token.rs
pub struct GovernedApprovalToken {
    pub id: String,
    pub approver: PublicKey,                 // Ed25519 key that signed
    pub subject: PublicKey,                  // agent's key
    pub governed_intent_hash: String,        // binds to one intent
    pub request_id: String,                  // binds to one tool call
    pub issued_at: u64,
    pub expires_at: u64,
    pub decision: GovernedApprovalDecision,  // Approved | Denied
    pub signature: Signature,
}

The token is cryptographically bound to five things: the request id, the intent hash, the approver's key, the agent's key, and a time window. The kernel validates all five before accepting. A token for a different request, a different intent, a different agent, or one outside its time window is rejected.

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: an LRU replay store records consumed (request_id, intent_hash) pairs. A token presented a second time is rejected with "replay detected".

Timeout policies

Every pending approval has a deadline (timeout_seconds). What happens when it passes is set by on_timeout, whose enum (HumanInLoopTimeoutAction) has exactly two variants:

on_timeoutBehavior
denyDefault. Fail closed: when the deadline passes with no decision, the call is denied, and the kernel signs a deny receipt with guard = approval-timeout.
deferDoes not fail closed at the deadline; the request is not auto-denied when the deadline elapses and stays pending for a later human decision.

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.py
import asyncio

from chio_sdk import ChioClient

async def main() -> None:
    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="cap-8f1e",
            tool_server="payment-server",
            tool_name="issue_refund",
            tool_args={"customer_id": "cust-9012", "amount": 450, "currency": "USD"},
            requested_by="9c7b3f...",  # agent Ed25519 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, and does not yet wrap the approval endpoints. Until then, call the approval REST API exposed by the sidecar directly:

approvals.ts
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.yaml
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

The policy block has six fields: enabled, require_confirmation (globs that collapse to a threshold of 0), approve_above with approve_above_currency, timeout_seconds, and on_timeout. There is no per-grant approver list, per-approver contact block, or escalation-tier config: the trusted-approver set is supplied to the kernel out of band, and quorum and escalation are not part of the shipped 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). The kernel consults the batch store before dispatching to channels: if a matching, unexpired batch exists, the call is approved immediately and the batch counters are incremented.

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 authorized it. Wiring that id into receipt metadata is not yet part of the kernel's receipt-emission path.


Receipts in the approval chain

Every transition is a signed receipt. An approved call produces a chain of two receipts: an Incomplete receipt at suspension, and an Allow receipt at execution. A denied-by-human call produces a chain of an Incomplete receipt at suspension and a Deny receipt at denial. A timeout under the default on_timeout: deny produces one Incomplete at suspension and one Deny at the deadline. The second receipt's metadata links back to the first by previous_receipt_id.

approved-chain.json
// Receipt 1: suspended (Decision::Incomplete with approval metadata)
{
  "id": "rc-001",
  "decision": { "verdict": "incomplete", "reason": "awaiting human approval" },
  "metadata": {
    "approval_request_id": "ar-d4e5",
    "summary": "Agent wants to issue a $450 refund",
    "deadline": 1713200400
  }
}

// Receipt 2: approved and dispatched (Decision::Allow)
{
  "id": "rc-002",
  "decision": { "verdict": "allow" },
  "metadata": {
    "approval_request_id": "ar-d4e5",
    "approval_token_id": "at-g6h7",
    "approver": "9c7b3f...",
    "approval_latency_ms": 127000,
    "channel": "webhook",
    "previous_receipt_id": "rc-001"
  }
}

Auditing approval activity

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

bash
# All suspended calls since a Unix timestamp. Incomplete receipts
# carry the approval request in metadata. --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

# All denied calls. The guard field in each receipt body distinguishes
# human-approval denials from approval-timeout denials.
chio receipt list --outcome deny --admin-all

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

Security properties

  • Fail-closed at every step. Channel dispatch failure, invalid signature, expired token, wrong request, capability revoked during wait, or timeout with no response: all produce a deny receipt.
  • Kernel-native. The agent never sees the approval token and never talks to the approver directly. The kernel owns the lifecycle, which is why the approval guard runs in the kernel pipeline.
  • Non-repudiation. Every decision is signed with the approver's key and recorded in the receipt chain. The chain proves who approved, what they approved, when, and through which channel.
  • Separation of concerns. Approvers see only the summary and intent, not raw arguments, unless the policy explicitly exposes them. Tool servers see only validated approved calls; they never learn that HITL was in the loop.

Adopting HITL in an existing deployment

The rollout sequence we recommend:

  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). Action-category constraints remain at the design stage; see Supported Approval Triggers and Open Design Questions.

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.

Open design questions

These items are on the roadmap but not yet in the stable protocol:

  • Multi-approver quorum. "2 of 3 must approve" policies for high-value operations. The kernel enforces single-approver today, but two schema slots for this already exist without enforcement: Constraint::RequireDualApproval(bool) is present on the constraint enum but is excluded from governed-requirements and always evaluates to Ok(false) in request-matching (an inert stub); and a HushSpec extension slot, chio.human_in_loop.approvers = { n, of: [...], timeout_seconds } (ChioApproverSet), declares an n-of-M shape that the kernel does not interpret: it is carried verbatim for chio-bridge consumers.
  • Approval delegation. Time-bounded handoff of approval authority (vacation coverage) reusing the existing delegation link mechanism.
  • Partial approval. A human approves an amended version of the request (approve the refund for $300 instead of $450), requiring the token to carry modified parameters that the kernel re-binds into the intent.
  • Cross-kernel approval. In federated deployments, whether an approval from one kernel can satisfy a pending request on another.

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 kernel re-validates capability and guards before dispatch, then emits an Allow receipt whose metadata identifies the approver and token. On deny it signs a Deny receipt with guard = "human-approval". On timeout it follows the configured policy.
  • The protocol is alpha; expect additions for quorum, delegation, partial approval, and cross-kernel approval.