terraform apply against production. The orchestration script does the obvious thing: prints “Approve? (y/n)” and blocks on stdin. The reviewer is asleep. Eight hours later the laptop rebooted, the process is gone, and with it every upstream result the run had computed. The approval never happened; neither did the rollback.
That failure is not about UX. It is about where the wait lives. If “waiting for a human” is a blocked OS process, the wait is only as durable as that process — and humans operate on a timescale (hours, days, a weekend) that no process should be expected to survive. The only design that works when the human replies tomorrow is to persist the question as state, let the process exit, and reconstruct the run when the answer arrives.
Smithers treats every human interaction this way. An <Approval>, <HumanTask>, or <WaitForEvent> node writes a durable pending request, the run enters a wait status (waiting-approval or waiting-event), and the owning process exits. The decision is delivered later — CLI, MCP tool, gateway UI, or another agent — and the run resumes from persisted state as if nothing had stopped.
How the industry solves it
Suspending on a human is the oldest problem in workflow engines. The mature systems all converged on the same shape: externalize the wait, correlate the reply.
The dividing line runs straight through this table: CrewAI, AutoGen, and coding-agent permission prompts block a live process; Temporal, Step Functions, Durable Functions, Camunda, and LangGraph suspend durable state. The first group works in a demo and fails on the first overnight wait. Smithers sits in the second group.
How Smithers does it
Four surfaces, one mechanism. Each is a node in the workflow graph that persists a pending request and parks the run.needsApproval on a Task is the minimal gate — pause before execution, no decision data:
<Approval> is a decision node. It persists a typed ApprovalDecision row (modes: approve, select, rank) that downstream rendering branches on:
onDeny picks the denial policy ("fail" | "continue" | "skip"); async lets unrelated branches keep executing while the approval is pending; autoApprove, allowedUsers, and allowedScopes restrict who and what can auto-clear the gate. <ApprovalGate when={...}> is the conditional form: it demands a human only when when is true and otherwise emits a real { approved: true, note: "auto-approved" } decision, so downstream branching stays uniform.
<HumanTask> collects arbitrary structured JSON validated against a Zod schema — the Camunda user-task shape, minus the BPMN. <Signal> / <WaitForEvent> wait for external events rather than decisions: <Signal id="user-feedback" schema={outputs.feedback}> renders <WaitForEvent event="user-feedback"> internally, and WaitForEvent adds correlationId matching (the Step Functions task-token idea, but with a named event plus a key instead of an opaque token), timeoutMs, and onTimeout: "fail" | "skip" | "continue".
Agents raise questions too, mid-task: the ask-human CLI command and the MCP ask_human tool create a durable pending human request from inside a running task and block that task until it is answered, cancelled, or expired — the escape hatch for “the agent is uncertain and must not guess.”
Suspension, not blocking
When a run hits an unresolved gate,smithers up exits. This is deliberate:
smithers signal RUN_ID user-feedback --data '{"rating":5}' then resume — and durable human requests: smithers human inbox, smithers human answer <requestId> --value '"yes"'.
Delivering a decision does not resume a suspended run by itself. An external controller — the Gateway’s
submitApproval/submitSignal + resumeRun, the supervise command, or an explicit up --resume — restarts execution. This is the same split Temporal makes between signal delivery and workflow progress, surfaced instead of hidden.Operator surfaces
- CLI:
approve,deny(auto-detect the node when only one gate is pending),signal,human inbox|answer|cancel,ask-human,why RUN_IDto see what a run is blocked on. - MCP:
list_pending_approvals,resolve_approval(with an ambiguity guard — it errors rather than guessing among multiple matches),ask_human. This is how an orchestrating agent (Claude Code, Hermes) relays a question to the human in conversation and records the answer. - Gateway UI: the
ApprovalPanelcomponent from@smithers-orchestrator/gateway-uirenders pending approvals with approve/deny buttons overuseGatewayApprovals+submitApproval; drop it into any custom workflow UI. - Notifications: the
@smithers-orchestrator/telegrampackage plus the reference Telegram approval Mini App push gates to a phone; GitHub/Linear/Telegram integrations turn inbound webhooks into durable workflow signals.
Guarantees and limits
Guaranteed: pending approvals, human requests, and awaited events survive process death, reboot, and days of silence — they are rows, not promises. Decisions are attributed (decidedBy, note, timestamp) and persisted as typed rows you can query later. Denials follow the declared policy instead of crashing the run. resolve_approval never guesses among ambiguous matches.
Not guaranteed: nobody is paged automatically — notification is your wiring (Telegram, a custom UI, an orchestrating agent watching human inbox). A delivered signal does not self-resume the run; something must call resume. timeoutMs on a gate is enforced when the run is being executed or resumed, not by a background clock while everything is parked. And unlike Camunda, there is no built-in assignment/escalation org model — <EscalationChain> composes one, but you define the levels.
See also
<Approval>·<ApprovalGate>·<HumanTask>·<Signal>·<WaitForEvent>·<EscalationChain>- Execution model — wait statuses and resume semantics
- MCP server —
ask_human,list_pending_approvals,resolve_approval - Custom workflow UIs — embedding
ApprovalPanel - Telegram integration — approvals from a phone