Full automation is a trap.
The failure mode isn't the workflow that breaks. It's the workflow that runs perfectly and does the wrong thing at scale: the auto-reply that shouldn't have been sent, the deploy that shouldn't have shipped, the record that shouldn't have been deleted. Nobody was asked.
The automation people actually trust knows when to stop and ask. So I built approval gates into my workflow engine: a pipeline runs, hits a review node, suspends, pings a human, and resumes when someone answers. The answer can come from a Slack button or the app. First answer wins.
I'm Travis, solo dev building Flywheel, a workflow automation platform (Go backend, Next.js frontend). Here's how the human-in-the-loop system works, the version I'm not proud of, and the four ways it could deadlock a run before I fixed them.
The version I'm not proud of
My first implementation was a hard-coded branch in the orchestrator:
// pipelines/execution/orchestrator_handler.go
if config.ProviderName == "human-input" {
return o.pauseRunForHumanInput(ctx, run, node, processed)
}
The execution engine, which is supposed to be a generic graph walker, knew the name of one specific provider. Every new thing that needed to pause (a Slack review, a delay node, a future webhook callback) would have meant another branch.
The fix was to invert it. Providers declare that they suspend; the orchestrator never asks why. A node processor returns a suspend request on its result, and the entire hot path is now:
if result != nil && result.Suspend != nil {
return o.suspendNode(ctx, run, node, result.Suspend)
}
suspendNode is provider-agnostic. It stamps the node waiting, appends a PausedNode{Kind, State, ExpiresAt, OnTimeout} to the run document, sets the run status to paused, and persists. The State field is an opaque blob only the owning provider understands.
Resume is a registry keyed by kind:
resume.Register(pipelines.NewHumanInputResumeHandler(pipelineService))
resume.Register(pipelines.NewDelayResumeHandler(pipelineService))
resume.Register(slack.NewReviewResumeHandler(pipelineService))
resume.Register(slack.NewDualReviewResumeHandler(pipelineService))
Four suspend kinds so far: in-app review, Slack review, dual-surface review, and delay. The delay node was the payoff proof: it rides the same suspend/expiry rails and needed zero orchestrator changes.
The Slack surface
A review node posts one Block Kit message per record into a channel: the rendered context, optional form inputs (text, select, radio, checkbox, date), and a row of decision buttons. The author defines the decisions; the default pair is Approve/Skip.
Two Slack constraints shaped everything:
The ~3-second ack. Slack retries your interactivity webhook if you don't answer fast. So the handler verifies the signature, parses, immediately acks 200, and does the real work (record the decision, resume the run, update the message) on a goroutine.
Buttons outlive runs. A button click can arrive after the run was resumed, timed out, or deleted. The button's value carries a JSON ref (org, pipeline, run, node, record index, decision), the handler re-binds it to the workspace that owns the run, and if the run is no longer paused it edits the message to "β οΈ This review is no longer active" instead of leaving live-looking buttons that silently fail.
One click does everything: the decision button doubles as the form submit, so any half-filled inputs on the message ride along with the decision in state.values. Then the message is edited to "β
approve by @travis" so it can't be clicked twice. No radio group plus separate submit button. One click, decided, done.
Dual surface: same gate, two doors
The interesting commit was making one gate answerable from two places. A review configured with renderAsWidget posts the Slack message AND renders an in-app widget (an HTML template with the record data, a stepper to move through records, and the same decision buttons).
Both surfaces write to the same submissions store. Release counting is just answered >= posted. The run doesn't know or care which surface answered; the first submission for a record wins, and the second surface finds out when its stale click gets retired.
One subtle bug here: for Slack-only reviews, the release count is trimmed to the records that actually posted to Slack (a failed post shouldn't block the run forever). For dual-surface reviews that trim would be wrong, because a record whose Slack post failed is still perfectly answerable in the app. It has to stay eligible. That distinction is one if statement and it took me longer to find than I want to admit.
Four ways to deadlock a run
Every one of these shipped to my dev environment before I caught it:
1. The empty review. Zero records reach the review node, the node suspends, and nothing can ever answer because there is nothing to answer. The run hangs until the timeout. Fix: zero records pass through without suspending.
2. All posts failed. Every Slack post errors (bad channel, revoked token), the node suspends anyway, and nobody ever sees a button. Fix: if nothing posted, fail the node loudly instead of pausing on nothing.
3. The poll that eats your answers. Multi-response reviews (polls) update a live tally block in the message after each vote. But Slack's chat.update resets every viewer's unsubmitted input state. If the poll also has form inputs, each vote silently wipes everyone else's half-typed answers. Fix: skip the live tally when form inputs are present; render the final tally only when the window closes.
4. The unattended gate. What happens when nobody answers for a week? Every review has a timeout (default 7 days) with a declared policy: fail the node, or release the records with a stamped timeoutDecision. The gate always resolves. Which policy you pick turns out to be a safety property, not a config detail. More below.
Real example 1: the deploy gate
I dogfood this on my own CI. A workflow template watches GitLab deployment webhooks: when a dev deploy succeeds, it chains three API reads to find the pipeline's manual promote-to-prod job, has AI write a changelog of the commits about to ship, and posts it to my approvals channel with two buttons:
π Dev deploy succeeded, approve to promote to prod
[β Approve & promote] [π Hold]
The same gate renders as a widget in the app. Approve, from either surface, plays the manual GitLab job and prod ships. The key config is three lines:
"timeout": "3d",
"onTimeout": "release",
"timeoutDecision": "rejected",
Fail closed. If nobody answers for three days, the release is HELD, never auto-promoted. The only path to production is a human pressing Approve. An approval gate that defaults to "approve" on timeout isn't an approval gate; it's a delay.
Real example 2: Slack Court
To prove the gate is just a node and not deploy-specific plumbing, the sillier template: /flywheel slack-court opens a form to file a (joking) case against a teammate. AI announces the case, creates a courtroom channel, drags in the defendant and witnesses, and posts a deliberation kickoff with one button: π¨ Adjourn court.
That button is the same review node. The run suspends for up to 15 minutes while everyone argues in the channel. Anyone clicking Adjourn releases it early; otherwise onTimeout: release adjourns automatically. Then the workflow reads the whole channel transcript and an AI judge hands down a verdict.
Same suspend machinery. One config gates production deploys, the other gates a courtroom bit.
What's still unsolved: cron
Here's the honest limitation. Inline gates work great for triggered runs (a webhook, a slash command, an @mention). They're wrong for scheduled pipelines.
A daily cron with an inline review suspends a new run every day. Miss a week and you have seven paused runs, each holding records hostage, each racing its own timeout, and timing out loses the records. Run-scoped pause/resume fundamentally can't give you "items wait indefinitely until I get to them."
The answer is splitting the workflow: a scheduled producer that collects items into a persistent queue and completes normally, plus a review pipeline that consumes from the queue whenever a human shows up. The queue itself is my next build; my entity system already does about 90% of what it needs.
More information can be found in my blog post


Top comments (0)