{
  "name": "Lumeor | AI review responses (Google Business Profile + Slack approval)",
  "nodes": [
    {
      "parameters": {
        "content": "## AI review responses with manager approval\nEvery hour during the day, the workflow pulls recent Google reviews for one location, skips reviews it has already handled, and has Claude classify each review and draft a reply. Code checks the draft (no compensation offers, no private details, sensible length). **Every reply waits for a manager to approve it in Slack before it is posted publicly.** Negative or sensitive reviews also open a Zendesk ticket so someone fixes the underlying problem, and every review is logged to Google Sheets.\n\n**Setup**\n1. Google Business Profile API access must be approved for your Google Cloud project first. Apply early; it can take time.\n2. Credentials: Google Business Profile OAuth2, Anthropic, Slack, Zendesk API token, Google Sheets OAuth2.\n3. Replace REPLACE_WITH_GBP_ACCOUNT_ID (accounts/...), REPLACE_WITH_GBP_LOCATION_ID (locations/...), REPLACE_WITH_APPROVAL_CHANNEL, REPLACE_WITH_ALERTS_CHANNEL, REPLACE_WITH_LOG_SHEET_ID and REPLACE_WITH_CONTACT_LINE.\n4. Add a \"Review log\" tab with headers: logged_at, review_id, rating, reviewer, review, reply, decision, sensitive, topics.\n5. Edit the reply rules in the Claude system prompt for your business.\n\nHandled reviews are remembered in workflow static data, which persists only for active (production) executions. Keep negative reviews on manual approval permanently.\n\nModel: claude-opus-5-5. Classification alone could run on claude-haiku-5-5, which is cheaper for high-volume, simple labeling.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/automate-review-responses/",
        "height": 860,
        "width": 580,
        "color": 7
      },
      "id": "36e71b9b-b69d-462c-ab55-1affea458477",
      "name": "About this template",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -80,
        -460
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 8-20 * * *"
            }
          ]
        }
      },
      "id": "08edd53e-e0da-4d31-9ccb-961456086886",
      "name": "Hourly, 8am to 8pm",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        300
      ]
    },
    {
      "parameters": {
        "resource": "review",
        "operation": "getAll",
        "account": {
          "__rl": true,
          "value": "REPLACE_WITH_GBP_ACCOUNT_ID",
          "mode": "name"
        },
        "location": {
          "__rl": true,
          "value": "REPLACE_WITH_GBP_LOCATION_ID",
          "mode": "name"
        },
        "returnAll": false,
        "limit": 50
      },
      "id": "6794b5bb-8dd8-4e1e-9216-d1ac6c203203",
      "name": "Get recent reviews",
      "type": "n8n-nodes-base.googleBusinessProfile",
      "typeVersion": 1,
      "position": [
        220,
        300
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Remembers each review's last updateTime, so new and edited reviews are handled once.\n// Reviews that already have an owner reply are skipped.\nconst MAX_PER_RUN = 20;\nconst seen = $getWorkflowStaticData('global');\nseen.reviews = seen.reviews || {};\nconst STARS = { ONE: 1, TWO: 2, THREE: 3, FOUR: 4, FIVE: 5 };\nconst out = [];\nfor (const { json: r } of $input.all()) {\n  if (!r.name) continue;\n  if (r.reviewReply && r.reviewReply.comment) { seen.reviews[r.name] = r.updateTime; continue; }\n  if (seen.reviews[r.name] === r.updateTime) continue;\n  out.push({\n    json: {\n      review_name: r.name,\n      review_id: r.reviewId,\n      reviewer: (r.reviewer && !r.reviewer.isAnonymous && r.reviewer.displayName) || '',\n      rating: STARS[r.starRating] || 0,\n      comment: String(r.comment || ''),\n      update_time: r.updateTime,\n    },\n  });\n  if (out.length >= MAX_PER_RUN) break;\n}\n// Mark as seen now; a rejected draft is handled by a person, not re-drafted every hour.\nfor (const o of out) seen.reviews[o.json.review_name] = o.json.update_time;\nreturn out;"
      },
      "id": "d95897cf-55cf-46d9-801f-3956188014e0",
      "name": "Keep new or edited reviews",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: 'claude-opus-5-5', max_tokens: 2000, system: \"You help a local business reply to public online reviews. First classify the review, then draft a reply.\\nClassification:\\n- topics: short labels such as wait time, staff, price, quality, cleanliness, booking.\\n- names_employee: true if the review names a staff member.\\n- sensitive: true if it mentions safety, health, injury, discrimination, harassment, a legal threat, or private medical or financial details.\\nReply rules:\\n- 2 to 4 sentences, warm and specific: reference one concrete thing the reviewer said.\\n- Use the reviewer's first name only if a name is provided.\\n- Never confirm someone is a customer or patient beyond what they wrote, never include private details, never argue.\\n- Never offer refunds, discounts, free items or other compensation in public.\\n- For ratings of 3 or lower, apologize briefly and invite them offline with exactly this line: REPLACE_WITH_CONTACT_LINE\\n- Vary openings; do not start with \\\"Thank you for your feedback\\\".\", output_config: { \"format\": { \"type\": \"json_schema\", \"schema\": { \"type\": \"object\", \"properties\": { \"topics\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } }, \"names_employee\": { \"type\": \"boolean\" }, \"sensitive\": { \"type\": \"boolean\" }, \"sensitive_reason\": { \"type\": \"string\", \"description\": \"Empty string if not sensitive\" }, \"sentiment\": { \"type\": \"string\", \"enum\": [ \"positive\", \"mixed\", \"negative\" ] }, \"reply\": { \"type\": \"string\" } }, \"required\": [ \"topics\", \"names_employee\", \"sensitive\", \"sensitive_reason\", \"sentiment\", \"reply\" ], \"additionalProperties\": false } } }, messages: [ { role: 'user', content: 'Review (JSON):\\n' + JSON.stringify({ rating: $json.rating, reviewer_name: $json.reviewer, text: $json.comment || '(rating only, no text)' }) } ] }) }}",
        "options": {
          "timeout": 120000
        }
      },
      "id": "c778e268-6627-4226-8d90-c1f6df1f680d",
      "name": "Classify and draft reply",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        300
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Public replies must not offer compensation or include contact details beyond the approved line.\nconst BLOCKED = [/refund/i, /discount/i, /\\bfree\\b/i, /compensat/i, /\\$\\s?\\d/, /voucher|coupon|gift card/i];\nconst PRIVATE = [/[\\w.+-]+@[\\w-]+\\.[\\w.]+/, /\\b\\d{3}[-.\\s]?\\d{3}[-.\\s]?\\d{4}\\b/];\nconst CONTACT_LINE = 'REPLACE_WITH_CONTACT_LINE';\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const res = items[i].json;\n  const review = $('Keep new or edited reviews').itemMatching(i).json;\n  const fail = (reason, extra = {}) => out.push({ json: { error: reason, details: { review_id: review.review_id, rating: review.rating, ...extra } } });\n  if (res.stop_reason === 'refusal' || res.stop_reason === 'max_tokens') { fail(`Claude stopped with stop_reason=${res.stop_reason}`); continue; }\n  // Opus 5.5 may return a thinking block first, so find the text block explicitly.\n  const block = (res.content || []).find((b) => b.type === 'text');\n  if (!block) { fail('Claude response had no text block'); continue; }\n  let d;\n  try { d = JSON.parse(block.text); } catch (e) { fail('Claude returned invalid JSON'); continue; }\n  const reply = String(d.reply || '').trim();\n  const flags = [];\n  if (reply.length < 40 || reply.length > 700) flags.push(`length ${reply.length}`);\n  const hit = BLOCKED.find((re) => re.test(reply));\n  if (hit) flags.push(`offers compensation (${hit})`);\n  const withoutContact = reply.split(CONTACT_LINE).join('');\n  if (PRIVATE.some((re) => re.test(withoutContact))) flags.push('contains an email or phone number');\n  if (review.rating <= 3 && !reply.includes(CONTACT_LINE)) flags.push('missing the offline contact line');\n  if (flags.length) { fail(`Draft failed checks: ${flags.join(', ')}`, { draft: reply.slice(0, 300) }); continue; }\n  out.push({\n    json: {\n      ...review,\n      reply,\n      topics: (d.topics || []).join(', '),\n      sensitive: Boolean(d.sensitive),\n      sensitive_reason: d.sensitive_reason || '',\n      names_employee: Boolean(d.names_employee),\n      needs_ticket: review.rating <= 3 || Boolean(d.sensitive),\n    },\n  });\n}\nreturn out;"
      },
      "id": "da4915cb-f455-4b25-b541-ff14837cf547",
      "name": "Validate draft and route",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        300
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "batchSize": 1,
        "options": {}
      },
      "id": "49bcc117-cc7f-4752-b7c8-c0defdb599a9",
      "name": "Loop Over Items",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        1100,
        280
      ]
    },
    {
      "parameters": {
        "operation": "sendAndWait",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_APPROVAL_CHANNEL",
          "mode": "name"
        },
        "message": "={{ $json.sensitive ? ':rotating_light: *Sensitive review*' + ($json.sensitive_reason ? ' (' + $json.sensitive_reason + ')' : '') : ($json.rating <= 3 ? ':warning: *Negative review*' : ':star: *New review*') }} {{ '★'.repeat($json.rating) }}\n*{{ $json.reviewer || 'Anonymous' }}:* {{ $json.comment || '(rating only)' }}\n_Topics: {{ $json.topics || 'none' }}{{ $json.names_employee ? '. Names an employee.' : '' }}_\n\n*Draft reply*\n{{ $json.reply }}\n\nApprove posts this reply publicly on Google. Reject logs it so you can reply by hand.",
        "responseType": "approval",
        "approvalOptions": {
          "values": {
            "approvalType": "double",
            "approveLabel": "Approve and post",
            "disapproveLabel": "Reject"
          }
        },
        "options": {
          "limitWaitTime": {
            "values": {
              "limitType": "afterTimeInterval",
              "resumeAmount": 24,
              "resumeUnit": "hours"
            }
          },
          "appendAttribution": false
        }
      },
      "id": "e29ba44a-6e67-484b-abc1-c2a94bbe554c",
      "name": "Manager approval in Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1320,
        300
      ],
      "webhookId": "1979e0af-e60d-43ec-a250-cc6daa8d434f"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "b161970b-c421-40df-a45e-7d06e623bb97",
              "leftValue": "={{ !!($json.data && $json.data.approved === true) }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "4ba097e5-7d9e-4afd-afab-e24c22322967",
      "name": "Approved?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1540,
        300
      ]
    },
    {
      "parameters": {
        "resource": "review",
        "operation": "reply",
        "account": {
          "__rl": true,
          "value": "REPLACE_WITH_GBP_ACCOUNT_ID",
          "mode": "name"
        },
        "location": {
          "__rl": true,
          "value": "REPLACE_WITH_GBP_LOCATION_ID",
          "mode": "name"
        },
        "review": {
          "__rl": true,
          "value": "={{ $('Loop Over Items').first().json.review_name }}",
          "mode": "name"
        },
        "reply": "={{ $('Loop Over Items').first().json.reply }}"
      },
      "id": "f6d75e62-7c05-459b-b928-93b2e7e193ea",
      "name": "Post reply on Google",
      "type": "n8n-nodes-base.googleBusinessProfile",
      "typeVersion": 1,
      "position": [
        1760,
        200
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "REPLACE_WITH_LOG_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Review log",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "logged_at": "={{ $now.toISO() }}",
            "review_id": "={{ $('Loop Over Items').first().json.review_id }}",
            "rating": "={{ $('Loop Over Items').first().json.rating }}",
            "reviewer": "={{ $('Loop Over Items').first().json.reviewer }}",
            "review": "={{ $('Loop Over Items').first().json.comment }}",
            "reply": "={{ $('Loop Over Items').first().json.reply }}",
            "decision": "={{ $json.error ? 'approved, posting failed' : $json.data ? 'rejected' : ($json.review_name ? 'approval expired' : 'approved and posted') }}",
            "sensitive": "={{ $('Loop Over Items').first().json.sensitive }}",
            "topics": "={{ $('Loop Over Items').first().json.topics }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "logged_at",
              "displayName": "logged_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "review_id",
              "displayName": "review_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "rating",
              "displayName": "rating",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "reviewer",
              "displayName": "reviewer",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "review",
              "displayName": "review",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "reply",
              "displayName": "reply",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "decision",
              "displayName": "decision",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "sensitive",
              "displayName": "sensitive",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "topics",
              "displayName": "topics",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "id": "3635fa78-4e5f-412f-bee5-ee066e124d61",
      "name": "Log review",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1980,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "e2e59151-6127-416c-a4a7-036619aa599d",
              "leftValue": "={{ $('Loop Over Items').first().json.needs_ticket }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "32dc01df-d670-4a6c-906e-9d5b469785b8",
      "name": "Needs follow-up ticket?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2200,
        300
      ]
    },
    {
      "parameters": {
        "resource": "ticket",
        "operation": "create",
        "description": "=A {{ $('Loop Over Items').first().json.rating }}-star Google review needs a private follow-up.\n\nReviewer: {{ $('Loop Over Items').first().json.reviewer || 'Anonymous' }}\nReview: {{ $('Loop Over Items').first().json.comment }}\nTopics: {{ $('Loop Over Items').first().json.topics }}\nSensitive: {{ $('Loop Over Items').first().json.sensitive ? 'yes, ' + $('Loop Over Items').first().json.sensitive_reason : 'no' }}\n\nContact the customer privately if you can identify them, fix the underlying issue, and note what changed.",
        "jsonParameters": false,
        "additionalFields": {
          "subject": "=Follow up on {{ $('Loop Over Items').first().json.rating }}-star review from {{ $('Loop Over Items').first().json.reviewer || 'a customer' }}",
          "type": "task"
        }
      },
      "id": "08e4edf9-70b1-4cf5-94ce-82d2d2b770bd",
      "name": "Create follow-up ticket",
      "type": "n8n-nodes-base.zendesk",
      "typeVersion": 1,
      "position": [
        2420,
        200
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_ALERTS_CHANNEL",
          "mode": "name"
        },
        "text": "=:warning: *Review response needs attention*\n*Reason:* {{ typeof $json.error === 'string' ? $json.error : (($json.error && $json.error.message) || 'Unknown error') }}\n*Details:* {{ JSON.stringify($json.details || $json.error || {}).slice(0, 600) }}\n*Execution:* {{ $execution.id }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "0ee92b13-1b42-41f9-8d7b-053290088bc1",
      "name": "Alert: review needs attention",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        880,
        560
      ]
    }
  ],
  "connections": {
    "Hourly, 8am to 8pm": {
      "main": [
        [
          {
            "node": "Get recent reviews",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get recent reviews": {
      "main": [
        [
          {
            "node": "Keep new or edited reviews",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Keep new or edited reviews": {
      "main": [
        [
          {
            "node": "Classify and draft reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Classify and draft reply": {
      "main": [
        [
          {
            "node": "Validate draft and route",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: review needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate draft and route": {
      "main": [
        [
          {
            "node": "Loop Over Items",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: review needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Over Items": {
      "main": [
        [],
        [
          {
            "node": "Manager approval in Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Manager approval in Slack": {
      "main": [
        [
          {
            "node": "Approved?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Approved?": {
      "main": [
        [
          {
            "node": "Post reply on Google",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post reply on Google": {
      "main": [
        [
          {
            "node": "Log review",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log review",
            "type": "main",
            "index": 0
          },
          {
            "node": "Alert: review needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log review": {
      "main": [
        [
          {
            "node": "Needs follow-up ticket?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Needs follow-up ticket?": {
      "main": [
        [
          {
            "node": "Create follow-up ticket",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Loop Over Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create follow-up ticket": {
      "main": [
        [
          {
            "node": "Loop Over Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
