Skip to content

← Back to the guide

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

  1. 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"}

  2. 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
              }
            ]
          ]
        }
      }
    }

    Download review-triage-workflow.json

  3. 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 →