{
  "name": "Lead triage: form to HubSpot with LLM scoring",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "lead-intake",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "webhook",
      "name": "Form submission",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -200,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "\nconst crypto = require('crypto');\nconst clean = (v) => (v === null || v === undefined) ? '' : String(v).trim();\n\nreturn $input.all().map((item) => {\n  const b = item.json.body ?? item.json;\n  const rec = {\n    submission_id: clean(b.submission_id),\n    full_name: clean(b.full_name),\n    email: clean(b.email).toLowerCase(),\n    company: clean(b.company),\n    website: clean(b.website),\n    role: clean(b.role),\n    project_description: clean(b.project_description),\n    budget_range: clean(b.budget_range),\n    timeline: clean(b.timeline),\n    how_heard: clean(b.how_heard),\n  };\n\n  // A submission is only scoreable if we can reach the person and know what they want.\n  const problems = [];\n  if (!rec.email) problems.push('no email address');\n  else if (!/^[^@\\s]+@[^@\\s.]+\\.[^@\\s]+$/.test(rec.email)) problems.push('email address is not usable');\n  if (!rec.project_description) problems.push('no description of the work');\n\n  // Fingerprint of the answers that matter, so the same form sent twice is recognised.\n  rec.submission_fingerprint = crypto.createHash('sha256')\n    .update([rec.email, rec.project_description, rec.budget_range, rec.timeline].join('|'))\n    .digest('hex');\n\n  const parts = rec.full_name.split(/\\s+/).filter(Boolean);\n  rec.first_name = parts.length ? parts[0] : '';\n  rec.last_name = parts.length > 1 ? parts.slice(1).join(' ') : '';\n\n  rec.usable = problems.length === 0;\n  rec.problems = problems;\n  rec.received_at = new Date().toISOString();\n  return { json: rec };\n});\n"
      },
      "id": "validate-and-normalise",
      "name": "Validate and normalise",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        20,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.usable }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "usable-submission",
      "name": "Usable submission?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        240,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "\nreturn $input.all().map((i) => ({ json: {\n  queue: 'lead-review',\n  reason: 'incomplete_submission',\n  detail: i.json.problems.join('; '),\n  submission_id: i.json.submission_id,\n  email: i.json.email,\n  received_at: i.json.received_at,\n  submission: i.json,\n}}));\n"
      },
      "id": "flag-for-review-incomplete",
      "name": "Flag for review (incomplete)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        460,
        520
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.hubapi.com/crm/v3/objects/contacts/search",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ filterGroups: [{ filters: [{ propertyName: 'email', operator: 'EQ', value: $json.email }] }], properties: ['email','lead_submission_hash','lead_score'], limit: 1 }) }}",
        "options": {},
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "hubspotAppToken"
      },
      "id": "hubspot-look-up-by-email",
      "name": "HubSpot: look up by email",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        460,
        160
      ],
      "credentials": {
        "hubspotAppToken": {
          "name": "HubSpot account"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "\nconst leads = $('Validate and normalise').all();\nreturn $input.all().map((item, i) => {\n  const lead = leads[i].json;\n  const results = (item.json && item.json.results) || [];\n  const existing = results[0] || null;\n  const seen = existing && existing.properties ? (existing.properties.lead_submission_hash || '') : '';\n  return { json: {\n    ...lead,\n    existing_contact_id: existing ? existing.id : null,\n    existing_score: existing && existing.properties ? (existing.properties.lead_score || null) : null,\n    // Same person AND same answers => this form has already been scored. Score it again\n    // and we overwrite a good score with a possibly different one, and re-alert Slack.\n    already_processed: Boolean(existing) && seen === lead.submission_fingerprint,\n  }};\n});\n"
      },
      "id": "assess-duplicate",
      "name": "Assess duplicate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        680,
        160
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.already_processed }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "already-processed",
      "name": "Already processed?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        900,
        160
      ]
    },
    {
      "parameters": {
        "jsCode": "\nconst RUBRIC_VERSION = 'v1.2';\nconst MODEL = 'claude-haiku-4-5-20251001';\n\nconst SYSTEM = [\n  'You score inbound leads for a studio that builds marketing sites, ecommerce builds,',\n  'replatforming projects and design systems.',\n  '',\n  'Score against exactly four criteria.',\n  '1. Budget signal. A stated range at or above the minimum engagement, an existing vendor',\n  '   being replaced, or a funded project is strong. Vague, absent or below minimum is weak.',\n  '2. Timeline. A named date, quarter or event they are working backwards from is strong.',\n  '   Just exploring is weak. Silence is neutral, not weak.',\n  '3. Fit. Marketing sites, ecommerce, replatforming and design systems are in scope.',\n  '   One-off graphics, logo tweaks, SEO link packages, unpaid or student work are out of',\n  '   scope. Anyone pitching their own services to us is out of scope however it is worded.',\n  '4. Authority. Founder, owner, CEO, VP and head-of are strong. Manager and coordinator are',\n  '   middling. Intern, student or no stated role is weak.',\n  '',\n  'Combine them: Hot means strong on fit AND at least two of budget, timeline, authority.',\n  'Warm means in scope but only one of those three is strong, or one is actively weak.',\n  'Cold means out of scope on fit, or in scope and weak on all three of the others.',\n  'Fit is the gate. Nothing out of scope is ever Hot or Warm, whatever budget it quotes.',\n  '',\n  'Reply with a single JSON object and nothing else, in this exact shape:',\n  '{\"score\":\"Hot|Warm|Cold\",\"reason\":\"one sentence\",\"criteria\":{\"budget\":\"strong|weak|unclear\",',\n  '\"timeline\":\"strong|weak|unclear\",\"fit\":\"in_scope|out_of_scope\",\"authority\":\"strong|middling|weak\"}}',\n].join('\\n');\n\nreturn $input.all().map((i) => {\n  const l = i.json;\n  const user = [\n    'Name: ' + (l.full_name || '(not given)'),\n    'Role: ' + (l.role || '(not given)'),\n    'Company: ' + (l.company || '(not given)'),\n    'Website: ' + (l.website || '(not given)'),\n    'Budget: ' + (l.budget_range || '(not given)'),\n    'Timeline: ' + (l.timeline || '(not given)'),\n    'Heard about us via: ' + (l.how_heard || '(not given)'),\n    '',\n    'What they wrote:',\n    l.project_description,\n  ].join('\\n');\n  return { json: { ...l, rubric_version: RUBRIC_VERSION, model: MODEL, system: SYSTEM, user } };\n});\n"
      },
      "id": "build-scoring-prompt",
      "name": "Build scoring prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1120,
        260
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: $json.model, max_tokens: 400, temperature: 0, system: $json.system, messages: [{ role: 'user', content: $json.user }] }) }}",
        "options": {},
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            },
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        }
      },
      "id": "claude-score-the-lead",
      "name": "Claude: score the lead",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1340,
        260
      ],
      "credentials": {
        "httpHeaderAuth": {
          "name": "Anthropic API"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "\nconst leads = $('Build scoring prompt').all();\nconst ALLOWED = ['Hot', 'Warm', 'Cold'];\n\nreturn $input.all().map((item, i) => {\n  const lead = leads[i].json;\n  const res = item.json || {};\n  const text = ((res.content || []).map((c) => c.text || '').join('')).trim();\n\n  let parsed = null;\n  let problem = null;\n  try {\n    const m = text.match(/\\{[\\s\\S]*\\}/);\n    if (!m) throw new Error('no JSON object in the reply');\n    parsed = JSON.parse(m[0]);\n  } catch (e) {\n    problem = 'model reply did not parse: ' + e.message;\n  }\n  // Fail closed. A score we cannot read must not quietly become Cold, because Cold is\n  // exactly where a mis-read Hot lead would go to die.\n  if (!problem && !ALLOWED.includes(parsed.score)) {\n    problem = 'model returned an unknown score: ' + JSON.stringify(parsed.score);\n  }\n  if (!problem && typeof parsed.reason !== 'string') {\n    problem = 'model reply carried no reason';\n  }\n\n  return { json: {\n    ...lead,\n    score: problem ? null : parsed.score,\n    reason: problem ? null : parsed.reason,\n    criteria: problem ? null : (parsed.criteria || null),\n    criteria_text: problem ? '' : JSON.stringify(parsed.criteria || {}),\n    score_usable: !problem,\n    score_problem: problem,\n    model_usage: res.usage || null,\n    raw_model_reply: text,\n    scored_at: new Date().toISOString(),\n  }};\n});\n"
      },
      "id": "parse-and-check-the-score",
      "name": "Parse and check the score",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1560,
        260
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.score_usable }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "score-usable",
      "name": "Score usable?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1780,
        260
      ]
    },
    {
      "parameters": {
        "jsCode": "\nreturn $input.all().map((i) => ({ json: {\n  queue: 'lead-review',\n  reason: 'unscoreable_reply',\n  detail: i.json.score_problem,\n  submission_id: i.json.submission_id,\n  email: i.json.email,\n  received_at: i.json.received_at,\n  raw_model_reply: i.json.raw_model_reply,\n  submission: i.json,\n}}));\n"
      },
      "id": "flag-for-review-unscoreable",
      "name": "Flag for review (unscoreable)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2000,
        420
      ]
    },
    {
      "parameters": {
        "authentication": "appToken",
        "resource": "contact",
        "operation": "upsert",
        "email": "={{ $json.email }}",
        "additionalFields": {
          "firstName": "={{ $json.first_name }}",
          "lastName": "={{ $json.last_name }}",
          "companyName": "={{ $json.company }}",
          "jobTitle": "={{ $json.role }}",
          "websiteUrl": "={{ $json.website }}",
          "customPropertiesUi": {
            "customPropertiesValues": [
              {
                "property": "lead_score",
                "value": "={{ $json.score }}"
              },
              {
                "property": "lead_score_reason",
                "value": "={{ $json.reason }}"
              },
              {
                "property": "lead_score_criteria",
                "value": "={{ $json.criteria_text }}"
              },
              {
                "property": "lead_rubric_version",
                "value": "={{ $json.rubric_version }}"
              },
              {
                "property": "lead_submission_hash",
                "value": "={{ $json.submission_fingerprint }}"
              },
              {
                "property": "lead_submission_id",
                "value": "={{ $json.submission_id }}"
              },
              {
                "property": "lead_scored_at",
                "value": "={{ $json.scored_at }}"
              }
            ]
          }
        }
      },
      "id": "hubspot-upsert",
      "name": "HubSpot: upsert scored contact",
      "type": "n8n-nodes-base.hubspot",
      "typeVersion": 2.2,
      "position": [
        2000,
        200
      ],
      "credentials": {
        "hubspotAppToken": {
          "name": "HubSpot account"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $('Parse and check the score').item.json.score }}",
              "rightValue": "Hot",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "hot-lead",
      "name": "Hot lead?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2220,
        200
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://hooks.slack.com/services/REPLACE/WITH/YOUR-WEBHOOK-URL",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: 'Hot lead: ' + ($('Parse and check the score').item.json.full_name || $('Parse and check the score').item.json.email), blocks: [ { type: 'header', text: { type: 'plain_text', text: 'Hot lead' } }, { type: 'section', fields: [ { type: 'mrkdwn', text: '*Name*\\n' + ($('Parse and check the score').item.json.full_name || '(not given)') }, { type: 'mrkdwn', text: '*Company*\\n' + ($('Parse and check the score').item.json.company || '(not given)') }, { type: 'mrkdwn', text: '*Email*\\n' + $('Parse and check the score').item.json.email }, { type: 'mrkdwn', text: '*Budget*\\n' + ($('Parse and check the score').item.json.budget_range || '(not given)') } ] }, { type: 'section', text: { type: 'mrkdwn', text: '*Why it scored Hot*\\n' + $('Parse and check the score').item.json.reason } }, { type: 'context', elements: [ { type: 'mrkdwn', text: 'Submission ' + $('Parse and check the score').item.json.submission_id + ' \u00b7 rubric ' + $('Parse and check the score').item.json.rubric_version } ] } ] }) }}",
        "options": {}
      },
      "id": "slack-hot-lead-alert",
      "name": "Slack: hot lead alert",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2440,
        120
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://127.0.0.1:5999/review-queue",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json) }}",
        "options": {}
      },
      "id": "send-to-review-queue",
      "name": "Send to review queue",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2220,
        470
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ status: 'needs_review', reason: $json.reason || 'flagged', submission_id: $json.submission_id }) }}",
        "options": {
          "responseCode": 202
        }
      },
      "id": "respond-needs-review",
      "name": "Respond: needs review",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        2440,
        470
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ status: 'duplicate_ignored', submission_id: $json.submission_id, existing_contact_id: $json.existing_contact_id, existing_score: $json.existing_score }) }}",
        "options": {
          "responseCode": 200
        }
      },
      "id": "respond-duplicate",
      "name": "Respond: duplicate",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1120,
        60
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ status: 'scored', score: $('Parse and check the score').item.json.score, reason: $('Parse and check the score').item.json.reason, notified: true, submission_id: $('Parse and check the score').item.json.submission_id }) }}",
        "options": {
          "responseCode": 200
        }
      },
      "id": "respond-scored-hot",
      "name": "Respond: scored hot",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        2660,
        120
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ status: 'scored', score: $('Parse and check the score').item.json.score, reason: $('Parse and check the score').item.json.reason, notified: false, submission_id: $('Parse and check the score').item.json.submission_id }) }}",
        "options": {
          "responseCode": 200
        }
      },
      "id": "respond-scored",
      "name": "Respond: scored",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        2440,
        280
      ]
    }
  ],
  "connections": {
    "Form submission": {
      "main": [
        [
          {
            "node": "Validate and normalise",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate and normalise": {
      "main": [
        [
          {
            "node": "Usable submission?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Usable submission?": {
      "main": [
        [
          {
            "node": "HubSpot: look up by email",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag for review (incomplete)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flag for review (incomplete)": {
      "main": [
        [
          {
            "node": "Send to review queue",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HubSpot: look up by email": {
      "main": [
        [
          {
            "node": "Assess duplicate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Assess duplicate": {
      "main": [
        [
          {
            "node": "Already processed?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Already processed?": {
      "main": [
        [
          {
            "node": "Respond: duplicate",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build scoring prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build scoring prompt": {
      "main": [
        [
          {
            "node": "Claude: score the lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude: score the lead": {
      "main": [
        [
          {
            "node": "Parse and check the score",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse and check the score": {
      "main": [
        [
          {
            "node": "Score usable?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Score usable?": {
      "main": [
        [
          {
            "node": "HubSpot: upsert scored contact",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag for review (unscoreable)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flag for review (unscoreable)": {
      "main": [
        [
          {
            "node": "Send to review queue",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send to review queue": {
      "main": [
        [
          {
            "node": "Respond: needs review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HubSpot: upsert scored contact": {
      "main": [
        [
          {
            "node": "Hot lead?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Hot lead?": {
      "main": [
        [
          {
            "node": "Slack: hot lead alert",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond: scored",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Slack: hot lead alert": {
      "main": [
        [
          {
            "node": "Respond: scored hot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}