{
  "name": "Lumeor | Real estate lead first reply (Follow Up Boss + Twilio)",
  "nodes": [
    {
      "parameters": {
        "content": "## Real estate lead first reply starter\n\nA new lead in Follow Up Boss fires a webhook. The workflow loads the person, checks SMS consent and eligibility, finds the property they asked about, confirms its status in your MLS feed (RESO Web API), and has Claude draft a short first text that names the property, uses one fact and asks one question. In approval mode (the default) the draft is saved as a note for the agent to send. With approval mode off, eligible leads get the text by Twilio and a note is logged. Replies of STOP are tagged **SMS Opt-Out** in Follow Up Boss; other replies are tagged **AI Text Replied** so an FUB automation can alert the agent.\n\n### Compliance (read first)\n- **TCPA:** only text leads with recorded prior express written consent for automated texts, honor opt-outs right away, and respect calling hours of 8am to 9pm in the lead's local time (this template only sends when that window is open from Eastern to Pacific time; narrow WEST_TZ and EAST_TZ if you serve one region). This workflow never auto-texts a lead without the consent field set, and every message identifies your team and says how to opt out. Not legal advice; have counsel review.\n- Register your Twilio number for A2P 10DLC. Twilio also blocks texts to numbers that replied STOP.\n- Use listing data only as your MLS data license allows.\n\n### Setup\n1. In Follow Up Boss, add a custom field for SMS consent (default `customSMSConsent`, value \"Yes\") filled by your lead forms, and register webhooks for `peopleCreated` to the **FUB: new lead** URL (the X-System header is required).\n2. Create an n8n Basic Auth credential: username = FUB API key, password empty. Replace `REPLACE_WITH_YOUR_SYSTEM_NAME`.\n3. In **Check consent and eligibility**, set `TEAM_NAME`, `TEAM_TZ`, `BLOCKED_STAGES` and keep `APPROVAL_MODE = true` for the first two weeks.\n4. Replace `REPLACE_WITH_RESO_BASE_URL` and add a Header Auth credential (Authorization: Bearer ...) for your MLS feed.\n5. Set `REPLACE_WITH_TWILIO_NUMBER` and point that number's incoming messages webhook at **Twilio: lead replied**.\n6. Credentials: FUB Basic Auth, MLS Header Auth, Anthropic API, Twilio.\n\nModel: `claude-opus-5-5`. `claude-haiku-5-5` is a cheaper option for high-volume, simple classification, but first replies read better from the stronger model.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/ai-real-estate-lead-follow-up/",
        "height": 1040,
        "width": 580,
        "color": 7
      },
      "id": "43737669-54e9-443a-b10b-458fa59e1a16",
      "name": "About this template",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -600,
        -300
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "fub-new-lead",
        "options": {}
      },
      "id": "4360c9b1-61d9-4931-9b1c-c862893bcc56",
      "name": "FUB: new lead",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        300
      ],
      "webhookId": "531758c2-1568-4fd1-90f0-3a3f3e8bbda4"
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.body.uri }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "fields",
              "value": "allFields"
            }
          ]
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-System",
              "value": "REPLACE_WITH_YOUR_SYSTEM_NAME"
            }
          ]
        },
        "options": {
          "timeout": 20000
        }
      },
      "id": "fdd53216-a5a0-4a40-b031-a0178f29abc8",
      "name": "FUB: get new people",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        220,
        300
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Config\nconst TEAM_NAME = 'REPLACE_WITH_TEAM_NAME';\nconst TEAM_TZ = 'America/New_York';\nconst CONSENT_FIELD = 'customSMSConsent'; // FUB custom field, value \"Yes\" when the lead gave written consent to automated texts\nconst OPT_OUT_TAG = 'SMS Opt-Out';\nconst HAS_AGENT_TAG = 'Has Agent';\nconst BLOCKED_STAGES = ['Trash', 'Closed', 'Past Client', 'Under Contract', 'Pending'];\nconst APPROVAL_MODE = true; // true: drafts go to the agent as notes. false: eligible leads are texted automatically.\n// TCPA calling hours are 8am to 9pm in the RECIPIENT's local time. Without a reliable lead time zone,\n// only send when it is at least 8am in the westernmost zone you serve and before 9pm in the easternmost.\nconst WEST_TZ = 'America/Los_Angeles', EAST_TZ = 'America/New_York';\nconst QUIET_START = 21, QUIET_END = 8;\n\nconst toE164 = (p) => { const d = String(p || '').replace(/[^\\d+]/g, ''); if (d.startsWith('+')) return d; if (d.length === 10) return '+1' + d; if (d.length === 11 && d.startsWith('1')) return '+' + d; return null; };\nconst westHour = $now.setZone(WEST_TZ).hour;\nconst eastHour = $now.setZone(EAST_TZ).hour;\nconst quiet = westHour < QUIET_END || eastHour >= QUIET_START;\n\nconst out = [];\nfor (const item of $input.all()) {\n  for (const p of item.json.people || []) {\n    const tags = p.tags || [];\n    // Hard stops: no outreach of any kind from this workflow.\n    if (tags.includes(OPT_OUT_TAG) || tags.includes(HAS_AGENT_TAG) || BLOCKED_STAGES.includes(p.stage)) continue;\n    const phone = toE164((p.phones || [])[0]?.value);\n    const reasons = [];\n    if (!phone) reasons.push('No valid mobile number');\n    if (String(p[CONSENT_FIELD] || '').toLowerCase() !== 'yes') reasons.push('No recorded SMS consent');\n    if (quiet) reasons.push('Outside 8am to 9pm somewhere between ' + EAST_TZ + ' and ' + WEST_TZ);\n    if (APPROVAL_MODE) reasons.push('Approval mode is on');\n    out.push({\n      json: {\n        personId: p.id,\n        firstName: p.firstName || '',\n        phone,\n        assignedTo: p.assignedTo || null,\n        autoSendAllowed: reasons.length === 0,\n        holdReasons: reasons,\n        teamName: TEAM_NAME,\n        leadContext: 'Lead first name: ' + (p.firstName || 'unknown') + '\\nSource: ' + (p.source || 'unknown') + '\\nTeam: ' + TEAM_NAME,\n        claudeRequest: {\n          model: 'claude-opus-5-5',\n          max_tokens: 1500,\n          system: 'You write the first text message from a real estate team to a new lead. Under 240 characters. Name the property they asked about and use exactly one fact from the listing data provided (an open house time, a price change or a feature). Ask exactly one question about their timeline or what they want to see. Sound like a friendly person on the team, signed as the team\\'s assistant. Never state facts that are not in the data. If the listing is missing or its status is not Active, do not describe it as available; ask what they are looking for instead. Do not mention schools, neighborhood demographics, crime or anything related to protected classes. Do not include links or an opt-out line; those are added later.',\n          output_config: {\n            format: {\n              type: 'json_schema',\n              schema: {\n                type: 'object',\n                additionalProperties: false,\n                required: ['sms_text', 'fact_used', 'question', 'needs_agent_review', 'review_reason'],\n                properties: {\n                  sms_text: { type: 'string' },\n                  fact_used: { anyOf: [{ type: 'string' }, { type: 'null' }] },\n                  question: { type: 'string' },\n                  needs_agent_review: { type: 'boolean', description: 'True if the inquiry is unusual, mentions another agent, or the data is too thin for a good first text.' },\n                  review_reason: { anyOf: [{ type: 'string' }, { type: 'null' }] },\n                },\n              },\n            },\n          },\n        },\n      },\n      pairedItem: { item: 0 },\n    });\n  }\n}\nreturn out;\n"
      },
      "id": "8094f24b-d9ce-453e-9868-c0098198e585",
      "name": "Check consent and eligibility",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        300
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "https://api.followupboss.com/v1/events",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "personId",
              "value": "={{ $json.personId }}"
            },
            {
              "name": "hasProperty",
              "value": "true"
            },
            {
              "name": "limit",
              "value": "1"
            }
          ]
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-System",
              "value": "REPLACE_WITH_YOUR_SYSTEM_NAME"
            }
          ]
        },
        "options": {
          "timeout": 20000
        }
      },
      "id": "79ee6610-dc4b-4bd7-83d5-28a5872a6386",
      "name": "FUB: get inquiry event",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        300
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "method": "GET",
        "url": "REPLACE_WITH_RESO_BASE_URL/Property",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "$filter",
              "value": "=ListingId eq '{{ ($json.events?.[0]?.property?.mlsNumber ?? 'NONE').replace(/'/g, '') }}'"
            },
            {
              "name": "$select",
              "value": "ListingId,StandardStatus,ListPrice,UnparsedAddress,BedroomsTotal,BathroomsTotalInteger,LivingArea,PublicRemarks"
            },
            {
              "name": "$top",
              "value": "1"
            }
          ]
        },
        "options": {
          "timeout": 20000
        }
      },
      "id": "dc59a398-1a1c-4af1-96e7-08bbd8956221",
      "name": "Listing lookup (RESO Web API)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        880,
        300
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 2000,
      "onError": "continueRegularOutput"
    },
    {
      "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(Object.assign({}, $('Check consent and eligibility').item.json.claudeRequest, { messages: [{ role: 'user', content: $('Check consent and eligibility').item.json.leadContext + '\\n<inquiry_property>' + JSON.stringify($('FUB: get inquiry event').item.json.events?.[0]?.property ?? null) + '</inquiry_property>\\n<mls_listing>' + JSON.stringify($json.value?.[0] ?? null) + '</mls_listing>' }] })) }}",
        "options": {
          "timeout": 120000
        }
      },
      "id": "d1a92905-5f29-4949-b07b-4f65b46ae3b8",
      "name": "Claude: draft first text",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1100,
        300
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Pull the structured JSON out of a Messages API response.\n// Opus 5.5 always thinks, so a (possibly empty) thinking block can come before the text block.\nfunction parseClaude(res) {\n  if (!res || typeof res !== 'object') return { error: 'Empty response from the Messages API' };\n  if (res.stop_reason === 'refusal') {\n    return { error: 'Model declined the request (refusal' + (res.stop_details?.category ? ': ' + res.stop_details.category : '') + ')' };\n  }\n  if (res.stop_reason === 'max_tokens') return { error: 'Response hit max_tokens before the JSON was complete' };\n  const block = (res.content || []).find((b) => b.type === 'text');\n  if (!block) return { error: 'No text block in the response' };\n  try {\n    return { data: JSON.parse(block.text) };\n  } catch (e) {\n    return { error: 'Could not parse model JSON: ' + e.message };\n  }\n}\n\nreturn $input.all().map((item, i) => {\n  const lead = $('Check consent and eligibility').itemMatching(i).json;\n  const listing = $('Listing lookup (RESO Web API)').itemMatching(i).json.value?.[0] || null;\n  const { data, error } = parseClaude(item.json);\n  const reasons = [...lead.holdReasons];\n  if (error) reasons.push(error);\n  let sms = null;\n  if (data) {\n    sms = data.sms_text.trim() + ' - ' + lead.teamName + '. Reply STOP to opt out.';\n    if (sms.length > 320) reasons.push('Draft too long (' + sms.length + ' chars)');\n    if (/https?:\\/\\//i.test(data.sms_text)) reasons.push('Draft contains a link');\n    if (data.needs_agent_review) reasons.push('Model flagged for review: ' + (data.review_reason || 'no reason given'));\n  }\n  if (!listing) reasons.push('Listing not found in MLS feed');\n  else if (listing.StandardStatus !== 'Active') reasons.push('Listing status is ' + listing.StandardStatus);\n\n  const sendNow = reasons.length === 0;\n  return {\n    json: {\n      personId: lead.personId,\n      phone: lead.phone,\n      sms,\n      sendNow,\n      noteBody: sendNow ? null : 'AI first text NOT sent. Why: ' + reasons.join('; ') + (sms ? '\\n\\nSuggested text for the agent:\\n' + sms : ''),\n    },\n    pairedItem: { item: i },\n  };\n});\n"
      },
      "id": "670dfee1-5bde-4143-9034-a9c8c6031661",
      "name": "Validate draft",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "5281a673-f1fd-4b28-8be8-53174b2f7f43",
              "leftValue": "={{ $json.sendNow }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "0f242116-7a17-4003-af9c-98f3d19d6e32",
      "name": "Send automatically?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1540,
        300
      ]
    },
    {
      "parameters": {
        "resource": "sms",
        "operation": "send",
        "from": "REPLACE_WITH_TWILIO_NUMBER",
        "to": "={{ $json.phone }}",
        "toWhatsapp": false,
        "message": "={{ $json.sms }}",
        "options": {}
      },
      "id": "c66183a9-b97b-4e02-be62-e2528162eeb4",
      "name": "Twilio: send first text",
      "type": "n8n-nodes-base.twilio",
      "typeVersion": 1,
      "position": [
        1760,
        200
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.followupboss.com/v1/notes",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-System",
              "value": "REPLACE_WITH_YOUR_SYSTEM_NAME"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ personId: $('Check consent and eligibility').item.json.personId, subject: 'AI lead follow-up', body: $json.sid ? 'AI first text sent to ' + $json.to + ':\\n' + $json.body : ($json.noteBody ?? ('AI first text NOT sent. Error: ' + ($json.error?.message ?? 'unknown error'))) }) }}",
        "options": {
          "timeout": 20000
        }
      },
      "id": "9f886632-84bb-4933-bab6-3a3f257b64a0",
      "name": "FUB: add note for agent",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1980,
        300
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "fub-sms-reply",
        "options": {
          "noResponseBody": true
        }
      },
      "id": "6b9f239a-34a7-464a-8816-ffc5eb138a68",
      "name": "Twilio: lead replied",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        760
      ],
      "webhookId": "cdd5ea20-a3b1-4eac-9da8-e9af7243d0f0"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Carrier and Twilio opt-out keywords. A single-word reply matching one of these is an opt-out.\nconst OPT_OUT = ['STOP', 'STOPALL', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT', 'REVOKE', 'OPTOUT'];\nreturn $input.all().map((item, i) => {\n  const b = item.json.body || {};\n  const word = String(b.Body || '').trim().toUpperCase().replace(/[^A-Z]/g, '');\n  const isOptOut = OPT_OUT.includes(word);\n  return {\n    json: {\n      phone: String(b.From || ''),\n      isOptOut,\n      // Opt-out tags stop this workflow. The reply tag lets an FUB automation alert the assigned agent.\n      tags: isOptOut ? ['SMS Opt-Out'] : ['AI Text Replied'],\n      fields: isOptOut ? { customSMSConsent: 'Opted out' } : {},\n    },\n    pairedItem: { item: i },\n  };\n});\n"
      },
      "id": "265d2671-8e29-4ff4-8727-1d4623c60716",
      "name": "Detect opt-out",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        760
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "https://api.followupboss.com/v1/people",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "phone",
              "value": "={{ $json.phone }}"
            },
            {
              "name": "fields",
              "value": "id,firstName,tags"
            },
            {
              "name": "limit",
              "value": "5"
            }
          ]
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-System",
              "value": "REPLACE_WITH_YOUR_SYSTEM_NAME"
            }
          ]
        },
        "options": {
          "timeout": 20000
        }
      },
      "id": "10333946-6587-4243-a831-ac7a8dec1d05",
      "name": "FUB: find person by phone",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        440,
        760
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "=https://api.followupboss.com/v1/people/{{ $json.people[0].id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "mergeTags",
              "value": "true"
            }
          ]
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-System",
              "value": "REPLACE_WITH_YOUR_SYSTEM_NAME"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify(Object.assign({ tags: $('Detect opt-out').item.json.tags }, $('Detect opt-out').item.json.fields)) }}",
        "options": {
          "timeout": 20000
        }
      },
      "id": "3d65b2ef-a504-4910-aeaa-bc108b9a9961",
      "name": "FUB: tag reply or opt-out",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        760
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "onError": "continueRegularOutput"
    }
  ],
  "connections": {
    "FUB: new lead": {
      "main": [
        [
          {
            "node": "FUB: get new people",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FUB: get new people": {
      "main": [
        [
          {
            "node": "Check consent and eligibility",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check consent and eligibility": {
      "main": [
        [
          {
            "node": "FUB: get inquiry event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FUB: get inquiry event": {
      "main": [
        [
          {
            "node": "Listing lookup (RESO Web API)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Listing lookup (RESO Web API)": {
      "main": [
        [
          {
            "node": "Claude: draft first text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude: draft first text": {
      "main": [
        [
          {
            "node": "Validate draft",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "FUB: add note for agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate draft": {
      "main": [
        [
          {
            "node": "Send automatically?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send automatically?": {
      "main": [
        [
          {
            "node": "Twilio: send first text",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "FUB: add note for agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Twilio: send first text": {
      "main": [
        [
          {
            "node": "FUB: add note for agent",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "FUB: add note for agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Twilio: lead replied": {
      "main": [
        [
          {
            "node": "Detect opt-out",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Detect opt-out": {
      "main": [
        [
          {
            "node": "FUB: find person by phone",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FUB: find person by phone": {
      "main": [
        [
          {
            "node": "FUB: tag reply or opt-out",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
