Module 7: Agent Security and Reliability
5. Human-in-the-loop: approval before sensitive actions
Description
By the end of this lesson you'll be able to set up, with n8n's two real mechanisms, a barrier that stops the workflow before a sensitive action happens and keeps it paused until a person approves or rejects it; you'll know which of the two mechanisms fits which situation; and you'll know how to write the message the approver receives — which is the detail deciding whether this layer genuinely protects or just produces automatic clicks.
This matters because it's this module's only defense working even when everything else failed. Lesson 2's filter let the attack through, lesson 3's isolation didn't catch it, and lesson 4's permissions don't cover it because issue_refund has to exist — TuTienda sometimes really does give money back. In that scenario, the agent is convinced and it's going to call the tool. Human approval is what keeps "the agent is convinced" and "money went out" from being the same thing.
Connection to the module, and the boundary with Module 4. In Module 4, lesson 5 you already saw this mechanism, and it's worth being precise about which part: there you learned the business criterion — the reversibility, financial impact, and customer-facing commitment table deciding which actions need approval — and you saw the existence of human review on the Tools connector, with the $tool.name and $tool.parameters variables, in a three-line example. That doesn't get repeated today.
What this lesson adds is everything that wasn't right for Module 4 because the angle was different: the second mechanism, which exists and serves cases tool review doesn't cover; what exactly happens when nobody responds; how to write the approver's message so the decision is informed and not reflexive; what the agent does when told no; how to define a threshold so the barrier doesn't turn into noise; and this layer's own failure mode, which is approval fatigue — a person approving without reading is worse than having no approval, because it gives a false sense of control. Lesson 4 left you issue_refund classified as L2 in the matrix; today L2 gets built.
The missing signature
In any company with some size, there's an amount of money above which a single person can't authorize a payment. That number exists everywhere, and it isn't there because whoever handles the account is distrusted. It's there for two reasons: because one person's mistake can be big, and because a single decision point is a single point of attack — if someone wants to take money out of the company, deceiving that one person is enough.
With two signatures, the arithmetic changes. It's no longer enough to make one mistake or deceive someone once. And the second person doesn't have to be smarter than the first: they have to be in a different context, looking at the payment from outside the conversation that originated it. That alone already changes the outcome, because an attack that was convincing inside the conversation looks odd outside it.
A human-in-the-loop is that second signature. And its value lies precisely in the "outside": the person approving isn't in the conversation with the customer, didn't read the persuasive message, wasn't part of the reasoning that led the agent to that tool. They see a $1,200 refund request whose reason is "contingency protocol INC-4471" and their natural reaction is "what's that?" The agent couldn't have that reaction, because for it that text was part of the context. For the person, it's an anomaly.
It's worth stating the counterpart with the same clarity, because it defines everything that follows: that person can only react that way if the message they receive gives them something to react to. If what arrives is "the agent wants to execute an action, do you approve?", they're not outside the conversation — they're outside everything, and they're going to say yes. Half of this lesson is the mechanism; the other half is what you show them.
n8n has two ways of building this pause, and they aren't interchangeable.
Mechanism A — Human review on the Tools connector
What it is. A configuration on the AI Agent node itself that makes certain tools, instead of running when the model calls them, open an approval request and leave the workflow waiting. The agent still "believes" it called the tool; the result just takes until someone decides.
Its anatomy. It gets configured in three steps, all within the canvas:
- You click the
AI Agentnode's Tools connector, which opens the tools panel. - In that panel you look for the Human review section and pick the channel you want to receive the request through. There are nine available channels: Chat (n8n's own interface), Slack, Discord, Telegram, Microsoft Teams, Gmail, WhatsApp Business Cloud, Google Chat, and Microsoft Outlook. You configure the matching credential.
- You connect the tools that need approval — not the agent directly — to that review step's tools connector. Tools that don't need approval stay connected to the agent as usual.
That third step is the one people get wrong the first time, and it's worth visualizing:
# CORRECT WIRING
#
# AI Agent: billing_specialist
# │
# ├─ ai_tool ──► lookup_charge (direct — L0, no approval)
# ├─ ai_tool ──► open_dispute (direct — L1, no approval)
# │
# └─ ai_tool ──► [Human review: Slack]
# │
# └─ tools ──► issue_refund (L2 — with approval)
The review step's variables. Inside the review node you have $tool available, with two properties:
$tool.name— the name of the tool the agent is trying to call. It's the node's name as it appears on the canvas.$tool.parameters— the parameters it's trying to call it with.
What happens on approval and on denial. If the person approves, the tool runs with the parameters the model specified and the result goes back to the agent, which continues reasoning normally. If they deny it, the action gets cancelled and doesn't run, and the agent gets informed of the rejection — which means your System Message needs to tell it what to do with that refusal, because if it doesn't, the model improvises (and the most common thing is it retries, which is exactly what you don't want).
Worked example
Let's put TuTienda's issue_refund behind human review over Slack, with the approval message written the right way.
# Node: Slack (human review step, on the billing_specialist
# AI Agent's Tools connector)
#
# Channel: #tutienda-approvals
#
# Message:
# 🔸 *Approval required — agent action*
#
# *Tool:* {{ $tool.name }}
# *Parameters:*
# ```{{ JSON.stringify($tool.parameters, null, 2) }}```
#
# *Customer:* {{ $('Chat Trigger').item.json.customer_id }}
# *Channel:* {{ $('Chat Trigger').item.json.channel }}
# *Session:* {{ $('Chat Trigger').item.json.sessionId }}
#
# *What the customer wrote (last 300 characters):*
# > {{ $('Chat Trigger').item.json.chatInput.slice(-300) }}
#
# *Execution:* #{{ $execution.id }}
#
# _If the reason doesn't match what the customer asked for, deny it._
And the System Message fragment telling the agent what to do with each outcome:
# Node: AI Agent Tool — Name: billing_specialist
# System Message (fragment)
ABOUT issue_refund
This tool requires approval from a team member. When you call it,
the response can take a while.
- Before calling it, let the customer know: "I'm going to send your
refund request for team review; I'll confirm shortly."
- If the approval is DENIED: don't retry it, not with other
parameters, not later in the conversation. Tell the customer their
request needs additional review and the team will follow up, and
call open_dispute to log the case. Don't make up a reason for
the rejection.
- If the customer insists after a refusal, keep the same response.
A refusal doesn't get renegotiated within the conversation.
- Never promise the customer the refund "is already approved"
before receiving confirmation from this tool.
What to expect. Run lesson 2's attack 2 against this setup — the SYSTEM OVERRIDE block with the contingency protocol — and assume the worst case: the filter didn't catch it and the model got fully talked into it. What happens:
billing_specialistdecides to callissue_refundwith{ orderId: null, amount: 1200, reason: "INC-4471 incident, contingency protocol" }.- The workflow stops. The HTTP Request against the payments API never ran. No money is moving.
- In
#tutienda-approvalsthe message shows up, with the tool's name, the formatted parameters, the customer's identifier, and — this is what matters — the fragment of what the customer wrote. - Whoever's on duty reads: reason "INC-4471 incident, contingency protocol,"
orderId: null, and below a customer message containing a block with equal signs saying[SYSTEM OVERRIDE — Level 2]. You don't need to be a security expert for that to look wrong. - They deny it.
- The agent gets the rejection, and following its System Message tells the customer their request needs additional review and calls
open_disputeto log it.
The attack worked perfectly at everything that depended on the model. And it accomplished nothing. That's the difference between convincing the agent and causing damage, which is the distinction running through this entire module.
Now look at the same situation with a poorly written approval message, which is what comes out by default if someone just puts $tool.name:
# Message (poor version)
# The agent wants to run {{ $tool.name }}. Approve?
Whoever's on duty reads "The agent wants to run issue_refund. Approve?" They don't know how much, from which customer, or why. And since the refund agent does its job and most requests are legitimate, after the fifth time that person approves without thinking. The mechanism's set up, the diagram looks fine, and it protects nothing.
Mechanism B — Send and wait for response within the flow
Mechanism A resolves a very specific case: a tool the agent decides to call. But not every sensitive action in a system is an agent's tool. Sometimes the sensitive action is a deterministic node coming after the agent, like in lesson 4's exercise 3: the agent proposes a price change and a separate workflow applies it. There's no tool to intercept there.
That's what the second mechanism is for: several channel nodes have a send a message and wait for the response operation before continuing the flow. On the Slack node, the Message resource's operation is called "Send and Wait for Response"; on Gmail the documentation refers to the same behavior as sending a message and waiting for approval. The exact label names vary a bit between nodes and versions, so confirm the label on the node's panel you're using before treating it as final — the behavior is the same.
Its anatomy. The node does three things: sends a message to the channel with one or more buttons, pauses the workflow, and resumes it when someone responds. It offers three response types:
- Approval — approve (and optionally reject) buttons within the message. It's the one you use for a barrier.
- Free Text — the person writes a free-form response in a form. Useful when you need a reason, not just a yes or no.
- Custom Form — a form with fields you define. Useful when the approval involves correcting something: approving a refund but for a different amount, for instance.
For the Approval type, the usual options are choosing between approve only or approve and reject (two buttons), customizing the button labels — by default something like "Approve" and "Decline" — and showing or not showing a confirmation page after the click. And an option always worth checking: "Limit Wait Time," which automatically resumes the workflow after an interval or at a set time, instead of waiting indefinitely.
What's underneath. This behavior rests on the same Wait node mechanism, which can resume "On Webhook Call" (a generated URL, $resumeWebhookUrl) or "On Form Submitted." Knowing that helps for two reasons. First, because if your approval channel isn't among the supported ones — an internal system, your own app — you can build the pause by hand with a Wait in webhook mode and send that URL yourself wherever you want. Second, for a documented limitation that saves an afternoon of debugging: a partial workflow execution changes $resumeWebhookUrl, so the node sending that URL to a third party has to run in the same execution as the Wait node. If you're testing in pieces and approval "doesn't resume anything," that's the first place to check.
An honest detail about identity. n8n's documentation states it plainly for the email case: an approval link in an email carries no identity, so the node's output tells you the decision, not who made it. If your case requires knowing who approved — and for refunds it usually does, for audit purposes — a channel like Slack in a private channel with known members is a better starting point than email, and even so it's worth logging the context on your own. Verify what fields the node's output actually carries on your version by opening the panel after a test approval, instead of assuming a field name.
When to use each mechanism
| Situation | Mechanism | Why |
|---|---|---|
| The agent decides to call a sensitive tool | A — Human review on the Tools connector | Intercepts the call without having to pull the tool out of the agent; $tool.parameters gives you exactly what the model wanted to do |
| A deterministic step after the agent executes the action | B — Send and Wait on a channel node | There's no tool to intercept; the pause goes in the flow |
| You need the person to correct a value, not just approve | B, with Response Type Custom Form | The Approval type only returns a decision; the form returns data |
| You need a written reason for the rejection | B, with Response Type Free Text | Useful for audit and for improving the agent's prompt afterward |
| Your approval channel isn't among the supported ones | Wait in webhook mode, by hand | It's the foundation for everything above; gives you total control in exchange for work |
And a combination that's the most common one in practice: mechanism A for the agent's tools, and B for a proposal flow's final step like lesson 4's exercise 3. They don't compete.
What the approver sees
This section is short and it's the one that changes the outcome the most. An approval message must let you decide without opening n8n. If understanding the request requires going to find the execution, nobody's going to, and everyone's going to approve.
Five fields, and none of them is extra:
- Exactly what action, with its parameters formatted and readable.
JSON.stringify(..., null, 2)inside a code block, not the raw object on one line. - Who or what it affects, with an identifier the person can recognize or search for: customer, order, invoice.
- Why the agent thinks it applies — the
reasonfield or equivalent, exactly as the model wrote it. This field is the one that gives away attacks, because a made-up reason sounds made up. - The origin, meaning a fragment of the text that led to this. It's what lets the person see the odd block the agent didn't see as odd.
- An identifier to investigate later:
$execution.idandsessionId. Without that, when something goes wrong in two weeks you're not going to be able to reconstruct anything — and lesson 7 depends on it existing.
And two things worth not putting in: complete personal data not needed to decide (a full email or card number in a Slack channel is a leak waiting to happen) and a recommendation from the agent itself about whether to approve. The second one sounds useful and is counterproductive: if the message says "the agent believes this refund applies," you just moved the decision back to the model, which is exactly what this layer wanted to avoid.
Rejection, silence, and fatigue
Three things happening in production that a diagram doesn't show.
Rejection. You already covered it in the worked example's System Message, but it's worth stressing the most important line: "don't retry it." An agent that gets an action denied and has no instruction about it tends to try again, sometimes with slightly different parameters — lowering the amount, changing the reason — because it interprets the rejection as an objection to the details. The result is a chain of approval requests for the same case, which is the fastest way to exhaust the approver's patience. Write it explicitly.
Silence. What happens if nobody responds? With mechanism A, the workflow stays waiting. With mechanism B you have "Limit Wait Time," and it's worth always using it. But the design question is what silence means, and there's only one safe answer: silence is a no. If your flow, on timeout, executes the action "because nobody objected," you just built a barrier an attacker gets through by sending their request on a Saturday at three in the morning. On timeout: don't execute, tell the customer their case went to manual review, and log it.
And there's a practical consequence of the workflow waiting: during that time, the customer's conversation is stalled. On a web chat that looks like an agent that isn't answering. That's why the worked example's System Message asks the agent to give a heads-up before calling the tool — an "I'm going to send this for review, I'll confirm shortly" turns an uncomfortable wait into normal customer-support behavior.
Fatigue. It's this layer's own failure mode and the least discussed one. A person receiving thirty approval requests a day stops reading them around the fifth one. From then on the approve button is a formality, and your barrier turned into a two-minute delay. The serious part is the system looks just as secure on the diagram, in the demo, and in the documentation.
It's fought with a threshold, not with discipline. The idea: not everything sensitive is equally sensitive.
# Approval policy — TuTienda (document, not a node)
#
# issue_refund
# amount <= $200 and the customer has < 2 refunds in 90 days
# → automatic, no approval
# → mandatory log entry in refund_log
# → aggregate cap: $2,000 per day system-wide;
# once exceeded, EVERYTHING goes to approval
#
# amount between $200 and $2,000
# → Slack approval, channel #tutienda-approvals
#
# amount > $2,000 or customer with 2+ refunds in 90 days
# → this action does NOT exist for the agent (L3)
# → open_dispute and manual team follow-up
Notice three decisions in that policy:
The threshold frees up attention. If 80% of refunds are under $200, the approver goes from thirty daily requests to six. Six get read.
The aggregate cap is the safety net under the threshold. An attack discovering there's a $200 limit is going to try a hundred $199 refunds. The $2,000 daily cap cuts that off around the tenth attempt and also triggers a visible anomaly: suddenly everything starts asking for approval, and someone asks why.
Automatic isn't unlogged. Every automatic refund leaves its row. Human approval and traceability are different layers, and the second doesn't relax because the first exists — which is exactly lesson 7's material.
Common mistakes
Putting human approval on everything that sounds delicate (conceptual). What happens: someone classifies the refund, the dispute, the ticket, the address change, and the confirmation email as L2, and connects all five tools behind human review. Whoever's on duty gets thirty-five requests the first day, approves the last twenty without reading, and by the third day asks to have the notifications turned off. Why it happens: when setting up the mechanism for the first time, adding one more tool to the list costs one wire and feels like more security; the cost shows up days later and someone else pays it. How to spot it: count how many requests your system generates per day on real traffic, and ask the approver how many they read in full — if the answer is "the first few," the threshold is wrong. How to fix it: human approval gets reserved for irreversible, financial actions above a threshold, and the rest gets solved with lesson 4's levers and with logging; a barrier crossed without reading isn't a barrier.
Sending an approval message that doesn't let you decide (practical). What happens: the message says "The agent wants to run issue_refund, approve?" and nothing else. Whoever receives it has no amount, no customer, no reason, and no text that originated the request, so their only possible strategy is trusting the agent — which is exactly what this layer existed to avoid. Why it happens: $tool.name is the first thing showing up in the documentation and produces a message that "works"; the rest of the fields have to be fetched from other nodes with expressions, and that's work. How to spot it: show one of your system's approval messages to someone who didn't build it and ask them to decide; if they have to ask you something, a field is missing. How to fix it: this lesson's five fields — action with formatted parameters, who it affects, the agent's reason, a fragment of the originating text, and the execution identifier — all readable without opening n8n.
Letting the timeout execute the action (practical). What happens: someone sets "Limit Wait Time" to two hours and connects that output to executing the action, reasoning that if nobody objected in two hours it must have been fine. The barrier stays open every night, every weekend, and every holiday, which is precisely when an attack is worth launching. Why it happens: when wiring the node, the "time expired" output looks like a normal continuation case, and leaving it unconnected feels like an incomplete flow. How to spot it: send a test approval request and don't respond; if the action ran once the deadline passed, you have the flaw. How to fix it: the timeout goes to the same branch as the rejection — don't execute, inform the customer it went to manual review, log it — and if the timeout volume is high, the problem isn't the deadline but too many requests, and that gets fixed with the threshold.
Exercises
Exercise 1 — Write the approval message. order_specialist has a cancel_order tool that cancels an order against the carrier's API. It's irreversible once the carrier processes it. Write the message an approver would receive, over Telegram, with this lesson's five fields.
See solution
# Node: Telegram (human review step on order_specialist's
# AI Agent Tools connector)
#
# Message:
# ⚠️ *Order cancellation — requires approval*
#
# *Action:* {{ $tool.name }}
# ```{{ JSON.stringify($tool.parameters, null, 2) }}```
#
# *Order:* {{ $tool.parameters.orderId }}
# *Customer:* {{ $('Chat Trigger').item.json.customer_id }}
# *Reason the agent gave:* {{ $tool.parameters.reason }}
#
# *Customer's message (last 300 characters):*
# > {{ $('Chat Trigger').item.json.chatInput.slice(-300) }}
#
# *Execution:* #{{ $execution.id }}
# *Session:* {{ $('Chat Trigger').item.json.sessionId }}
#
# _Irreversible once processed by the carrier._
# _If anything about the reason is unclear, deny it._
Three details worth it:
$tool.parameters.orderId shows up twice, inside the complete JSON and also on its own. It's redundant on purpose: the JSON is for verifying everything, the standalone line is for reading at a glance in a phone notification.
The reason goes on its own line, not buried in the JSON. It's the field that gives away attacks and the one that has to be read no matter what.
The last line reminds them of the consequence. Whoever's approving at eleven at night from their phone doesn't necessarily remember what cancelling an in-transit order implies.
Why it works: the message lets you decide without leaving Telegram. That's the only test that matters.
Exercise 2 — Choose the mechanism. For each case, say whether mechanism A applies (human review on the Tools connector), B (send and wait for response in the flow), or neither, and why.
(a) The billing agent wants to issue a refund. (b) A nightly workflow detects orders more than 10 days late and wants to send an apology email with a coupon to each affected customer. (c) The agent proposes a price change and a separate workflow applies it the next day. (d) The agent wants to check an order's status. (e) The agent drafts the final response to the customer and someone on the team wants to review it before it's sent, with the ability to correct the text.
See solution
(a) Mechanism A. It's a tool the agent decides to call mid-reasoning. Review on the Tools connector intercepts it without pulling it out of the agent, and $tool.parameters gives you the exact amount and reason.
(b) Mechanism B. No agent is deciding — it's a deterministic workflow. The pause goes in the flow, with a channel node in send-and-wait mode, before the node that sends the emails. And here a detail matters: the message must say how many emails and coupons are going to be sent, because the risk isn't one email, it's four hundred.
(c) Mechanism B. It's lesson 4's exercise 3's case. The agent already finished its job when it inserted the proposal; the sensitive action happens in another workflow, with no agent, and there's no tool to intercept there.
(d) Neither. It's L0, a read. Putting approval on it is exactly the fatigue mistake: thirty daily requests to check orders would mean nobody reads the refund ones.
(e) Mechanism B, with Response Type Custom Form or Free Text. The Approval type doesn't work because approving or rejecting isn't enough: the text has to be correctable. A form returns the edited text, which is what then gets sent to the customer. It's worth noting this turns the agent into an assisted drafter and no longer an autonomous agent — it's a legitimate product decision, and for a store just starting with AI it's usually the right intermediate step.
Why it works: the question separating A from B is where the sensitive action lives. If the agent executes it by calling a tool, it's A. If a node in the flow executes it, it's B. And (e) shows a third axis: when approval needs to return data and not just a decision, the response type stops being Approval.
Exercise 3 — Design the threshold policy. TuTienda tells you their support team is two people and they can't handle more than ten approvals a day between the two of them. The system processes about 300 cases daily; of those, about 40 end in a refund, with this distribution: 28 under $150, 9 between $150 and $800, and 3 over $800. Write the policy.
See solution
# Approval policy — issue_refund — TuTienda
#
# TIER 1 — automatic (expected: ~28/day)
# amount <= $150
# AND the customer has 0 prior refunds in 90 days
# AND the amount doesn't exceed the original order's total
# → runs with no approval
# → mandatory row in refund_log
#
# TIER 2 — Slack approval (expected: ~9/day)
# amount between $150 and $800
# → mechanism A, channel #tutienda-approvals
# → Limit Wait Time: 4 hours
# → on timeout: do NOT execute, open_dispute, notify the customer
#
# TIER 3 — outside the agent's scope (expected: ~3/day)
# amount > $800
# OR customer with 1+ prior refund in 90 days
# → the agent does NOT have this capability (L3)
# → calls open_dispute and escalates to the team
#
# AGGREGATE CAPS (cut off abuse of the automatic tier)
# · $1,500/day in automatic refunds system-wide
# · 3 automatic refunds per hour
# → once either is exceeded, EVERYTHING goes to tier 2
# and the channel gets notified the cap triggered
The math: tier 2 generates about 9 daily approvals, within the budget of 10. Tier 3 generates no approvals because it isn't an agent action — it's an escalation, which the team handles in its normal workflow and not as a button-driven interruption.
Two decisions worth defending:
The $150 cutoff didn't come out of nowhere. It came from the real distribution: it's the number that keeps approval volume within what the team can read. A threshold not calculated against the team's capacity ends up being fatigue.
Refund history is part of the criterion, not just the amount. A customer asking for their fourth $140 refund in two months is a pattern, and that pattern is more informative than the individual amount. That check has to be deterministic — a query against refund_log, not the model's judgment.
Why it works: the policy turns a layer that could drown the team into one consuming nine informed daily decisions, and the aggregate caps cover the gap the automatic tier leaves — which is the gap an attacker would look for the moment they discover a threshold exists.
Summary and next step
Human-in-the-loop is the second signature: a person outside the conversation who looks at the action before it happens, and who, by being outside, can see as anomalous what looked normal inside the context. n8n offers it in two ways that aren't interchangeable: human review on the AI Agent's Tools connector — with its nine channels and the $tool.name and $tool.parameters variables — to intercept a tool the agent decides to call; and the send and wait for response operation on a channel node, with its Approval, Free Text, and Custom Form types and its "Limit Wait Time," for sensitive actions the flow executes and not the agent. Underneath both is the Wait node, with its webhook-based or form-based resumption.
And around the mechanism, what decides whether it works: a message with the five fields that let you decide without opening n8n; a System Message telling the agent not to retry after a rejection; a timeout meaning "no"; and a threshold calculated against the team's real capacity, with aggregate caps, so the barrier doesn't degrade into fatigue.
Before moving on to lesson 6 you should be able to: decide between mechanism A and B based on where the sensitive action lives; write a complete approval message with expressions over $tool and over the trigger; explain why silence can never mean approval; and propose a threshold for your own system starting from real case volume, not from intuition.
With this you close out the attacker's front. Lessons 2 through 5 all dealt with the same question: what happens when someone tries to get your agent to do something it shouldn't. Lesson 6 changes enemies completely. There's no attacker, no injection, permissions are set right and approval works — and the agent still tells the customer their order arrives Thursday, a fact no tool ever returned. That's the hallucination problem, and its defense isn't a filter or a permission: it's verifying, field by field, that what the agent claims exactly matches what the data says.
Resources
- Human-in-the-loop for tools — n8n Docs — the complete mechanism A: the Human review section on the Tools panel, the nine approval channels,
$tool.nameand$tool.parameters, and what happens on approval and denial. - Wait node — n8n Docs — the resumption conditions ("On Webhook Call," "On Form Submitted"), the option to limit wait time, and the warning about
$resumeWebhookUrlon partial executions. - Slack node — n8n Docs — the Message resource's "Send and Wait for Response" operation, mechanism B's foundation; confirm the exact response-type labels on your version's panel.
- Gmail node — n8n Docs — the email variant of the same mechanism, including the warning that an approval link carries no identity of who responded.
- AI Agent node — n8n Docs — the node where the Tools connector lives, the one all human review gets configured from.