{
  "nodes": [
    {
      "id": "sticky-a",
      "name": "Section A note",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -680,
        20
      ],
      "parameters": {
        "content": "## A. The daily wake-up batch\n- Every weekday at 10am, up to 25 leads who went quiet 90 or more days ago each get one short personal text.\n- A sent tag goes on the contact the moment their text goes out, so tomorrow's run skips them.",
        "height": 160,
        "width": 660,
        "color": 4
      }
    },
    {
      "id": "a1-schedule",
      "name": "Every weekday at 10am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -640,
        340
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 10 * * 1-5"
            }
          ]
        }
      }
    },
    {
      "id": "a2-pull-list",
      "name": "Pull the old lead list from GoHighLevel",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -420,
        340
      ],
      "parameters": {
        "method": "GET",
        "url": "https://services.leadconnectorhq.com/contacts/",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "locationId",
              "value": "bsHomeSvcDemo01"
            },
            {
              "name": "query",
              "value": "old-lead"
            },
            {
              "name": "limit",
              "value": "100"
            }
          ]
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Version",
              "value": "2021-07-28"
            }
          ]
        },
        "options": {}
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "a3-pick-leads",
      "name": "Pick the leads worth waking up",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -200,
        340
      ],
      "parameters": {
        "jsCode": "// ===== The rules, all in one place. Edit here. =====\nconst RULES = {\n  quietForAtLeastDays: 90,               // only text people who went silent this long ago\n  dailyCap: 25,                          // max texts per day, keeps it human and inside texting limits\n  mustHaveTag: 'old-lead',               // the list we are waking up\n  skipTags: ['reactivation-sent', 'dnd'] // already texted, or asked us not to\n};\n// ===================================================\n\n// GoHighLevel returns the list in a couple of shapes, so check each one.\nconst contacts = [];\nfor (const item of $input.all()) {\n  const raw = item.json || {};\n  const body = raw.body || {};\n  if (Array.isArray(raw.contacts)) {\n    contacts.push(...raw.contacts);\n  } else if (Array.isArray(body.contacts)) {\n    contacts.push(...body.contacts);\n  } else if (raw.id || raw.contactId) {\n    contacts.push(raw);\n  }\n}\n\nconst tagsOf = (contact) => {\n  const rawTags = contact.tags ?? contact.tag ?? [];\n  const list = Array.isArray(rawTags) ? rawTags : String(rawTags).split(',');\n  return list.map(t => String(t).trim().toLowerCase()).filter(Boolean);\n};\n\nconst toPhone = (rawPhone) => {\n  let digits = String(rawPhone ?? '').replace(/[^0-9+]/g, '');\n  if (!digits) return '';\n  if (!digits.startsWith('+')) digits = '+' + digits;\n  return '+' + digits.slice(1).replace(/[^0-9]/g, '');\n};\n\nconst now = Date.now();\nconst dayMs = 24 * 60 * 60 * 1000;\nconst qualified = [];\n\nfor (const contact of contacts) {\n  const tags = tagsOf(contact);\n  if (!tags.includes(RULES.mustHaveTag)) continue;\n  if (RULES.skipTags.some(t => tags.includes(t))) continue;\n\n  const lastTouch = contact.lastActivity || contact.lastActivityAt || contact.dateUpdated || contact.dateAdded;\n  const lastTouchMs = lastTouch ? new Date(lastTouch).getTime() : 0;\n  if (!lastTouchMs) continue;\n\n  const daysQuiet = Math.floor((now - lastTouchMs) / dayMs);\n  if (daysQuiet < RULES.quietForAtLeastDays) continue;\n\n  const phone = toPhone(contact.phone || contact.phoneNumber || contact.mobile);\n  if (!phone) continue;\n\n  const contactId = contact.id || contact.contactId || contact._id;\n  if (!contactId) continue;\n\n  const firstName = String(contact.firstName || contact.first_name || contact.contactName || contact.name || '').trim().split(' ')[0] || 'there';\n  const service = String(contact.serviceInterest || contact.service || contact.source || 'HVAC or plumbing work').trim();\n\n  qualified.push({ json: { contactId, firstName, phone, service, daysQuiet } });\n}\n\n// Longest-quiet leads go first, then stop at the daily cap.\nqualified.sort((a, b) => b.json.daysQuiet - a.json.daysQuiet);\n\n// Nobody qualifies today? Return nothing and the run ends quietly.\nreturn qualified.slice(0, RULES.dailyCap);\n"
      }
    },
    {
      "id": "a4-write-opener",
      "name": "Write one personal opener per lead",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "typeVersion": 1,
      "position": [
        20,
        340
      ],
      "parameters": {
        "resource": "text",
        "operation": "message",
        "modelId": {
          "__rl": true,
          "mode": "id",
          "value": "claude-haiku-4-5"
        },
        "messages": {
          "values": [
            {
              "role": "user",
              "content": "=Lead first name: {{ $json.firstName }}\nService they originally asked about: {{ $json.service }}\nThey have been quiet for {{ $json.daysQuiet }} days."
            }
          ]
        },
        "options": {
          "system": "You write one short reactivation text for Blue Summit Home Services, an HVAC and plumbing company in Denver. The reader asked about a job months ago and went quiet. This is one person texting one person.\nVoice rules, follow them exactly: plain texting register, like a real office texting a customer. No corporate phrases (\"we appreciate your business\", \"reaching out\"). No emojis. No em dashes. No exclamation enthusiasm. No forced rule-of-three. No \"-ing\" analysis tails. Short sentences.\nBanned, because it smells like a blast: \"hope you're well\", \"just checking in\", \"following up\", any offer, discount, or promotion. Do not mention how long it has been. Do not sell anything in this first text.\nAim for this shape: Hey {name}, we spoke a while back about your {service}. Still on your list?\nUse their first name and the service they asked about. One or two sentences, under 160 characters. Reply with the text message only, nothing else."
        }
      },
      "credentials": {
        "anthropicApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "a5-send-text",
      "name": "Text the opener to the lead",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        240,
        340
      ],
      "parameters": {
        "method": "POST",
        "url": "https://services.leadconnectorhq.com/conversations/messages",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Version",
              "value": "2021-07-28"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ type: 'SMS', contactId: $('Pick the leads worth waking up').item.json.contactId, message: ((($json.content || [])[0] || {}).text || ((($json.message || {}).content || [])[0] || {}).text || $json.text || $json.output || ('Hey ' + $('Pick the leads worth waking up').item.json.firstName + ', we spoke a while back about your ' + $('Pick the leads worth waking up').item.json.service + '. Still on your list?')) }) }}",
        "options": {}
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "a6-stamp-tag",
      "name": "Mark them as messaged so tomorrow skips them",
      "type": "n8n-nodes-base.highLevel",
      "typeVersion": 2,
      "position": [
        460,
        340
      ],
      "parameters": {
        "resource": "contact",
        "operation": "update",
        "contactId": "={{ $('Pick the leads worth waking up').item.json.contactId }}",
        "updateFields": {
          "tags": "reactivation-sent"
        }
      },
      "credentials": {
        "highLevelOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "a7-count",
      "name": "Add up how many went out",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        680,
        340
      ],
      "parameters": {
        "jsCode": "// Squash the batch down to one line for Slack, so the team gets one message, not twenty five.\nconst sentToday = $input.all().length;\nreturn [{ json: { sentToday } }];\n"
      }
    },
    {
      "id": "a8-slack-digest",
      "name": "Tell the sales team how many went out",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [
        900,
        340
      ],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#sales-team"
        },
        "text": "=Wake-up batch done for today. {{ $json.sentToday }} old leads each got one personal text. If any of them reply, you will see it here with their words.",
        "otherOptions": {}
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "sticky-b",
      "name": "Section B note",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -680,
        640
      ],
      "parameters": {
        "content": "## B. When an old lead texts back\n- The reply tags them as hot, posts their exact words in Slack, and puts a call task on Sam's desk due in 30 minutes.",
        "height": 140,
        "width": 660,
        "color": 4
      }
    },
    {
      "id": "b1-webhook",
      "name": "An old lead texts back",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -640,
        940
      ],
      "parameters": {
        "httpMethod": "POST",
        "path": "demo/reactivation-reply",
        "responseMode": "responseNode",
        "options": {}
      }
    },
    {
      "id": "b2-read-reply",
      "name": "Read the texted reply",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -420,
        940
      ],
      "parameters": {
        "jsCode": "// GoHighLevel and other senders shape this webhook a few different ways, so check each one.\nconst out = [];\n\nfor (const item of $input.all()) {\n  const raw = item.json || {};\n  const body = raw.body || {};\n  const contact = raw.contact || body.contact || {};\n  const message = raw.message || body.message || {};\n  const sources = [raw, body, contact, message];\n\n  const pick = (...keys) => {\n    for (const key of keys) {\n      for (const source of sources) {\n        if (!source || typeof source !== 'object') continue;\n        const value = source[key];\n        if (value === undefined || value === null) continue;\n        const text = String(value).trim();\n        if (text !== '') return text;\n      }\n    }\n    return '';\n  };\n\n  const contactId = pick('contact_id', 'contactId', 'id');\n\n  const fullName = pick('first_name', 'firstName', 'full_name', 'name');\n  const firstName = fullName.split(' ')[0] || 'there';\n\n  let phone = pick('phone', 'phone_number', 'phoneNumber', 'from').replace(/[^0-9+]/g, '');\n  if (phone && !phone.startsWith('+')) phone = '+' + phone;\n  if (phone) phone = '+' + phone.slice(1).replace(/[^0-9]/g, '');\n\n  const said = typeof (raw.message || body.message) === 'string'\n    ? String(raw.message || body.message)\n    : pick('body', 'text', 'messageBody');\n\n  out.push({\n    json: {\n      contactId,\n      firstName,\n      phone,\n      message: String(said).trim().slice(0, 300)\n    }\n  });\n}\n\nreturn out;\n"
      }
    },
    {
      "id": "b3-tag-hot",
      "name": "Tag them as a hot lead in GoHighLevel",
      "type": "n8n-nodes-base.highLevel",
      "typeVersion": 2,
      "position": [
        -200,
        940
      ],
      "parameters": {
        "resource": "contact",
        "operation": "update",
        "contactId": "={{ $json.contactId }}",
        "updateFields": {
          "tags": "reactivated-hot"
        }
      },
      "credentials": {
        "highLevelOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "b4-slack-alert",
      "name": "Tell the sales team this one came back",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [
        20,
        940
      ],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#sales-team"
        },
        "text": "=One from the old list just came back to life. {{ $('Read the texted reply').item.json.firstName }} texted: \"{{ $('Read the texted reply').item.json.message }}\". Sam has a task to call them in the next 30 minutes.",
        "otherOptions": {}
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "b5-sam-task",
      "name": "Give Sam Porter a call task due in 30 minutes",
      "type": "n8n-nodes-base.highLevel",
      "typeVersion": 2,
      "position": [
        240,
        940
      ],
      "parameters": {
        "resource": "task",
        "operation": "create",
        "contactId": "={{ $('Read the texted reply').item.json.contactId }}",
        "title": "=Call {{ $('Read the texted reply').item.json.firstName }} back, they replied to the wake-up text",
        "dueDate": "={{ $now.plus({ minutes: 30 }).toISO() }}",
        "additionalFields": {
          "description": "=For Sam Porter (sam@blue-summit.example.com). They texted: {{ $('Read the texted reply').item.json.message }}. Their number: {{ $('Read the texted reply').item.json.phone }}"
        }
      },
      "credentials": {
        "highLevelOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "b6-respond",
      "name": "Confirm the reply was handled",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        460,
        940
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ ok: true, message: \"Reply handled. \" + $('Read the texted reply').item.json.firstName + \" is tagged as hot, the sales team knows, and Sam has a call task due in 30 minutes.\" }) }}",
        "options": {}
      }
    }
  ],
  "connections": {
    "Every weekday at 10am": {
      "main": [
        [
          {
            "node": "Pull the old lead list from GoHighLevel",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pull the old lead list from GoHighLevel": {
      "main": [
        [
          {
            "node": "Pick the leads worth waking up",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick the leads worth waking up": {
      "main": [
        [
          {
            "node": "Write one personal opener per lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write one personal opener per lead": {
      "main": [
        [
          {
            "node": "Text the opener to the lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Text the opener to the lead": {
      "main": [
        [
          {
            "node": "Mark them as messaged so tomorrow skips them",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark them as messaged so tomorrow skips them": {
      "main": [
        [
          {
            "node": "Add up how many went out",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add up how many went out": {
      "main": [
        [
          {
            "node": "Tell the sales team how many went out",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "An old lead texts back": {
      "main": [
        [
          {
            "node": "Read the texted reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read the texted reply": {
      "main": [
        [
          {
            "node": "Tag them as a hot lead in GoHighLevel",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Tag them as a hot lead in GoHighLevel": {
      "main": [
        [
          {
            "node": "Tell the sales team this one came back",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Tell the sales team this one came back": {
      "main": [
        [
          {
            "node": "Give Sam Porter a call task due in 30 minutes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Give Sam Porter a call task due in 30 minutes": {
      "main": [
        [
          {
            "node": "Confirm the reply was handled",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}