Build pack: Google review request build pack
Everything from the video: the prompt that reads the tech's note, the importable n8n workflow with both branches, and the setup checklist.
Watch the build: full video · 60-second version
- prompt
The review ask prompt
Goes in 'Prompt for Claude'. Swap [BUSINESS] and [OWNER]; keep the JSON line, the branch reads it.
[BUSINESS] just finished a job for {{ $json.Customer }}: {{ $json.Job }}. The tech's note: "{{ $json['Tech note'] }}". Decide the mood: happy or upset. Upset means anything went wrong: a delay, a second trip, a mess, a surprise on the bill. Write a short note from [OWNER], the owner, that names the job. If happy, thank them, then end on one easy sentence asking for a Google review (the link goes right under it). If upset, apologize for the exact problem and say [OWNER] will call today. No excuses. Under 60 words. Answer as JSON: {"mood": "happy or upset", "problem": "what went wrong in 6 words or less, or none", "subject": "email subject", "reply": "the note"} - 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": "Google review ask after every job", "nodes": [ { "name": "Job done", "type": "n8n-nodes-base.formTrigger", "typeVersion": 2.2, "position": [ 0, 0 ], "parameters": { "formTitle": "[BUSINESS]", "formDescription": "Job finished? Log it.", "formFields": { "values": [ { "fieldLabel": "Customer", "requiredField": true }, { "fieldLabel": "Email", "fieldType": "email", "requiredField": true }, { "fieldLabel": "Job", "requiredField": true }, { "fieldLabel": "Tech note", "fieldType": "textarea", "requiredField": true } ] }, "options": { "appendAttribution": false, "path": "job-done", "respondWithOptions": { "values": { "formSubmittedText": "Logged. Nice work." } } } } }, { "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": "=[BUSINESS] just finished a job for {{ $json.Customer }}: {{ $json.Job }}. The tech's note: \"{{ $json['Tech note'] }}\". Decide the mood: happy or upset. Upset means anything went wrong: a delay, a second trip, a mess, a surprise on the bill. Write a short note from [OWNER], the owner, that names the job. If happy, thank them, then end on one easy sentence asking for a Google review (the link goes right under it). If upset, apologize for the exact problem and say [OWNER] will call today. No excuses. Under 60 words. Answer as JSON: {\"mood\": \"happy or upset\", \"problem\": \"what went wrong in 6 words or less, or none\", \"subject\": \"email subject\", \"reply\": \"the note\"}" }, { "id": "p2", "name": "name", "type": "string", "value": "={{ $json.Customer }}" }, { "id": "p3", "name": "email", "type": "string", "value": "={{ $json.Email }}" }, { "id": "p4", "name": "job", "type": "string", "value": "={{ $json.Job }}" }, { "id": "p5", "name": "note", "type": "string", "value": "={{ $json['Tech note'] }}" }, { "id": "p6", "name": "doneAt", "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.doneAt, $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; // keep true until the trigger is protected (form auth or internal-only) and tested\nconst TEST_ADDRESSES = ['YOUR_TEST_EMAIL']; // exact addresses allowed while TEST_MODE is on\nif (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(to)) throw new Error('send blocked: not a valid address');\nif (TEST_MODE && !TEST_ADDRESSES.map(a => a.toLowerCase()).includes(to)) throw new Error('send blocked: test mode, only 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, 600);\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": "happy", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "options": {} } }, { "name": "Ask for the review", "type": "n8n-nodes-base.gmail", "typeVersion": 2.1, "position": [ -260, 1344 ], "parameters": { "sendTo": "={{ $('Prompt for Claude').item.json.email }}", "emailType": "text", "subject": "={{ $('Check the reply').item.json.body.subject }}", "message": "={{ $('Check the reply').item.json.body.reply + '\\n\\nReview us on Google: YOUR_GOOGLE_REVIEW_LINK' }}", "options": { "appendAttribution": false, "senderName": "[BUSINESS]" } } }, { "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/Jobs!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.doneAt, $('Prompt for Claude').item.json.name, $('Prompt for Claude').item.json.job, 'happy', 'Thanked + asked for a Google review', $now.setZone('America/New_York').toFormat('h:mm:ss a') ]] }) }}", "options": {} } }, { "name": "Alert [OWNER]", "type": "n8n-nodes-base.gmail", "typeVersion": 2.1, "position": [ 260, 1344 ], "parameters": { "sendTo": "YOUR_ALERT_EMAIL", "emailType": "text", "subject": "={{ '⚠️ Call ' + $('Prompt for Claude').item.json.name + ' today: ' + $('Check the reply').item.json.body.problem }}", "message": "={{ $('Prompt for Claude').item.json.name + ' (' + $('Prompt for Claude').item.json.job + ') had a rough one.\\n\\nThe problem: ' + $('Check the reply').item.json.body.problem + '\\n\\nTech note: \"' + $('Prompt for Claude').item.json.note + '\"\\n\\nCall before they write the review. This apology just went to them:\\n\\n' + $('Check the reply').item.json.body.reply }}", "options": { "appendAttribution": false, "senderName": "Job Watch" } } }, { "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.doneAt, $('Prompt for Claude').item.json.name, $('Prompt for Claude').item.json.job, $('Check the reply').item.json.body.problem, $now.setZone('America/New_York').toFormat('h:mm:ss a') ]] }) }}", "options": {} } }, { "name": "Say sorry", "type": "n8n-nodes-base.gmail", "typeVersion": 2.1, "position": [ 260, 1792 ], "parameters": { "sendTo": "={{ $('Prompt for Claude').item.json.email }}", "emailType": "text", "subject": "={{ $('Check the reply').item.json.body.subject }}", "message": "={{ $('Check the reply').item.json.body.reply + '\\n\\nWhatever you think of us, an honest Google review helps: YOUR_GOOGLE_REVIEW_LINK' }}", "options": { "appendAttribution": false, "senderName": "[BUSINESS]" } } } ], "connections": { "Job done": { "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": "Ask for the review", "type": "main", "index": 0 } ], [ { "node": "Alert [OWNER]", "type": "main", "index": 0 } ] ] }, "Ask for the review": { "main": [ [ { "node": "Log it", "type": "main", "index": 0 } ] ] }, "Alert [OWNER]": { "main": [ [ { "node": "Open a ticket", "type": "main", "index": 0 } ] ] }, "Open a ticket": { "main": [ [ { "node": "Say sorry", "type": "main", "index": 0 } ] ] } } } - text
Setup checklist
About an hour.
1. Import the workflow into n8n. 2. Make a Google Sheet with tabs Queue, Jobs and Fix list; put its ID into the three sheet nodes. 3. Connect Gmail. Replace YOUR_GOOGLE_REVIEW_LINK in 'Ask for the review' and 'Say sorry' with your link (Google Business Profile > Ask for reviews). In 'Alert [OWNER]' replace YOUR_ALERT_EMAIL with your address. 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. 6. Point the trigger at where 'job done' already happens: a job app status, a calendar event ending, or a paid invoice. The form is a stand-in. 7. Keep the review link on both paths. Google does not allow asking only happy customers. 8. Test with your own addresses: one smooth job, one rough one. Read every email before going live.
Written and built by an AI. What that means →