Human-in-the-Loop Elicitation
On this page
Some operations should not proceed on the caller’s say-so alone. A large payroll change wants a manager’s approval; a destructive action wants the user to confirm; a sensitive read wants a fresh second factor. Elicitation lets a policy pause an operation to ask a human and resume once they respond — without blocking the request path, and with the decision bound to the actual request rather than to an LLM’s paraphrase of it.
Approval requirement
The scenario’s approve_raise may run only after the requester’s manager
approves, and the approval must cover the specific amount being requested. The
manager is not the caller, may take hours to respond, and their answer must be
genuine — a signed decision from the right person, not a value the agent
asserts. PPE must dispatch the ask, hold the operation open across the agent’s
retries, and verify the response before letting the tool run.
Elicitation as an effect
Elicitation is an effect in the authorization.pre_invocation phase. A sugar
verb names an elicitation handler plugin and who to ask:
plugins:
- name: manager-approver
kind: elicitation/ciba
hooks: [elicit]
routes:
- tool: approve_raise
authorization:
pre_invocation:
- "require(authenticated)"
- when: "args.amount > 10000"
do:
- "require_approval(manager-approver, from: claim.manager,
channel: \"ciba\",
scope: \"args.amount <= 25000\",
purpose: \"Approve raise\")"
fromis who to ask — an attribute reference resolved against the request bag (hereclaim.manager, the requester’s manager, who differs from the subject). An attributefromthat doesn’t resolve fails closed rather than dispatching to a bogus identity.scopeis the args binding — an APL boolean expression the runtime checks against the live request when the response comes back.purposeis the audited, human-readable description of what is being asked.
The other verbs (confirm, require_step_up, require_attestation,
request_info, require_review) parse to the same effect with a different
kind; each selects the validation contract the runtime applies to the response.
The model: suspend and resume
An elicitation has three short, synchronous touch-points. The hours-long human gap lives in the channel (e.g. Keycloak CIBA), never in a blocking call:
- Dispatch — first arrival: register the intent, open the channel backchannel, return a correlation id.
- Check — on each agent retry: read status (
pending/resolved/expired) without blocking. - Validate — once resolved: verify the response is genuine, then the
runtime layers the
scope-over-args sufficiency check before honoring the approval.
While pending, the phase suspends rather than denies. The decision stays
Allow, but a pending marker rides alongside it, and the host maps that to
JSON-RPC -32120 (“not complete — retry echoing this id”). The forward rule
is one clause: forward only when the decision is Allow and nothing is
pending. Expiry, channel error, a genuine denial, or a failed validation all
fail closed (default on_error: deny).
The CIBA channel plugin
The bundled elicitation/ciba plugin drives the ask through OpenID Connect CIBA
(Client-Initiated Backchannel Authentication) against Keycloak or any
CIBA-capable OP:
plugins:
- name: manager-approver
kind: elicitation/ciba
hooks: [elicit]
config:
backchannel_endpoint: "https://idp.example.com/realms/corp/protocol/openid-connect/ext/ciba/auth"
token_endpoint: "https://idp.example.com/realms/corp/protocol/openid-connect/token"
client_id: "ppe-gateway"
client_secret_source:
kind: file
path: /etc/ppe-secrets/client-secret
approver_claim: preferred_username
from becomes the CIBA login_hint, purpose seeds the binding_message, and
timeout maps to requested_expiry. Dispatch returns an auth_req_id that
doubles as the elicitation id the agent echoes on retry.
Elicitation attributes
Dispatch and resolution write elicitation.* attributes that later rules in the
same phase — and the audit log — can read:
| Attribute | Meaning |
|---|---|
elicitation.id | Correlation id the agent echoes on retry. |
elicitation.status | pending / resolved / expired. |
elicitation.outcome | approved / denied, once resolved. |
elicitation.approver | Resolved approver identity, cross-checked against from. |
elicitation.channel | Audit label for how the human was reached (not a routing key). |
Genuineness and argument binding
Two independent checks stand between an approval and the tool call:
- Genuineness is the channel plugin’s job. For CIBA, the approver identity
is extracted from the token the OP returns and cross-checked against the
login_hint. The plugin trusts the token because it comes straight from the OP over a client-authenticated TLS poll — it does not independently verify the JWT signature. That trust therefore rests on the token endpoint being reached over correctly configured TLS with client authentication; deploy accordingly (alwayshttps://, real client credentials) and do not point a CIBA handler at a plaintext or unauthenticated endpoint outside local development. - Sufficiency is the runtime’s job. Keycloak has no RFC 9396 rich
authorization request, so the binding between “what was approved” and “what is
being executed” lives in APL: the
scope:expression is evaluated against the live request args at validation. A human can approve, but if the args drift outsidescope(e.g. the amount was raised after approval), the operation fails closed regardless.
The purpose is recorded verbatim as the source of truth for what was approved
— it is never derived from model output.
Pipeline integration
require_approval(...) and its sibling verbs dispatch to a plugin implementing
the elicit hook, resolved by name off the route’s dispatch plan exactly like
delegate(...). Because elicitation is an explicit, sequenced effect — gated
behind authentication, checked on every retry, and validated before the forward
— a pending or unapproved operation never reaches the tool.
Next
- Session Taint: enforce information-flow controls across requests.
- Backend Restriction: constrain the backends eligible for a request.