{
  "name": "Lumeor | AI lead qualification agent (HubSpot + Gmail)",
  "nodes": [
    {
      "parameters": {
        "content": "## AI lead qualification agent (HubSpot + Gmail + Slack)\n\n**What it does**\n1. Starts on a website form or chat webhook, or on the lead's email reply.\n2. Loads the conversation state from HubSpot contact properties, so it never re-asks.\n3. Claude writes the next reply and returns structured answers (need, timeline, size, budget band, role, location).\n4. Code merges answers, scores the tier with a versioned rubric and decides: ask, book (A), hand off (B), nurture (C) or decline.\n5. Writes answers, tier, status and transcript back to HubSpot. Replies by email, and hands off to sales in Slack.\n6. SHADOW_MODE is on by default: replies go to Slack for a person to send until you trust them.\n\n**Setup**\n1. Credentials: Header Auth (webhook), Gmail OAuth2, HubSpot private app token (n8n \"HubSpot Service Key\"/App Token, used by both HubSpot nodes), Anthropic, Slack.\n2. In HubSpot, create contact properties lumeor_qual_answers, lumeor_qual_transcript (multi-line text), lumeor_qual_status and lumeor_qual_tier.\n3. Edit the constants at the top of **Build agent turn** and **Score and decide** (company, offer, booking link, rubric).\n4. Replace REPLACE_WITH_SALES_CHANNEL.\n\nModel: claude-opus-5-5. claude-haiku-5-5 is a cheaper option for simple, high-volume qualification.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/ai-lead-qualification-agent/",
        "height": 720,
        "width": 520
      },
      "id": "7ad9962a-9ca4-44a7-9a52-fb160e44daa1",
      "name": "Template notes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -620,
        -260
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "lumeor-lead-agent",
        "authentication": "headerAuth",
        "responseMode": "onReceived",
        "options": {}
      },
      "id": "31d892d3-9e99-4ca8-b389-b2238709863a",
      "name": "New lead message",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        0
      ],
      "webhookId": "3b25932c-c3c3-4fe8-ae49-039b507209aa"
    },
    {
      "parameters": {
        "pollTimes": {
          "item": [
            {
              "mode": "everyMinute"
            }
          ]
        },
        "simple": false,
        "filters": {
          "q": "subject:\"Your inquiry with\" -from:me in:inbox",
          "readStatus": "unread"
        },
        "options": {}
      },
      "id": "dfe0b9e8-0daa-49b5-8998-59a4250d2514",
      "name": "Lead email reply",
      "type": "n8n-nodes-base.gmailTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        220
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Accepts either a webhook body { email, name, company, message, source } or a Gmail reply.\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const j = items[i].json;\n  let lead;\n  if (j.body && j.body.email) {\n    const b = j.body;\n    lead = { email: String(b.email).toLowerCase().trim(), name: b.name || '', company: b.company || '', message: String(b.message || '').trim(), channel: b.source || 'form' };\n  } else {\n    const from = ((j.from && j.from.value && j.from.value[0]) || {});\n    // Keep only the new part of the reply, not the quoted history.\n    const text = String(j.text || '').split(/\\nOn .+wrote:|\\n>|\\n-----Original Message-----/)[0].trim();\n    lead = { email: String(from.address || '').toLowerCase(), name: from.name || '', company: '', message: text, channel: 'email' };\n  }\n  if (!lead.email || !lead.message) continue;\n  out.push({ json: { ...lead, receivedAt: new Date().toISOString() }, pairedItem: { item: i } });\n}\nreturn out;\n"
      },
      "id": "b49f8ace-0e3a-44f7-8044-b5a0d498e8e6",
      "name": "Normalize inbound",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        100
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.hubapi.com/crm/v3/objects/contacts/search",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "hubspotAppToken",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ filterGroups: [{ filters: [{ propertyName: 'email', operator: 'EQ', value: $json.email }] }], properties: ['email', 'firstname', 'lastname', 'company', 'lumeor_qual_answers', 'lumeor_qual_transcript', 'lumeor_qual_status', 'lumeor_qual_tier'], limit: 1 }) }}",
        "options": {}
      },
      "id": "b472a56d-a881-49c7-8a22-eb188d0e32b5",
      "name": "HubSpot: find contact",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        440,
        100
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Loads what the agent already knows from HubSpot and asks Claude for the next reply plus structured answers.\nconst COMPANY_NAME = 'REPLACE_WITH_COMPANY_NAME';\nconst WHAT_WE_SELL = 'REPLACE_WITH_ONE_SENTENCE_ABOUT_YOUR_OFFER';\nconst FINAL = ['booked', 'handed_off', 'nurture', 'declined'];\n\nconst ANSWER_FIELDS = {\n  need: { type: 'string', description: 'What they want help with, in a few words. Empty string if unknown.' },\n  timeline: { type: 'string', enum: ['now', '1_3_months', '3_6_months', '6_plus_months', 'unknown'] },\n  company_size: { type: 'string', enum: ['1_10', '11_50', '51_200', '201_1000', '1000_plus', 'unknown'] },\n  budget_band: { type: 'string', enum: ['under_5k', '5k_15k', '15k_50k', '50k_plus', 'unknown'] },\n  decision_role: { type: 'string', enum: ['decision_maker', 'influencer', 'researcher', 'unknown'] },\n  location: { type: 'string', description: 'City and country or US state. Empty string if unknown.' },\n};\nconst schema = {\n  type: 'object', additionalProperties: false,\n  required: ['answers', 'reply_to_lead', 'wants_human', 'off_topic'],\n  properties: {\n    answers: { type: 'object', additionalProperties: false, required: Object.keys(ANSWER_FIELDS), properties: ANSWER_FIELDS },\n    reply_to_lead: { type: 'string', description: 'The next message to the lead, under 90 words, asking at most two open questions' },\n    wants_human: { type: 'boolean', description: 'True if the lead asks for a person or is upset' },\n    off_topic: { type: 'boolean', description: 'True if this is spam, a job application or a vendor pitch' },\n  },\n};\n\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const lead = $('Normalize inbound').itemMatching(i).json;\n  const contact = (items[i].json.results || [])[0] || null;\n  const props = contact ? contact.properties : {};\n  if (FINAL.includes(props.lumeor_qual_status)) continue; // conversation already finished; a person owns it now\n  let known = {};\n  try { known = JSON.parse(props.lumeor_qual_answers || '{}'); } catch (e) { known = {}; }\n  const transcript = String(props.lumeor_qual_transcript || '');\n  const firstTurn = !transcript;\n  const open = Object.keys(ANSWER_FIELDS).filter((k) => !known[k] || known[k] === 'unknown');\n\n  const system = 'You are an AI assistant for ' + COMPANY_NAME + '. ' + WHAT_WE_SELL + '\\n' +\n    'Your job is to understand the lead\\'s need and qualify them politely. ' +\n    (firstTurn ? 'This is your first reply: say you are an AI assistant and reference what they wrote. ' : '') +\n    'Still unknown: ' + (open.join(', ') || 'nothing') + '. Ask about at most two of these, conversationally. ' +\n    'Never quote prices, discounts, contract terms or delivery dates. Never promise a meeting time. ' +\n    'Fill answers only from what the lead actually said, using unknown or empty string otherwise. Already known: ' + JSON.stringify(known);\n\n  out.push({\n    json: {\n      ...lead,\n      contactId: contact ? contact.id : '',\n      known,\n      transcript,\n      firstTurn,\n      turns: (transcript.match(/^Lead:/gm) || []).length + 1,\n      claudeRequest: {\n        model: 'claude-opus-5-5',\n        max_tokens: 2000,\n        system,\n        messages: [{ role: 'user', content: (transcript ? 'Conversation so far:\\n' + transcript.slice(-12000) + '\\n\\n' : '') + 'New message from ' + (lead.name || lead.email) + ' via ' + lead.channel + ':\\n' + lead.message }],\n        output_config: { format: { type: 'json_schema', schema } },\n      },\n    },\n    pairedItem: { item: i },\n  });\n}\nreturn out;\n"
      },
      "id": "264b52d9-a059-42bb-8f0a-4f526d87e806",
      "name": "Build agent turn",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        100
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.claudeRequest) }}",
        "options": {
          "timeout": 180000
        }
      },
      "id": "01221bda-9ae9-4cf2-a317-58db37223f1e",
      "name": "Claude: next reply",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        880,
        100
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Scoring is code, not the model. Rules are versioned here so every decision is explainable.\n\n// Reads a Messages API response. Thinking blocks may come first, so find the text block.\nfunction readClaude(resp) {\n  if (!resp || resp.error) {\n    const msg = resp && resp.error ? (resp.error.message || JSON.stringify(resp.error)) : 'empty response';\n    return { ok: false, reason: 'api_error: ' + String(msg).slice(0, 300) };\n  }\n  if (resp.stop_reason === 'refusal') {\n    const cat = resp.stop_details && resp.stop_details.category ? ' (' + resp.stop_details.category + ')' : '';\n    return { ok: false, reason: 'model_refusal' + cat };\n  }\n  if (resp.stop_reason === 'max_tokens') return { ok: false, reason: 'max_tokens: output was cut off, raise max_tokens' };\n  const block = (resp.content || []).find((b) => b.type === 'text');\n  if (!block) return { ok: false, reason: 'no_text_block_in_response' };\n  try {\n    return { ok: true, data: JSON.parse(block.text) };\n  } catch (e) {\n    return { ok: false, reason: 'invalid_json: ' + e.message };\n  }\n}\n\nconst RUBRIC_VERSION = 'v1';\nconst COMPANY_NAME = 'REPLACE_WITH_COMPANY_NAME';\nconst BOOKING_LINK = 'REPLACE_WITH_BOOKING_LINK';\nconst SHADOW_MODE = true; // true = every reply goes to Slack for a person to send. Set false to send automatically.\nconst MAX_TURNS = 4;\nconst REQUIRED = ['need', 'timeline', 'company_size', 'budget_band', 'decision_role'];\n\nfunction score(a) {\n  if (['1_10'].includes(a.company_size) && a.budget_band === 'under_5k') return 'DQ';\n  const soon = ['now', '1_3_months'].includes(a.timeline);\n  const budget = ['15k_50k', '50k_plus'].includes(a.budget_band);\n  const role = ['decision_maker', 'influencer'].includes(a.decision_role);\n  if (soon && budget && role) return 'A';\n  if ((soon || budget) && a.budget_band !== 'under_5k') return 'B';\n  return 'C';\n}\n\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const ctx = $('Build agent turn').itemMatching(i).json;\n  const r = readClaude(items[i].json);\n  const base = { email: ctx.email, name: ctx.name, company: ctx.company };\n  if (!r.ok) {\n    out.push({ json: { ...base, route: 'slack_only', status: 'needs_review', tier: '', answersJson: JSON.stringify(ctx.known), transcript: ctx.transcript,\n      slackText: ':warning: Lead agent could not answer ' + ctx.email + ' (' + r.reason + '). Last message: ' + ctx.message.slice(0, 500) }, pairedItem: { item: i } });\n    continue;\n  }\n  const d = r.data;\n  const answers = { ...ctx.known };\n  for (const [k, v] of Object.entries(d.answers)) if (v && v !== 'unknown') answers[k] = v; // never overwrite a known answer with unknown\n  const missing = REQUIRED.filter((k) => !answers[k] || answers[k] === 'unknown');\n\n  let reply = String(d.reply_to_lead || '').trim();\n  if (/\\$\\s?\\d/.test(reply)) reply = 'Thanks, that helps. Could you tell me a bit more about your timeline and who else is involved in the decision?';\n  if (ctx.firstTurn && !/\\bAI\\b/.test(reply)) reply = 'I am an AI assistant for ' + COMPANY_NAME + '. ' + reply;\n\n  let status = 'qualifying', tier = '', action = 'ask';\n  if (d.off_topic) { status = 'declined'; action = 'none'; }\n  else if (d.wants_human) { status = 'handed_off'; action = 'handoff'; reply = 'Of course. I have passed this to a member of our team, who will reply shortly.'; }\n  else if (!missing.length || ctx.turns >= MAX_TURNS) {\n    tier = missing.length ? 'B' : score(answers);\n    if (tier === 'A') { status = 'booked'; action = 'book'; reply = 'Thanks, this sounds like a good fit. You can pick a time with our team here: ' + BOOKING_LINK; }\n    else if (tier === 'B') { status = 'handed_off'; action = 'handoff'; reply = 'Thanks for the detail. A member of our team will follow up within one business day.'; }\n    else if (tier === 'C') { status = 'nurture'; action = 'nurture'; reply = 'Thanks for sharing this. It sounds a little early for a project, so we will send you a few useful resources and you can reply any time.'; }\n    else { status = 'declined'; action = 'decline'; reply = 'Thanks for reaching out. We are probably not the right fit for this one, and we would rather say so now than waste your time.'; }\n  }\n\n  const transcript = (ctx.transcript + '\\nLead: ' + ctx.message + (action === 'none' ? '' : '\\nAgent: ' + reply)).trim().slice(-20000);\n  const summary = (ctx.name || ctx.email) + (ctx.company ? ' at ' + ctx.company : '') + ' | tier ' + (tier || 'pending') + ' (' + RUBRIC_VERSION + ')\\n' +\n    'Need: ' + (answers.need || '?') + ' | timeline ' + (answers.timeline || '?') + ' | size ' + (answers.company_size || '?') + ' | budget ' + (answers.budget_band || '?') + ' | role ' + (answers.decision_role || '?');\n  let route = action === 'none' ? 'none' : action === 'handoff' ? 'handoff' : 'reply';\n  if (SHADOW_MODE && route === 'reply') route = 'slack_only';\n  out.push({\n    json: {\n      ...base, route, status, tier, answersJson: JSON.stringify(answers), transcript,\n      subject: 'Your inquiry with ' + COMPANY_NAME,\n      reply,\n      slackText: (action === 'handoff' ? ':handshake: *Lead handoff*\\n' : ':robot_face: *Lead agent draft (shadow mode, send it yourself)*\\nTo: ' + ctx.email + '\\n') + summary + '\\n\\nLast message: ' + ctx.message.slice(0, 600) + '\\n\\nReply: ' + reply,\n    },\n    pairedItem: { item: i },\n  });\n}\nreturn out;\n"
      },
      "id": "69ce04a8-75fa-415a-a2e9-baf98b4e79f2",
      "name": "Score and decide",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        100
      ]
    },
    {
      "parameters": {
        "authentication": "appToken",
        "resource": "contact",
        "operation": "upsert",
        "email": "={{ $json.email }}",
        "additionalFields": {
          "firstName": "={{ ($json.name || '').split(' ')[0] }}",
          "customPropertiesUi": {
            "customPropertiesValues": [
              {
                "property": "lumeor_qual_answers",
                "value": "={{ $json.answersJson }}"
              },
              {
                "property": "lumeor_qual_transcript",
                "value": "={{ $json.transcript }}"
              },
              {
                "property": "lumeor_qual_status",
                "value": "={{ $json.status }}"
              },
              {
                "property": "lumeor_qual_tier",
                "value": "={{ $json.tier }}"
              }
            ]
          }
        }
      },
      "id": "bb2a5e01-2da6-4db2-b16d-dbf30569a5aa",
      "name": "HubSpot: save answers",
      "type": "n8n-nodes-base.hubspot",
      "typeVersion": 2.1,
      "position": [
        1320,
        -60
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "2414fc4e-944f-4105-a3f5-6e77dd2dbf3b",
                    "leftValue": "={{ $json.route }}",
                    "rightValue": "reply",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "reply"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "b2d4c227-1b96-4168-a87a-2e2f5833620c",
                    "leftValue": "={{ $json.route }}",
                    "rightValue": "handoff",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "handoff"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "2419bc44-7f56-4de2-9deb-1ee31cedafb8",
                    "leftValue": "={{ $json.route }}",
                    "rightValue": "slack_only",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "slack_only"
            }
          ]
        },
        "options": {}
      },
      "id": "22f35898-7344-4fe2-8286-3963d68f98b0",
      "name": "Next action",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        1320,
        160
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "send",
        "sendTo": "={{ $json.email }}",
        "subject": "={{ $json.subject }}",
        "emailType": "text",
        "message": "={{ $json.reply }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "b5fd0003-5e51-4728-b12e-0223612fdcda",
      "name": "Email the lead",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1540,
        60
      ]
    },
    {
      "parameters": {
        "authentication": "accessToken",
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#REPLACE_WITH_SALES_CHANNEL"
        },
        "messageType": "text",
        "text": "={{ $json.slackText || (':warning: Lead agent error: ' + (($json.error && ($json.error.message || $json.error.description)) || JSON.stringify($json.error || $json).slice(0, 500)) + ' (execution ' + $execution.id + ')') }}",
        "otherOptions": {
          "includeLinkToWorkflow": true
        }
      },
      "id": "b6ba0dc8-24ce-40e9-97d5-f6e83b625411",
      "name": "Notify sales",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1540,
        280
      ]
    }
  ],
  "connections": {
    "New lead message": {
      "main": [
        [
          {
            "node": "Normalize inbound",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lead email reply": {
      "main": [
        [
          {
            "node": "Normalize inbound",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize inbound": {
      "main": [
        [
          {
            "node": "HubSpot: find contact",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HubSpot: find contact": {
      "main": [
        [
          {
            "node": "Build agent turn",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Notify sales",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build agent turn": {
      "main": [
        [
          {
            "node": "Claude: next reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude: next reply": {
      "main": [
        [
          {
            "node": "Score and decide",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Score and decide",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Score and decide": {
      "main": [
        [
          {
            "node": "HubSpot: save answers",
            "type": "main",
            "index": 0
          },
          {
            "node": "Next action",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HubSpot: save answers": {
      "main": [
        [],
        [
          {
            "node": "Notify sales",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Next action": {
      "main": [
        [
          {
            "node": "Email the lead",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Email the lead",
            "type": "main",
            "index": 0
          },
          {
            "node": "Notify sales",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Notify sales",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
