REF · STACKBONE / WIKI · v0.9.4 stackbone

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 requestsrequest, cancel, get, list are HTTP calls to the control plane (POST /api/approvals, …). Required capability: approval.fire_and_forget.
  • Verify callbacksverify(request, options?) validates the HMAC signature on the inbound Request from the control plane. Local crypto only; no datapath round-trip and not gated by the contract handshake.
  • Wrap as an LLM tooltool({ name, parameters, … }) returns an ApprovalTool whose invoke() either pauses for approval or executes the underlying handler immediately, depending on the needsApproval predicate.

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 same approvalId instead 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 — pair tool() with chat.completions for 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.