Some steps should not run without a person saying yes. Refunding money, sending a campaign to a hundred thousand inboxes, publishing what an AI model drafted, deleting records that took years to collect. Human-in-the-loop, HITL for short, is the pattern where an automated flow stops at those moments, shows a person what is about to happen, and continues only with their decision. This tutorial explains the idea, builds an approval into a pipeline, and shows how to approve from the inbox, from your own tools, and how to make sure the approver hears about it.
What human in the loop is#
Fully automatic flows are fast and fully manual ones are safe; HITL takes the safety of a human decision and applies it only where it matters, keeping the rest automatic. The person is not a bottleneck for every run, only for the few that reach the gate, and the flow keeps its state while waiting, so the decision can come minutes or days later. Typical places for a gate:
- Money and contracts. A refund above a threshold, a payout, a discount an agent negotiated.
- AI-generated content. A model drafts the reply, the summary or the code change; a person reads it before it is sent, published or merged. The model does the work, the human keeps the responsibility.
- Irreversible actions at scale. A mass e-mail, a price change across a catalogue, a rollout to all customers.
- Ambiguity the machine cannot resolve. Two customer records that might be the same person; a document that could belong to two categories. Instead of approving, the person answers a question and the flow uses the answer.
How the platform does it#
In a graph pipeline the gate is a node called Human gate. When a run reaches it, the run is marked WAITING, its state is stored, and no container is kept busy. The node has two modes:
- Approve: the person sees the prompt and presses Approve or Reject. The node has two outgoing edges,
approveandreject, and the run continues down the one that was chosen. - Question: the person answers with a value, any JSON, and the run continues down the single default edge with the answer attached to the payload.
Waiting runs collect in the inbox, Pipelines → Human in the loop, and can also be resumed from the pipeline editor or through the API. Whichever way the decision arrives, the run picks up exactly where it stopped, with the decision or the answer merged into the payload for the steps that follow.
1. Build a pipeline with an approval#
The example is a refund flow: a function prepares the refund, a person approves it, and depending on the decision the refund is applied or the customer is told no. Open Pipelines, create a new graph pipeline and add the nodes:
- Manual trigger as the start. A webhook or schedule trigger works the same way once the flow is proven.
- A Lambda node running a function that computes the refund and returns something like
{"customer": "…", "amount": 120, "reason": "…"}. Connect the trigger to it, and its success output to the gate. - A Human gate node in mode Approve. In Prompt template write what the approver should see; it is a template over the upstream payload, so
Refund {{input.amount}} to {{input.customer}}?renders with the real values of each run. The template can also be a JSON object when the approver needs several fields laid out. - Two more Lambda nodes: one connected to the gate's approve output that applies the refund, one connected to reject that notifies the customer. Any branch may be longer or missing; a gate with no reject edge simply ends the run on rejection.
The same graph as JSON, which you can paste into the Draft graph (JSON) panel of the editor after replacing the function ids with your own:
{ "schemaVersion": 1, "nodes": [ { "id": "start", "kind": "manualTrigger", "name": "Start", "position": { "x": 0, "y": 0 }, "config": {} }, { "id": "draft", "kind": "lambda", "name": "Draft the refund", "position": { "x": 0, "y": 140 }, "config": { "functionId": "<draft-refund function id>", "onError": "failPipeline" } }, { "id": "gate", "kind": "humanGate", "name": "Approve the refund", "position": { "x": 0, "y": 280 }, "config": { "mode": "approve", "promptTemplate": { "text": "Refund {{input.amount}} to {{input.customer}}?", "amount": "{{input.amount}}" } } }, { "id": "apply", "kind": "lambda", "name": "Apply the refund", "position": { "x": -180, "y": 420 }, "config": { "functionId": "<apply-refund function id>" } }, { "id": "decline", "kind": "lambda", "name": "Tell the customer", "position": { "x": 180, "y": 420 }, "config": { "functionId": "<notify-declined function id>" } } ], "edges": [ { "id": "e1", "sourceNodeId": "start", "targetNodeId": "draft" }, { "id": "e2", "sourceNodeId": "draft", "targetNodeId": "gate", "sourceHandle": "success" }, { "id": "e3", "sourceNodeId": "gate", "targetNodeId": "apply", "sourceHandle": "approve" }, { "id": "e4", "sourceNodeId": "gate", "targetNodeId": "decline", "sourceHandle": "reject" } ], "metadata": { "pipeline": { "executionMode": "async" } } }
Press Publish. The editor validates the graph, reports anything missing such as a gate without outgoing edges, and answers with the version number. Only published versions run in production and only their runs show up in alerts and history.
2. Run it and approve#
Press Run (manual) and pass a payload for the draft function, for example {"orderId": "A-1042"}. The first step runs, the run reaches the gate and its status turns to WAITING. Nothing else happens until someone decides; the run can wait for as long as it needs to.
Open the inbox with the Human in the loop button on the Pipelines page. Each waiting run shows the pipeline, when it paused, the rendered Prompt / instructions, and the Previous step output so the approver sees the actual data, not a summary of it. The paused run also appears in the pipeline editor with a Human input panel on the side.
Press Approve. The run resumes, the apply branch runs and the run finishes green; the timeline of the execution shows the gate step with the decision and who made it. Run it again and press Reject to see the other branch.
3. Ask a question instead#
Switch the gate to mode Question when the flow needs information rather than permission. The node then has one outgoing edge, and the inbox shows an Answer (JSON) field with a Submit answer button. Whatever the person submits, a number, a string, an object, arrives in the payload as humanGate.answer.
A common shape is the agent that asks. A function proposes three candidate replies, the gate asks which one to send or whether to edit it, and the next step sends the chosen text. The prompt template can include the candidates, so the whole exchange fits on one screen.
4. Approve from your own tools#
The inbox is a client of a small API, and so can be your Slack bot, back-office or mobile app. A token with write permission on jobs lists the waiting runs and resumes or cancels them:
# Runs waiting for a person — the same list the inbox shows curl "https://api.inquir.org/pipeline-graph-executions/waiting" \ -H "Authorization: Bearer $INQUIR_TOKEN" # Approve (or send {"decision":"reject"}) curl -X POST "https://api.inquir.org/pipeline-graph-executions/<executionId>/resume" \ -H "Authorization: Bearer $INQUIR_TOKEN" -H "Content-Type: application/json" \ -d '{"decision":"approve"}' # Question mode: any JSON value is the answer curl -X POST "https://api.inquir.org/pipeline-graph-executions/<executionId>/resume" \ -H "Authorization: Bearer $INQUIR_TOKEN" -H "Content-Type: application/json" \ -d '{"answer":{"discount":10}}' # Give up on a paused run curl -X POST "https://api.inquir.org/pipeline-graph-executions/<executionId>/cancel" \ -H "Authorization: Bearer $INQUIR_TOKEN"
The decision field takes approve or reject for approve-mode gates; question-mode gates take an answer. Resuming a run that is not waiting returns a conflict, so two approvers pressing the button at once cannot apply a refund twice.
5. Tell the approver#
A gate that nobody knows about is a run that waits forever. Create an alert rule with the kind Human gate and a Slack or webhook channel: every time a run pauses, the approvers see a message with the pipeline, the prompt and a link to the inbox. The alerts tutorial walks through creating it; combine it with a Duration condition on the same pipeline to escalate approvals that have been waiting too long.
Design notes#
- Run gated pipelines asynchronously. A webhook caller cannot hold an HTTP connection while a person thinks. Set the execution mode to async in the pipeline settings; the trigger returns the run id immediately and the caller can poll or be notified.
- Parallel branches keep working. If the graph forks before the gate, the other branches run while the gate waits, and the run only completes when everything, including the gated branch, is done.
- Make the prompt self-contained. The approver should be able to decide from the prompt alone: the amount, the customer, the reason, a link to the record. Put the fields in the template rather than making them open the previous step output.
- Decide what a non-answer means. Runs wait indefinitely by design. Cancel stale ones through the API from a scheduled function, or alert on their age; a rejection edge that cleans up is better than an orphaned run.
- Keep the trail. The execution history records the gate step with the decision, the answer and the timestamp. For regulated flows that record is the audit log; do not skip the gate for "trusted" inputs, narrow it with a condition instead.
Where to go next#
The pipelines reference describes every node type, templates and execution modes; the alerts tutorial makes sure approvals are seen. From here, a good next step is to put a gate in front of the one automated action in your system that you would least like to see run by mistake.