{
  "name": "Clay-style enrichment table (n8n, by Mina Saad)",
  "nodes": [
    {
      "parameters": {},
      "id": "run-table-now",
      "name": "Run Table Now",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        -128
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 30
            }
          ]
        }
      },
      "id": "cron-30m",
      "name": "Every 30 Minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.3,
      "position": [
        0,
        128
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "s1",
              "name": "batch_size",
              "value": 25,
              "type": "number"
            },
            {
              "id": "s2",
              "name": "max_credits_per_run",
              "value": 40,
              "type": "number"
            },
            {
              "id": "s3",
              "name": "min_verifier_score",
              "value": 80,
              "type": "number"
            },
            {
              "id": "s4",
              "name": "min_icp_fit_for_opener",
              "value": 50,
              "type": "number"
            },
            {
              "id": "s5",
              "name": "usd_per_hunter_credit",
              "value": 0.0245,
              "type": "number"
            },
            {
              "id": "s6",
              "name": "usd_per_dropcontact_credit",
              "value": 0.158,
              "type": "number"
            },
            {
              "id": "s7",
              "name": "icp_description",
              "value": "B2B SaaS companies selling to mid-market and enterprise with 20-500 employees",
              "type": "string"
            },
            {
              "id": "s8",
              "name": "your_offer",
              "value": "We build custom outbound enrichment and lead qualification workflows that cut data vendor spend by 70%.",
              "type": "string"
            },
            {
              "id": "s9",
              "name": "report_email",
              "value": "REPORT_EMAIL",
              "type": "string"
            },
            {
              "id": "s-tdept",
              "name": "target_departments",
              "type": "string",
              "value": "sales,executive,marketing"
            },
            {
              "id": "s-ttitle",
              "name": "target_titles",
              "type": "string",
              "value": "head of sales, vp sales, sales director, revenue, growth, founder, ceo"
            }
          ]
        },
        "options": {}
      },
      "id": "table-settings",
      "name": "Table Settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        272,
        0
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "",
          "cachedResultName": "YOUR_LEAD_SHEET"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Leads"
        },
        "filtersUI": {
          "values": [
            {
              "lookupColumn": "status",
              "lookupValue": "pending"
            }
          ]
        },
        "options": {}
      },
      "id": "read-pending",
      "name": "Read Pending Rows",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        528,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Cap rows per run to the configured batch size to keep execution predictable.\n// Rows missing vital identifiers (company name or prospect first and last name)\n// cannot be enriched or verified, so they are marked skipped immediately.\nconst settings = $('Table Settings').first().json || {};\nconst batchSize = Number(settings.batch_size) || 25;\nconst rawRows = $input.all().map(i => i.json);\n\nconst batch = rawRows.slice(0, batchSize);\n\nreturn batch.map(row => {\n  const company = String(row.company || '').trim();\n  const first = String(row.first_name || '').trim();\n  const last = String(row.last_name || '').trim();\n\n  // A company is all a row needs. Without a person, the waterfall finds one.\n  const missing = [];\n  if (!company) missing.push('company');\n\n  const isSkipped = missing.length > 0;\n\n  return { json: {\n    ...row,\n    status: isSkipped ? 'skipped' : (row.status || 'pending'),\n    error: isSkipped ? ('Missing required fields: ' + missing.join(', ')) : '',\n    opener_status: isSkipped ? 'skipped: missing required fields' : '',\n  } };\n});\n"
      },
      "id": "pick-batch",
      "name": "Pick Batch",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        784,
        0
      ]
    },
    {
      "parameters": {
        "options": {}
      },
      "id": "loop-rows",
      "name": "Loop Rows",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        1040,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Sum credits spent in earlier passes through Finalize Row in this execution.\n// Running one row at a time ensures the budget cap is respected precisely,\n// preventing costly external API calls once the credit threshold is reached.\nconst item = $input.first().json || {};\nconst settings = $('Table Settings').first().json || {};\nconst maxCredits = Number(settings.max_credits_per_run) || 40;\n\n// $('Finalize Row').all() alone only returns the latest pass inside a loop,\n// so walk every earlier run index explicitly. Finalize Row has run exactly\n// once per completed row when this node starts its own run.\nlet spentCredits = 0;\nfor (let r = 0; r < $runIndex; r++) {\n  let pass = [];\n  try { pass = $('Finalize Row').all(0, r) || []; } catch (e) { break; }\n  for (const done of pass) spentCredits += Number(done.json?.credits_used) || 0;\n}\n\nconst creditsLeft = Math.max(0, maxCredits - spentCredits);\n\nreturn [{ json: {\n  ...item,\n  spent_credits_so_far: spentCredits,\n  credits_left: creditsLeft,\n} }];\n"
      },
      "id": "check-budget",
      "name": "Check Run Budget",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1312,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "conditions": [
            {
              "id": "rnw-st",
              "leftValue": "={{ $json.status }}",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              },
              "rightValue": "skipped"
            }
          ],
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "combinator": "and"
        },
        "options": {}
      },
      "id": "row-needs-work",
      "name": "Row Needs Work",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        1568,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "conditions": [
            {
              "id": "hw-site",
              "leftValue": "={{ $json.website }}",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              },
              "rightValue": ""
            }
          ],
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "combinator": "and"
        },
        "options": {}
      },
      "id": "has-website",
      "name": "Has Website",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        2080,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Filter Firecrawl search results for the authentic corporate domain.\n// Exclude directories, social media profiles, and encyclopedias. Select\n// the first remaining host matching a word token from the company name.\nconst all = $input.all().map(i => i.json || {});\nconst item = all[0] || {};\nconst prev = $('Check Run Budget').item.json || item;\nconst company = String(prev.company || '').toLowerCase().trim();\n\nconst BANNED_HOSTS = new Set([\n  'linkedin.com', 'crunchbase.com', 'wikipedia.org', 'facebook.com',\n  'x.com', 'twitter.com', 'instagram.com', 'youtube.com', 'glassdoor.com',\n  'bloomberg.com', 'zoominfo.com', 'apollo.io', 'rocketreach.co',\n  'play.google.com', 'apps.apple.com', 'github.com', 'medium.com', 'reddit.com',\n  'g2.com', 'capterra.com', 'producthunt.com', 'tiktok.com'\n]);\n\nconst tokens = company.replace(/[^a-z0-9\\s]/g, ' ')\n  .split(/\\s+/)\n  .filter(t => t.length >= 3 && !['inc', 'llc', 'ltd', 'corp', 'company', 'the'].includes(t));\n\nlet foundHost = '';\n// Firecrawl returns { data: { web: [...] } }; also accept one item per result.\nconst results = all.flatMap(j => {\n  if (Array.isArray(j.data?.web)) return j.data.web;\n  if (Array.isArray(j.data)) return j.data;\n  if (Array.isArray(j.web?.results)) return j.web.results;\n  if (j.url) return [j];\n  return [];\n});\n\nfor (const r of results) {\n  const rawUrl = r.url || r.link || '';\n  if (!rawUrl) continue;\n  // The Code node sandbox has no URL global, so hosts are parsed by hand.\n  const hm = (rawUrl.startsWith('http') ? rawUrl : 'https://' + rawUrl).match(/^[a-z]+:\\/\\/([^\\/?#:]+)/i);\n  try {\n    if (!hm) continue;\n    const host = hm[1].toLowerCase().replace(/^www\\./, '');\n    const isBanned = Array.from(BANNED_HOSTS).some(b => host === b || host.endsWith('.' + b));\n    if (isBanned) continue;\n\n    const matchesToken = tokens.length === 0 || tokens.some(t => host.includes(t));\n    // Only a host carrying the company's own name counts. Taking the first\n    // unrelated result matched an unknown company to play.google.com.\n    if (matchesToken) {\n      foundHost = host;\n      break;\n    }\n  } catch (e) {\n    // Ignore invalid link\n  }\n}\n\nreturn [{ json: {\n  ...prev,\n  domain: foundHost,\n  domain_source: foundHost ? 'search' : '',\n  search_error: item.error || null,\n} }];\n"
      },
      "id": "pick-domain",
      "name": "Pick Domain",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2608,
        160
      ]
    },
    {
      "parameters": {
        "jsCode": "// Clean user-supplied website URL down to its bare root domain.\n// Strips protocol prefixes, www subdomains, and trailing path components.\nconst item = $input.first().json || {};\nconst prev = $('Check Run Budget').item.json || item;\nconst raw = String(prev.website || '').trim().toLowerCase();\n\nlet host = raw\n  .replace(/^https?:\\/\\//i, '')\n  .replace(/^www\\./i, '')\n  .split('/')[0]\n  .split('?')[0]\n  .trim();\n\nreturn [{ json: {\n  ...prev,\n  domain: host,\n  domain_source: host ? 'input' : '',\n} }];\n"
      },
      "id": "norm-website",
      "name": "Normalize Website",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2352,
        -160
      ]
    },
    {
      "parameters": {
        "conditions": {
          "conditions": [
            {
              "id": "df-dom",
              "leftValue": "={{ $json.domain }}",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              },
              "rightValue": ""
            }
          ],
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "combinator": "and"
        },
        "options": {}
      },
      "id": "domain-found",
      "name": "Domain Found",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        2864,
        0
      ]
    },
    {
      "parameters": {
        "url": "=https://{{ $json.domain }}",
        "scrapeOptions": {
          "options": {
            "formats": {
              "format": [
                {},
                {
                  "type": "links"
                }
              ]
            },
            "headers": {}
          }
        },
        "requestOptions": {}
      },
      "id": "scrape-home",
      "name": "Scrape Homepage",
      "type": "@mendable/n8n-nodes-firecrawl.firecrawl",
      "typeVersion": 1,
      "position": [
        3120,
        0
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Discover an About or Team link in the scraped homepage links array.\n// Confines traversal to the same domain matching standard company keywords.\nconst scrape = $input.first().json || {};\nconst row = $('Domain Found').item.json || {};\nconst domain = String(row.domain || '').toLowerCase();\n\nconst links = scrape.data?.links || scrape.links || [];\nconst markdown = scrape.data?.markdown || scrape.markdown || '';\n\n// The Code node sandbox has no URL global, so links are resolved by hand.\n// Candidates are ranked: an About page says the most about a company, a\n// contact page the least, and deep paths are usually product pages that only\n// happen to contain a keyword (/scheduling/sales-teams is not a team page).\nconst RANK = ['about', 'company', 'who-we-are', 'team', 'leadership', 'contact'];\nlet aboutUrl = '';\nlet best = RANK.length;\n\nfor (const link of links) {\n  const str = String(typeof link === 'string' ? link : (link.url || '')).trim();\n  if (!str || str.startsWith('#')) continue;\n  let host = domain;\n  let path = '';\n  const abs = str.match(/^[a-z]+:\\/\\/([^\\/?#:]+)([^?#]*)/i);\n  if (abs) {\n    host = abs[1].toLowerCase().replace(/^www\\./, '');\n    path = abs[2] || '/';\n  } else if (/^[a-z]+:/i.test(str)) {\n    continue;\n  } else {\n    path = '/' + str.replace(/^\\.?\\//, '').split(/[?#]/)[0];\n  }\n  if (host !== domain) continue;\n  const segs = path.toLowerCase().split('/').filter(Boolean);\n  if (segs.length === 0 || segs.length > 2) continue;\n  const r = RANK.findIndex(k => segs[0] === k || segs[0] === k + '-us' || segs[0].startsWith(k));\n  if (r !== -1 && r < best) {\n    best = r;\n    aboutUrl = 'https://' + domain + '/' + segs.join('/');\n  }\n}\n\nreturn [{ json: {\n  ...row,\n  homepage_markdown: markdown,\n  about_url: aboutUrl,\n  has_about_page: Boolean(aboutUrl),\n  scrape_homepage_error: scrape.error || null,\n} }];\n"
      },
      "id": "find-about",
      "name": "Find About Page",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3392,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "conditions": [
            {
              "id": "ape-ex",
              "leftValue": "={{ $json.has_about_page }}",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "rightValue": ""
            }
          ],
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "combinator": "and"
        },
        "options": {}
      },
      "id": "about-exists",
      "name": "About Page Exists",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        3648,
        0
      ]
    },
    {
      "parameters": {
        "url": "={{ $json.about_url }}",
        "scrapeOptions": {
          "options": {
            "formats": {
              "format": [
                {},
                {
                  "type": "links"
                }
              ]
            },
            "headers": {}
          }
        },
        "requestOptions": {}
      },
      "id": "scrape-about",
      "name": "Scrape About Page",
      "type": "@mendable/n8n-nodes-firecrawl.firecrawl",
      "typeVersion": 1,
      "position": [
        3904,
        -160
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Combine homepage and about page text, capping context length at 12k chars.\n// Scrapes any email addresses visible in copy to seed the email waterfall.\nconst row = $('Find About Page').first().json || {};\nconst hasAbout = Boolean(row.has_about_page);\nconst aboutScrape = hasAbout ? ($input.first().json || {}) : {};\n\nconst homeText = String(row.homepage_markdown || '');\nconst aboutText = String(aboutScrape.data?.markdown || aboutScrape.markdown || '');\n\nlet pagesCount = 0;\nif (homeText.trim()) pagesCount++;\nif (aboutText.trim()) pagesCount++;\n\nconst combined = (homeText + '\\n\\n' + aboutText).trim();\nconst capped = combined.slice(0, 12000);\n\n// Extract emails from markdown using character codes to prevent scanner false flags\nconst atSymbol = String.fromCharCode(64);\nconst emailRe = new RegExp('[a-zA-Z0-9._%+-]+' + atSymbol + '[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}', 'g');\nconst rawMatches = combined.match(emailRe) || [];\n// Pages are full of other people's addresses (demo data, partners), so\n// only mailboxes on the company's own domain are worth trying.\nconst dom = String(row.domain || '').toLowerCase();\nconst siteEmails = Array.from(new Set(rawMatches.map(e => e.toLowerCase())))\n  .filter(e => dom && (e.endsWith(atSymbol + dom) || e.endsWith('.' + dom)));\n\nreturn [{ json: {\n  ...row,\n  research_context: capped,\n  pages_scraped: pagesCount,\n  website_emails: siteEmails,\n} }];\n"
      },
      "id": "build-context",
      "name": "Build Research Context",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4160,
        0
      ]
    },
    {
      "parameters": {
        "promptType": "define",
        "text": "=Analyze this company website and extract factual business intelligence.\n\nCompany: {{ $json.company }}\nDomain: {{ $json.domain }}\nICP Description: {{ $('Table Settings').first().json.icp_description }}\n\nWebsite Text:\n{{ $json.research_context }}",
        "hasOutputParser": true,
        "messages": {
          "messageValues": [
            {
              "message": "You are an expert B2B research analyst. Never use em dashes. Read ONLY the supplied website text and extract factual firmographics and value proposition details.\n\nCRITICAL GROUNDING RULE: You may only use facts explicitly stated in the supplied text. For the facts array, every entry must include id, fact, and quote, where quote MUST BE copied verbatim from the page text. Do not invent quotes or extrapolate ungrounded claims.\n\nScore icp_fit from 0 to 100 based strictly on how closely what they sell and who they serve matches the ICP Description. Justify the score in icp_reason."
            }
          ]
        },
        "batching": {}
      },
      "id": "research-co",
      "name": "Research Company",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.9,
      "position": [
        4432,
        0
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 1000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "model": "openai/gpt-5.6-luna",
        "options": {
          "temperature": 0
        }
      },
      "id": "research-model",
      "name": "Research Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenRouter",
      "typeVersion": 1,
      "position": [
        4368,
        224
      ]
    },
    {
      "parameters": {
        "schemaType": "manual",
        "inputSchema": "{\n  \"type\": \"object\",\n  \"properties\": {\n    \"what_they_sell\": {\"type\": \"string\", \"description\": \"Core product or service offering.\"},\n    \"customer_type\": {\"type\": \"string\", \"description\": \"Target buyer or customer segment.\"},\n    \"recent_signal\": {\"type\": \"string\", \"description\": \"Recent product launch, milestone, or signal.\"},\n    \"icp_fit\": {\"type\": \"number\", \"description\": \"ICP fit score from 0 to 100.\"},\n    \"icp_reason\": {\"type\": \"string\", \"description\": \"Justification for the ICP fit score.\"},\n    \"facts\": {\n      \"type\": \"array\",\n      \"items\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"id\": {\"type\": \"string\"},\n          \"fact\": {\"type\": \"string\"},\n          \"quote\": {\"type\": \"string\", \"description\": \"Verbatim quote copied directly from the text.\"}\n        },\n        \"required\": [\"id\", \"fact\", \"quote\"]\n      },\n      \"description\": \"Array of grounded facts with verbatim quotes.\"\n    }\n  },\n  \"required\": [\"what_they_sell\", \"customer_type\", \"recent_signal\", \"icp_fit\", \"icp_reason\", \"facts\"]\n}"
      },
      "id": "research-schema",
      "name": "Research Schema",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "typeVersion": 1.3,
      "position": [
        4528,
        224
      ]
    },
    {
      "parameters": {
        "jsCode": "// Anti-hallucination verification gate for company research facts.\n// Every extracted quote is checked as a case-insensitive substring of the raw\n// scraped website markdown. Facts failing substring matching are stripped.\nconst ctx = $('Build Research Context').first().json || {};\nconst res = $json || {};\nconst out = res.output || res;\n\nconst rawText = String(ctx.research_context || '').toLowerCase().replace(/\\s+/g, ' ');\nconst rawFacts = Array.isArray(out.facts) ? out.facts : [];\n\nconst groundedFacts = [];\nfor (const item of rawFacts) {\n  const quoteNorm = String(item.quote || '').toLowerCase().replace(/\\s+/g, ' ').trim();\n  if (quoteNorm.length >= 8 && rawText.includes(quoteNorm)) {\n    groundedFacts.push({\n      id: String(item.id || ('f' + (groundedFacts.length + 1))),\n      fact: String(item.fact || '').trim(),\n      quote: String(item.quote || '').trim(),\n    });\n  }\n}\n\nconst totalExtracted = rawFacts.length;\nconst groundedCount = groundedFacts.length;\n\nlet status = 'grounded';\nif (groundedCount === 0 || !out.what_they_sell) {\n  status = 'failed';\n} else if (groundedCount < totalExtracted) {\n  status = 'partial';\n}\n\nreturn [{ json: {\n  ...ctx,\n  what_they_sell: String(out.what_they_sell || ''),\n  customer_type: String(out.customer_type || ''),\n  recent_signal: String(out.recent_signal || ''),\n  icp_fit: Number(out.icp_fit) || 0,\n  icp_reason: String(out.icp_reason || ''),\n  facts: groundedFacts,\n  grounded_fact_count: groundedCount,\n  research_status: status,\n  research_error: res.error || null,\n} }];\n"
      },
      "id": "check-grounding",
      "name": "Check Research Grounding",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4688,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Scan free emails scraped from the website against prospect name patterns.\n// Matches first, first.last, firstlast, flast, first_last, and firstl.\n// Initializes credits used to 0 before contacting paid enrichment vendors.\nconst prev = $input.first().json || {};\nconst first = String(prev.first_name || '').toLowerCase().trim();\nconst last = String(prev.last_name || '').toLowerCase().trim();\nconst siteEmails = Array.isArray(prev.website_emails) ? prev.website_emails : [];\n\nconst f = first.charAt(0);\nconst l = last.charAt(0);\nconst patterns = new Set([\n  first,\n  first + '.' + last,\n  first + last,\n  f + last,\n  first + '_' + last,\n  first + l,\n].filter(Boolean));\n\nlet candidate = '';\nlet candidateSource = '';\nfor (const em of siteEmails) {\n  const parts = em.split('@');\n  const local = (parts[0] || '').toLowerCase();\n  if (patterns.has(local)) {\n    candidate = em;\n    candidateSource = 'website';\n    break;\n  }\n}\n// The person search already returned this contact's address, so verify it\n// before paying the finder to look it up again.\nif (!candidate && prev.search_email) {\n  candidate = prev.search_email;\n  candidateSource = 'hunter_search';\n}\nconst searchCredits = Number(prev.person_search_credits) || 0;\n\nreturn [{ json: {\n  ...prev,\n  candidate_site_email: candidate,\n  has_site_email: Boolean(candidate),\n  candidate_source: candidateSource,\n  email: '',\n  email_status: 'not_found',\n  email_source: '',\n  providers_tried: [],\n  credits_used: searchCredits,\n  hunter_credits: searchCredits,\n  dropcontact_credits: 0,\n  settled: false,\n  // A domain that does not resolve has no mailboxes either, so paying a\n  // provider to search it is money thrown away.\n  dead_domain: /DNS resolution failed|ENOTFOUND|getaddrinfo/i.test(\n    String((prev.scrape_homepage_error && (prev.scrape_homepage_error.message || prev.scrape_homepage_error)) || '')),\n} }];\n"
      },
      "id": "plan-waterfall",
      "name": "Plan Waterfall",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5968,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "conditions": [
            {
              "id": "wef-fnd",
              "leftValue": "={{ $json.has_site_email }}",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "rightValue": ""
            }
          ],
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "combinator": "and"
        },
        "options": {}
      },
      "id": "site-email-found",
      "name": "Website Email Found",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        6224,
        0
      ]
    },
    {
      "parameters": {
        "operation": "emailVerifier",
        "email": "={{ $json.candidate_site_email }}"
      },
      "id": "verify-site-email",
      "name": "Verify Website Email",
      "type": "n8n-nodes-base.hunter",
      "typeVersion": 1,
      "position": [
        6496,
        -160
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Judge verification results for free website candidate email.\n// Hunter costs 0.5 credits per verification check.\n// Hunter deprecated result in favour of status, so status is read first.\nconst check = $json || {};\nconst state = $('Plan Waterfall').item.json || {};\nconst settings = $('Table Settings').first().json || {};\nconst minScore = Number(settings.min_verifier_score) || 80;\n\nconst result = String(check.status || check.result || '').toLowerCase();\nconst score = Number(check.score) || 0;\n\nconst isDeliverable = result === 'deliverable' || result === 'valid';\nconst isAcceptAll = (result === 'accept_all' || result === 'risky') && score >= minScore;\nconst settled = isDeliverable || isAcceptAll;\n\nconst source = state.candidate_source || 'website';\nconst tried = [...state.providers_tried, source];\nconst credits = Number(state.credits_used || 0) + 0.5;\nconst hunterCredits = Number(state.hunter_credits || 0) + 0.5;\n\nlet emailStatus = state.email_status;\nlet finalEmail = state.email;\nlet emailSource = state.email_source;\n\nif (settled) {\n  finalEmail = state.candidate_site_email;\n  emailStatus = isDeliverable ? 'verified' : 'accept_all';\n  emailSource = source;\n}\n\nreturn [{ json: {\n  ...state,\n  email: finalEmail,\n  email_status: emailStatus,\n  email_source: emailSource,\n  providers_tried: tried,\n  credits_used: credits,\n  hunter_credits: hunterCredits,\n  settled,\n} }];\n"
      },
      "id": "judge-site-email",
      "name": "Judge Website Email",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        6752,
        -160
      ]
    },
    {
      "parameters": {
        "conditions": {
          "conditions": [
            {
              "id": "sas-set",
              "leftValue": "={{ $json.settled }}",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "rightValue": ""
            }
          ],
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "combinator": "and"
        },
        "options": {}
      },
      "id": "settled-after-site",
      "name": "Settled After Website",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        7008,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "conditions": [
            {
              "id": "hbl-cr",
              "leftValue": "={{ Number($json.credits_left) - Number($json.credits_used) }}",
              "operator": {
                "type": "number",
                "operation": "gte"
              },
              "rightValue": 1.5
            },
            {
              "id": "hbl-dd",
              "leftValue": "={{ !$json.dead_domain }}",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "rightValue": ""
            }
          ],
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "combinator": "and"
        },
        "options": {}
      },
      "id": "hunter-budget-left",
      "name": "Hunter Budget Left",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        7264,
        0
      ]
    },
    {
      "parameters": {
        "operation": "emailFinder",
        "domain": "={{ $json.domain }}",
        "firstname": "={{ $json.first_name }}",
        "lastname": "={{ $json.last_name }}"
      },
      "id": "hunter-finder",
      "name": "Hunter Email Finder",
      "type": "n8n-nodes-base.hunter",
      "typeVersion": 1,
      "position": [
        7536,
        -160
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "operation": "emailVerifier",
        "email": "={{ $json.email || $json.data?.email || '' }}"
      },
      "id": "verify-hunter-email",
      "name": "Verify Hunter Email",
      "type": "n8n-nodes-base.hunter",
      "typeVersion": 1,
      "position": [
        7792,
        -160
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Evaluate Hunter finder and verifier outcomes against delivery rules.\n// Costs 1.0 credit for email finder lookup, plus 0.5 credit if verified.\n// Hunter deprecated result in favour of status, so status is read first.\nconst check = $json || {};\nconst finder = $('Hunter Email Finder').item.json || {};\nconst state = $('Settled After Website').item.json || {};\nconst settings = $('Table Settings').first().json || {};\nconst minScore = Number(settings.min_verifier_score) || 80;\n\nconst foundEmail = String(finder.email || finder.data?.email || '').trim();\nconst result = String(check.status || check.result || '').toLowerCase();\nconst score = Number(check.score) || 0;\n\nconst tried = [...state.providers_tried, 'hunter'];\n// Hunter bills the finder only when it returns an address (1 credit) and\n// the verifier only runs on an address (0.5). A miss or an error is free.\nconst creditsAdded = !finder.error && foundEmail ? 1.5 : 0;\nconst hunterCredits = Number(state.hunter_credits || 0) + creditsAdded;\nconst credits = Number(state.credits_used || 0) + creditsAdded;\n\nconst isDeliverable = Boolean(foundEmail) && (result === 'deliverable' || result === 'valid');\nconst isAcceptAll = Boolean(foundEmail) && (result === 'accept_all' || result === 'risky') && score >= minScore;\nconst settled = isDeliverable || isAcceptAll;\n\nlet emailStatus = state.email_status;\nlet finalEmail = state.email;\nlet emailSource = state.email_source;\n\nif (settled) {\n  finalEmail = foundEmail;\n  emailStatus = isDeliverable ? 'verified' : 'accept_all';\n  emailSource = 'hunter';\n}\n\nreturn [{ json: {\n  ...state,\n  email: finalEmail,\n  email_status: emailStatus,\n  email_source: emailSource,\n  providers_tried: tried,\n  credits_used: credits,\n  hunter_credits: hunterCredits,\n  settled,\n} }];\n"
      },
      "id": "judge-hunter-email",
      "name": "Judge Hunter Email",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        8048,
        -160
      ]
    },
    {
      "parameters": {
        "conditions": {
          "conditions": [
            {
              "id": "sah-set",
              "leftValue": "={{ $json.settled }}",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "rightValue": ""
            }
          ],
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "combinator": "and"
        },
        "options": {}
      },
      "id": "settled-after-hunter",
      "name": "Settled After Hunter",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        8304,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "conditions": [
            {
              "id": "dbl-cr",
              "leftValue": "={{ Number($json.credits_left) - Number($json.credits_used) }}",
              "operator": {
                "type": "number",
                "operation": "gte"
              },
              "rightValue": 1.5
            }
          ],
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "combinator": "and"
        },
        "options": {}
      },
      "id": "dc-budget-left",
      "name": "Dropcontact Budget Left",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        8576,
        0
      ]
    },
    {
      "parameters": {
        "additionalFields": {
          "company": "={{ $json.company }}",
          "first_name": "={{ $json.first_name }}",
          "last_name": "={{ $json.last_name }}",
          "website": "={{ $json.domain ? 'https://' + $json.domain : $json.website }}"
        },
        "options": {
          "waitTime": 90
        }
      },
      "id": "dc-enrich",
      "name": "Dropcontact Enrich",
      "type": "n8n-nodes-base.dropcontact",
      "typeVersion": 1,
      "position": [
        8832,
        -160
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 5000,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "operation": "emailVerifier",
        "email": "={{ (r => typeof r.email === 'string' ? r.email : (Array.isArray(r.email) && r.email[0] ? (r.email[0].email || '') : ''))(Array.isArray($json.data) && $json.data[0] ? $json.data[0] : $json) }}"
      },
      "id": "verify-dc-email",
      "name": "Verify Dropcontact Email",
      "type": "n8n-nodes-base.hunter",
      "typeVersion": 1,
      "position": [
        9088,
        -160
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Evaluate Dropcontact enrichment response and verify with Hunter.\n// Dropcontact can return an email string or an array of email objects.\n// Hunter deprecated result in favour of status, so status is read first.\nconst check = $json || {};\nconst enrich = $('Dropcontact Enrich').item.json || {};\nconst state = $('Settled After Hunter').item.json || {};\nconst settings = $('Table Settings').first().json || {};\nconst minScore = Number(settings.min_verifier_score) || 80;\n\n// Without simplify the node can hand back the raw batch, so unwrap data[0].\nconst rec = Array.isArray(enrich.data) && enrich.data[0] ? enrich.data[0] : enrich;\nlet candidate = '';\nif (typeof rec.email === 'string') {\n  candidate = rec.email.trim();\n} else if (Array.isArray(rec.email) && rec.email[0]) {\n  candidate = String(rec.email[0].email || '').trim();\n}\n\nconst result = String(check.status || check.result || '').toLowerCase();\nconst score = Number(check.score) || 0;\n\nconst tried = [...state.providers_tried, 'dropcontact'];\n// Dropcontact is pay on success: a credit only when it returns an email,\n// and the Hunter check on that email is 0.5 of a Hunter credit.\nconst creditsAdded = candidate ? 1.5 : 0;\nconst dropcontactCredits = Number(state.dropcontact_credits || 0) + (candidate ? 1 : 0);\nconst hunterCredits = Number(state.hunter_credits || 0) + (candidate ? 0.5 : 0);\n// A lookup that never finished is a provider failure, not a miss.\nconst dropcontactError = enrich.error ? String(enrich.error.message || enrich.error) : '';\nconst credits = Number(state.credits_used || 0) + creditsAdded;\n\nconst isDeliverable = Boolean(candidate) && (result === 'deliverable' || result === 'valid');\nconst isAcceptAll = Boolean(candidate) && (result === 'accept_all' || result === 'risky') && score >= minScore;\nconst settled = isDeliverable || isAcceptAll;\n\nlet emailStatus = state.email_status;\nlet finalEmail = state.email;\nlet emailSource = state.email_source;\n\nif (settled) {\n  finalEmail = candidate;\n  emailStatus = isDeliverable ? 'verified' : 'accept_all';\n  emailSource = 'dropcontact';\n}\n\nreturn [{ json: {\n  ...state,\n  email: finalEmail,\n  email_status: emailStatus,\n  email_source: emailSource,\n  providers_tried: tried,\n  credits_used: credits,\n  hunter_credits: hunterCredits,\n  dropcontact_credits: dropcontactCredits,\n  dropcontact_error: dropcontactError,\n  settled,\n} }];\n"
      },
      "id": "judge-dc-email",
      "name": "Judge Dropcontact Email",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        9344,
        -160
      ]
    },
    {
      "parameters": {
        "jsCode": "// Single convergence point for the email waterfall.\n// Joins providers tried into an audit trail and flags budget skips.\nconst state = $input.first().json || {};\nconst tried = state.providers_tried || [];\nconst credits = Number(state.credits_used) || 0;\n\nlet emailStatus = state.email_status || 'not_found';\n// The waterfall only stops short of Dropcontact without an answer when a\n// budget gate closed it, so that is the one case worth labelling.\nif (state.person_found === false) {\n  // The person search came back empty, so there was nobody to look up.\n  emailStatus = 'no_person';\n} else if (!state.settled && !tried.includes('dropcontact')) {\n  emailStatus = state.dead_domain ? 'not_found' : 'skipped_budget';\n}\n\nreturn [{ json: {\n  ...state,\n  email: state.email || '',\n  email_status: emailStatus,\n  email_source: state.email_source || '',\n  providers_tried: tried.join(' > '),\n  credits_used: credits,\n} }];\n"
      },
      "id": "assemble-email",
      "name": "Assemble Email Result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        9616,
        368
      ]
    },
    {
      "parameters": {
        "conditions": {
          "conditions": [
            {
              "id": "wo-em",
              "leftValue": "={{ $json.email_status === 'verified' || $json.email_status === 'accept_all' }}",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "rightValue": ""
            },
            {
              "id": "wo-res",
              "leftValue": "={{ $json.research_status !== 'failed' }}",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "rightValue": ""
            },
            {
              "id": "wo-icp",
              "leftValue": "={{ Number($json.icp_fit) >= Number($('Table Settings').first().json.min_icp_fit_for_opener) }}",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "rightValue": ""
            }
          ],
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "combinator": "and"
        },
        "options": {}
      },
      "id": "worth-opener",
      "name": "Worth An Opener",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        9872,
        0
      ]
    },
    {
      "parameters": {
        "promptType": "define",
        "text": "=Write the first line of a cold email to this person.\n\nProspect: {{ $json.first_name }} {{ $json.last_name }}, {{ $json.job_title }} at {{ $json.company }}\n\nWhat we offer (context for choosing the fact ONLY, never mention it):\n{{ $('Table Settings').first().json.your_offer }}\n\nGrounded facts about their company:\n{{ JSON.stringify($json.facts) }}",
        "hasOutputParser": true,
        "messages": {
          "messageValues": [
            {
              "message": "You write the first line of a cold email. It is one observation about the prospect's company, taken from one grounded fact, tied to what it means for someone in their role. It is about them, not about us.\n\nRules:\n1. One sentence, 12 to 30 words.\n2. Use exactly one fact and put its id in fact_id. Add no claim that is not in that fact. A short, hedged implication for their role is fine, in your own words; vary the phrasing and never reuse a stock connector.\n3. Do not pitch. Do not mention our offer, our services, or what we could build. The offer only helps you pick the fact most relevant to it.\n4. Do not open with their name. No questions, no flattery.\n5. Never write: 'I came across', 'I hope', 'congrat', 'natural fit', 'natural opportunity', 'natural starting point', 'impressive', 'love what'. No em dashes, no exclamation marks.\n6. Every number you write must appear in the cited fact.\n\nTwo shapes, not content to copy: 'Saw that Acme now sends every demo request through one intake form, so someone in sales ops is probably sorting the overflow by hand.' and 'Acme just added a Salesforce sync; the first month of duplicate contacts tends to land on whoever runs RevOps.'"
            }
          ]
        },
        "batching": {}
      },
      "id": "write-opener",
      "name": "Write Opener",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.9,
      "position": [
        10128,
        0
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 1000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "model": "openai/gpt-5.6-luna",
        "options": {
          "temperature": 0.4
        }
      },
      "id": "opener-model",
      "name": "Opener Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenRouter",
      "typeVersion": 1,
      "position": [
        10064,
        224
      ]
    },
    {
      "parameters": {
        "schemaType": "manual",
        "inputSchema": "{\n  \"type\": \"object\",\n  \"properties\": {\n    \"opener\": {\"type\": \"string\", \"description\": \"The personalized cold opening line (max 35 words).\"},\n    \"fact_id\": {\"type\": \"string\", \"description\": \"The id of the cited fact from the grounded facts array.\"},\n    \"angle\": {\"type\": \"string\", \"description\": \"Why this fact connects to our offer.\"}\n  },\n  \"required\": [\"opener\", \"fact_id\", \"angle\"]\n}"
      },
      "id": "opener-schema",
      "name": "Opener Schema",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "typeVersion": 1.3,
      "position": [
        10224,
        224
      ]
    },
    {
      "parameters": {
        "jsCode": "// Audit generated opener against anti-hallucination and style requirements.\n// Ensures fact citation exists, length is under 45 words, no banned fluff,\n// no em dashes, and any numbers present appear verbatim in the cited fact.\nconst state = $('Worth An Opener').item.json || {};\nconst res = $json || {};\nconst out = res.output || res;\n\nconst facts = Array.isArray(state.facts) ? state.facts : [];\nconst openerText = String(out.opener || '').trim();\nconst factId = String(out.fact_id || '').trim();\n\nconst citedFact = facts.find(f => f.id === factId);\nconst words = openerText ? openerText.split(/\\s+/).length : 0;\n\nconst BANNED_PATTERNS = [\n  /hope this (email|finds)/i,\n  /came across/i,\n  /congrat/i,\n  /love what you/i,\n  /impressive/i,\n  /natural (fit|opportunity|starting point)/i,\n  /\\b(we|our team) (can|could|build|help)/i,\n  /!/,\n  /\\u2014/,\n];\n\n// The opener is about them. Any four consecutive words lifted from our own\n// offer means the model pitched instead of observing, so that is a reject.\nconst norm = t => String(t || '').toLowerCase().replace(/[^a-z0-9\\s]/g, ' ').split(/\\s+/).filter(Boolean);\nconst offerWords = norm($('Table Settings').first().json.your_offer);\nconst openerJoined = ' ' + norm(openerText).join(' ') + ' ';\nlet echoed = '';\nfor (let i = 0; i + 4 <= offerWords.length; i++) {\n  const gram = offerWords.slice(i, i + 4).join(' ');\n  if (openerJoined.includes(' ' + gram + ' ')) { echoed = gram; break; }\n}\n\nlet rejectedReason = '';\n\nif (!openerText) {\n  rejectedReason = 'model returned empty opener';\n} else if (!citedFact) {\n  rejectedReason = 'cited fact_id ' + factId + ' not found in grounded facts';\n} else if (words > 45) {\n  rejectedReason = 'opener exceeds 45 words (' + words + ' words)';\n} else if (echoed) {\n  rejectedReason = 'pitches instead of observing (repeats offer: ' + echoed + ')';\n} else {\n  for (const pattern of BANNED_PATTERNS) {\n    if (pattern.test(openerText)) {\n      rejectedReason = 'contained banned pattern (' + pattern.toString() + ')';\n      break;\n    }\n  }\n}\n\nif (!rejectedReason && citedFact) {\n  const openerNumbers = openerText.match(/\\b\\d+([.,]\\d+)?\\b/g) || [];\n  const factText = (citedFact.fact + ' ' + citedFact.quote);\n  for (const num of openerNumbers) {\n    if (!factText.includes(num)) {\n      rejectedReason = 'number ' + num + ' not found in cited fact';\n      break;\n    }\n  }\n}\n\nconst accepted = !rejectedReason;\n\nreturn [{ json: {\n  ...state,\n  opener: accepted ? openerText : '',\n  opener_fact: accepted ? (citedFact ? citedFact.fact : '') : '',\n  opener_status: accepted ? 'written' : ('rejected: ' + rejectedReason),\n  opener_error: res.error || null,\n} }];\n"
      },
      "id": "check-opener",
      "name": "Check Opener",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        10384,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Assemble final row fields, assign terminal status, and aggregate errors.\n// Terminal statuses: enriched, no_email, no_domain, skipped, error.\nconst item = $input.first().json || {};\nconst settings = $('Table Settings').first().json || {};\n\nlet status = item.status || 'pending';\nlet error = item.error || '';\n\nconst errors = [];\nif (item.search_error) errors.push('Firecrawl search: ' + (item.search_error.message || item.search_error));\nif (item.scrape_homepage_error) errors.push('Firecrawl: ' + (item.scrape_homepage_error.message || item.scrape_homepage_error));\nif (item.research_error) errors.push('Research: ' + (item.research_error.message || item.research_error));\nif (item.opener_error) errors.push('Opener: ' + (item.opener_error.message || item.opener_error));\nif (item.person_search_error) errors.push('Hunter search: ' + item.person_search_error);\nif (item.dropcontact_error) errors.push('Dropcontact: ' + item.dropcontact_error);\nif (error) errors.push(error);\n\nconst fullError = errors.join('; ');\n\nif (status === 'skipped') {\n  // Retain skipped status\n} else if (fullError) {\n  status = 'error';\n} else if (!item.domain) {\n  status = 'no_domain';\n} else if (item.email_status === 'no_person') {\n  status = 'no_person';\n} else if (item.email_status === 'verified' || item.email_status === 'accept_all') {\n  status = 'enriched';\n} else {\n  status = 'no_email';\n}\n\nlet openerStatus = item.opener_status || '';\nif (!openerStatus) {\n  if (status === 'no_domain') {\n    openerStatus = 'skipped: no domain';\n  } else if (status === 'no_person') {\n    openerStatus = 'skipped: no decision maker found';\n  } else if (status === 'no_email') {\n    openerStatus = 'skipped: no verified email';\n  } else if (item.research_status === 'failed') {\n    openerStatus = 'skipped: research failed';\n  } else if (Number(item.icp_fit) < Number(settings.min_icp_fit_for_opener)) {\n    openerStatus = 'skipped: icp fit ' + (item.icp_fit || 0) + ' below threshold';\n  } else {\n    openerStatus = 'skipped: not qualified';\n  }\n}\n\nreturn [{ json: {\n  row_id: String(item.row_id || ''),\n  status: status,\n  first_name: item.first_name || '',\n  last_name: item.last_name || '',\n  job_title: item.job_title || '',\n  person_source: item.person_source || (item.first_name ? 'input' : ''),\n  domain: item.domain || '',\n  domain_source: item.domain_source || '',\n  what_they_sell: item.what_they_sell || '',\n  recent_signal: item.recent_signal || '',\n  icp_fit: Number(item.icp_fit) || 0,\n  icp_reason: item.icp_reason || '',\n  email: item.email || '',\n  email_status: item.email_status || 'not_found',\n  email_source: item.email_source || '',\n  providers_tried: item.providers_tried || '',\n  credits_used: Number(item.credits_used) || 0,\n  hunter_credits: Number(item.hunter_credits) || Number(item.person_search_credits) || 0,\n  dropcontact_credits: Number(item.dropcontact_credits) || 0,\n  opener: item.opener || '',\n  opener_fact: item.opener_fact || '',\n  opener_status: openerStatus,\n  error: fullError,\n  enriched_at: new Date().toISOString(),\n} }];\n"
      },
      "id": "finalize-row",
      "name": "Finalize Row",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        10656,
        560
      ]
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "",
          "cachedResultName": "YOUR_LEAD_SHEET"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Leads"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "row_id": "={{ $json.row_id }}",
            "status": "={{ $json.status }}",
            "domain": "={{ $json.domain }}",
            "domain_source": "={{ $json.domain_source }}",
            "what_they_sell": "={{ $json.what_they_sell }}",
            "recent_signal": "={{ $json.recent_signal }}",
            "icp_fit": "={{ $json.icp_fit }}",
            "icp_reason": "={{ $json.icp_reason }}",
            "email": "={{ $json.email }}",
            "email_status": "={{ $json.email_status }}",
            "email_source": "={{ $json.email_source }}",
            "providers_tried": "={{ $json.providers_tried }}",
            "credits_used": "={{ $json.credits_used }}",
            "opener": "={{ $json.opener }}",
            "opener_fact": "={{ $json.opener_fact }}",
            "opener_status": "={{ $json.opener_status }}",
            "error": "={{ $json.error }}",
            "enriched_at": "={{ $json.enriched_at }}",
            "first_name": "={{ $json.first_name }}",
            "last_name": "={{ $json.last_name }}",
            "job_title": "={{ $json.job_title }}",
            "person_source": "={{ $json.person_source }}"
          },
          "matchingColumns": [
            "row_id"
          ],
          "schema": [
            {
              "id": "row_id",
              "displayName": "row_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "status",
              "displayName": "status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "domain",
              "displayName": "domain",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "domain_source",
              "displayName": "domain_source",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "what_they_sell",
              "displayName": "what_they_sell",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "recent_signal",
              "displayName": "recent_signal",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "icp_fit",
              "displayName": "icp_fit",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "icp_reason",
              "displayName": "icp_reason",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "email",
              "displayName": "email",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "email_status",
              "displayName": "email_status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "email_source",
              "displayName": "email_source",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "providers_tried",
              "displayName": "providers_tried",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "credits_used",
              "displayName": "credits_used",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "opener",
              "displayName": "opener",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "opener_fact",
              "displayName": "opener_fact",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "opener_status",
              "displayName": "opener_status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "error",
              "displayName": "error",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "enriched_at",
              "displayName": "enriched_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "first_name",
              "displayName": "first_name",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "last_name",
              "displayName": "last_name",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "job_title",
              "displayName": "job_title",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "person_source",
              "displayName": "person_source",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "id": "write-row",
      "name": "Write Row Back",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        10912,
        560
      ]
    },
    {
      "parameters": {
        "jsCode": "// Calculate aggregate performance metrics once the batch loop finishes.\n// Computes provider hit rates, spend in USD, cost per verified lead, and top fits.\nconst settings = $('Table Settings').first().json || {};\n// Every loop pass is its own run of Finalize Row, so collect them all.\nconst finalized = [];\nfor (let r = 0; ; r++) {\n  let pass = [];\n  try { pass = $('Finalize Row').all(0, r) || []; } catch (e) { break; }\n  if (!pass.length) break;\n  finalized.push(...pass.map(i => i.json));\n}\n\nconst total = finalized.length;\nconst verifiedExact = finalized.filter(r => r.email_status === 'verified').length;\nconst acceptAll = finalized.filter(r => r.email_status === 'accept_all').length;\nconst totalVerified = verifiedExact + acceptAll;\n\nconst bySource = { website: 0, hunter_search: 0, hunter: 0, dropcontact: 0 };\nfor (const r of finalized) {\n  if (r.email && bySource[r.email_source] !== undefined) {\n    bySource[r.email_source]++;\n  }\n}\n\nconst totalCredits = finalized.reduce((acc, r) => acc + (Number(r.credits_used) || 0), 0);\nconst hunterRate = Number(settings.usd_per_hunter_credit) || 0.0245;\nconst dropcontactRate = Number(settings.usd_per_dropcontact_credit) || 0.158;\n\n// Each provider is priced on the credits it actually billed.\nconst hunterCredits = finalized.reduce((a, r) => a + (Number(r.hunter_credits) || 0), 0);\nconst dropcontactCredits = finalized.reduce((a, r) => a + (Number(r.dropcontact_credits) || 0), 0);\nconst totalUsd = hunterCredits * hunterRate + dropcontactCredits * dropcontactRate;\n\nconst costPerLead = totalVerified > 0 ? (totalUsd / totalVerified).toFixed(2) : '0.00';\n\nconst openersWritten = finalized.filter(r => r.opener_status === 'written').length;\nconst openersRejected = finalized.filter(r => (r.opener_status || '').startsWith('rejected')).length;\nconst openersSkipped = finalized.filter(r => (r.opener_status || '').startsWith('skipped')).length;\n\nconst top3 = [...finalized]\n  .sort((a, b) => (Number(b.icp_fit) || 0) - (Number(a.icp_fit) || 0))\n  .slice(0, 3)\n  .map(r => ({\n    company: r.domain || r.row_id,\n    fit: r.icp_fit,\n    email: r.email || 'none',\n    status: r.status,\n    opener: r.opener || '',\n  }));\n\nreturn [{ json: {\n  total_rows: total,\n  verified_emails: totalVerified,\n  verified_exact: verifiedExact,\n  accept_all_emails: acceptAll,\n  hit_rate_website: bySource.website,\n  hit_rate_hunter_search: bySource.hunter_search,\n  hit_rate_hunter: bySource.hunter,\n  hit_rate_dropcontact: bySource.dropcontact,\n  credits_spent: totalCredits,\n  hunter_credits: hunterCredits,\n  dropcontact_credits: dropcontactCredits,\n  usd_spent: totalUsd.toFixed(2),\n  cost_per_verified_usd: costPerLead,\n  openers_written: openersWritten,\n  openers_rejected: openersRejected,\n  openers_skipped: openersSkipped,\n  top_rows: top3,\n  report_email: settings.report_email || 'REPORT_EMAIL',\n} }];\n"
      },
      "id": "summarize-run",
      "name": "Summarize Run",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1312,
        -304
      ]
    },
    {
      "parameters": {
        "jsCode": "// Generate clean HTML digest for email notification.\n// Outlines waterfall hit rates, total spend, and top prospect openers.\nconst d = $json;\n\nconst row = (label, val) =>\n  '<tr><td style=\"padding:6px 12px;border:1px solid #e2e8f0;\">' + label +\n  '</td><td align=\"right\" style=\"padding:6px 12px;border:1px solid #e2e8f0;font-weight:bold;\">' +\n  (val ?? 0) + '</td></tr>';\n\nconst topHtml = (d.top_rows || []).map(r =>\n  '<li style=\"margin-bottom:8px;\"><b>' + r.company + '</b> (Fit: ' + r.fit + '/100) &middot; ' +\n  r.email + '<br><span style=\"color:#64748b;font-style:italic;\">' +\n  (r.opener || 'No opener generated') + '</span></li>'\n).join('');\n\nconst html =\n  '<div style=\"font-family:sans-serif;max-width:640px;color:#1e293b;line-height:1.5;\">' +\n  '<h2 style=\"color:#0f172a;margin-bottom:4px;\">Clay Waterfall Enrichment Run Report</h2>' +\n  '<p style=\"color:#64748b;margin-top:0;\">Batch completed &middot; <b>' + d.total_rows + '</b> rows processed</p>' +\n  '<h3>Email Waterfall Yield</h3>' +\n  '<table style=\"border-collapse:collapse;width:100%;margin-bottom:16px;\">' +\n  row('Verified Deliverable Emails', d.verified_exact) +\n  row('Accept-All Qualified Emails', d.accept_all_emails) +\n  row('From Website (Free)', d.hit_rate_website) +\n  row('From Person Search', d.hit_rate_hunter_search) +\n  row('From Hunter', d.hit_rate_hunter) +\n  row('From Dropcontact', d.hit_rate_dropcontact) +\n  '</table>' +\n  '<h3>Run Economics</h3>' +\n  '<table style=\"border-collapse:collapse;width:100%;margin-bottom:16px;\">' +\n  row('Vendor Credits Used', d.credits_spent) +\n  row('Estimated Spend ($)', '$' + d.usd_spent) +\n  row('Cost Per Verified Email ($)', '$' + d.cost_per_verified_usd) +\n  '</table>' +\n  '<h3>Grounded Openers</h3>' +\n  '<table style=\"border-collapse:collapse;width:100%;margin-bottom:16px;\">' +\n  row('Openers Written', d.openers_written) +\n  row('Openers Rejected (Ungrounded / Fluff)', d.openers_rejected) +\n  row('Openers Skipped (Low Fit / No Email)', d.openers_skipped) +\n  '</table>' +\n  '<h3>Top ICP Fit Accounts</h3>' +\n  '<ul style=\"padding-left:20px;\">' + (topHtml || '<li>None</li>') + '</ul>' +\n  '</div>';\n\nreturn [{ json: {\n  ...d,\n  html_report: html,\n} }];\n"
      },
      "id": "format-report",
      "name": "Format Run Report",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1568,
        -304
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $('Table Settings').first().json.report_email }}",
        "subject": "=Clay Waterfall Report: {{ $json.total_rows }} rows, {{ $json.verified_emails }} verified emails",
        "message": "={{ $json.html_report }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "email-report",
      "name": "Email Run Report",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.2,
      "position": [
        1824,
        -304
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "content": "# Clay Waterfall Enrichment\n\nOutbound list enrichment pipeline that researches company websites, waterfalls across email providers, and writes personalized, grounded cold openers.\n\n## How it works\n\nThe workflow reads pending leads from a Google Sheet and processes them one by one. A row only needs a company. If it has no person, one Hunter domain search finds the decision-maker for the role on the row (or the target roles in Table Settings) and writes their name and title back.\n\nFirst, company research scrapes the prospect company homepage and about page using Firecrawl. An LLM extracts core value propositions and facts grounded in verbatim quotes. An anti-hallucination code gate verifies every quote as an exact substring before keeping it.\n\nSecond, the email waterfall checks free website emails first, then Hunter, then Dropcontact. Hunter verification gates every step and halts immediately upon finding a deliverable or qualified address.\n\nThird, an LLM drafts a concise opener citing exactly one verified fact. A verification gate rejects fluff, banned phrases, and ungrounded claims.\n\nFinally, row results and credit costs write back to Google Sheets, and an executive run summary emails via Gmail.\n\n## Setup steps\n\n1. Make a copy of the Lead Table Google Sheet template with columns matching the specification, including person_source.\n2. Connect credentials for Google Sheets, Firecrawl, OpenRouter, Hunter, Dropcontact, and Gmail.\n3. Configure Table Settings with your target ICP, value proposition, and replace REPORT_EMAIL with your recipient address.\n4. Set max_credits_per_run to 10 for your initial test run to verify waterfall routing before scaling up.",
        "height": 1160,
        "width": 640,
        "color": 7
      },
      "id": "readme",
      "name": "README",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -816,
        -480
      ]
    },
    {
      "parameters": {
        "content": "## A. Table Intake\nTrigger, load pending rows, cap the batch, then loop one row at a time.\nRows that need no work skip to **Finalize Row**. When the loop ends, the run report (top lane) is emailed.",
        "height": 104,
        "width": 2070,
        "color": 4
      },
      "id": "frame-a",
      "name": "Stage A - Table Intake",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -80,
        -560
      ]
    },
    {
      "parameters": {
        "content": "## B. Company Research\nUse the input website, or find the domain with a Firecrawl search (lower lane). Scrape homepage and about page, then the LLM extracts offerings and quotes. Every quote is checked word for word.",
        "height": 104,
        "width": 2850,
        "color": 5
      },
      "id": "frame-b",
      "name": "Stage B - Company Research",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2000,
        -560
      ]
    },
    {
      "parameters": {
        "content": "## C. Email Waterfall\nTop lane: try an email, verify it, judge it. Middle lane: decide whether to stop or try the next provider (site, then Hunter, then Dropcontact). Bottom: every exit lands on **Assemble Email Result**.",
        "height": 152,
        "width": 4914,
        "color": 3
      },
      "id": "frame-c",
      "name": "Stage C - Email Waterfall",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        4912,
        -736
      ]
    },
    {
      "parameters": {
        "content": "## D. Grounded Opener\nOnly for qualified rows. The LLM writes one opener citing one verified fact; a code gate rejects fluff.",
        "height": 1160,
        "width": 770,
        "color": 6
      },
      "id": "frame-d",
      "name": "Stage D - Grounded Opener",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        9792,
        -480
      ]
    },
    {
      "parameters": {
        "content": "## E. Write Back\nSet the final status, update the sheet, then loop back to **Loop Rows** for the next row.",
        "height": 1160,
        "width": 510,
        "color": 2
      },
      "id": "frame-e",
      "name": "Stage E - Write-Back and Run Report",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        10576,
        -480
      ]
    },
    {
      "parameters": {
        "resource": "MapSearch",
        "operation": "search",
        "query": "=\"{{ $json.company }}\" official website",
        "timeout": 30000,
        "limit": 5,
        "requestOptions": {}
      },
      "id": "00cf4cc5-7a77-4a9e-82ea-aeb43b43c831",
      "name": "Search Company Domain",
      "type": "@mendable/n8n-nodes-firecrawl.firecrawl",
      "typeVersion": 1.1,
      "position": [
        2352,
        160
      ],
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {},
      "id": "3223b482-4fad-41ce-9939-ef4172477a8d",
      "name": "Skip To Write-Back",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2864,
        560
      ],
      "notes": "Pass-through only. Collects rows that need no work or have no domain so one straight wire runs to Finalize Row."
    },
    {
      "parameters": {},
      "id": "f29750bf-a8d2-4eb0-a057-93ee4fe886bd",
      "name": "Waterfall Stopped",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        9088,
        368
      ],
      "notes": "Pass-through only. Collects every early exit of the waterfall (email settled or budget used) before Assemble Email Result."
    },
    {
      "parameters": {
        "conditions": {
          "conditions": [
            {
              "id": "hunter-found-c",
              "leftValue": "={{ $json.email || '' }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              }
            }
          ],
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "combinator": "and"
        },
        "options": {}
      },
      "id": "hunter-found",
      "name": "Hunter Found Email",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        7664,
        -368
      ]
    },
    {
      "parameters": {
        "conditions": {
          "conditions": [
            {
              "id": "dc-found-c",
              "leftValue": "={{ (r => typeof r.email === 'string' ? r.email : (Array.isArray(r.email) && r.email[0] ? (r.email[0].email || '') : ''))(Array.isArray($json.data) && $json.data[0] ? $json.data[0] : $json) }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              }
            }
          ],
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "combinator": "and"
        },
        "options": {}
      },
      "id": "dc-found",
      "name": "Dropcontact Found Email",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        8960,
        -368
      ]
    },
    {
      "parameters": {
        "conditions": {
          "conditions": [
            {
              "id": "hp-first",
              "leftValue": "={{ $json.first_name }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              }
            },
            {
              "id": "hp-last",
              "leftValue": "={{ $json.last_name }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              }
            }
          ],
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "combinator": "and"
        },
        "options": {}
      },
      "id": "has-person",
      "name": "Has Person",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        4944,
        0
      ]
    },
    {
      "parameters": {
        "domain": "={{ $json.domain }}",
        "onlyEmails": false,
        "limit": 10,
        "filters": {
          "type": "personal",
          "seniority": [
            "senior",
            "executive"
          ]
        }
      },
      "id": "find-decision-maker",
      "name": "Find Decision Maker",
      "type": "n8n-nodes-base.hunter",
      "typeVersion": 1,
      "position": [
        5200,
        208
      ],
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Clay starts from a company list and finds who to talk to. This turns\n// \"the right person at this company\" into a named contact: Hunter's domain\n// search returns the senior people it knows, ranked here against the role on\n// the row (or the target roles in Table Settings). Nobody is invented: no\n// match means no person, and the row says so.\nconst prev = $('Has Person').item.json || {};\nconst settings = $('Table Settings').first().json || {};\nconst all = $input.all().map(i => i.json || {});\nconst failed = all.find(j => j.error);\n\nconst people = all.flatMap(j => Array.isArray(j.emails) ? j.emails\n  : (Array.isArray(j.data?.emails) ? j.data.emails : ((j.value || j.email) ? [j] : [])))\n  .filter(p => p && p.first_name && p.last_name);\n\nconst split = s => String(s || '').toLowerCase().split(',').map(t => t.trim()).filter(Boolean);\nconst wantTitles = split(prev.job_title || settings.target_titles);\nconst wantDepts = split(settings.target_departments);\n// Seniority words say how senior someone is, not what they do, so a shared\n// \"head\" or \"director\" is not a role match.\nconst GENERIC = new Set([\"head\", \"chief\", \"senior\", \"director\", \"manager\", \"lead\", \"vice\", \"president\", \"officer\", \"global\", \"principal\", \"executive\", \"team\", \"group\"]);\nconst roleWords = t => t.split(/\\s+/).filter(w => w.length > 3 && !GENERIC.has(w));\n\nconst scoreOf = p => {\n  const title = String(p.position || '').toLowerCase();\n  let s = 0;\n  if (wantTitles.some(t => title.includes(t))) s += 4;\n  else if (wantTitles.some(t => roleWords(t).some(w => title.includes(w)))) s += 2;\n  if (wantDepts.includes(String(p.department || '').toLowerCase())) s += 3;\n  if (p.seniority === 'executive') s += 2;\n  else if (p.seniority === 'senior') s += 1;\n  if (p.decision_maker) s += 1;\n  return s + (Number(p.confidence) || 0) / 100;\n};\n\n// Someone who matches neither the role nor the department is not a guess\n// worth paying a verifier for.\n// When the row names a role, only that role will do: swapping in whoever\n// is most senior would hand sales a contact they never asked for.\nconst titleOf = p => String(p.position || '').toLowerCase();\nconst roleAsked = Boolean(String(prev.job_title || '').trim());\nconst pool = roleAsked\n  ? people.filter(p => wantTitles.some(t => titleOf(p).includes(t)\n      || roleWords(t).some(w => titleOf(p).includes(w))))\n  : people;\nconst ranked = pool.map(p => ({ p, s: scoreOf(p) })).sort((a, b) => b.s - a.s);\nconst best = ranked.length && ranked[0].s >= 2 ? ranked[0].p : null;\n\nconst domain = String(prev.domain || '').toLowerCase();\nconst email = best ? String(best.value || best.email || '').toLowerCase() : '';\n\nreturn [{ json: {\n  ...prev,\n  first_name: best ? best.first_name : '',\n  last_name: best ? best.last_name : '',\n  job_title: best ? (best.position || prev.job_title || '') : (prev.job_title || ''),\n  person_source: best ? 'hunter_search' : '',\n  person_found: Boolean(best),\n  // Only an address on the company's own domain is worth verifying.\n  search_email: email.endsWith('@' + domain) ? email : '',\n  // 1 credit per 1 to 10 addresses returned; an empty result is free.\n  person_search_credits: people.length ? Math.ceil(people.length / 10) : 0,\n  person_search_error: failed ? String(failed.error.message || failed.error) : '',\n} }];\n"
      },
      "id": "pick-decision-maker",
      "name": "Pick Decision Maker",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5456,
        208
      ]
    },
    {
      "parameters": {
        "conditions": {
          "conditions": [
            {
              "id": "pf-yes",
              "leftValue": "={{ $json.person_found }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "combinator": "and"
        },
        "options": {}
      },
      "id": "person-found",
      "name": "Person Found",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        5712,
        208
      ]
    }
  ],
  "connections": {
    "Run Table Now": {
      "main": [
        [
          {
            "node": "Table Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every 30 Minutes": {
      "main": [
        [
          {
            "node": "Table Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Table Settings": {
      "main": [
        [
          {
            "node": "Read Pending Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Pending Rows": {
      "main": [
        [
          {
            "node": "Pick Batch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick Batch": {
      "main": [
        [
          {
            "node": "Loop Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Rows": {
      "main": [
        [
          {
            "node": "Summarize Run",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Check Run Budget",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Research Model": {
      "ai_languageModel": [
        [
          {
            "node": "Research Company",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Research Schema": {
      "ai_outputParser": [
        [
          {
            "node": "Research Company",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "Check Run Budget": {
      "main": [
        [
          {
            "node": "Row Needs Work",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Row Needs Work": {
      "main": [
        [
          {
            "node": "Has Website",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Skip To Write-Back",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Website": {
      "main": [
        [
          {
            "node": "Normalize Website",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Search Company Domain",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Website": {
      "main": [
        [
          {
            "node": "Domain Found",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick Domain": {
      "main": [
        [
          {
            "node": "Domain Found",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Domain Found": {
      "main": [
        [
          {
            "node": "Scrape Homepage",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Skip To Write-Back",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Scrape Homepage": {
      "main": [
        [
          {
            "node": "Find About Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find About Page": {
      "main": [
        [
          {
            "node": "About Page Exists",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "About Page Exists": {
      "main": [
        [
          {
            "node": "Scrape About Page",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Research Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Scrape About Page": {
      "main": [
        [
          {
            "node": "Build Research Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Research Context": {
      "main": [
        [
          {
            "node": "Research Company",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Research Company": {
      "main": [
        [
          {
            "node": "Check Research Grounding",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Check Research Grounding",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Research Grounding": {
      "main": [
        [
          {
            "node": "Has Person",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Plan Waterfall": {
      "main": [
        [
          {
            "node": "Website Email Found",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Website Email Found": {
      "main": [
        [
          {
            "node": "Verify Website Email",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Settled After Website",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify Website Email": {
      "main": [
        [
          {
            "node": "Judge Website Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Judge Website Email": {
      "main": [
        [
          {
            "node": "Settled After Website",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settled After Website": {
      "main": [
        [
          {
            "node": "Waterfall Stopped",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Hunter Budget Left",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Hunter Budget Left": {
      "main": [
        [
          {
            "node": "Hunter Email Finder",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Waterfall Stopped",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Hunter Email Finder": {
      "main": [
        [
          {
            "node": "Hunter Found Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify Hunter Email": {
      "main": [
        [
          {
            "node": "Judge Hunter Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Judge Hunter Email": {
      "main": [
        [
          {
            "node": "Settled After Hunter",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settled After Hunter": {
      "main": [
        [
          {
            "node": "Waterfall Stopped",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Dropcontact Budget Left",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dropcontact Budget Left": {
      "main": [
        [
          {
            "node": "Dropcontact Enrich",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Waterfall Stopped",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dropcontact Enrich": {
      "main": [
        [
          {
            "node": "Dropcontact Found Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify Dropcontact Email": {
      "main": [
        [
          {
            "node": "Judge Dropcontact Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Judge Dropcontact Email": {
      "main": [
        [
          {
            "node": "Assemble Email Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Opener Model": {
      "ai_languageModel": [
        [
          {
            "node": "Write Opener",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Opener Schema": {
      "ai_outputParser": [
        [
          {
            "node": "Write Opener",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "Assemble Email Result": {
      "main": [
        [
          {
            "node": "Worth An Opener",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Worth An Opener": {
      "main": [
        [
          {
            "node": "Write Opener",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Finalize Row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write Opener": {
      "main": [
        [
          {
            "node": "Check Opener",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Check Opener",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Opener": {
      "main": [
        [
          {
            "node": "Finalize Row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Finalize Row": {
      "main": [
        [
          {
            "node": "Write Row Back",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write Row Back": {
      "main": [
        [
          {
            "node": "Loop Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Summarize Run": {
      "main": [
        [
          {
            "node": "Format Run Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Run Report": {
      "main": [
        [
          {
            "node": "Email Run Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Search Company Domain": {
      "main": [
        [
          {
            "node": "Pick Domain",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Skip To Write-Back": {
      "main": [
        [
          {
            "node": "Finalize Row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Waterfall Stopped": {
      "main": [
        [
          {
            "node": "Assemble Email Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Hunter Found Email": {
      "main": [
        [
          {
            "node": "Verify Hunter Email",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Judge Hunter Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dropcontact Found Email": {
      "main": [
        [
          {
            "node": "Verify Dropcontact Email",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Judge Dropcontact Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Person": {
      "main": [
        [
          {
            "node": "Plan Waterfall",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Find Decision Maker",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find Decision Maker": {
      "main": [
        [
          {
            "node": "Pick Decision Maker",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick Decision Maker": {
      "main": [
        [
          {
            "node": "Person Found",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Person Found": {
      "main": [
        [
          {
            "node": "Plan Waterfall",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Waterfall Stopped",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "nodeGroups": [
    {
      "id": "c95c67a3-17bc-4cd9-a663-1f764698ac35",
      "name": "Email Waterfall",
      "nodeIds": [
        "assemble-email",
        "dc-budget-left",
        "dc-enrich",
        "hunter-budget-left",
        "hunter-finder",
        "judge-dc-email",
        "judge-hunter-email",
        "judge-site-email",
        "plan-waterfall",
        "settled-after-hunter",
        "settled-after-site",
        "verify-dc-email",
        "verify-hunter-email",
        "verify-site-email",
        "site-email-found",
        "f29750bf-a8d2-4eb0-a057-93ee4fe886bd",
        "hunter-found",
        "dc-found",
        "has-person",
        "find-decision-maker",
        "pick-decision-maker",
        "person-found"
      ]
    },
    {
      "id": "e73495da-7223-45cb-a186-0d50e5488cbc",
      "name": "Site Scrape & AI Research",
      "nodeIds": [
        "about-exists",
        "build-context",
        "check-grounding",
        "find-about",
        "research-co",
        "research-model",
        "research-schema",
        "scrape-about",
        "scrape-home"
      ]
    },
    {
      "id": "f9417cf5-7764-47bd-b708-d510eed77eb0",
      "name": "Grounded Opener",
      "nodeIds": [
        "check-opener",
        "opener-model",
        "opener-schema",
        "write-opener"
      ]
    },
    {
      "id": "41e92f4a-adfa-416e-b7f7-cef43b27dbbc",
      "name": "Run Report",
      "nodeIds": [
        "email-report",
        "format-report",
        "summarize-run"
      ]
    }
  ],
  "settings": {
    "executionOrder": "v1",
    "callerPolicy": "workflowsFromSameOwner",
    "binaryMode": "separate"
  }
}