client.approval
Human-in-the-loop inbox with HMAC-signed callbacks and an LLM-tool wrapper.
client.approval
Pause an agent and wait for a human decision. Issue a fire-and-forget approval request, let a reviewer approve or reject from the dashboard, then resume the agent through an HMAC-signed callback the SDK verifies for you.
Mental model
client.approval is split into three responsibilities:
- Issue requests —
request,cancel,get,listare HTTP calls to the control plane (POST /api/approvals, …). Required capability:approval.fire_and_forget. - Verify callbacks —
verify(request, options?)validates the HMAC signature on the inboundRequestfrom the control plane. Local crypto only; no datapath round-trip and not gated by the contract handshake. - Wrap as an LLM tool —
tool({ name, parameters, … })returns anApprovalToolwhoseinvoke()either pauses for approval or executes the underlying handler immediately, depending on theneedsApprovalpredicate.
Issuing methods await the contract handshake before forwarding.
Configuration
| Source | Falls back to |
|---|---|
createClient({ approvalSigningKey }) |
STACKBONE_APPROVAL_SIGNING_KEY |
createClient({ stackboneApiUrl }) + agentJwt |
STACKBONE_API_URL, STACKBONE_AGENT_JWT |
approvalSigningKey is the HMAC-SHA256 key the control plane uses
to sign decision callbacks. Without it, verify() returns
approval_signing_key_missing.
Requesting approval
import { createClient } from '@stackbone/sdk';
const client = createClient();
const result = await client.approval.request({
topic: 'refund.approve',
payload: { orderId: 'ord-123', amountCents: 4_900 },
title: 'Refund $49.00 to Jane Doe?',
description: 'Customer requested refund — order shipped 3 days ago.',
onDecide: '/webhooks/approvals/refund',
timeout: '24h', // ISO 8601 duration string or milliseconds
onTimeout: 'reject',
approver: 'finance-team',
idempotencyKey: 'refund:ord-123',
metadata: { runId: process.env['STACKBONE_RUN_ID'] },
});
if (result.error) throw new Error(result.error.code);
console.log('Pending approval:', result.data.approvalId);Notable fields:
onDecide— path on the agent the control plane will POST the decision to (resolved against the agent's public URL server-side). Required.timeout— ISO 8601 duration ('24h','15m') or milliseconds. Defaults to 24h.onTimeout—'reject'(default),'approve', or'ignore'.idempotencyKey— same(topic, idempotencyKey)returns the sameapprovalIdinstead of creating a new request — safe to retry on partial failure.
request returns the approvalId, the callbackUrl to be hit by
the reviewer, and the expiresAt timestamp.
Verifying decision callbacks
When the reviewer decides, the control plane POSTs to your
onDecide URL with a stackbone-signature: t=<unix>,v1=<hex>
header. Verify it before trusting the body:
import { Hono } from 'hono';
const app = new Hono();
app.post('/webhooks/approvals/refund', async (c) => {
const result = await client.approval.verify<{ orderId: string; amountCents: number }>(c.req.raw);
if (result.error) {
return c.json({ error: result.error.code }, 400);
}
switch (result.data.status) {
case 'approved':
await issueRefund(result.data.payload.orderId);
break;
case 'rejected':
await markRefundRejected(result.data.payload.orderId, result.data.reason);
break;
case 'timed_out':
case 'cancelled':
// No payload — just close the run.
break;
}
return c.json({ ok: true });
});verify reads the raw body, recomputes
HMAC-SHA256(signingKey, ${timestamp}.${rawBody}), compares in
constant time, and rejects timestamps outside toleranceSeconds
(default 300s) to bound replay risk. A small clock-drift allowance
into the future (30s) is permitted.
LLM tool wrapping
For agent loops that delegate decisions to an LLM, wrap a handler in
client.approval.tool and feed tool.openaiSpec() to the model.
When the LLM picks the tool, the SDK transparently routes through the
inbox if needsApproval says yes:
const refundTool = client.approval.tool<
{ orderId: string; amountCents: number },
{ refunded: true }
>({
name: 'refund_order',
description: 'Refund a customer order. Requires human approval over $1,000.',
parameters: {
type: 'object',
properties: {
orderId: { type: 'string' },
amountCents: { type: 'integer', minimum: 1 },
},
required: ['orderId', 'amountCents'],
},
needsApproval: (input) => input.amountCents > 100_000,
toRequest: (input) => ({
onDecide: '/webhooks/approvals/refund',
title: `Refund $${(input.amountCents / 100).toFixed(2)}?`,
timeout: '24h',
}),
execute: async (input) => {
await stripe.refunds.create({ amount: input.amountCents /* … */ });
return { refunded: true };
},
});
const completion = await client.ai.chat.completions.create({
model: 'openai/gpt-4o-mini',
messages,
tools: [refundTool.openaiSpec()],
});
// Later, when the LLM emits a tool call:
const result = await refundTool.invoke({ orderId: 'ord-123', amountCents: 4_900 });
if (result.error) throw new Error(result.error.code);
if (result.data.status === 'pending') {
console.log('Waiting on human:', result.data.approvalId);
} else {
console.log('Executed inline:', result.data.result);
}needsApproval can be a boolean, a sync predicate, or an async
predicate. toRequest defaults topic to tool:${name} and
payload to the raw input when omitted.
Inspecting requests
// Single request — generic narrows the typed `payload`.
const detail = await client.approval.get<{ orderId: string }>(approvalId);
// Paginated list with optional filters.
const page = await client.approval.list({
status: 'pending',
topic: 'refund.approve',
limit: 50,
});
// Cancel an outstanding request.
await client.approval.cancel(approvalId, 'duplicate');list is cursor-based — page.data.nextCursor is set when more
items are available.
Errors
| Code | When |
|---|---|
approval_invalid_request |
Missing topic, onDecide, or approvalId. |
approval_invalid_signature |
Header missing, malformed, or HMAC mismatch. |
approval_signing_key_missing |
No signing key resolved for verify(). |
HTTP errors from the control plane (http_*) propagate verbatim. |
The contract gate adds contract_version_unsupported,
capability_unavailable, contract_unreachable and
contract_malformed — see
overview. verify() runs
locally and is intentionally exempt.
Where to go next
client.ai— pairtool()withchat.completionsfor human-in-the-loop agent loops.client.config— configure approval thresholds (e.g. "amounts above $X need approval") from the dashboard instead of hard-coding them.