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]
capabilities: [perform_http]
config:
claim_mapper: standard
trusted_issuers:
- issuer: http://localhost:8081/realms/cpex-tutorial
audiences: [cpex-tutorial]
algorithms: [RS256]
decoding_key:
kind: jwks_url
url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs
insecure_http: true
leeway_seconds: 60
- 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:
impl HookHandler<ElicitationHook> for ChannelApprover {
async fn handle(
&self,
payload: &ElicitationPayload,
_ext: &Extensions,
_ctx: &mut PluginContext,
) -> PluginResult<ElicitationPayload> {
let mut out = payload.clone();
match payload.operation() {
ElicitationOp::Dispatch => {
let approver = payload.from().to_string();
let id = elicitation_id_for(&approver);
self.channel
.open(&id, &approver, payload.purpose().unwrap_or("(no purpose)"));
out.id = Some(id);
out.status = Some(ElicitationStatusKind::Pending);
out.approver = Some(approver);
},
ElicitationOp::Check => {
let id = payload.elicitation_id().unwrap_or_default();
match self.channel.status(id) {
Some(Status::Approved) => {
out.status = Some(ElicitationStatusKind::Resolved);
out.outcome = Some(ElicitationOutcomeKind::Approved);
},
Some(Status::Denied) => {
out.status = Some(ElicitationStatusKind::Resolved);
out.outcome = Some(ElicitationOutcomeKind::Denied);
},
_ => out.status = Some(ElicitationStatusKind::Pending),
}
},
ElicitationOp::Validate => {
out.valid = Some(true);
out.approver = Some(payload.from().to_string());
},
}
PluginResult::modify_payload(out)
}
}The plugin traits come from cpex::prelude, and the elicitation types (ElicitationHook, ElicitationOp, ElicitationPayload) from praxis_policy_core::elicitation.
approval-channel is not a builtin. The host registers its factory on the engine before loading the policy:
let engine = Arc::new(PolicyEngine::default());
engine.register_factory(
"approval-channel",
Box::new(ChannelApproverFactory {
channel: channel.clone(),
}),
);
cpex::install_builtins(&engine);
cpex_tutorial::install_http_transport(&engine);
engine
.load_config_yaml(POLICY)
.expect("policy m08.yaml should load");
engine.initialize().await.expect("initialize");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.