Human-in-the-loop
Gate risky tools behind human sign-off: a toolset policy pauses the call, your product shows the approval, and approve() replays it server-side.
Scenario: Acme's assistant may read anything, but ACME_ISSUEREFUND and ACME_SENDREPLY need a human to click Approve first. The agent shouldn't fail — it should pause, a reviewer should see exactly what was about to run, and approval should execute it without the agent re-asking.
How the gate works
Approval replays the frozen call as-is — the reviewer approves this call with these arguments, not a vague intent.
1. Flag which tools need sign-off
The gate is a toolset policy — set it on the toolset that serves your agents. It takes true (every tool in the toolset) or a list of served tool names:
await protobox.toolsets.update(toolset.id, {
policies: { requiresApproval: ['ACME_ISSUEREFUND', 'ACME_SENDREPLY'] },
});From a terminal, the same gate is one command:
protobox toolsets policy support-tools --require-approval ACME_ISSUEREFUNDAny surface served by that toolset — sessions, published servers, stateless executes with toolsetId — now pauses those calls.
2. The agent sees "pending", not "failed"
const result = await handle.execute('ACME_ISSUEREFUND', {
arguments: { ticketId: 'T-4821', amountCents: 4900 },
});
if (result.status === 'pending_approval') {
// result.approval: { id, expiresAt }
// Tell the user: "Sent for approval — I'll confirm once a teammate signs off."
}Adapters surface the same state (pendingApproval: true + approvalId on the tool result), so conversational agents can narrate the pause instead of erroring.
3. Build your approval inbox
The inbox is your UI — a list in Acme's admin, a Slack message with buttons, whatever fits. The data comes from one call:
const { items } = await protobox.approvals.list({ status: 'pending', limit: 50 });
for (const a of items) {
// a.actionSlug — human-facing tool name
// a.arguments — exactly what will run
// a.callerId — which of your end-users triggered it
// a.requestedAt / a.expiresAt
}Get pushed instead of polling — the execution.pending_approval webhook event carries the approvalId:
if (event.type === 'execution.pending_approval') {
const approval = await protobox.approvals.get(event.data.approvalId!);
await postToReviewChannel(approval); // your notifier
}4. Decide
// Approve — Protobox replays the frozen call server-side:
const approved = await protobox.approvals.approve(approvalId);
// approved.executionId → the resulting execution record, when the replay ran
// Deny — with a reason the requester can see:
await protobox.approvals.deny(approvalId, { reason: 'Refund exceeds tier limit.' });Undecided approvals expire (expiresAt, then status: 'expired') — an unwatched inbox fails closed, never open.
Production notes
- Approve ≠ succeeded. The decision and the replayed execution are separate facts; check
executionId/ the execution record for the outcome, and treat a missingexecutionIdas "decided, outcome unknown". - Start writes gated, then relax. Turning
requiresApprovalon for every write-shaped tool on day one costs a click per action; turning it on after an incident costs much more. - Record
decidedBy. The approval row carries who decided and when — that plus the execution trace is your audit story for compliance reviews.
Related
- Automations — approvals gate background executes the same way
- Observability & audit
Your API as agent tools
Register your own OpenAPI spec — or a code bundle — as a private connector, and your product's actions become first-class agent tools beside the catalog.
Observability & audit
Every tool call is a queryable record: execution logs, per-session activity, and signed platform webhooks — the material for debug panels, alerting, and compliance answers.