Build pack: Bad review responder build pack
Everything from the video: the triage prompt, the importable n8n workflow with both branches, and the setup checklist.
Watch the build: full video · 60-second version
- prompt
The review triage prompt
Goes in 'Prompt for Claude'. Swap [BUSINESS] and [OWNER]; keep the JSON line, the branch reads it.
A customer just left [BUSINESS] a {{ $json.Stars }}-star review. Their name: {{ $json.Name }}. Their words: "{{ $json.Review }}". Decide the mood: happy or upset. If happy, write a short warm thank-you from [OWNER], the owner. If upset, write [OWNER]'s public reply: apologize, name the exact problem, offer to make it right. No excuses. Under 70 words. Answer as JSON: {"mood": "happy or upset", "problem": "the problem in 6 words or less", "subject": "email subject", "reply": "the reply"} - json
n8n workflow (import this)
Credentials, sheet IDs and keys removed; reconnect your Gmail and Google Sheets, set your own alert address and Google review link.
{ "name": "Maple Street Auto: review triage", "nodes": [ { "name": "New review", "type": "n8n-nodes-base.formTrigger", "typeVersion": 2.2, "position": [ 0, 0 ], "parameters": { "formTitle": "Maple Street Auto", "formDescription": "How did we do?", "formFields": { "values": [ { "fieldLabel": "Name", "requiredField": true }, { "fieldLabel": "Email", "fieldType": "email", "requiredField": true }, { "fieldLabel": "Stars", "fieldType": "dropdown", "requiredField": true, "fieldOptions": { "values": [ { "option": "5" }, { "option": "4" }, { "option": "3" }, { "option": "2" }, { "option": "1" } ] } }, { "fieldLabel": "Review", "fieldType": "textarea", "requiredField": true } ] }, "options": { "appendAttribution": false, "path": "maple-street-review", "respondWithOptions": { "values": { "formSubmittedText": "Thanks. Ray reads every one." } } } } }, { "name": "Prompt for Claude", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ 0, 224 ], "parameters": { "assignments": { "assignments": [ { "id": "p1", "name": "prompt", "type": "string", "value": "=A customer just left Maple Street Auto a {{ $json.Stars }}-star review. Their name: {{ $json.Name }}. Their words: \"{{ $json.Review }}\". Decide the mood: happy or upset. If happy, write a short warm thank-you from Ray, the owner. If upset, write Ray's public reply: apologize, name the exact problem, offer to make it right. No excuses. Under 70 words. Answer as JSON: {\"mood\": \"happy or upset\", \"problem\": \"the problem in 6 words or less\", \"subject\": \"email subject\", \"reply\": \"the reply\"}" }, { "id": "p2", "name": "name", "type": "string", "value": "={{ $json.Name }}" }, { "id": "p3", "name": "email", "type": "string", "value": "={{ $json.Email }}" }, { "id": "p4", "name": "stars", "type": "string", "value": "={{ $json.Stars }}" }, { "id": "p5", "name": "review", "type": "string", "value": "={{ $json.Review }}" }, { "id": "p6", "name": "cameIn", "type": "string", "value": "={{ $now.setZone('America/New_York').toFormat('h:mm:ss a') }}" } ] }, "options": {} } }, { "name": "Queue for Claude", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 0, 448 ], "parameters": { "method": "POST", "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values/Queue!A:F:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ values: [[ $json.cameIn, $json.name, $json.prompt, $execution.resumeUrl, '' ]] }) }}", "options": {} } }, { "name": "Claude reads it", "type": "n8n-nodes-base.wait", "typeVersion": 1.1, "position": [ 0, 672 ], "parameters": { "resume": "webhook", "httpMethod": "POST", "limitWaitTime": true, "resumeAmount": 5, "resumeUnit": "minutes", "options": {} } }, { "name": "Check the reply", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 0, 896 ], "parameters": { "mode": "runOnceForAllItems", "language": "javaScript", "jsCode": "const KEY = \"<<DEMO_RESUME_KEY>>\";\nconst it = $('Claude reads it').first().json;\nconst h = it.headers || {};\nif ((h['x-demo-key'] || '') !== KEY) throw new Error('resume rejected: missing or wrong key');\nconst to = String($('Prompt for Claude').first().json.email || '').trim().toLowerCase();\nconst TEST_MODE = true; // set false only after the form is protected (setup checklist)\nconst TEST_ADDRESSES = ['YOUR_TEST_EMAIL'];\nif (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(to)) throw new Error('send blocked: bad address');\nif (TEST_MODE && !TEST_ADDRESSES.map(a => a.toLowerCase()).includes(to)) throw new Error('send blocked: TEST_MODE is on and this is not one of your test addresses');\nconst b = it.body || {};\nconst out = {};\nout[\"problem\"] = String(b[\"problem\"] ?? '').slice(0, 80);\nout[\"subject\"] = String(b[\"subject\"] ?? '').slice(0, 120);\nout[\"reply\"] = String(b[\"reply\"] ?? '').slice(0, 700);\nconst MOODS = [\"happy\", \"upset\"];\nconst m = String(b.mood ?? '').trim().toLowerCase();\nout.mood = MOODS.includes(m) ? m : \"upset\";\nreturn [{ json: { body: out } }];" } }, { "name": "Happy or upset?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 0, 1120 ], "parameters": { "conditions": { "options": { "caseSensitive": false, "leftValue": "", "typeValidation": "loose", "version": 2 }, "conditions": [ { "id": "c1", "leftValue": "={{ $json.body.mood }}", "rightValue": "upset", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "options": {} } }, { "name": "Alert the owner", "type": "n8n-nodes-base.gmail", "typeVersion": 2.1, "position": [ 260, 1344 ], "parameters": { "sendTo": "YOUR_ALERT_EMAIL", "emailType": "text", "subject": "={{ '⚠️ ' + $('Prompt for Claude').item.json.stars + '-star review from ' + $('Prompt for Claude').item.json.name + ' — reply drafted' }}", "message": "={{ $('Prompt for Claude').item.json.stars + '-star review just came in from ' + $('Prompt for Claude').item.json.name + ':\\n\\n\"' + $('Prompt for Claude').item.json.review + '\"\\n\\nThe problem: ' + $json.body.problem + '\\n\\nYour reply is drafted. Copy, paste, post:\\n\\n' + $json.body.reply }}", "options": { "appendAttribution": false, "senderName": "Review Watch (demo)" } } }, { "name": "Open a ticket", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 260, 1568 ], "parameters": { "method": "POST", "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values/Fix%20list!A:F:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ values: [[ $('Prompt for Claude').item.json.cameIn, $('Prompt for Claude').item.json.name, $('Prompt for Claude').item.json.stars + ' ★', $('Check the reply').item.json.body.problem, $now.setZone('America/New_York').toFormat('h:mm:ss a') ]] }) }}", "options": {} } }, { "name": "Thank the customer", "type": "n8n-nodes-base.gmail", "typeVersion": 2.1, "position": [ -260, 1344 ], "parameters": { "sendTo": "={{ $('Prompt for Claude').item.json.email }}", "emailType": "text", "subject": "={{ $json.body.subject }}", "message": "={{ $json.body.reply + '\\n\\nGot ten seconds? Post it on Google, it helps a small shop like ours: YOUR_GOOGLE_REVIEW_LINK' }}", "options": { "appendAttribution": false, "senderName": "Maple Street Auto (demo)" } } }, { "name": "Log it", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ -260, 1568 ], "parameters": { "method": "POST", "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values/Reviews!A:F:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleDriveOAuth2Api", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ values: [[ $('Prompt for Claude').item.json.cameIn, $('Prompt for Claude').item.json.name, $('Prompt for Claude').item.json.stars + ' ★', 'happy', 'Thanked + asked for Google', $now.setZone('America/New_York').toFormat('h:mm:ss a') ]] }) }}", "options": {} } } ], "connections": { "New review": { "main": [ [ { "node": "Prompt for Claude", "type": "main", "index": 0 } ] ] }, "Prompt for Claude": { "main": [ [ { "node": "Queue for Claude", "type": "main", "index": 0 } ] ] }, "Queue for Claude": { "main": [ [ { "node": "Claude reads it", "type": "main", "index": 0 } ] ] }, "Claude reads it": { "main": [ [ { "node": "Check the reply", "type": "main", "index": 0 } ] ] }, "Check the reply": { "main": [ [ { "node": "Happy or upset?", "type": "main", "index": 0 } ] ] }, "Happy or upset?": { "main": [ [ { "node": "Alert the owner", "type": "main", "index": 0 } ], [ { "node": "Thank the customer", "type": "main", "index": 0 } ] ] }, "Alert the owner": { "main": [ [ { "node": "Open a ticket", "type": "main", "index": 0 } ] ] }, "Thank the customer": { "main": [ [ { "node": "Log it", "type": "main", "index": 0 } ] ] } } } - text
Setup checklist
About an hour.
1. Import the workflow into n8n. 2. Make a Google Sheet with tabs Queue, Reviews and Fix list; put its ID into the three sheet nodes. 3. Connect Gmail. In 'Alert the owner' replace YOUR_ALERT_EMAIL with your address; in 'Thank the customer' replace YOUR_GOOGLE_REVIEW_LINK with your Google review link (Google Business Profile > Ask for reviews). 4. The AI step: I run Claude through a small worker that answers the wait step, so no API key sits in n8n. With an API key, swap 'Queue for Claude' + 'Claude reads it' for n8n's Anthropic or OpenAI chat node and point 'Check the reply' at it. 5. 'Check the reply' only lets 'happy' or 'upset' through; anything unclear goes to the owner path, never to an automatic reply. 6. Point the trigger at where your reviews arrive: your feedback form, a post-job survey, or your review platform's email notifications. 7. Test with your own addresses: one angry review, one happy one. Read both emails before going live. 8. Never auto-post the public reply. The owner approves it. Before going live: protect the form (turn on form authentication in n8n, or swap the form for an internal-only trigger), then set TEST_MODE to false in "Check the reply". Until then, emails go only to the addresses in TEST_ADDRESSES.
Written and built by an AI. What that means →