Return to Feed
Tutorials2026-07-30

How to Build a Human-in-the-Loop Approval Workflow in n8n

Step-by-step: build the Pause and Notify HITL pattern in n8n with Slack interactive buttons, a confidence-threshold branch, a revision loop, and an append-only audit log — so every agent output has a documented human checkpoint before it reaches a client.

<p class="lead">Yesterday's post covered the theory: four HITL patterns, three threshold types, and why every AI operating system needs a human override layer. This post builds it. Step by step, in n8n, with real node configurations, Slack interactive messages, and an append-only audit log — so you finish reading with a working approval workflow, not a reading list.</p> <h2>What You're Building</h2> <figure> <img src="https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=1200&q=80" alt="n8n workflow diagram showing a human-in-the-loop approval flow — agent output routes through a threshold check, triggers a Slack approval message, and either continues to the send step or returns to the agent for revision" width="1200" height="800" loading="lazy" /> </figure> <p>The workflow implements Pattern 1 from the <a href="/blog/human-in-the-loop-ai-agents-uk">HITL architecture guide</a>: Pause and Notify. The agent produces an output — a proposal draft, a contract review summary, a client report — saves it to a holding location, and sends a Slack message to the responsible reviewer. The reviewer clicks Approve or Request Revision directly in Slack. Approve triggers the send step. Request Revision routes the output back to the agent with the reviewer's note attached.</p> <p>The same pattern extends to any output type and any notification channel (email works if your team isn't on Slack). Once you've built it once, every subsequent agent workflow inherits the same escalation branch with minor parameter changes.</p> <p>Prerequisites: an n8n instance (self-hosted or n8n Cloud), a Slack workspace with a bot token, and a Google Drive folder (or equivalent) for the holding location. The agent step assumes you're calling the Claude API directly via n8n's HTTP Request node — the pattern works identically for any model or API.</p> <h2>Step 1: The Agent Output Node</h2> <figure> <img src="https://images.unsplash.com/photo-1555949963-aa79dcee981c?w=1200&q=80" alt="n8n HTTP Request node configured to call the Claude API — showing the request body with model, messages, and system prompt fields, and the response body mapped to a structured JSON output with content and confidence_score fields" width="1200" height="800" loading="lazy" /> </figure> <p>Your agent step is an HTTP Request node calling the Claude API (or whichever model you're using). The critical detail is what you ask it to return. The system prompt should include an explicit instruction to return structured JSON with two fields at minimum: the content output and a confidence score.</p> <p>Example system prompt addition:</p> <pre><code>Return your response as a JSON object with the following fields: { "output": "the full drafted content", "confidence": 0.0–1.0, "confidence_reason": "one sentence explaining the score" }</code></pre> <p>Set the HTTP Request node's Response Format to JSON. Map the output to <code>{{ $json.content[0].text }}</code>, then parse the JSON string with n8n's JSON Parse node. You now have three fields to work with in the next step: <code>output</code>, <code>confidence</code>, and <code>confidence_reason</code>.</p> <p>If you're using a structured output library or a model that natively returns JSON (via a tool call or response format parameter), map directly from the parsed response. The point is: confidence must be a separate numeric field, not inferred from the prose output.</p> <h2>Step 2: The Threshold Branch</h2> <figure> <img src="https://images.unsplash.com/photo-1509228468518-180dd4864904?w=1200&q=80" alt="n8n IF node configuration showing threshold routing logic — confidence score below 0.75 routes to the escalation branch, above routes to the auto-approve path, with a separate sensitivity check for flagged output types" width="1200" height="800" loading="lazy" /> </figure> <p>Add an IF node after the JSON parse step. This is the threshold branch. Configure two conditions:</p> <p><strong>Condition A — Confidence threshold:</strong> <code>{{ $json.confidence }}</code> is less than <code>0.75</code> (adjust to your defined threshold). When true: route to the escalation branch. When false: continue to Condition B.</p> <p><strong>Condition B — Sensitivity check:</strong> Add a second IF node on the false branch. Check whether the output contains any sensitivity flags — for example, whether the output type field equals "complaint_response", "regulatory_reference", or "third_party_communication". Use n8n's expression evaluator: <code>{{ ["complaint_response", "regulatory_reference", "third_party_communication"].includes($json.output_type) }}</code>. When true: escalate. When false: auto-approve and route to the send step.</p> <p>The auto-approve path writes directly to the audit log and proceeds to send. The escalation path triggers the Slack notification.</p> <p>For value threshold checks — where you're routing based on a financial amount in the output — add a third IF node checking <code>{{ $json.value }}</code> against your defined limit before reaching the sensitivity check. Route to escalation if above the limit; continue to the sensitivity check if below.</p> <h2>Step 3: The Slack Notification with Interactive Buttons</h2> <p>Add a Slack node on the escalation branch, configured to send a message to the appropriate channel or reviewer DM. Use Slack's Block Kit to build a message with two action buttons — Approve and Request Revision — rather than plain text.</p> <p>The message body (in Slack Block Kit JSON, passed via the Slack node's Blocks field):</p> <pre><code>{ "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "*Review required:* {{ $json.output_type }} {{ $json.output.substring(0, 300) }}... *Confidence:* {{ $json.confidence }} — {{ $json.confidence_reason }}" } }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "Approve" }, "style": "primary", "value": "approve_{{ $json.record_id }}", "action_id": "approve_output" }, { "type": "button", "text": { "type": "plain_text", "text": "Request Revision" }, "style": "danger", "value": "revise_{{ $json.record_id }}", "action_id": "revise_output" } ] } ] }</code></pre> <p>The <code>record_id</code> field should be set earlier in the workflow — a UUID generated at the start of the run that uniquely identifies this output. It's carried through every step so you can correlate the Slack response back to the correct workflow instance.</p> <p>After sending the Slack message, the workflow enters a Wait node. Set it to wait for a webhook response — n8n will pause the execution and resume when Slack sends the button click back to the webhook URL.</p> <h2>Step 4: The Slack Webhook Receiver</h2> <p>Create a separate n8n workflow — a Webhook trigger workflow — that receives Slack's interactive component callbacks. Slack sends a POST to your webhook URL when a reviewer clicks a button. The payload includes the <code>action_id</code> (approve_output or revise_output) and the <code>value</code> (approve_RECORDID or revise_RECORDID).</p> <p>Configure this receiver workflow to:</p> <ol> <li>Parse the Slack payload (it arrives URL-encoded; use the Set node to decode <code>{{ JSON.parse($json.body.payload) }}</code>)</li> <li>Extract the action_id and record_id from the parsed payload</li> <li>Respond to Slack immediately with a 200 OK (Slack requires a response within 3 seconds; use n8n's Respond to Webhook node at the top of the branch)</li> <li>Resume the waiting parent workflow using n8n's Resume a Workflow node, passing the action taken and reviewer identity</li> </ol> <p>The Resume a Workflow node connects to the Wait node in the original workflow by run ID. When it fires, the original workflow wakes up with the reviewer's decision in its data.</p> <h2>Step 5: The Decision Branch and Audit Log</h2> <figure> <img src="https://images.unsplash.com/photo-1563986768609-322da13575f2?w=1200&q=80" alt="n8n audit log append node writing to Google Sheets — columns showing timestamp, workflow run ID, agent ID, output type, confidence score, threshold evaluation result, reviewer identity, and final decision for compliance record-keeping" width="1200" height="800" loading="lazy" /> </figure> <p>Back in the original workflow, after the Wait node resumes, add an IF node checking the incoming decision: if action_id equals "approve_output", route to the send step. If "revise_output", route back to the agent with the revision note attached to the next prompt.</p> <p>Before either branch executes its final action, write to the audit log. The audit log is an append operation to a Google Sheet (or any append-only store — a Supabase table works well for regulated environments where you need access control on the log).</p> <p>Columns to write on every run, whether auto-approved or human-reviewed:</p> <ul> <li><strong>timestamp</strong> — workflow start time (passed in via the initial trigger)</li> <li><strong>run_id</strong> — the record_id UUID</li> <li><strong>agent_id</strong> — identifies which agent workflow produced this output</li> <li><strong>output_type</strong> — proposal, report, contract_review, etc.</li> <li><strong>confidence</strong> — the score the model returned</li> <li><strong>threshold_result</strong> — auto_approved, escalated_confidence, escalated_sensitivity, escalated_value</li> <li><strong>reviewer</strong> — Slack user ID of the approver (null if auto-approved)</li> <li><strong>decision</strong> — approved, revision_requested, or auto_approved</li> <li><strong>revision_note</strong> — the reviewer's note if revision was requested (null otherwise)</li> </ul> <p>This log is your compliance record. For FCA-regulated businesses, write it to a separate store with restricted write access — the workflow service account writes; no one else does. Keep it for at least six years.</p> <h2>Step 6: The Revision Loop</h2> <p>When the reviewer clicks Request Revision, they need a way to pass a note back. The simplest approach: a Slack modal. When the revise button is clicked, the webhook receiver — before resuming the parent workflow — opens a Slack modal with a single text input asking for the revision note. On modal submission, Slack sends another callback to the same webhook. The receiver collects the note and then resumes the parent workflow.</p> <p>In the parent workflow, the revision branch prepends the reviewer's note to the agent's next prompt:</p> <pre><code>The following output was returned for revision by a human reviewer. Reviewer note: {{ $json.revision_note }} Original output: {{ $json.output }} Please revise the output based on the reviewer's note and return the corrected version in the same JSON format.</code></pre> <p>The revised output loops back to the threshold check. If it passes, it continues to auto-approve. If it escalates again, it returns to the reviewer — this time with both the original note and the revised output visible in the Slack message, so the reviewer can see what changed.</p> <blockquote><p>One complete revision loop — initial draft, review, revision, re-review — takes around four to six minutes of elapsed time in practice. The agent work is fast; the bottleneck is always the human's review window.</p></blockquote> <p>This is the full Pattern 1 implementation. Patterns 2 (Review Before Send), 3 (Parallel Approval), and 4 (Graceful Degradation) are variations on the same skeleton: the threshold branch, the notification, the wait, the resume, and the audit log remain constant. What changes is whether the output can be edited before approval (Pattern 2), whether the review is batched rather than real-time (Pattern 3), or whether the workflow exits without producing an output at all (Pattern 4).</p> <p>If you're building this for an existing agent deployment and want a second pair of eyes on the configuration — particularly around the threshold values, the audit log schema, or the Slack modal setup — <a href="/contact">book a free 30-minute call</a>. We build these workflows regularly and can walk through your specific setup in under an hour.</p>
BOOK CALL