Skip to content

← Back to the guide

Build pack: Win back regulars build pack

Everything from the video: the prompt that writes each come-back note, the importable n8n workflow with both paths, and the setup checklist.

Watch the build: full video · 60-second version

  1. prompt

    The come-back prompt

    Goes in 'Prompt for Claude'. Swap [BUSINESS] and [OWNER]; not a groomer? Change 'brings {dog} in' to whatever you sell. Keep the JSON line, the branch and the safety check read it.

    [BUSINESS] wants a regular back. {{ $json.customer }} brings {{ $json.dog }} in for a {{ $json.usual }} every {{ $json.every }} weeks. Last visit: {{ $json.weeks }} weeks ago. Note from that visit: "{{ $json.note || 'none' }}". The code says: {{ $json.want }}. comeback = write {{ $json.customer }} a short, warm note in [OWNER]'s voice: name {{ $json.dog }}, say we miss them, and offer exactly this, word for word: {{ $json.offer }}. No other prices or discounts. owner = they left unhappy or have been away 6 months: write nothing to them, tell [OWNER] why in one line, and draft [OWNER]'s personal note. Answer as JSON: {"route": "comeback or owner", "message": "under 60 words, signed [OWNER], [BUSINESS]", "why": "owner only, under 12 words, or none", "draft": "[OWNER]'s note, under 60 words, or none"}

  2. json

    n8n workflow (import this)

    Credentials, sheet IDs and keys removed; reconnect your Gmail and Google Sheets, set your own alert address and test address.

    {
     "name": "Win back regulars (counts weeks since each customer's last visit against their own rhythm: overdue = AI come-back note with your offer, left unhappy or long gone = the owner, with a note drafted)",
     "nodes": [
      {
       "name": "Regulars check",
       "type": "n8n-nodes-base.formTrigger",
       "typeVersion": 2.2,
       "position": [
        0,
        0
       ],
       "parameters": {
        "formTitle": "[BUSINESS]",
        "formDescription": "Regulars check (runs every Monday at 8).",
        "formFields": {
         "values": [
          {
           "fieldLabel": "Customer",
           "requiredField": true
          }
         ]
        },
        "options": {
         "appendAttribution": false,
         "path": "regulars-check",
         "respondWithOptions": {
          "values": {
           "formSubmittedText": "Checking it now."
          }
         }
        }
       }
      },
      {
       "name": "Read the sheet",
       "type": "n8n-nodes-base.httpRequest",
       "typeVersion": 4.2,
       "position": [
        0,
        224
       ],
       "parameters": {
        "method": "GET",
        "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values:batchGet?ranges=Customers!A2:G200&ranges=Settings!A2:B20",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleDriveOAuth2Api",
        "options": {}
       }
      },
      {
       "name": "How long since?",
       "type": "n8n-nodes-base.code",
       "typeVersion": 2,
       "position": [
        0,
        448
       ],
       "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "const want = String($('Regulars check').first().json['Customer'] || '').trim().toLowerCase();\nconst vr = $('Read the sheet').first().json.valueRanges || [];\nconst rows = (vr[0] || {}).values || [];\nconst set = Object.fromEntries(((vr[1] || {}).values || []).map(r => [String(r[0] ?? '').trim().toLowerCase(), String(r[1] ?? '').trim()]));\nconst r = rows.find(x => String(x[0] ?? '').trim().toLowerCase() === want);\nif (!want || !r) return [];\nconst [customer, email, dog, usual, every, last, note] = [0, 1, 2, 3, 4, 5, 6].map(i => String(r[i] ?? '').trim());\nconst d = DateTime.fromFormat(last, 'MM/dd/yyyy', { zone: 'America/New_York' });\nconst wk = parseInt(every, 10);\nif (!d.isValid || !(wk > 0)) return [];\nconst days = Math.floor($now.setZone('America/New_York').startOf('day').diff(d.startOf('day'), 'days').days);\nif (days - wk * 7 < 7) return [];\nconst unhappy = /^unhappy/i.test(note), gone = days >= 182;\nconst route = unhappy || gone ? 'owner' : 'comeback';\nconst reason = unhappy ? 'left unhappy last visit' : gone ? 'away over 6 months' : '';\nreturn [{ json: { customer, email, dog, usual, every: String(wk), last, days, weeks: String(Math.round(days / 7)), note,\n  offer: set['come-back offer'] || '', book: set['booking link'] || '', want: route, reason } }];"
       }
      },
      {
       "name": "Prompt for Claude",
       "type": "n8n-nodes-base.set",
       "typeVersion": 3.4,
       "position": [
        0,
        672
       ],
       "parameters": {
        "assignments": {
         "assignments": [
          {
           "id": "p1",
           "name": "prompt",
           "type": "string",
           "value": "=[BUSINESS] wants a regular back. {{ $json.customer }} brings {{ $json.dog }} in for a {{ $json.usual }} every {{ $json.every }} weeks. Last visit: {{ $json.weeks }} weeks ago. Note from that visit: \"{{ $json.note || 'none' }}\". The code says: {{ $json.want }}. comeback = write {{ $json.customer }} a short, warm note in [OWNER]'s voice: name {{ $json.dog }}, say we miss them, and offer exactly this, word for word: {{ $json.offer }}. No other prices or discounts. owner = they left unhappy or have been away 6 months: write nothing to them, tell [OWNER] why in one line, and draft [OWNER]'s personal note. Answer as JSON: {\"route\": \"comeback or owner\", \"message\": \"under 60 words, signed [OWNER], [BUSINESS]\", \"why\": \"owner only, under 12 words, or none\", \"draft\": \"[OWNER]'s note, under 60 words, or none\"}"
          },
          {
           "id": "p2",
           "name": "name",
           "type": "string",
           "value": "={{ $json.customer }}"
          },
          {
           "id": "p3",
           "name": "email",
           "type": "string",
           "value": "={{ $json.email }}"
          },
          {
           "id": "p4",
           "name": "dog",
           "type": "string",
           "value": "={{ $json.dog }}"
          },
          {
           "id": "p5",
           "name": "usual",
           "type": "string",
           "value": "={{ $json.usual }}"
          },
          {
           "id": "p6",
           "name": "every",
           "type": "string",
           "value": "={{ $json.every }}"
          },
          {
           "id": "p7",
           "name": "weeks",
           "type": "string",
           "value": "={{ $json.weeks }}"
          },
          {
           "id": "p8",
           "name": "offer",
           "type": "string",
           "value": "={{ $json.offer }}"
          },
          {
           "id": "p9",
           "name": "book",
           "type": "string",
           "value": "={{ $json.book }}"
          },
          {
           "id": "p10",
           "name": "gotAt",
           "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,
        896
       ],
       "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.gotAt, $json.name, $json.prompt, $execution.resumeUrl, '' ]] }) }}",
        "options": {}
       }
      },
      {
       "name": "Claude writes it",
       "type": "n8n-nodes-base.wait",
       "typeVersion": 1.1,
       "position": [
        0,
        1120
       ],
       "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,
        1344
       ],
       "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "const KEY = \"<<DEMO_RESUME_KEY>>\";\nconst it = $('Claude writes 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 trigger 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[\"message\"] = String(b[\"message\"] ?? '').slice(0, 600);\nout[\"why\"] = String(b[\"why\"] ?? '').slice(0, 120);\nout[\"draft\"] = String(b[\"draft\"] ?? '').slice(0, 600);\nconst MOODS = [\"comeback\", \"owner\"];\nconst m = String(b.route ?? '').trim().toLowerCase();\nout.route = MOODS.includes(m) ? m : \"owner\";\nconst c = $('How long since?').first().json;\nif (c.want === 'owner') out.route = 'owner';\nif (out.route !== 'owner' && out.route !== c.want) { out.route = 'owner'; out.why = 'the reply did not match the sheet, nothing was sent'; }\nconst money = s => (String(s).match(/\\$\\s?\\d[\\d,]*(\\.\\d+)?|\\d+(\\.\\d+)?\\s?%/g) || []).map(x => x.replace(/\\s/g, ''));\nconst offer = money(c.offer);\nif (out.route !== 'owner' && money(out.message).some(x => !offer.includes(x))) { out.route = 'owner'; out.why = 'the note named an offer that is not on the sheet, nothing was sent'; }\nif (out.route !== 'owner' && out.message.trim().length < 20) { out.route = 'owner'; out.why = 'the reply came back empty'; }\nif (!out.why || out.why.toLowerCase() === 'none') out.why = c.reason || 'needs a personal touch';\nif (!out.draft || out.draft.toLowerCase() === 'none') out.draft = 'no draft, read their last visit note';\nreturn [{ json: { body: out } }];"
       }
      },
      {
       "name": "Who handles it?",
       "type": "n8n-nodes-base.if",
       "typeVersion": 2.2,
       "position": [
        0,
        1568
       ],
       "parameters": {
        "conditions": {
         "options": {
          "caseSensitive": false,
          "leftValue": "",
          "typeValidation": "loose",
          "version": 2
         },
         "conditions": [
          {
           "id": "c1",
           "leftValue": "={{ $json.body.route }}",
           "rightValue": "comeback",
           "operator": {
            "type": "string",
            "operation": "equals"
           }
          }
         ],
         "combinator": "and"
        },
        "options": {}
       }
      },
      {
       "name": "Send come-back note",
       "type": "n8n-nodes-base.gmail",
       "typeVersion": 2.1,
       "position": [
        -260,
        1792
       ],
       "parameters": {
        "sendTo": "={{ $('Prompt for Claude').item.json.email }}",
        "emailType": "text",
        "subject": "We miss you at [BUSINESS]",
        "message": "={{ $('Check the reply').item.json.body.message + '\\n\\nYour usual: ' + $('Prompt for Claude').item.json.usual + ' for ' + $('Prompt for Claude').item.json.dog + ' · ' + $('Prompt for Claude').item.json.offer + '\\nBook a time: ' + $('Prompt for Claude').item.json.book }}",
        "options": {
         "appendAttribution": false,
         "senderName": "[BUSINESS]"
        }
       }
      },
      {
       "name": "Log it",
       "type": "n8n-nodes-base.httpRequest",
       "typeVersion": 4.2,
       "position": [
        -260,
        2016
       ],
       "parameters": {
        "method": "POST",
        "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values/Come-backs!A:F:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleDriveOAuth2Api",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ values: [[ $now.setZone('America/New_York').toFormat('h:mm:ss a'), $('Prompt for Claude').item.json.name, $('Prompt for Claude').item.json.dog, $('Prompt for Claude').item.json.weeks, $('Prompt for Claude').item.json.usual, $('Prompt for Claude').item.json.offer ]] }) }}",
        "options": {}
       }
      },
      {
       "name": "Flag [OWNER]",
       "type": "n8n-nodes-base.gmail",
       "typeVersion": 2.1,
       "position": [
        260,
        1792
       ],
       "parameters": {
        "sendTo": "YOUR_ALERT_EMAIL",
        "emailType": "text",
        "subject": "A regular needs a personal touch",
        "message": "={{ $('Prompt for Claude').item.json.name + ' and ' + $('Prompt for Claude').item.json.dog + ' have not been in for ' + $('Prompt for Claude').item.json.weeks + ' weeks (usual: every ' + $('Prompt for Claude').item.json.every + ').\\n\\nWhy it came to you: ' + $('Check the reply').item.json.body.why + '\\n\\nYour note, drafted:\\n' + $('Check the reply').item.json.body.draft + '\\n\\nNothing went to ' + $('Prompt for Claude').item.json.name + '. It is on your Personal touch list.' }}",
        "options": {
         "appendAttribution": false,
         "senderName": "Regulars Watch"
        }
       }
      },
      {
       "name": "Add to [OWNER]'s list",
       "type": "n8n-nodes-base.httpRequest",
       "typeVersion": 4.2,
       "position": [
        260,
        2016
       ],
       "parameters": {
        "method": "POST",
        "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_GOOGLE_SHEET_ID/values/Personal%20touch!A:F:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleDriveOAuth2Api",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ values: [[ $now.setZone('America/New_York').toFormat('h:mm:ss a'), $('Prompt for Claude').item.json.name, $('Prompt for Claude').item.json.dog, $('Prompt for Claude').item.json.weeks, $('Check the reply').item.json.body.why, $('Check the reply').item.json.body.draft ]] }) }}",
        "options": {}
       }
      }
     ],
     "connections": {
      "Regulars check": {
       "main": [
        [
         {
          "node": "Read the sheet",
          "type": "main",
          "index": 0
         }
        ]
       ]
      },
      "Read the sheet": {
       "main": [
        [
         {
          "node": "How long since?",
          "type": "main",
          "index": 0
         }
        ]
       ]
      },
      "How long since?": {
       "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 writes it",
          "type": "main",
          "index": 0
         }
        ]
       ]
      },
      "Claude writes it": {
       "main": [
        [
         {
          "node": "Check the reply",
          "type": "main",
          "index": 0
         }
        ]
       ]
      },
      "Check the reply": {
       "main": [
        [
         {
          "node": "Who handles it?",
          "type": "main",
          "index": 0
         }
        ]
       ]
      },
      "Who handles it?": {
       "main": [
        [
         {
          "node": "Send come-back note",
          "type": "main",
          "index": 0
         }
        ],
        [
         {
          "node": "Flag [OWNER]",
          "type": "main",
          "index": 0
         }
        ]
       ]
      },
      "Send come-back note": {
       "main": [
        [
         {
          "node": "Log it",
          "type": "main",
          "index": 0
         }
        ]
       ]
      },
      "Flag [OWNER]": {
       "main": [
        [
         {
          "node": "Add to [OWNER]'s list",
          "type": "main",
          "index": 0
         }
        ]
       ]
      }
     }
    }

    Download win-back-regulars-workflow.json

  3. text

    Setup checklist

    About an hour, most of it filling in your customer list.

    1. Make a Google Sheet with tabs Customers, Settings, Come-backs, Personal touch and Queue. Customers columns, in this order: Customer, Email, Dog (or what they bring in: car, kid, account), Usual, Every (weeks), Last visit (MM/DD/YYYY), Note from last visit. Settings: a row 'Come-back offer' (for example '$10 off') and a row 'Booking link'.
    2. Import the workflow into n8n and put the sheet ID into 'Read the sheet', 'Queue for Claude', 'Log it' and 'Add to [OWNER]'s list'.
    3. The trigger: the demo uses a one-field form so you can watch it fire for one customer. For real use, swap it for a Schedule trigger (Monday 8 AM) plus a step that sends every customer name through, or keep the form for one-off checks.
    4. The rules live in 'How long since?': a customer is due once they are a week past their usual rhythm, a note that starts with 'Unhappy' sends them to you, and so does anyone gone six months or more. Change those numbers there. After a come-back note goes out, update their Last visit or add a 'Contacted' date so they aren't asked twice.
    5. Connect Gmail. In 'Flag [OWNER]' replace YOUR_ALERT_EMAIL with the address that should get the customers who need a personal touch. In 'Check the reply' put your own test address in TEST_ADDRESSES. Edit the come-back subject to your business name.
    6. 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 writes it' for n8n's Anthropic or OpenAI chat node and point 'Check the reply' at it.
    7. What the code decides, not the AI: 'How long since?' does the date math and picks the path. 'Check the reply' sends it to you if the AI's route doesn't match, or if the note names any $ or % that isn't your Settings offer. The 'Your usual' line, the offer and the booking link are added from the sheet, and subjects are fixed text.
    8. Test with your own addresses first: one regular who is overdue, one who isn't due yet (nobody should be emailed), one with an 'Unhappy' note (only you should get an email).
    
    Before going live: protect the trigger (switch to the Schedule trigger, or turn on form authentication in n8n), 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 →