DEV Community

Crzy undefined (undefined)
Crzy undefined (undefined)

Posted on

My n8n Review-Reply Bot Opened 48 of 48 Replies with "Thank You For". Here's the Fix

Every small business owner I know has the same problem with Google reviews. Replying matters, it takes time, and the replies written by AI tools all sound the same: "Thank you for your feedback! We value your input and strive to provide the best experience."

Customers can spot that from a mile away. So I took the review-reply workflow I already had in n8n and tested how often it produces that kind of reply, then tried to fix it.

Short version: rewriting the prompt helped less than I expected. What actually worked was taking the "is this reply OK to post?" decision away from the model and giving it to a Code node.

The setup

  • n8n 1.123.84, self-hosted, workflows triggered through production webhooks.
  • The model: a small 1.8B Qwen model running locally in Ollama, through its OpenAI-compatible /v1/chat/completions endpoint. That's deliberately weak. Small models show the failure patterns faster, and a lot of people self-host n8n exactly so they can use a free local model. A bigger model like gpt-4o-mini will do better, but I didn't test one here, so I'm not going to guess numbers for it.
  • The business: a made-up South Indian breakfast cafe in Bangalore, "Udupi Corner", with the owner signing as Ramesh.
  • The reviews: 8 of them, picked to cover what real review feeds look like:
# Stars Review
r1 5 Best filter coffee in Jayanagar, counter guy remembered my order, benne dosa crisp
r2 4 Good food, but waited 25 minutes on Sunday; kesari bath worth it
r3 1 Found a hair in my idli, waiter shrugged, wants a refund
r4 2 Swiggy order, soggy vada, chutney leaked in the bag
r5 3 "Ok place. Nothing special."
r6 1 Claims food poisoning, says they're reporting to FSSAI
r7 5 "Great"
r8 4 Loved the ghee roast masala dosa; parking is a nightmare, park near the temple

Each review went through each workflow twice per run, over 3 runs, so 48 drafts per workflow. I scored every draft with the same script (it's the same check you'll see below).

Version 1: the obvious workflow

The workflow is the one in the free repo: Webhook → Config → Build Prompt → HTTP Request to /chat/completions in JSON mode → parse → Respond to Webhook. The system prompt is reasonable:

You manage online reputation for a small business. Classify the review and draft a warm, specific, brand-safe reply (<=80 words). Never admit legal liability or offer compensation unless policy says so.

It returns sentiment, topics, reply, and a needs_owner_attention flag the model sets itself.

Here's what came back across the 48 drafts:

Check Version 1
Opened with "Thank you for…" 48 / 48
Used a stock phrase ("we strive", "we value your feedback", "your satisfaction"…) 31 / 48
Didn't mention anything the reviewer actually wrote 20 / 48
Mentioned a refund on the hair-in-idli review 1 / 48
Average length (last run) 41 words

A typical reply to the 2-star soggy vada review:

Thank you for your feedback. We appreciate your input and will take steps to improve the quality of our products and services. Please let us know if you have any other suggestions or concerns.

Nothing in it is wrong. It just doesn't say "vada", "Swiggy" or "chutney", so it could be a reply to any review on the internet. That's what "sounds like a bot" really means.

To be fair to version 1: the model's own needs_owner_attention flag did catch the hair, soggy vada and food poisoning reviews in the last run. But that flag is a guess by the same model that wrote the reply, and nothing in the workflow stops you from posting a reply it didn't flag.

Version 2, attempt 1: a better prompt

My first instinct was the same as everyone's: write a stricter prompt. So I told it to write as the owner, mention one specific thing from the review, stay under 60 words, never start with "Thank you for", and I gave it the exact list of banned phrases. I also passed in the star rating, a sign-off and a contact method, and raised temperature to 0.7 so it would vary its wording.

'1. Mention one specific thing the reviewer wrote (a dish, a person, the wait, the parking).',
'2. Two to four sentences, under 60 words. Do not start with "Thank you for".',
`3. Never use these phrases: ${banned.join('; ')}.`,
`4. Never offer refunds, discounts, vouchers or free items, and never admit fault. For complaints, say you want to look into it and ask them to ${contact}.`,
`5. End with the sign-off: ${signoff}`,
Enter fullscreen mode Exit fullscreen mode

Results for the same 48 drafts:

Check Version 1 Version 2 prompt
Opened with "Thank you for…" 48 / 48 29 / 48
Used a banned stock phrase 31 / 48 26 / 48
Not specific to the review 20 / 48 14 / 48
Mentioned a refund or a "special offer" 1 / 48 3 / 48
Left a placeholder or made up a phone number 0 / 48 6 / 48
Average length (last run) 41 words 33 words

Better, but not good. A few of those deserve a closer look, because they're the ones that would actually hurt a business:

  • It used the banned phrases anyway. 26 drafts still had at least one phrase that was listed word for word in the prompt as "never use". Small models are bad at negative instructions. Honestly, bigger ones aren't perfect at it either.
  • The refund rule didn't hold. The hair-in-idli reviewer asked for a refund. The prompt said "never offer refunds" and the policy field said "No refunds via review replies." One draft still said "Please contact us at [WhatsApp number] for a refund."
  • It invented contact details. Asking it to point people to WhatsApp made it write [insert number], [our profile number], and once a phone number, 9876543210, that nobody gave it. If that gets posted, angry customers call a stranger.
  • It offered a "special offer" on a 5-star review. No one asked for that.

So the prompt moved the averages, but you still couldn't safely post anything without reading it first. And if you have to read everything anyway, you want the workflow to tell you which ones need real attention.

Version 2, the fix that worked: let code decide

The change that mattered was adding a Check Reply Code node right after the LLM call. The model writes the draft. The Code node decides whether it's safe to post, using rules that don't depend on the model behaving:

  1. Stock phrases: the same banned list, checked in code instead of hoped for in the prompt.
  2. Money or fault: refund, voucher, discount, "for free", "offer", "our fault", "liable" and so on. (My first version flagged "feel free" as a freebie and "offerings" as an offer. Test your word lists against real output.)
  3. Placeholders and made-up numbers: anything in [brackets] or any run of 7+ digits.
  4. Specific to this review: the reply has to reuse at least one real word from the review. That's skipped for one-word reviews like "Great", where there's nothing to reuse.
  5. Length: fewer than 12 words is too thin, more than 70 is too long.
  6. Opening: no "Thank you for…".
  7. Review risk: 1–2 star reviews, and anything mentioning poisoning, sickness, hair, insects, FSSAI, lawyers, police or allergies, always go to a human, no matter how good the draft is.
  8. The sign-off is added in code, because the model forgot it or mangled it (one draft signed off as "Ramesh, Jayanagar"; another thanked "Mr. Swiggy").

Here's the whole node. It's plain JavaScript, so you can paste it into any n8n Code node after your LLM call:

// Deterministic checks. The LLM drafts; this node decides whether a human must look first.
const prev = $('Build Prompt').first().json;
const raw = ($json.choices?.[0]?.message?.content || '').trim().replace(/^```
{% endraw %}
(?:json)?\s*/i, '').replace(/\s*
{% raw %}
```$/, '');
let out; try { out = JSON.parse(raw); } catch (e) { out = { reply: '', parse_error: raw.slice(0, 200) }; }
const reply = String(out.reply || '');
const r = reply.toLowerCase();
const banned = ["thank you for your feedback", "we value your feedback", "valued customer", "we apologize for any inconvenience", "sorry for any inconvenience", "we strive", "we are committed", "your satisfaction", "we appreciate you taking the time", "rest assured", "we hope to see you again", "delighted", "thank you for choosing", "we take your feedback seriously", "feedback is important"];
const risky = ['refund', 'compensat', 'for free', 'free meal', 'free coffee', 'free dessert', 'free visit', 'voucher', 'discount', '% off', 'on the house', 'our fault', 'responsibility', 'liable', 'negligen'];
const alarm = /(poison|sick|vomit|hospital|hair|cockroach|insect|fssai|lawyer|legal|police|consumer court|allerg)/i;
const reasons = [];
if (!reply) reasons.push('no usable reply');
const botHits = banned.filter(p => r.includes(p));
if (botHits.length) reasons.push('sounds like a bot: ' + botHits.join(', '));
const riskHits = risky.filter(p => r.includes(p));
if (/\b(special )?offers?\b/.test(r)) riskHits.push('offer');
if (riskHits.length) reasons.push('promises money or admits fault: ' + riskHits.join(', '));
if (/\[[^\]]*\]|\d{7,}/.test(reply)) reasons.push('placeholder or made-up contact detail');
const words = reply.split(/\s+/).filter(Boolean).length;
if (words > 70) reasons.push(`too long (${words} words)`);
if (reply && words < 12) reasons.push(`too thin (${words} words)`);
// Specific = reuses at least one real word from the review (skip for one-word reviews like "Great").
const stop = new Set('this that with have were they there their what when your just from will about been very good great place food here never coming back nothing special'.split(' '));
const reviewWords = (prev.review.toLowerCase().match(/[a-z]{4,}/g) || []).filter(w => !stop.has(w));
if (reviewWords.length >= 3 && !reviewWords.some(w => r.includes(w))) reasons.push('not specific to this review');
if (/^thank you for/i.test(reply)) reasons.push('generic opening');
if (prev.stars !== null && prev.stars <= 2) reasons.push(`${prev.stars}-star review`);
if (alarm.test(prev.review)) reasons.push('health, safety or legal claim in review');
// The sign-off is added in code, not trusted to the model.
const finalReply = reply && !r.includes(prev.signoff.toLowerCase().replace(/^[-\s]+/, '')) ? `${reply}\n${prev.signoff}` : reply;
return [{ pairedItem: 0, json: {
  sentiment: out.sentiment || null,
  topics: out.topics || [],
  reply: finalReply,
  words,
  needs_owner_attention: reasons.length > 0,
  reasons,
  auto_post_ok: reasons.length === 0
} }];
Enter fullscreen mode Exit fullscreen mode

The output gives you auto_post_ok, plus reasons you can show to whoever reviews the drafts:

{
  "sentiment": "neutral",
  "topics": ["food", "parking"],
  "reply": "Thank you for your feedback. We appreciate your suggestion for an alternative parking spot. ...\n- Ramesh, Udupi Corner",
  "needs_owner_attention": true,
  "reasons": ["sounds like a bot: thank you for your feedback, we strive", "generic opening"],
  "auto_post_ok": false
}
Enter fullscreen mode Exit fullscreen mode

What the gate did

I replayed all 96 recorded drafts (48 from each version) through the final Check Reply node:

Version 1 drafts Version 2 drafts
Passed the gate (auto_post_ok) 0 / 48 2 / 48
Held for a human 48 / 48 46 / 48

On a 1.8B model, almost nothing passes, and that's the correct result. These drafts weren't good enough to post unread. The two that passed were both for 5-star reviews, for example:

We are glad you enjoyed your experience here. Looking forward to your next visit!
- Ramesh, Udupi Corner

It's fine. It's not great. I'd still tighten the specificity rule before letting anything auto-post.

The bigger win is that every held draft now comes with a reason. "Placeholder or made-up contact detail" takes ten seconds to fix. "Health, safety or legal claim in review" means the owner should write this one personally, and probably call the customer. That's a much better use of a human's two minutes than reading every reply.

How I'd wire it for real

  • Pull new reviews on a schedule (the Google Business Profile API, or whatever export your review tool gives you) and POST each one to the webhook with business, review, stars, signoff and contact.
  • Add an IF node on auto_post_ok. True goes to a "post reply" step. False goes to Slack, email or a Google Sheet with the draft and the reasons, so the owner can edit and post it.
  • Start with everything going to the human branch for a week. Look at what the gate flags, adjust the word lists for your business, and only then turn on auto-posting for 4–5 star reviews.
  • Use a stronger model if you can. The check node doesn't care which model you use, and it'll keep catching the same failure types if a bigger model has an off day.

The free workflow

The base review-reply workflow (Webhook → prompt → LLM → parse) is in the free repo: arkintof/n8n-ai-agent-workflows, under workflows/07-review-reply-agent.json. To get the version from this post, replace its Build Prompt node with the rules above and its parse node with the Check Reply code. It works with any OpenAI-compatible endpoint, including a local Ollama model.

What I took away

  1. A prompt can't enforce a rule. It can only make the rule more likely. If breaking it would be embarrassing or costly, check it in code.
  2. Placeholders mean you asked for data you didn't give. I asked it to point people to WhatsApp without giving it the number, so it invented one. Pass the real contact details in, or add them in code.
  3. "Sounds like a bot" is measurable. A stock-phrase list, a "Thank you for" opener check, and a "did you mention anything they said?" check catch most of it.
  4. The goal isn't zero human review. It's sending the human only the replies that need them, with a reason attached.

What's the worst AI-written review reply you've seen from a business? And if you auto-reply to reviews today, do you let anything post without a human reading it first?

Written with AI assistance; workflow built and tested by me.

Top comments (0)