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 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 builds on the upstream Vercel
Workflow SDK: the same durable-execution
engine behind 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:
- 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
timeoutelapses first, the runtime applies thefallback('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 for the
'use workflow' / 'use step' split.
Quick start
A workflow that refunds an order, but only after a human approves:
import { z } from 'zod';
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<typeof inputSchema>) {
'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 side-effect (issueRefund) sits after the gate and runs only when
decision.status === 'approved', which is what makes this safe. 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. Duration string ('24h', '15m') or milliseconds. Races the human decision. Defaults to '24h'. |
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:
interface ApprovalDecision<T = unknown> {
status: 'approved' | 'rejected';
payload?: T; // what the reviewer attached when deciding, if any
timedOut: boolean; // true whenever no human decided
conflict?: boolean; // true when this run lost a `token` collision
}Branch on status. Use timedOut if you want to treat "nobody decided"
differently from a real human decision (for example, to alert that
nobody responded in time), and conflict to tell a duplicate run apart
from an expired deadline.
Only an explicit approval opens the gate. 'approved' and
'approve' open it, 'rejected' and 'reject' close it. Anything else
is read as a rejection and logged: an unknown value, or a resume with no
body at all. A gate in front of a side effect does not open on a value it
cannot read.
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 run is rejected at once, with
conflict: true, instead of waiting for a person. Your fallback does
not change that. If you want an inbound reply to wake this exact run, use a raw
hook rather than requestApproval(). See
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:
# 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 <id>. 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 andtimedOut: false. - If nobody decides in time, the runtime applies the
fallbackdecision withtimedOut: true.fallback: 'reject'(the default) fails closed;fallback: 'approve'fails open. - If your own
tokenis already held by another live run, the decision isrejectedwithtimedOut: trueandconflict: true.fallbackdoes not apply here. A collision means the platform caught a duplicate, not that a human was absent, sofallback: 'approve'would have performed the side effect with nobody looking.
timeout defaults to '24h'. A gate with no deadline is not a longer
wait: it used to throw after the inbox card had already been written,
leaving a pending card for a run that was already dead.
Common patterns:
// 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, which the same subpath re-exports verbatim:
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.
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:
import { 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
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
Prefer requestApproval(): it pauses a durable workflow and resumes it
deterministically. The ambient client also exposes stackbone.approval, the
same approvals inbox from inside an agent. tool(spec) puts one LLM tool
behind a gate, and verify(request) reconciles the decision when it arrives.
Both are below. It also reads and withdraws its own entries:
get(id) for one approval, list(options) for the newest ones, 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.
Gate one LLM tool: stackbone.approval.tool()
stackbone.approval.tool(spec) wraps a single tool so the model cannot run it
until a person agrees. You describe the tool once, and the object it returns
carries the two pieces a tool loop needs: the function spec you hand the model,
and an invoke that either runs the tool or parks it.
import { stackbone } from '@stackbone/sdk';
const refund = stackbone.approval.tool<{ orderId: string; amount: number }, { refundId: string }>({
name: 'issue_refund',
description: 'Refund an order.',
parameters: {
type: 'object',
properties: { orderId: { type: 'string' }, amount: { type: 'number' } },
required: ['orderId', 'amount'],
},
// Gate above your own threshold. Omit this and the tool never gates.
needsApproval: (input) => input.amount > 100,
toRequest: (input) => ({
onDecide: '/approvals/decide',
timeout: '4h',
metadata: { orderId: input.orderId },
}),
// Your own code. It runs only once the gate is open.
execute: async (input) => ({ refundId: await issueRefund(input) }),
});refund.openaiSpec() returns
{ type: 'function', function: { name, description, parameters } }, which is
the shape an OpenAI-compatible tool list expects. Put it in the array you send
with the turn.
invoke(input) runs the gate and answers with the shared { data, error }
envelope. Its data holds one of two states, so branch on status:
const call = await refund.invoke({ orderId: 'ord_1', amount: 250 });
if (call.error) {
// `approval_tool_execute_failed` carries the message your `execute` threw.
return `The tool failed: ${call.error.message}`;
}
if (call.data.status === 'pending') {
// A person has to decide. `execute` did not run.
return `Waiting on approval ${call.data.approvalId} until ${call.data.expiresAt}.`;
}
return `Refunded ${call.data.result.refundId}.`;needsApproval picks the path. It takes a boolean or a function of the input,
and it defaults to no gate: leave it out and invoke calls execute every
time. When it resolves true, invoke opens the approval, comes back pending
and never touches execute, so the tool has no effect until somebody decides.
toRequest(input) fills that approval request. onDecide is the one field you
must supply:
| Field | Type | Notes |
|---|---|---|
onDecide |
string |
Required. Path on your agent the decision is POSTed back to. |
topic |
string |
Optional. Defaults to tool:<name>. |
payload |
T |
Optional. Defaults to the raw tool input. This is what the reviewer looks at. |
title |
string |
Optional. Human-readable title shown in the inbox. |
description |
string |
Optional. Longer context for the reviewer. |
timeout |
string | number |
Optional. Duration ('4h', '90m') or milliseconds. Defaults to 24 hours. |
onTimeout |
'reject' | 'approve' | 'ignore' |
Optional. What the deadline means. Defaults to rejecting. |
approver |
string |
Optional. Who should decide. |
idempotencyKey |
string |
Optional. The same (topic, idempotencyKey) returns the existing approval id. |
metadata |
Record<string, unknown> |
Optional. Anything else you want on the record. |
Reconcile the decision with verify()
The in-agent path is a callback, not a pause: your agent hosts the onDecide
path, and the control plane POSTs the signed decision to it.
stackbone.approval.verify(request) checks that signature and hands you the
decision.
// `request` is the incoming POST your agent serves at the `onDecide` path.
const decided = await stackbone.approval.verify<{ orderId: string }>(request);
if (decided.error) {
// A bad signature, a stale timestamp, or no signing key to check against.
return new Response('rejected', { status: 400 });
}
if (decided.data.status === 'approved') {
await issueRefund(decided.data.payload);
}
return new Response('ok');The runtime derives the key verify() checks against from the box's own
HMAC_SECRET and injects it, so you set nothing. verify() also refuses a
signature whose timestamp is more than five minutes old, which is what stops a
replayed callback from reaching your code. Pass { toleranceSeconds } to widen
that window. An approval_signing_key_missing error means the box booted with
no HMAC_SECRET.
The decision is one of approved, rejected, timed_out or cancelled.
Only approved and rejected carry the reviewer's payload, an approver
and an optional reason; the other two carry decidedAt alone.
Read and withdraw entries
Three more methods let an agent look at its own inbox. All three answer with
the shared { data, error } envelope.
stackbone.approval.get(approvalId) reads one entry:
const found = await stackbone.approval.get<{ orderId: string }>(approvalId);
if (found.error) {
// `approval_not_found` when the id does not exist on this agent.
return `Could not read that approval: ${found.error.message}`;
}
const { status, payload, decision } = found.data;The record carries approvalId, topic, status, the payload the reviewer
sees, createdAt, expiresAt and your metadata. Once somebody decides, it
also carries decision, the same object verify() hands you. Until then
status is pending and there is no decision field.
This record is the current state, not a history. It holds the one decision that
settled the approval. For the full audit trail of every decision written against
it, read the entry with stackbone hitl get instead.
stackbone.approval.list(options) reads the newest entries, most recent first:
| Option | Type | Notes |
|---|---|---|
status |
ApprovalStatus |
Optional. Keep one of pending, approved, rejected, timed_out, cancelled. |
topic |
string |
Optional. Keep one topic, e.g. tool:issue_refund. |
limit |
number |
Optional. 1 to 100, default 50. A value above 100 is clamped to 100. |
const inbox = await stackbone.approval.list({ status: 'pending', limit: 20 });
if (inbox.error) return `Inbox unavailable: ${inbox.error.message}`;
for (const entry of inbox.data.items) {
console.log(entry.approvalId, entry.topic, entry.expiresAt);
}This list does not paginate today. It returns one page of items and no
nextCursor, so limit is the whole window you get. Filter with status and
topic rather than trying to walk past it.
stackbone.approval.cancel(approvalId, reason?) withdraws a pending entry your
agent no longer needs. The entry moves to cancelled, the reviewer can no
longer act on it, and the reason you pass lands on the record's metadata as
cancelReason. It answers { data: undefined } on success, so branch on
error alone:
const withdrawn = await stackbone.approval.cancel(approvalId, 'the order was already refunded');
if (withdrawn.error) return `Could not cancel: ${withdrawn.error.message}`;cancel only touches an entry that is still pending, and it reports success
either way. Cancelling one a reviewer already decided changes nothing and still
comes back without an error. When you need to know which happened, read the
entry back with get(approvalId) and check its status.
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. |
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: the
'use workflow'/'use step'modelrequestApproval()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.
- Guardrails: an operator can send a turn to this
same inbox from a screen, with no code, using the
require_approvalaction. - Workflow SDK: human-in-the-loop, the upstream hook + resume primitives this is built on.