Search

Search the documentation

Protobox
Use cases

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

no yes approve deny Agent calls tool requiresApproval? Runs immediately Approval created: pending Webhook: execution.pending_approval Your approval inbox Protobox replays the exact call Nothing runs, denial recorded Execution record
The pending call is frozen with its arguments server-side; approve() replays it as-is.

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_ISSUEREFUND

Any 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 missing executionId as "decided, outcome unknown".
  • Start writes gated, then relax. Turning requiresApproval on 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.

On this page