Actions (HITL)
Human-in-the-loop approval for agent actions
What is HITL?
Human-in-the-loop (HITL) is a safety pattern for AI agents: before an agent executes a high-stakes action (bank transfer, deployment, data deletion), a human must explicitly approve or reject it.
Today, HITL is tightly coupled to each application — every team builds its own approval UI, webhook plumbing, and audit trail. Blanc turns HITL into a protocol. You submit an action, a human reviews it on Blanc, and you get back a cryptographically signed attestation proving the decision was made by a real, engaged human.
The same evidence requirements (time spent, scroll depth, presence verification) that apply to document reviews also apply to action approvals, so you get proof of genuine engagement — not just a rubber stamp.
Workflow
- Agent creates an action — Your agent calls
POST /api/v1/actions(or thecreate-actionMCP tool) with the action type, parameters, and an optionalcallback_url - Human reviews on Blanc — The response includes a
review_url. The human opens it, sees the action details and risk level, and reviews the request - Human approves or rejects — After meeting the criteria requirements (minimum time, scroll, etc.), the human submits their decision
- Attestation issued — A cryptographic attestation is generated with the decision, evidence hashes, and a digital signature
- Decision delivered — If a
callback_urlwas provided, the decision is delivered via webhook. Otherwise, poll withGET /api/v1/actions/:id - Agent proceeds or halts — Your agent reads the
decisionfield and acts accordingly
Quick start
The fastest path is via the MCP server. No API key required (free tier: 50 actions+reviews/month).
Step 1 — Create the action:
Use the create-action MCP tool:
{
"action_type": "bank_transfer",
"action_params": {
"from": "acct_001",
"to": "acct_002",
"amount": 15000,
"currency": "USD"
},
"risk_level": "high",
"callback_url": "https://your-app.com/webhooks/poh"
}The response includes an action_id and a review_url.
Step 2 — Human opens the review URL and approves or rejects the action.
Step 3 — Get the decision:
Either poll with get-action:
{
"action_id": "uuid-from-step-1"
}Or receive it on your callback_url via webhook (see Webhooks below).
API reference
Create action
POST /api/v1/actions
Authorization: Bearer <api_key>
Content-Type: application/jsonRequest body:
| Field | Type | Required | Description |
|---|---|---|---|
action_type | string | Yes | Type of action (e.g. bank_transfer, deployment, data_deletion) |
action_params | object | Yes | Structured parameters for the action |
risk_level | string | No | low, medium, high, or critical. Defaults to medium. |
callback_url | string | No | URL to receive a webhook POST when the human decides |
action_context | object | No | Additional context for the reviewer |
criteria_profile_id | string | No | Criteria profile override. Free tier uses General Review. |
reviewer_id | string | No | Assigned reviewer ID |
Example:
curl -X POST https://blanc.dev/api/v1/actions \
-H "Content-Type: application/json" \
-d '{
"action_type": "deployment",
"action_params": {
"service": "payments-api",
"environment": "production",
"version": "v2.4.1"
},
"risk_level": "critical",
"callback_url": "https://your-app.com/webhooks/poh"
}'Response 201:
{
"action_id": "uuid-abc123",
"review_url": "https://blanc.dev/action/uuid-abc123?token=rt_abc123",
"status": "pending",
"action_type": "deployment",
"risk_level": "critical",
"review_token": "rt_abc123",
"expires_at": "2026-03-17T12:00:00Z",
"created_at": "2026-03-16T12:00:00Z"
}Get action
GET /api/v1/actions/:id
Authorization: Bearer <api_key>Example:
curl https://blanc.dev/api/v1/actions/uuid-abc123 \
-H "Authorization: Bearer your-api-key"Response 200:
{
"id": "uuid-abc123",
"status": "completed",
"action_type": "deployment",
"action_params": {
"service": "payments-api",
"environment": "production",
"version": "v2.4.1"
},
"risk_level": "critical",
"callback_url": "https://your-app.com/webhooks/poh",
"document_hash": "sha256:abc123...",
"criteria": { ... },
"attestation_id": "att_xyz789",
"decision": "approved",
"expires_at": "2026-03-17T12:00:00Z",
"completed_at": "2026-03-16T12:05:00Z",
"created_at": "2026-03-16T12:00:00Z"
}The decision field is null while pending, and "approved" or "rejected" once the human has decided.
Decision semantics
Both approve and reject generate attestations. The attestation proves the human actually engaged with the action details before making their decision — they spent the required time, scrolled through the content, and met all criteria profile requirements.
The decision field on the attestation distinguishes the outcome:
"approved"— The human reviewed the action and approved it to proceed"rejected"— The human reviewed the action and rejected it
This means a rejection is not an absence of proof — it's cryptographic proof that a human deliberately chose to block the action.
Webhooks
When you provide a callback_url, Blanc delivers the decision via an HTTP POST as soon as the human decides.
Payload:
{
"event": "action.decided",
"action_id": "uuid-abc123",
"decision": "approved",
"attestation": {
"id": "att_xyz789",
"evidence_hash": "sha256:def456...",
"signature": "ed25519:...",
"timestamp": "2026-03-16T12:05:00Z"
},
"action": {
"type": "deployment",
"params": {
"service": "payments-api",
"environment": "production",
"version": "v2.4.1"
},
"risk_level": "critical"
},
"timestamp": "2026-03-16T12:05:00Z"
}Headers:
| Header | Description |
|---|---|
Content-Type | application/json |
X-Blanc-Signature | Signature for verifying webhook authenticity |
User-Agent | ProofOfHuman-Webhook/1.0 |
Verifying webhook signatures
The X-Blanc-Signature header uses a Stripe-style format:
t=1710590700,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdTo verify:
- Extract the
t(timestamp) andv1(signature) values - Construct the signed content:
{t}.{raw_request_body} - Compute
HMAC-SHA256of that string using your webhook signing secret - Compare the computed hex digest with the
v1value
import { createHmac } from "crypto";
function verifyWebhook(body: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=") as [string, string])
);
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${body}`)
.digest("hex");
return expected === parts.v1;
}Webhooks have a 10-second timeout. Delivery attempts are recorded for debugging.
Risk levels
The risk_level field controls how the action is presented on the review page:
| Level | Use case |
|---|---|
low | Read-only operations, notifications, logging |
medium | Standard mutations, config changes (default) |
high | Financial transactions, access grants, data exports |
critical | Production deployments, data deletions, irreversible operations |
Risk levels are visual indicators — they help the reviewer understand the stakes but don't change the criteria requirements. Use your criteria profile to enforce stricter review requirements for high-risk actions.
Free tier
Actions work without an API key. The free tier shares the same 50/month limit with document reviews — creating an action counts as one review toward the limit.
On the free tier:
- The General Review criteria profile is used automatically (30s minimum time, full scroll required)
- The response includes a
free_tierobject showing usage