Human-in-the-Loop Approvals
A policy rule with action: step_up holds the agent's tool call until a human
decides. On interactive coding agents (Claude Code, Copilot, Qwen Code) the
question appears inline at the keyboard. A headless agent — a LangChain,
CrewAI, browser-use, or OpenAI Agents worker with no human attached — posts a
pending approval request to the control plane instead, and waits. Someone
on your team approves or denies from the console, from Slack, or from anything
you point a webhook at, and the agent continues or fails closed.
Every path fails closed: timeout, denial, network error, or no reviewer configured all resolve to a block, never a silent allow.
The flow
- A guarded tool call matches a
step_uprule. - The runtime posts the request (tool, arguments preview, rule, severity,
session) to
POST /api/approvals, deduplicated by fingerprint so a retrying agent coalesces onto one request instead of spamming the queue. - Approval routes pick who gets told (see below). The request also appears in the console under Approvals.
- A teammate decides. The decision settles once — two approvers racing cannot both win, and an expired request cannot be approved after the agent has already given up.
- The agent's next poll sees the decision. Approved → the tool call runs. Anything else → blocked, with the outcome written to the signed audit trail.
Two ways to require approval
A policy rule with action: step_up — the rule-level route, for a class of
action ("any destructive command in production").
A single tool, from the console. Open a blocked or risky call in a session
or on the Threats page, and in Apply a rule pick Ask → this session /
agent / device / whole org. Tick which policy profiles it should land in — a
rule like "kubectl_delete needs approval" usually belongs in every profile
governing production, and applying it one profile at a time leaves the fleet
half-covered. The staged diff names every policy that will change before you
commit.
Either way the verdict is the same, and so are the guarantees: approval is
strictly softer than a deny and never softer than an allow. If any rule
says block, no one is asked. Denial, expiry, timeout and network errors all
fail closed. And like a deny it is categorised agent-control, so a local
mode="observe" cannot drop an approval requirement the org set.
Tool-level step-up needs prismor >= 1.38.0 on the device. Earlier runtimes
write the entry but skip it.
Routing: who gets asked
Approval routes (console → Approvals → Routing) are evaluated first-match- wins by priority. A route matches on any combination of severity, tool pattern, rule id, agent pattern, or agent tag, and targets a Slack channel or DM. A route with no matchers is your catch-all; the console warns when requests are queueing without one, because an unrouted request notifies nobody and the agent fails closed after its timeout.
Slack setup lives in the route panel: connect the workspace, set the signing secret, and the Approve/Deny buttons work from the channel. A Slack click is resolved back to a Prismor account and must belong to an ADMIN or OWNER of the org — the button is a convenience, not a bypass.
Webhooks: bring your own approver
Register an endpoint (console → Approvals → Webhooks) and Prismor POSTs
signed JSON for approval.requested, approval.decided, and
approval.expired. Each payload for a new request carries single-purpose
approve_url / deny_url links: one POST settles the request, so a pager, an
internal portal, or a ten-line bot can act as the approval surface with no
further API integration. Signing the links needs a key: APPROVAL_LINK_SECRET
if you set one, otherwise a key derived from BETTER_AUTH_SECRET — the session
secret itself is never used directly, so an approval link and a session cookie
never share a signing key. With neither set the links are omitted from the
payload, never sent unsigned.
Verify deliveries with the per-endpoint secret (shown once at creation):
X-Prismor-Event: approval.requested
X-Prismor-Delivery: whd_... # idempotency key — retries reuse it
X-Prismor-Timestamp: 1754190000
X-Prismor-Signature: v1=HMAC_SHA256(secret, "<timestamp>.<raw body>")
Compare timing-safe and reject timestamps older than 300 seconds. Failed deliveries retry with backoff (max 5 attempts); the console shows recent delivery outcomes per endpoint.
Runtime configuration
| Knob | Where | Behavior |
|---|---|---|
action: step_up | policy rule | Hold this class of action for a human decision |
| Ask in Apply-a-rule | one tool, chosen policies | Hold this tool for a human decision (needs prismor >= 1.38.0) |
PRISMOR_APPROVALS=0 | env, fleet-wide | Disable escalation: step_up fails closed with no control-plane round trip |
approvals=False | adapter keyword | Opt one guard out of escalation (guard_tools, guard_controller, guard_agent, ...) |
PRISMOR_APPROVAL_TIMEOUT | env | Seconds the agent waits for a decision (default 300) |
PRISMOR_APPROVAL_POLL | env | Poll interval in seconds (default 3) |
The async adapters (LangChain's coroutine path, browser-use, OpenAI Agents) wait in a worker thread, so a pending approval never stalls the event loop running the rest of your agent — concurrent tools, streams, and the browser CDP connection keep servicing while the human decides.
Requires
Approvals need an enrolled device or agent key
(connect to the platform) and prismor
≥ 1.36.0 for the configuration knobs above.