I built an AI customer support desk in n8n (37 nodes). It reads tickets from an inbox or a form, classifies them with Gemini, looks up the order in Shopify, drafts a reply from the business's own knowledge base, and logs everything.
The hard part was not the AI. It was deciding when the AI is allowed to hit Send.
An LLM will happily write "your refund has been approved" to an angry customer. So between the AI draft and the Send Email node there is one Code node, the Policy Guard. It collects reasons to hand the ticket to a human. If the list is empty, the reply is safe to send. If not, it escalates with the draft attached.
The guard
const reasons = []; if (!reply) reasons.push('no AI draft'); if (!s.autoSendIntents.includes(t.intent)) reasons.push('"' + t.intent + '" tickets always go to a person'); if (t.needs_human) reasons.push('classifier asked for a person'); if (reply && conf < s.minConfidenceToSend) reasons.push('draft confidence ' + conf + ' is below ' + s.minConfidenceToSend); if (t.sentiment === 'angry') reasons.push('customer is angry'); if (t.urgency === 'urgent') reasons.push('urgent ticket'); if (/\[[a-z ]+\]|\{\{|lorem/i.test(reply)) reasons.push('draft contains a placeholder'); if (/refund|reimburs|money back|back (on|to) your (card|account)|credit (it|your|the)|store credit|replacement|free of charge|compensat|discount|voucher|coupon|waive|guarantee/i.test(reply)) reasons.push('draft mentions money, refunds or compensation'); if (!Array.isArray(d.promises)) reasons.push('AI did not report its promises (fail closed)'); else if (d.promises.length) reasons.push('draft promises: ' + d.promises.join('; ')); if (t.contacted_last_7_days >= 2) reasons.push('repeat contact this week'); const decision = reasons.length ? 'escalate' : 'auto_send'; Six lessons
- Allow-list intents, never block-list them. Only order status, shipping and product questions can ever auto-send. Anything new defaults to a human.
- Ask the model to list its own promises. The draft prompt returns a
promisesarray next to the reply. If it is not empty, escalate. - Keep a regex net anyway. Models sometimes promise without admitting it.
- Repeat contacts are a signal. The third message this week deserves a person, even if the question looks easy.
- Ship in draft mode.
autoSendEnabledis off by default, so every "safe" reply goes to the team first until you trust the drafts. - Tell the human why. The escalation email starts with "Why you are seeing this: urgent ticket; customer is angry", plus the draft and a one-click review link.
What I got wrong the first time
The first version of this guard had two holes, and a reader on the n8n forum found them within a day:
- The money check was a phrase block-list. It matched "refund has been approved" but let "we'll put the money back on your card" straight through. Matching the concept (refund, money back, credit, replacement, discount, voucher, waive...) is much safer than matching sentences.
- The
promisescheck failed open. If the model returned a string or null, or dropped the field,Array.isArraywas false and nothing was pushed. Now anything that is not an array escalates. For anything touching money, fail closed.
Both fixes make the guard more conservative: more tickets go to a person. For refunds and compensation that is the right trade-off.
Test it
Before trusting any guard like this, run adversarial cases through it: a paraphrased refund promise, a null promises field, a string instead of an array, an angry repeat customer. All of them should escalate, and a plain "your order shipped, tracking attached" reply should still auto-send.
I packaged the full workflow (inbox and form triggers, Shopify order lookup, SLA alerts, daily report, plus the guard) as a ready-to-import n8n template: https://nasarhayatt.gumroad.com/l/n8n-ai-customer-support-autopilot
Questions about the guard or the rest of the flow are welcome in the comments.
