Module 8: Human in the loop (Elicitation)#
You are in the CPEX tutorial. This module needs the IdP.
Goal: suspend a sensitive operation until a human approves it, then resume it. The agent cannot proceed on its own.
The problem#
Some actions are too consequential to run on the agent’s say-so: a large transfer, an irreversible change, an outbound message to a client. You want policy to pause the call, ask a human, and only continue once they approve. That means the operation must be able to suspend and resume, not just allow or deny.
Build it#
Add an elicitation plugin on the elicit hook and a require_approval(...) step. From policies/m08.yaml:
plugins:
- name: keycloak
kind: identity/jwt
hooks: [identity.resolve]
config: { ... as in module 2 ... }
- name: manager-approval
kind: approval-channel
hooks: [elicit]
routes:
- tool: send_email
authentication: [keycloak]
authorization:
pre_invocation:
- "require(authenticated)"
- "require_approval(manager-approval, from: claim.manager, purpose: \"Approve outbound email\")"The first time a caller hits this route, the approval is pending, so policy suspends the call and returns an elicitation id. A human approves out of band. The caller retries with the id, and now the approval is resolved, so the call proceeds. from: claim.manager resolves to the caller’s manager from their token (evan’s manager is mona).
The approval plugin (examples/m08_elicitation.rs) implements the elicit hook’s three operations against a tiny approval channel:
match payload.operation() {
ElicitationOp::Dispatch => { /* open a pending request, return its id */ }
ElicitationOp::Check => { /* report Pending / Resolved{Approved|Denied} */ }
ElicitationOp::Validate => { /* confirm the approver */ }
}The channel is served over HTTP so a human can approve with curl. In production this would be an OIDC CIBA backchannel or a push to the approver’s phone. CIBA (Client-Initiated Backchannel Authentication, OpenID spec) is the OpenID flow where the app asks the identity provider to prompt a user on a separate device and polls for their decision; see Human-in-the-Loop Elicitation for how CPEX drives it. The point here is CPEX’s suspend and resume model, not the notification transport.
Run it#
cargo run -p cpex-tutorial --example m08_elicitationThe first attempt suspends:
▸ evan → send_email (first attempt: suspends for manager approval)
⏸ PENDING awaiting mona's approval (id elic-mona)
Approve it from another terminal with:
curl -X POST localhost:8090/approvals/elic-mona/approveRun that curl in a second terminal. You do not re-run anything: the same program is polling the approval channel in a loop, so a moment after your curl (it polls on an interval, so allow a few seconds) it picks up the decision and resumes on its own:
▸ evan → send_email (retry with the approval: resumes and runs)
✓ ALLOWED {"sent":true, ...}Here “retry” means that automatic re-check inside the running program, not a second cargo run. In a real agent it is the agent re-sending the request with the elicitation id; the tutorial harness does it for you.
Run with -- --check to have it approve itself and exercise the whole path unattended.
Try it#
- Deny instead. Use
curl -X POST localhost:8090/approvals/elic-mona/deny. Expect: the program’s next poll picks up the denial (again allow a few seconds), and the call ends denied, not allowed. - List pending.
curl localhost:8090/approvalsshows the open request while the program waits. - Let it stay pending. Do nothing. The program keeps polling and the operation never runs; it only proceeds once someone approves. There is no manual retry step, the running program resumes itself.
Checkpoint#
What is different about pending versus denied?
How does the retry reach the same approval?
Go deeper#
- Human-in-the-Loop Elicitation for the suspend/resume model, CIBA, and genuineness.
Next#
Module 9: Write your own plugin: build a custom plugin with the SDK and reference it from policy.