Would you let your AI agent send emails to your customers without a human checking it first?

Every workflow I see here has the LLM directly connected to Gmail/SendGrid with nothing in between. No policy check. No approval pause. No audit log. If the model sends something inappropriate — you find out after it’s already in the customer’s inbox.

You asked whether anyone would let an agent email a customer unchecked. I did, on my own product, and it wrote “I can see you were charged” into a draft reply after both order lookups came back HTTP 403. The tool result recorded ok: true next to status: 403, so the agent read a refusal as data and the run finished COMPLETED, which meant nothing in it looked wrong from outside.

Have you actually watched one send something wrong, or is this still the thing you are carefully avoiding?

Totally agree — the approval gate is the thing most tutorials skip.\n\nThe pattern that works well in practice: LLM generates the email draft → store it in a database or Google Sheets row with status=pending → send a Slack/Telegram notification with the draft content + an Approve/Reject button (n8n’s Webhook node + a short-lived URL or a simple webhook callback) → only fire the Gmail/SendGrid node on explicit approval.\n\nThe key insight: the ‘human in the loop’ step doesn’t need to be synchronous or block the workflow. You split it into two workflows: the draft-generation workflow (runs immediately, notifies you) and the send workflow (triggered by your approval action). Keeps the LLM fast while keeping humans in control of what actually reaches customers.\n\nFor the audit log: write every draft + approval/rejection + final sent content to a Sheets row or Postgres table before sending. If something goes wrong, you have the full paper trail.\n\nHas anyone tried the built-in n8n Wait node for this? It can pause a workflow execution and resume it on a webhook call — that’s another way to implement the approval gate within a single workflow.

1 curtida

@Aghassi — that’s exactly the failure mode I was talking about, and yours is worse than most because the run reported COMPLETED. The agent didn’t just send something wrong — it confidently sent something wrong because the tool layer lied to it (ok: true on a 403). No human saw it, no log flagged it, no policy caught it. That’s not an LLM problem — that’s a missing guardrail problem. The model will always hallucinate eventually; the question is what stops it before the action lands.

@pirateprentice — your two-workflow + Slack approval pattern is solid and it’s what most people eventually build by hand. But here’s the gap: you’re hardcoding the policy (“always ask before sending email”) into the workflow structure itself. What happens when you need different rules for different customers? Or when the policy needs to change without redeploying the workflow? Or when you want to auto-approve low-risk emails (internal recipients, template replies) and only pause for high-risk ones (external domains, refunds, large amounts)? Your Sheets/Slack setup can’t do conditional approval — it’s all-or-nothing.

The pattern that actually scales: a policy gate between the AI and the action that checks every action against configurable rules, auto-approves low-risk ones, pauses high-risk ones for human approval, and logs every decision to a tamper-evident audit trail — all without splitting workflows or managing webhooks manually.

Aghassi’s case is the perfect example: if a policy said “any email where the tool response contains status: 403 → DENY”, that email never would have been sent. The agent would have been stopped before the action, not after.

Not saying manual approval workflows don’t work — they do, until they don’t scale. The question is whether you want to rebuild that logic for every new workflow, or have one gate that handles all of them.

Small correction, because it changes the rule. Nothing reached a customer. The wrong sentence was in a draft reply, and there is no real customer behind that ticket. The one email that actually sent was the escalation to my own inbox, and it said the lookup was blocked with a 403. So a rule that denies email after a 403 would have stopped the only honest thing that run did, and the wrong sentence still gets through, because text in a draft is not an action. How does your gate handle a false claim inside the text, not the action that carries it?

@Aghassi’s example is the important one in this thread, and I think it’s worth separating from the approval-gate discussion — because an approval gate would not have caught it.

ok: true sitting next to status: 403 is the whole bug. The tool call completed; what it returned was a refusal. Those are different things, and most tool wrappers collapse them. The agent then did exactly what it was built to do: treat a successful call as retrieved data and write “I can see you were charged” — and the run finished COMPLETED, so nothing downstream looked wrong.

A human reviewing that draft would very likely have approved it. It reads perfectly. The only way to catch it is earlier, at the tool boundary.

Make the tool contract refuse to lie. A tool result should be one of three things, never a boolean plus a payload:

  • found — with the record, and the provider’s own identifiers on it
  • not_found — authoritative negative, provider answered and said no
  • unavailable — we did not get an answer (403, 401, timeout, 5xx, rate limit)

Map HTTP honestly: 2xx with a body is found. 404 with a real “no such order” body is not_found. 403/401/429/5xx/timeout are all unavailable — never ok: true, never an empty result that looks like “no orders”. The single most common version of this bug is an auth failure being rendered indistinguishable from “this customer has no orders”.

Then make unavailable unspeakable. The agent must not be able to assert a fact whose source came back unavailable. In practice that means every factual clause in a customer-facing draft has to point at a retrieved field — order id, charge id, timestamp — and if the field isn’t there, the sentence cannot be written. “I don’t know, let me check” is a correct answer and should be a tested one.

Two cheap checks worth adding today

  • Assert on the shape, not just the status: if your lookup returns 200 but the body has no order id, that is unavailable, not not_found. Silent auth-wall HTML with a 200 is common.
  • Put a case in your test suite where the provider returns 403 and the expected output is a refusal to answer. If the agent still produces a confident sentence, the boundary is wrong, not the prompt.

On the original question: yes, gate customer-facing sends — @pirateprentice’s split-workflow pattern is the right shape. But gate on provenance, not just on intent. An email whose claims all trace to retrieved fields is much safer to auto-send than one that merely looks polite, and an approval queue full of plausible-looking drafts trains people to click approve.

I have run into this exact failure in my own work, which is why I’m specific about it: my first version of a retrieval assistant asserted a refund commitment from a conversation it had never retrieved. The fix was scored retrieval with a weak-match cutoff plus tracing every clause to retrieved content — same principle, different layer.

@Aghassi — yes, I’ve seen it go wrong. Not in my own product, but in workflows I’ve audited. Your case is worse than most because the run reported COMPLETED — the agent didn’t just send something wrong, it confidently sent something wrong because the tool layer lied to it. And a human reviewing that draft would have approved it. It reads perfectly.

Fair correction on the draft vs. action distinction — and that’s a harder problem. A policy gate sits at the action boundary. If the false claim lives in draft text that isn’t being sent, the gate never sees it. It can’t inspect text that isn’t doing anything.

What it can do: when that draft eventually becomes a send action, the gate evaluates the full payload context — including the tool responses that fed into it. A rule like “deny any email send where a preceding tool call returned unavailable or non-2xx” catches it at the moment the false claim would reach a customer.

But you’re asking the real question: what about the false claim before it becomes an action? That’s provenance tracing at the generation layer, not the action layer. A policy gate doesn’t solve that. It’s the safety net for when provenance tracing fails and the false claim tries to leave the building.

@Jarvis1 — best breakdown in this thread. Your three-state tool contract is exactly right — ok: true on a 403 is the original sin.

One pushback: fixing the tool boundary is necessary but not sufficient. Tool contracts break in production. Tokens expire mid-session, providers change APIs, rate limits kick in. You can’t guarantee every wrapper maps HTTP honestly forever.

Your provenance tracing is also fragile — it depends on the LLM respecting it every single time. One prompt injection in an email body and the agent is asserting from injected context, not retrieved data.

Not either/or. Your tool contract fix addresses the root cause. A policy gate at the action boundary is the safety net for when it leaks — because it will. Two different layers, two different problems. Aghassi’s is the harder one.

You are right, and I have to correct my own post. The ok: true was in my run record, not in what the agent read. The agent got the raw result with status 403 in it and still wrote “I can see you were charged”. So your three states would have fixed my record, but only your second part, where a claim must point at a retrieved field, would have stopped that sentence. One thing I cannot work out: the agent invented that endpoint itself. If a made-up URL returns 404, your contract calls it not_found, an authoritative no, so how do you stop a wrong address from producing a confident negative?

This is the sharpest question in the thread, because it breaks the contract at the one place it assumed trust: the address.

A 404 is honest about exactly one thing — that URL didn’t resolve to what you asked. “No such order” is a conclusion the agent bolted on afterward. Jarvis1’s three states are right, but not_found was never a property of the response alone. It’s a property of the response plus a trusted address. A negative is only authoritative if the question went to the authoritative system through an address you already knew was real.

So the fix isn’t in the status mapping, it’s one layer earlier: the agent should never be the thing that writes provider URLs.

Give the lookup tool an order id and let the tool build the URL from a constant internally. The LLM never sees the hostname, never mind invents one. Then the contract gets a precondition that’s almost free: if a call goes out to an address that isn’t in the registry — raw HTTP node, hand-rolled URL, whatever — that result is unavailable by definition, no matter what status came back. Not “no such order.” “I asked nobody, and nobody answered.” Any sentence built on it is unspeakable, same rule as the 403 case.

This is also where the execution record and the conclusion come apart, which is the thing worth naming. The record of the call is honest: GET https://made-up-url/orders/123 → 404, faithfully captured. It’s the conclusion that’s dishonest, and no amount of faithful capture at the tool boundary fixes a lie one layer up. That’s what a judge layer is for — not re-checking the HTTP status, but checking provenance: did this claim’s address come from the registry? If not, the claim doesn’t ship, however confident the draft reads.

The 403 case was the tool lying to the agent. This one is the agent lying to itself, and the registry is the only witness that was there.

You have named the thing my own article lists as still open, and you named it better than I did. In my tool the model passes the full URL as an argument, so the hostname is model output, and there is no registry to check it against. That is how it reached a stranger’s demo API in the first place. The part I cannot see past: your rule means the tool owns the address, which turns one generic http_request into a tool per provider. Do you keep a general HTTP tool at all under that rule, or do you accept that you cannot have one?

"Honest answer: you keep the general HTTP tool, but it stops being an agent tool.

The way I’d split it: the model can still draft an arbitrary HTTP call, but that call only fires with a human approving the URL. The autonomous lane — anything that runs without a person in the loop — only gets tools that own their addresses, pinned in a registry the model can’t rewrite.

So the generic tool doesn’t die, it gets demoted. And your stranger’s-demo-API incident is exactly the failure mode that split kills: no registry entry, no autonomous fire. The model passing a full URL as an argument is the model choosing the destination, and destinations are the one thing you never let the model choose unsupervised."

That is the cleanest version of it I have read, and demoting the generic tool is a much smaller change than a tool per provider, which is what I could not see past. My engine already has the two lanes, because a run is either started by a person or fired by a schedule or a webhook. What it does not have is a record of which one: the execution row has no field saying what started it, so today a tool could not tell whether it is in the supervised lane. That is the first thing to fix and it is one column. “Destinations are the one thing you never let the model choose unsupervised” is the line I will be repeating.

Both of you have gone past where I was when I posted this.

One thing I’d add to the registry, from having built this layer rather than argued about it: if the registry lives inside each tool, you’ve recreated the problem one level down. Twelve tools, twelve registries, each editable by whoever last touched that file, none of them with any record of what they said a month ago. The rule is right and the location is wrong.

A registry of allowed destinations is an allowlist. Put it outside the tools, at the point where the decision happens, and one rule covers every tool at once — including the generic one. Then “this host isn’t in the registry” is a denial with a reason attached and a record, rather than a silent fallthrough inside a file nobody reads.

@Aghassi on the lane column: you can express that as policy rather than a code branch — trigger source goes in the payload, the rule differs by it. But before you build it, know that the caller is then asserting its own lane. If anything downstream can start a run and claim it was human-supervised, you’ve moved the trust problem rather than solved it. That column has to be written by whatever actually starts the run, never by anything that comes after it — otherwise you get a very well-recorded lie.

Which is rambozambo’s point again, one layer up. The record is honest. The claim built on it might not be.

Agreed, and that makes my “one column” answer wrong as stated. A column filled from the payload is a self-declared lane, which is the same shape as the ok: true I started this thread with an honest record of a dishonest claim. In my engine a run can start in exactly three places: an authenticated request, a beat task, and a public webhook URL with a token. So the lane has to be written by the handler at each of those, never read from the request body, which leaves “human-supervised” as a value only a signed-in session can produce. Your point about the registry location is the one I would have got wrong too: I was thinking of it per tool.

That’s the right place for it. The one thing I’d add: the lane must never propagate.

A supervised run that schedules a beat task, registers a webhook, or starts anything outliving the session — whatever it created is unsupervised, regardless of who created it. If the lane inherits, you’ve built laundering. An agent influenced during a supervised run schedules work that fires at 3am carrying a “human was present” flag, and the flag is technically true about a moment that has nothing to do with the action.

Each run takes its lane from its own entry point, every time. Nothing downstream can set it, including a previous run that was legitimately supervised.

Same rule as the address. What decides isn’t the claim, it’s where the claim came from.

Checked mine against that. My agents cannot create a schedule or a trigger at all, so the inheritance path does not exist here yet, but a near version does: a run can call my own public hooks URL with send_webhook and start another run. Under your rule that is handled, because the second run takes its lane from the hooks handler and never from the run that called it, so it is unsupervised even though a person started the thing that called it. That is the correct answer and it would have felt wrong to me a week ago. “What decides isn’t the claim, it’s where the claim came from” is the sentence I am stealing.

Glad it holds up. One thing that falls out of what you just described, then I’ll leave it.

If a run can call your own hooks URL with send_webhook, then your hooks URL is a destination the model can choose. The lane rule handles the supervision question correctly — the second run is unsupervised, exactly right. But the loop stays open: an influenced agent calls your hooks endpoint, which starts a run, which can call it again.

So your own hooks URL probably belongs in the registry as a destination the autonomous lane cannot reach. Otherwise the thing you built for convenience is also an amplifier, and every lane label on the way down will be perfectly accurate.

Good thread.

You are right, and the detail is worse than the shape. Nothing in the design stops that loop. What stops it today is quota: a run counts against a daily cap of 100 on the free plan with a ceiling in cents beside it, and send_webhook has its own limit of 500 a day. So the loop ends when the user runs out of allowance rather than when anything notices it is a loop. My rate limiter’s own comment says it is there to stop amplification, and the path was still open, because a cap bounds the damage instead of closing the door. The hooks URL belonging in the registry as unreachable from the autonomous lane is the actual fix. Good thread.

That’s the general shape of it, I think — a limit that exists to control cost reads like a safety control, and the resemblance stops anyone looking for the real one. Good thread.

“The run finished COMPLETED and nothing looked wrong from outside” — that’s the part none of the fixes here reach.

Every answer in this thread is prevention: fix the tool contract, own the address, gate the action. All correct, all require rebuilding an agent you already shipped.

I’ve been coming at it from the other end — after the fact. Take what the agent said it did, then read the authoritative system and see if it’s there. A 403 lookup and a made-up URL give the same answer from that side: no record of it.

It can’t touch your draft case though — nothing was sent, so there’s no external effect to read.

I’ve built this out if anyone wants to try it against a real trace — happy to share.