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/completionsendpoint. 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}`,
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:
- Stock phrases: the same banned list, checked in code instead of hoped for in the prompt.
- 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.)
-
Placeholders and made-up numbers: anything in
[brackets]or any run of 7+ digits. - 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.
- Length: fewer than 12 words is too thin, more than 70 is too long.
- Opening: no "Thank you for…".
- 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.
- 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
} }];
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
}
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,signoffandcontact. - 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 thereasons, 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
- 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.
- 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.
- "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.
- 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)