--- title: 'Human-in-the-loop' description: 'Pause a durable workflow until a human approves or rejects, then resume exactly where it stopped.' position: 6 --- # Human-in-the-loop > Some steps shouldn't run without a person signing off: a refund, or a > destructive change. `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: ```ts import { requestApproval } from '@stackbone/sdk/workflow'; ``` It is built on the upstream Vercel [Workflow SDK](https://workflow-sdk.dev/docs): the same durable-execution engine that powers your [durable workflows](/docs/sdk/workflows/overview). 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: - It 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`. - It pauses the run on a hook with its own resume key. The SDK assigns that key per run, so you don't invent one. 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. - It races the decision against a timeout, resuming with the human's decision when one arrives first. If the `timeout` elapses first, the runtime applies the `fallback` (`'approve'` or `'reject'`) instead. One hard rule: **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](/docs/sdk/workflows/overview) for the `'use workflow'` / `'use step'` split. ## Quick start A workflow that refunds an order, but only after a human approves: ```ts // 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(), }); export const outputSchema = z.object({ orderId: z.string(), refunded: z.boolean(), decision: z.string(), }); export async function refundWorkflow(input: z.infer) { 'use workflow'; const decision = await requestApproval({ // No `token`: the SDK assigns a unique resume key for this run. 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 runs only when `decision.status === 'approved'`. The durable pause covers everything in between (the wait, a crash, a redeploy). ## Options `requestApproval(options)` accepts: | Field | Type | Notes | | ---------- | ----------------------- | ----------------------------------------------------------------------------------------- | | `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'`. | | `token` | `string` | Optional. Your own resume key. Omit it: the SDK assigns a unique one per run. | It returns an `ApprovalDecision`: ```ts interface ApprovalDecision { 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). ### When to pass your own `token` Almost never. Leave `token` out and the SDK generates a resume key that is unique to that run, so a retry, or a second run started while the first approval is still pending, can't collide with a key another live run already holds. Two gates in the same run each get their own key too. Pass an explicit `token` only when something outside the run has to rebuild the same key to resume it, and then make sure it is unique per approval per run. Reusing a key another active run still holds is the one case that can't resume: the approval falls back instead of waiting for a person. If you want an inbound reply to wake this exact run, use a raw hook rather than `requestApproval()`. See [Wait for a reply](#wait-for-a-reply). ## Deciding When the workflow pauses, the approval lands in the inbox. A reviewer decides from the dashboard, or from the shell: ```sh # 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." ``` The CLI records `--reason` 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. A pending approval that nobody attends reaches `timed_out` on its own once its deadline passes. The runtime checks for overdue approvals about once a minute, and again every time the agent starts, so a restart still expires whatever fell due while it was down. The runtime writes the expiry into the audit trail as a decision taken by the system, and the dashboard labels it that way, so you can tell an automatic expiry from one a person signed. By default `stackbone hitl` targets your local-dev installation; point it at any installation with `--agent `. Editing an approval's payload is a dashboard-only action. ## Timeout and fallback The runtime races the `timeout` against the human decision: - If a reviewer decides before `timeout`, you get their decision and `timedOut: false`. - If nobody decides in time, the runtime applies the `fallback` decision with `timedOut: true`. `fallback: 'reject'` (the default) fails closed; `fallback: 'approve'` fails open. Common patterns: ```ts // High-risk: auto-reject if no one looks within an hour. await requestApproval({ topic, payload, timeout: '1h', fallback: 'reject' }); // Low-risk: auto-approve after a grace period if no one objects. await requestApproval({ topic, payload, timeout: '15m', fallback: 'approve' }); ``` Inside a workflow the run resumes by itself when the timeout wins. The runtime then closes the inbox row as `timed_out`, which only stops it from sitting in the pending list forever. Nothing resumes the run twice. ## 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 that waits and then asks a second approver) drop down to the raw Workflow SDK primitives, re-exported verbatim from the same subpath: ```ts 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](https://workflow-sdk.dev/docs/ai) and [directives](https://workflow-sdk.dev/docs/how-it-works/understanding-directives) docs for hook and replay semantics. ### Wait for a reply A common shape has no approve/reject in it. You send someone a message and you want the **same run** to wake up when they reply. `requestApproval()` does not fit here, for two reasons: it models a decision and returns `{ status, payload, timedOut }`, and the SDK auto-generates its resume key, so no incoming event can match it. A reply is free-form content and needs a key you choose, so drop down to a raw hook. A resumed hook returns whatever payload woke it, which is the reply itself. Park the hook on a `token` you control, and make that token the conversation's own id: the email thread id the provider hands back when you send the message. Then race the hook against a timeout: ```ts import { z, stackbone } from '@stackbone/sdk'; import { defineHook, sleep } from '@stackbone/sdk/workflow'; const replyHook = defineHook({ name: 'awaitReply' }); const TIMED_OUT = Symbol('reply.timedOut'); export async function askAndWaitWorkflow(input: { to: string; question: string }) { 'use workflow'; const { threadId } = await sendQuestion(input.to, input.question); // Park on the thread id, so an inbound reply can find this exact run. const hook = replyHook.create({ token: threadId }); const reply = await Promise.race([hook, sleep('72h').then(() => TIMED_OUT)]); if (reply === TIMED_OUT) { return { answered: false }; // nobody replied within the window } return { answered: true, body: (reply as { body: string }).body }; } async function sendQuestion(to: string, question: string) { 'use step'; const out = await stackbone.connection('stub-mail').sendMail({ to, subject: 'Quick question', body: question, }); return { threadId: out.threadId }; } ``` For the reply to resume this run, the trigger that receives it needs to know which value identifies the conversation. You set that on the trigger with a `correlationKey`: it pulls the same thread id out of the incoming event that you parked the hook on. When a reply arrives and a run is parked on that key, the trigger resumes it. When no run is parked (the run already finished, or the hook timed out), it starts a fresh run instead, the same way a first message would. See [Stackbone Connect](/docs/home/features/integrations#resume-a-run-instead-of-starting-a-new-one) for the trigger side. Two rules keep this reliable. Set the `timeout` at least as long as you expect a person to take, or the hook expires before the reply lands and the reply turns into a brand-new run. Keep your steps safe to run more than once, because a late reply restarts the run from the top. ## The in-agent inbox: `stackbone.approval` `requestApproval()` is the path to prefer: 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 a `request` / `verify` / `tool` shape for wrapping an LLM tool so the model waits for a human before the runtime applies its result. It also reads and withdraws its own entries: `get(id)` for one approval with its audit trail, `list(options)` for a page of them, and `cancel(id, reason?)` to withdraw a pending one your agent no longer needs. The durable-workflow gate needs no callback URL to host and no signature to verify, and its pause survives restarts. Use `stackbone.approval` only when the decision has to live inside an agent turn rather than a workflow run. ### What happens when nobody answers `stackbone.approval.request()` takes a `timeout` (a duration string like `'4h'`, `'90m'` or `'30s'`, or a raw number of milliseconds, 24 hours by default) and an `onTimeout` policy that says what the deadline means: | `onTimeout` | On expiry | | ----------- | ----------------------------------------------------------------------------- | | omitted | **Rejects.** A gate nobody attends closes rather than opens. | | `'reject'` | Rejects. The explicit form of the default. | | `'approve'` | Approves, so the agent continues as if a reviewer had said yes. | | `'ignore'` | Nothing. The approval stays pending forever and only a person can resolve it. | ```ts const gate = await stackbone.approval.request({ topic: 'refund', payload: { orderId, amount }, onDecide: '/approvals/decide', timeout: '4h', onTimeout: 'reject', }); if (gate.error) { // handle the failure to open the gate } ``` When the deadline passes, the runtime closes the approval as `timed_out` and applies the policy to whatever was waiting on it. An agent holding the promise open receives the signed decision on its `onDecide` callback, exactly as it would from a person, so `verify()` reconciles it the same way. A tool call a deep agent paused on resumes with the policy as the decision, which means an unanswered tool gate ends its turn after 24 hours instead of parking the conversation for good. Pick `onTimeout: 'ignore'` when a decision must wait for a human, however long that takes. ## Where to go next - **[Durable workflows](/docs/sdk/workflows/overview)**: the `'use workflow'` / `'use step'` model `requestApproval()` lives inside. - **[Agents and sessions](/docs/sdk/agents/overview)**: how a workflow calls a durable agent for the decision it gates on. - **[Stackbone Connect](/docs/home/features/integrations)**: gate a connector call (send mail, charge a card) behind an approval. - **[Guardrails](/docs/sdk/humans/guardrails)**: an operator can send a turn to this same inbox from a screen, with no code, using the `require_approval` action. - **Workflow SDK: [human-in-the-loop](https://workflow-sdk.dev/docs/ai)**, the upstream hook + resume primitives this is built on.