Human-in-the-loop

Some steps shouldn't run without a person signing off — a refund, a destructive change, a high-value action. requestApproval() pauses a durable workflow on a Workflow SDK hook, writes the approval to the inbox, and waits. A reviewer decides; the run resumes exactly where it stopped. Because the pause is durable, the run survives crashes, redeploys, and waits of hours or days.

requestApproval() lives on the workflow subpath, not the main barrel:

import { requestApproval } from '@stackbone/sdk/workflow';

It is built on the upstream Vercel Workflow SDK — the same durable-execution engine that powers your durable workflows. The workflow package is an optional peer dependency: install it in projects that author workflows (pnpm add workflow), since this subpath imports it directly.

Mental model

requestApproval() does three things in one call:

  • Records the approval so the run shows up in the inbox. The row is tied to its run, so you can find it from the run in the dashboard or with stackbone hitl list.
  • Pauses the run on a hook keyed by your token. The pause is durable — the hook's state lives in Redis, so the run survives a crash, a cold start, or a redeploy and resumes deterministically.
  • Races the decision against a timeout. If a human decides first, the run resumes with that decision. If the timeout elapses first, the fallback ('approve' or 'reject') is applied instead.

The one rule that matters: call requestApproval() from the workflow body, never inside a 'use step'. It is a workflow primitive that suspends the run — putting it inside a step breaks the durability contract. Do your I/O and side-effects in steps; keep the gate in the workflow body. See durable workflows for the 'use workflow' / 'use step' split.

Quick start

A workflow that refunds an order, but only after a human approves:

workflows/refund.workflow.ts
import { z } from '@stackbone/sdk';
import { requestApproval } from '@stackbone/sdk/workflow';

export const inputSchema = z.object({
  orderId: z.string(),
  amount: z.number().positive(),
  approvalToken: z.string(),
});

export const outputSchema = z.object({
  orderId: z.string(),
  refunded: z.boolean(),
  decision: z.string(),
});

export async function refundWorkflow(input: z.infer<typeof inputSchema>) {
  'use workflow';

  const decision = await requestApproval({
    token: input.approvalToken,
    topic: 'refund',
    payload: { orderId: input.orderId, amount: input.amount },
    title: 'Approve this refund?',
    timeout: '24h',
    fallback: 'reject',
  });

  if (decision.status !== 'approved') {
    return { orderId: input.orderId, refunded: false, decision: decision.status };
  }

  // Gated: this line never runs without a fresh approved decision.
  await issueRefund(input.orderId, input.amount);
  return { orderId: input.orderId, refunded: true, decision: decision.status };
}

async function issueRefund(orderId: string, amount: number) {
  'use step'; // runs once, persisted, retried on failure — keep it idempotent
  // ... call your payment provider here.
}

The shape that makes this safe: the side-effect (issueRefund) sits after the gate and is only reached when decision.status === 'approved'. Everything in between — the wait, a crash, a redeploy — is handled by the durable pause.

Options

requestApproval(options) accepts:

Field Type Notes
token string Required. The resume key — stable and unguessable, unique per approval within a run.
topic string Required. Approval category, shown in the inbox (e.g. 'refund', 'deploy').
payload T Required. What the reviewer looks at when deciding.
title string Optional. Human-readable title surfaced in the inbox and run view.
timeout string | number Optional. ISO 8601 duration ('24h', '15m') or milliseconds. Races the human decision.
fallback 'approve' | 'reject' Optional. Applied when the timeout wins the race. Defaults to 'reject'.

It returns an ApprovalDecision:

interface ApprovalDecision<T = unknown> {
  status: 'approved' | 'rejected';
  payload?: T; // what the reviewer attached when deciding, if any
  timedOut: boolean; // true only when the fallback was applied
}

Branch on status. Use timedOut if you want to treat a fallback differently from a real human decision (for example, to alert that nobody responded in time).

Deciding

When the workflow pauses, the approval lands in the inbox. A reviewer decides from the dashboard, or from the shell:

# List pending approvals.
stackbone hitl list --status pending

# Inspect one, including its audit trail.
stackbone hitl get appr_123

# Approve or reject (both are destructive, so they need --yes).
stackbone hitl approve appr_123 --yes --reason "Verified with the customer."
stackbone hitl reject  appr_123 --yes --reason "Order already disputed."

--reason is recorded as the decision comment. Approvals move through five statuses: pending, approved, rejected, timed_out, cancelled. The moment a reviewer approves or rejects, the parked run wakes up and requestApproval() returns the matching decision.

By default stackbone hitl targets your local-dev installation; point it at any installation with --agent <id>. Editing an approval's payload is a dashboard-only action.

Timeout and fallback

The timeout and the human decision are raced against each other:

  • If a reviewer decides before timeout, you get their decision and timedOut: false.
  • If nobody decides in time, the fallback decision is applied with timedOut: true. fallback: 'reject' (the default) fails closed; fallback: 'approve' fails open.

Common patterns:

// High-risk: auto-reject if no one looks within an hour.
await requestApproval({ token, topic, payload, timeout: '1h', fallback: 'reject' });

// Low-risk: auto-approve after a grace period if no one objects.
await requestApproval({ token, topic, payload, timeout: '15m', fallback: 'approve' });

Escape hatch: custom hooks

requestApproval() covers the common case. For anything more — a custom payload schema, several independent gates in one run, or an escalation ladder (wait, then ask a second approver) — drop down to the raw Workflow SDK primitives, re-exported verbatim from the same subpath:

import { defineHook, sleep } from '@stackbone/sdk/workflow';

defineHook creates a named hook your run can pause on; sleep pauses for a duration. Race them yourself to build whatever gate you need. The same hard rule applies: hook.create() runs in the workflow body, never inside a 'use step'. See the Workflow SDK's durable AI agents and directives docs for hook and replay semantics.

The in-agent inbox: stackbone.approval

requestApproval() is the path you should reach for: it pauses a durable workflow and resumes it deterministically. The ambient client also exposes stackbone.approval — the same approvals inbox surfaced from inside an agent, with an request / verify / tool shape for wrapping an LLM tool so the model has to wait for a human before its result is applied. Prefer the durable-workflow gate: with requestApproval() there is no callback URL to host and no signature to verify, and the pause survives restarts. Reach for stackbone.approval only when the decision genuinely has to live inside an agent turn rather than a workflow run.

Where to go next

  • Durable workflows — the 'use workflow' / 'use step' model requestApproval() lives inside.
  • Agents and sessions — how a workflow calls a durable agent for the decision it gates on.
  • Stackbone Connect — gate a connector call (send mail, charge a card) behind an approval.
  • Workflow SDK — human-in-the-loop — the upstream hook + resume primitives this is built on.

The platform for agent developers.Build, ship and observe agentsthat survive prod.

© 2026 · STACKBONEBUILT WITH ❤️ FROM CANADA AND SPAIN