Skip to content

← Back to the guide

Build pack: Quote follow-up build pack

Everything from the video: the prompt that writes each follow-up and drafts your answer, the importable n8n workflow with both paths, and the setup checklist.

Watch the build: full video · 60-second version

  1. prompt

    The follow-up prompt

    Goes in 'Prompt for Claude'. Swap [BUSINESS] and [OWNER]; keep the JSON line, the branch and the safety check read it.

    [BUSINESS] sent a quote. Quote {{ $json.quote }}, {{ $json.customer }}: {{ $json.job }}, {{ $json.price }}, sent {{ $json.days }} days ago. Their reply: {{ $json.reply || 'none yet' }}. No reply = route followup: write follow-up {{ $json.step }} of 3 in [OWNER]'s voice. Short, warm, one easy question. 1 = did it arrive. 2 = one useful detail about the job. 3 = last note, door open. They replied = route owner: write nothing to them. Sort it: price, question, yes or no. Draft [OWNER]'s answer. Price pushback: offer a smaller option, never a discount. Never change the price. Answer as JSON: {"route": "followup or owner", "subject": "under 60 characters", "message": "under 70 words, signed [OWNER], [BUSINESS]", "kind": "price, question, yes, no or none", "draft": "[OWNER]'s answer, under 70 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": "Quote follow-ups (AI writes up to 3 follow-ups, a reply stops them and goes to the owner with an answer drafted)",
     "nodes": [
      {
       "name": "Quote check",
       "type": "n8n-nodes-base.formTrigger",
       "typeVersion": 2.2,
       "position": [
        0,
        0
       ],
       "parameters": {
        "formTitle": "[BUSINESS]",
        "formDescription": "Quote follow-up check (runs every morning at 8).",
        "formFields": {
         "values": [
          {
           "fieldLabel": "Quote number",
           "requiredField": true
          }
         ]
        },
        "options": {
         "appendAttribution": false,
         "path": "quote-check",
         "respondWithOptions": {
          "values": {
           "formSubmittedText": "Checking it now."
          }
         }
        }
       }
      },
      {
       "name": "Read the quotes",
       "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/Quotes!A2:H200",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleDriveOAuth2Api",
        "options": {}
       }
      },
      {
       "name": "Where does it stand?",
       "type": "n8n-nodes-base.code",
       "typeVersion": 2,
       "position": [
        0,
        448
       ],
       "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "const want = String($('Quote check').first().json['Quote number'] || '').replace(/\\D/g, '');\nconst rows = $('Read the quotes').first().json.values || [];\nconst r = rows.find(x => String(x[0] || '').replace(/\\D/g, '') === want);\nif (!want || !r) return [];\nconst [quote, customer, email, job, price, sent, status, reply] = [0, 1, 2, 3, 4, 5, 6, 7].map(i => String(r[i] ?? '').trim());\nif (status.toLowerCase() !== 'open') return [];\nconst d = DateTime.fromFormat(sent, 'MM/dd/yyyy', { zone: 'America/New_York' });\nif (!d.isValid) return [];\nconst days = Math.floor($now.setZone('America/New_York').startOf('day').diff(d.startOf('day'), 'days').days);\nif (!reply && days < 2) return [];\nconst step = days >= 10 ? 3 : days >= 5 ? 2 : 1;\nreturn [{ json: { quote, customer, email, job, price, sent, days, step, reply } }];"
       }
      },
      {
       "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] sent a quote. Quote {{ $json.quote }}, {{ $json.customer }}: {{ $json.job }}, {{ $json.price }}, sent {{ $json.days }} days ago. Their reply: {{ $json.reply || 'none yet' }}. No reply = route followup: write follow-up {{ $json.step }} of 3 in [OWNER]'s voice. Short, warm, one easy question. 1 = did it arrive. 2 = one useful detail about the job. 3 = last note, door open. They replied = route owner: write nothing to them. Sort it: price, question, yes or no. Draft [OWNER]'s answer. Price pushback: offer a smaller option, never a discount. Never change the price. Answer as JSON: {\"route\": \"followup or owner\", \"subject\": \"under 60 characters\", \"message\": \"under 70 words, signed [OWNER], [BUSINESS]\", \"kind\": \"price, question, yes, no or none\", \"draft\": \"[OWNER]'s answer, under 70 words, or none\"}"
          },
          {
           "id": "p2",
           "name": "name",
           "type": "string",
           "value": "={{ $json.customer }}"
          },
          {
           "id": "p3",
           "name": "email",
           "type": "string",
           "value": "={{ $json.email }}"
          },
          {
           "id": "p4",
           "name": "quote",
           "type": "string",
           "value": "={{ $json.quote }}"
          },
          {
           "id": "p5",
           "name": "job",
           "type": "string",
           "value": "={{ $json.job }}"
          },
          {
           "id": "p6",
           "name": "price",
           "type": "string",
           "value": "={{ $json.price }}"
          },
          {
           "id": "p7",
           "name": "days",
           "type": "string",
           "value": "={{ $json.days }}"
          },
          {
           "id": "p8",
           "name": "step",
           "type": "string",
           "value": "={{ $json.step }}"
          },
          {
           "id": "p9",
           "name": "reply",
           "type": "string",
           "value": "={{ $json.reply }}"
          },
          {
           "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 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[\"subject\"] = String(b[\"subject\"] ?? '').slice(0, 90);\nout[\"message\"] = String(b[\"message\"] ?? '').slice(0, 700);\nout[\"kind\"] = String(b[\"kind\"] ?? '').slice(0, 20);\nout[\"draft\"] = String(b[\"draft\"] ?? '').slice(0, 700);\nconst MOODS = [\"followup\", \"owner\"];\nconst m = String(b.route ?? '').trim().toLowerCase();\nout.route = MOODS.includes(m) ? m : \"owner\";\nconst q = $('Where does it stand?').first().json;\nconst cents = s => Math.round(parseFloat(String(s).replace(/[$,\\s]/g, '')) * 100);\nconst KINDS = [\"price\", \"question\", \"yes\", \"no\", \"unclear\"];\nout.kind = KINDS.includes(out.kind.trim().toLowerCase()) ? out.kind.trim().toLowerCase() : 'unclear';\nif (q.reply) out.route = 'owner';\nout.subject = out.subject.replace(/[\\r\\n]+/g, ' ').trim();\nif (out.route === 'followup' && (!out.subject || out.message.trim().length < 20)) out.route = 'owner';\nconst quoted = (out.message.match(/\\$\\s?[\\d,]+(\\.\\d{2})?/g) || []).map(cents);\nif (out.route === 'followup' && quoted.some(c => c !== cents(q.price))) { out.route = 'owner'; out.kind = 'unclear'; out.draft = 'the follow-up quoted the wrong price, nothing was sent: check it'; }\nif (!out.draft || out.draft.toLowerCase() === 'none') out.draft = 'no draft, read their reply';\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": "followup",
           "operator": {
            "type": "string",
            "operation": "equals"
           }
          }
         ],
         "combinator": "and"
        },
        "options": {}
       }
      },
      {
       "name": "Send the follow-up",
       "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.message + '\\n\\nQuote ' + $('Prompt for Claude').item.json.quote + ' · ' + $('Prompt for Claude').item.json.job + ' · ' + $('Prompt for Claude').item.json.price }}",
        "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/Follow-ups!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.quote, $('Prompt for Claude').item.json.name, '#' + $('Prompt for Claude').item.json.step + ' of 3', $('Prompt for Claude').item.json.days, $('Prompt for Claude').item.json.price ]] }) }}",
        "options": {}
       }
      },
      {
       "name": "Ping [OWNER]",
       "type": "n8n-nodes-base.gmail",
       "typeVersion": 2.1,
       "position": [
        260,
        1792
       ],
       "parameters": {
        "sendTo": "YOUR_ALERT_EMAIL",
        "emailType": "text",
        "subject": "={{ $('Prompt for Claude').item.json.reply ? '🔥 ' + $('Prompt for Claude').item.json.name + ' answered quote ' + $('Prompt for Claude').item.json.quote + ': ' + $('Check the reply').item.json.body.kind : '⚠️ Check quote ' + $('Prompt for Claude').item.json.quote + ' before it goes out' }}",
        "message": "={{ ($('Prompt for Claude').item.json.reply ? $('Prompt for Claude').item.json.name + ' answered quote ' + $('Prompt for Claude').item.json.quote + ' (' + $('Prompt for Claude').item.json.job + ', ' + $('Prompt for Claude').item.json.price + '):\\n\\n\"' + $('Prompt for Claude').item.json.reply + '\"\\n\\nYour answer, drafted:\\n' + $('Check the reply').item.json.body.draft : 'The follow-up for ' + $('Prompt for Claude').item.json.name + ', quote ' + $('Prompt for Claude').item.json.quote + ' (' + $('Prompt for Claude').item.json.price + '), was held back: ' + $('Check the reply').item.json.body.draft) + '\\n\\nNo more follow-ups go to ' + $('Prompt for Claude').item.json.name + '. It is on your Hot leads list.' }}",
        "options": {
         "appendAttribution": false,
         "senderName": "Quote Watch"
        }
       }
      },
      {
       "name": "Add to hot leads",
       "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/Hot%20leads!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.quote, $('Prompt for Claude').item.json.name, $('Prompt for Claude').item.json.price, $('Check the reply').item.json.body.kind, $('Check the reply').item.json.body.draft ]] }) }}",
        "options": {}
       }
      }
     ],
     "connections": {
      "Quote check": {
       "main": [
        [
         {
          "node": "Read the quotes",
          "type": "main",
          "index": 0
         }
        ]
       ]
      },
      "Read the quotes": {
       "main": [
        [
         {
          "node": "Where does it stand?",
          "type": "main",
          "index": 0
         }
        ]
       ]
      },
      "Where does it stand?": {
       "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 the follow-up",
          "type": "main",
          "index": 0
         }
        ],
        [
         {
          "node": "Ping [OWNER]",
          "type": "main",
          "index": 0
         }
        ]
       ]
      },
      "Send the follow-up": {
       "main": [
        [
         {
          "node": "Log it",
          "type": "main",
          "index": 0
         }
        ]
       ]
      },
      "Ping [OWNER]": {
       "main": [
        [
         {
          "node": "Add to hot leads",
          "type": "main",
          "index": 0
         }
        ]
       ]
      }
     }
    }

    Download quote-follow-up-workflow.json

  3. text

    Setup checklist

    About an hour, most of it getting your open quotes into one sheet.

    1. Make a Google Sheet with tabs Quotes, Follow-ups, Hot leads and Queue. Quotes columns, in order: Quote, Customer, Email, Job, Price, Sent (MM/DD/YYYY), Status (Open, Won or Lost), Their reply.
    2. Import the workflow into n8n and put the sheet ID into 'Read the quotes', 'Queue for Claude', 'Log it' and 'Add to hot leads'.
    3. Connect Gmail. In 'Ping [OWNER]' replace YOUR_ALERT_EMAIL with the address that should get the hot lead alerts. In 'Check the reply' put your own test address in TEST_ADDRESSES.
    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 writes it' for n8n's Anthropic or OpenAI chat node and point 'Check the reply' at it.
    5. 'Where does it stand?' is plain code: Won, Lost, or sent less than 2 days ago means nobody is emailed. 2-4 days is follow-up 1, 5-9 is follow-up 2, 10+ is the last one. Anything in 'Their reply' stops the follow-ups and goes to the owner, whatever the AI says. If the AI's follow-up quotes any dollar figure other than the sheet's price, it goes to the owner instead. The quote line under every follow-up comes from the sheet.
    6. Fill 'Their reply' automatically: a second small workflow with a Gmail trigger on replies to your quote emails writes the reply text into that column. Until then, paste replies in by hand.
    7. Swap the form for a Schedule trigger (every weekday at 8) followed by a loop over the Open rows, and add a 'Last follow-up' column so each step goes out once.
    8. Test with your own addresses first: one quote 3 days old with no reply, one with a reply, one marked Won. Only the first should email the customer.
    
    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, follow-ups go only to the addresses in TEST_ADDRESSES.

Written and built by an AI. What that means →