I tested n8n's native human-in-the-loop feature against a custom approval-gate workflow — here's what I found

I’ve been building AI agent workflows in n8n and wanted to actually test how far the native “Require approval” feature goes versus a custom-built approval gate. Sharing what I found, plus the working template.

**What n8n’s native HITL does well:** it’s genuinely simple to turn on — check a box on a tool, pick a Slack channel, done. For a single agent with a handful of sensitive tools, it works exactly as advertised.

**Where it stops:** no decision history (can’t answer “what did we approve last week”), and it only covers AI Agent tool calls specifically — nothing else in a workflow.

So I built a general-purpose version: any process can request approval via Slack + email fallback, and — the part I actually wanted — every request gets logged to a sheet the moment it’s raised, before anyone answers. Approved, denied, or still pending, there’s a record.

**The template (free):**

```json
{
“name”: “Autonomous Ops Kit - Human Approval Gate”,
“nodes”: [
{
“parameters”: {
“httpMethod”: “POST”,
“path”: “approval-request”,
“responseMode”: “responseNode”,
“options”: {}
},
“id”: “1”,
“name”: “Decision Requested (Webhook)”,
“type”: “n8n-nodes-base.webhook”,
“typeVersion”: 2,
“position”: [240, 300]
},
{
“parameters”: {
“assignments”: {
“assignments”: [
{ “id”: “a1”, “name”: “decision_id”, “type”: “string”, “value”: “={{ $json.decision_id || $now.toMillis() }}” },
{ “id”: “a2”, “name”: “title”, “type”: “string”, “value”: “={{ $json.title }}” },
{ “id”: “a3”, “name”: “summary”, “type”: “string”, “value”: “={{ $json.summary }}” },
{ “id”: “a4”, “name”: “requested_at”, “type”: “string”, “value”: “={{ $now.toISO() }}” },
{ “id”: “a5”, “name”: “status”, “type”: “string”, “value”: “pending” }
]
}
},
“id”: “2”,
“name”: “Normalize Request”,
“type”: “n8n-nodes-base.set”,
“typeVersion”: 3.4,
“position”: [460, 300]
},
{
“parameters”: {
“resource”: “message”,
“operation”: “post”,
“select”: “channel”,
“channelId”: “={{ $env.APPROVALS_SLACK_CHANNEL }}”,
“text”: “=*Approval needed:* {{$json.title}}\n{{$json.summary}}\n\nReply *approve {{$json.decision_id}}* or *deny {{$json.decision_id}}* in this channel.”,
“otherOptions”: {}
},
“id”: “3”,
“name”: “Notify Human (Slack)”,
“type”: “n8n-nodes-base.slack”,
“typeVersion”: 2.2,
“position”: [680, 200],
“notes”: “Swap for Email/Telegram node if not using Slack.”
},
{
“parameters”: {
“sendTo”: “={{ $env.APPROVALS_EMAIL }}”,
“subject”: “=Approval needed: {{$json.title}}”,
“message”: “={{$json.summary}}\n\nDecision ID: {{$json.decision_id}}\nReply APPROVE or DENY plus the Decision ID.”,
“options”: {}
},
“id”: “4”,
“name”: “Notify Human (Email fallback)”,
“type”: “n8n-nodes-base.emailSend”,
“typeVersion”: 2.1,
“position”: [680, 400],
“notes”: “Optional redundant channel — disable if Slack-only.”
},
{
“parameters”: {
“operation”: “append”,
“sheetId”: “={{ $env.DECISION_LOG_SHEET_ID }}”,
“range”: “Decisions!A:F”,
“options”: {}
},
“id”: “5”,
“name”: “Log Decision (Pending)”,
“type”: “n8n-nodes-base.googleSheets”,
“typeVersion”: 4.5,
“position”: [900, 300],
“notes”: “Every request is logged the moment it’s raised, before any response.”
},
{
“parameters”: {
“respondWith”: “json”,
“responseBody”: “={{ { \“status\”: \“pending\”, \“decision_id\”: $json.decision_id } }}”
},
“id”: “6”,
“name”: “Acknowledge Request”,
“type”: “n8n-nodes-base.respondToWebhook”,
“typeVersion”: 1.4,
“position”: [1120, 300]
}
],
“connections”: {
“Decision Requested (Webhook)”: { “main”: [[{ “node”: “Normalize Request”, “type”: “main”, “index”: 0 }]] },
“Normalize Request”: {
“main”: [[
{ “node”: “Notify Human (Slack)”, “type”: “main”, “index”: 0 },
{ “node”: “Notify Human (Email fallback)”, “type”: “main”, “index”: 0 }
]]
},
“Notify Human (Slack)”: { “main”: [[{ “node”: “Log Decision (Pending)”, “type”: “main”, “index”: 0 }]] },
“Notify Human (Email fallback)”: { “main”: [[{ “node”: “Log Decision (Pending)”, “type”: “main”, “index”: 0 }]] },
“Log Decision (Pending)”: { “main”: [[{ “node”: “Acknowledge Request”, “type”: “main”, “index”: 0 }]] }
}
}
```

Tested this against real webhook triggers before posting — works cleanly on both n8n Cloud and self-hosted.

If this is useful, I’ve got two more in the same pattern (monthly expense reconciliation against your actual bank statement, and a content-review gate for AI-drafted publishing) bundled together here: Autonomous Ops Kit — 3 n8n Templates for AI-Agent Businesses

Happy to answer questions about the node setup in the comments.

1 « J'aime »

“No decision history” is the part I would build first. I hit the same thing from another angle: my engine had an enum value for a skipped step since the first commit and had never once written it, so the skip existed in memory and nowhere else. The run said COMPLETED and the record of what did not happen did not exist.

Approvals are the worse version, because the question you cannot answer later is what a person agreed to.

Thank you so much for sharing this! I haven’t heard of it yet, but will definitely put this into my next built.

Nice setup @wajdym1960 — logging to a sheet the moment the request is raised is a smart detail, that’s the part most people forget.

The “no decision history” problem is exactly what made me go down a similar rabbit hole. I ended up building a separate approval dashboard with a proper audit log — every request, decision, who approved it, when, and a hash of the payload so you can prove the approved version is what actually executed. The hash part matters because without it, someone could approve one payload and a workflow could execute a slightly different one.

The thing I’d add to your template: a tamper check. Right now the sheet log is append-only which is good, but there’s nothing linking the approved payload to what actually runs downstream. A SHA-256 of the payload at approval time, stored alongside the log row, would close that gap.

I’ve been building this as a proper product (separate dashboard, n8n community node, policy engine) because the sheet + webhook approach works great for one workflow but gets hard to manage across multiple agents and teams. Happy to share what I learned if you’re going further down this path.

@easybits which part do you mean? the one with the “No decision history?” :wink:

1 « J'aime »