{
  "name": "Lumeor | Sourced sales email openers (Sheets + Claude)",
  "nodes": [
    {
      "parameters": {
        "content": "## Personalized sales email openers with sources\n\n**What it does**\n1. Weekday mornings, reads up to 25 new prospects from a Google Sheet.\n2. Fetches each company homepage and splits it into numbered snippets.\n3. Claude writes an opener and a bridge sentence and must cite one snippet ID (enforced with an enum).\n4. Code checks the quoted fact really appears in the cited snippet, the length and banned phrases. Failures fall back to a role-based opener.\n5. Writes everything to a Review tab for a rep, marks prospects as drafted and pings Slack.\nNothing is sent. Approved openers go to your sequencing tool as custom fields.\n\n**Setup**\n1. Credentials: Google Sheets, Anthropic, Slack.\n2. Sheet tabs: \"Prospects\" (email, first_name, last_name, title, company, domain, status) and \"Review\" (headers in the review node). Replace REPLACE_WITH_SHEET_ID.\n3. Set REPLACE_WITH_ONE_SENTENCE_OFFER and your example openers in **Build opener request**.\n4. Replace REPLACE_WITH_SALES_CHANNEL.\n\nModel: claude-opus-5-5. claude-haiku-5-5 is a cheaper option for large lists.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/ai-sales-email-personalization/",
        "height": 720,
        "width": 520
      },
      "id": "f3ed8bb9-183f-4e4f-acf9-ff6724c1e5f4",
      "name": "Template notes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -620,
        -260
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 7 * * 1-5"
            }
          ]
        }
      },
      "id": "25b6e369-c68b-4b25-993a-212984b78b45",
      "name": "Weekdays 7am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_SHEET_ID"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Prospects"
        },
        "options": {}
      },
      "id": "6cdcf70b-a683-44ab-9290-cfb886e9b866",
      "name": "Read prospects",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        220,
        0
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Takes up to BATCH_SIZE prospects with status new (or blank). Expected columns:\n// email, first_name, last_name, title, company, domain, status\nconst BATCH_SIZE = 25;\nconst rows = $input.all().map((i) => i.json);\nconst out = [];\nfor (const r of rows) {\n  const status = String(r.status || '').toLowerCase();\n  if (status && status !== 'new') continue;\n  const domain = String(r.domain || String(r.email || '').split('@')[1] || '').toLowerCase().replace(/^https?:\\/\\//, '').replace(/^www\\./, '').replace(/\\/.*$/, '');\n  if (!r.email || !domain) continue;\n  out.push({ json: { email: String(r.email).trim(), firstName: r.first_name || '', title: r.title || '', company: r.company || '', domain }, pairedItem: { item: 0 } });\n  if (out.length >= BATCH_SIZE) break;\n}\nreturn out;\n"
      },
      "id": "f2a72a6d-aa3e-4fb7-8876-6d70621c99b0",
      "name": "Pick batch",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        0
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "=https://{{ $json.domain }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text",
              "outputPropertyName": "data"
            }
          },
          "timeout": 15000
        }
      },
      "id": "e3d5b526-dbc2-4e10-bdc8-f4bfd061b606",
      "name": "Fetch homepage",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        0
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Splits the homepage into numbered snippets. Claude must cite one snippet ID (enforced by enum).\nconst OFFER = 'REPLACE_WITH_ONE_SENTENCE_OFFER'; // what you do and for whom\nconst EXAMPLES = [\n  'Saw that you just opened a second warehouse in Ohio, which usually doubles the order exceptions someone has to chase.',\n  'Your careers page lists three AP clerk openings, so invoice volume must be climbing.',\n];\nconst clean = (html) => String(html || '')\n  .replace(/<script[\\s\\S]*?<\\/script>/gi, ' ').replace(/<style[\\s\\S]*?<\\/style>/gi, ' ')\n  .replace(/<\\/(p|h1|h2|h3|li|div|section)>/gi, '\\n').replace(/<[^>]+>/g, ' ')\n  .replace(/&nbsp;|&#160;/g, ' ').replace(/&amp;/g, '&').replace(/[ \\t]+/g, ' ');\n\nconst schema = (ids) => ({\n  type: 'object', additionalProperties: false,\n  required: ['opener', 'bridge', 'snippet_id', 'fact_used'],\n  properties: {\n    opener: { type: 'string', description: 'One sentence, under 30 words, about the company, not flattery' },\n    bridge: { type: 'string', description: 'One sentence linking that fact to why we are writing' },\n    snippet_id: { type: 'string', enum: ids.concat(['none']) },\n    fact_used: { type: 'string', description: 'The exact words from the snippet that the opener relies on' },\n  },\n});\n\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const p = $('Pick batch').itemMatching(i).json;\n  const url = 'https://' + p.domain;\n  const snippets = clean(items[i].json.data).split('\\n').map((s) => s.trim()).filter((s) => s.length >= 40 && s.length <= 400)\n    .slice(0, 25).map((text, idx) => ({ id: 'S' + (idx + 1), text, url }));\n  const prompt = 'Write the first two sentences of a cold email to ' + (p.firstName || 'the reader') + ', ' + (p.title || 'a leader') + ' at ' + (p.company || p.domain) + '.\\n' +\n    'Our offer: ' + OFFER + '\\n' +\n    'Use exactly one fact from the snippets below and cite its ID. If no snippet has a specific, business-relevant fact, set snippet_id to none.\\n' +\n    'No flattery, no questions about their weekend, no claims that are not in the snippet.\\n' +\n    'Examples of the style we want:\\n- ' + EXAMPLES.join('\\n- ') + '\\n\\nSnippets:\\n' +\n    (snippets.map((s) => s.id + ': ' + s.text).join('\\n') || '(none)');\n  out.push({\n    json: {\n      ...p,\n      snippets,\n      claudeRequest: {\n        model: 'claude-opus-5-5',\n        max_tokens: 1500,\n        messages: [{ role: 'user', content: prompt }],\n        output_config: { format: { type: 'json_schema', schema: schema(snippets.map((s) => s.id)) } },\n      },\n    },\n    pairedItem: { item: i },\n  });\n}\nreturn out;\n"
      },
      "id": "e3c06960-a39c-46ab-866e-6c17df3cdd5c",
      "name": "Build opener request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.claudeRequest) }}",
        "options": {
          "timeout": 180000
        }
      },
      "id": "14356d67-e47e-4f5f-aa58-5759060279e5",
      "name": "Claude: write opener",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1100,
        0
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Fact-to-source and style checks in code. Failures fall back to a reviewed role-based opener.\n\n// Reads a Messages API response. Thinking blocks may come first, so find the text block.\nfunction readClaude(resp) {\n  if (!resp || resp.error) {\n    const msg = resp && resp.error ? (resp.error.message || JSON.stringify(resp.error)) : 'empty response';\n    return { ok: false, reason: 'api_error: ' + String(msg).slice(0, 300) };\n  }\n  if (resp.stop_reason === 'refusal') {\n    const cat = resp.stop_details && resp.stop_details.category ? ' (' + resp.stop_details.category + ')' : '';\n    return { ok: false, reason: 'model_refusal' + cat };\n  }\n  if (resp.stop_reason === 'max_tokens') return { ok: false, reason: 'max_tokens: output was cut off, raise max_tokens' };\n  const block = (resp.content || []).find((b) => b.type === 'text');\n  if (!block) return { ok: false, reason: 'no_text_block_in_response' };\n  try {\n    return { ok: true, data: JSON.parse(block.text) };\n  } catch (e) {\n    return { ok: false, reason: 'invalid_json: ' + e.message };\n  }\n}\n\nconst BANNED = ['i hope this finds you', 'i came across', 'impressive', 'amazing', 'love what you', 'game-changer', 'synergy', 'quick question'];\nconst FALLBACK = {\n  finance: 'Most finance teams we talk to still key invoices by hand at month end.',\n  sales: 'Most sales teams we talk to lose a day a week to CRM updates.',\n  operations: 'Most operations leaders we talk to have one spreadsheet everything depends on.',\n  default: 'Most teams your size have at least one workflow someone retypes every week.',\n};\nconst norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9 ]/g, ' ').replace(/\\s+/g, ' ').trim();\nconst fallbackFor = (title) => {\n  const t = String(title || '').toLowerCase();\n  if (/financ|cfo|controller|account/.test(t)) return FALLBACK.finance;\n  if (/sales|revenue|cro|business development/.test(t)) return FALLBACK.sales;\n  if (/operat|coo|supply|logistic/.test(t)) return FALLBACK.operations;\n  return FALLBACK.default;\n};\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const p = $('Build opener request').itemMatching(i).json;\n  const r = readClaude(items[i].json);\n  const problems = [];\n  let opener = '', bridge = '', source = null, fact = '';\n  if (!r.ok) problems.push(r.reason);\n  else {\n    ({ opener, bridge } = r.data);\n    fact = r.data.fact_used;\n    source = p.snippets.find((s) => s.id === r.data.snippet_id) || null;\n    if (!source) problems.push('no_usable_source');\n    else if (!norm(source.text).includes(norm(fact)) || norm(fact).length < 12) problems.push('fact_not_in_source');\n    if (opener.split(/\\s+/).length > 32) problems.push('opener_too_long');\n    const lower = (opener + ' ' + bridge).toLowerCase();\n    const hit = BANNED.filter((b) => lower.includes(b));\n    if (hit.length) problems.push('banned: ' + hit.join(', '));\n  }\n  const ok = !problems.length;\n  out.push({\n    json: {\n      email: p.email, firstName: p.firstName, title: p.title, company: p.company,\n      opener: ok ? opener : fallbackFor(p.title),\n      bridge: ok ? bridge : '',\n      sourceUrl: ok ? source.url : '',\n      sourceText: ok ? source.text : '',\n      status: ok ? 'needs_review' : 'fallback_needs_review',\n      reason: problems.join('; '),\n    },\n    pairedItem: { item: i },\n  });\n}\nreturn out;\n"
      },
      "id": "d718348a-c40d-4aa1-8778-f6c61173e668",
      "name": "Check sources and style",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        0
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_SHEET_ID"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Review"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "created_at": "={{ $now.toISO() }}",
            "email": "={{ $json.email }}",
            "first_name": "={{ $json.firstName }}",
            "title": "={{ $json.title }}",
            "company": "={{ $json.company }}",
            "opener": "={{ $json.opener }}",
            "bridge": "={{ $json.bridge }}",
            "source_url": "={{ $json.sourceUrl }}",
            "source_text": "={{ $json.sourceText }}",
            "status": "={{ $json.status }}",
            "reason": "={{ $json.reason }}",
            "rep_decision": ""
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "created_at",
              "displayName": "created_at",
              "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": "first_name",
              "displayName": "first_name",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "title",
              "displayName": "title",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "company",
              "displayName": "company",
              "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": "bridge",
              "displayName": "bridge",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "source_url",
              "displayName": "source_url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "source_text",
              "displayName": "source_text",
              "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": "reason",
              "displayName": "reason",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "rep_decision",
              "displayName": "rep_decision",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": true
        },
        "options": {
          "cellFormat": "RAW"
        }
      },
      "id": "9d584a11-2043-4a55-bc17-a4a2f6c3973d",
      "name": "Append to review tab",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1540,
        -120
      ]
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "REPLACE_WITH_SHEET_ID"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Prospects"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "email": "={{ $json.email }}",
            "status": "drafted"
          },
          "matchingColumns": [
            "email"
          ],
          "schema": [
            {
              "id": "email",
              "displayName": "email",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "status",
              "displayName": "status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": true
        },
        "options": {
          "cellFormat": "RAW"
        }
      },
      "id": "9deaff96-e7c2-4cfd-b7eb-92154e37cd89",
      "name": "Mark prospect drafted",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1540,
        100
      ]
    },
    {
      "parameters": {
        "authentication": "accessToken",
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#REPLACE_WITH_SALES_CHANNEL"
        },
        "messageType": "text",
        "text": "={{ ':memo: ' + $('Check sources and style').all().length + ' new openers are ready for review (' + $('Check sources and style').all().filter(i => i.json.status === 'fallback_needs_review').length + ' used the fallback). Open the Review tab to approve or edit.' }}",
        "otherOptions": {
          "includeLinkToWorkflow": true
        }
      },
      "id": "eaca33b9-4dc7-43f1-88ef-af258fc381b4",
      "name": "Tell reps the batch is ready",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1760,
        100
      ],
      "executeOnce": true
    }
  ],
  "connections": {
    "Weekdays 7am": {
      "main": [
        [
          {
            "node": "Read prospects",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read prospects": {
      "main": [
        [
          {
            "node": "Pick batch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick batch": {
      "main": [
        [
          {
            "node": "Fetch homepage",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch homepage": {
      "main": [
        [
          {
            "node": "Build opener request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build opener request": {
      "main": [
        [
          {
            "node": "Claude: write opener",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude: write opener": {
      "main": [
        [
          {
            "node": "Check sources and style",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Check sources and style",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check sources and style": {
      "main": [
        [
          {
            "node": "Append to review tab",
            "type": "main",
            "index": 0
          },
          {
            "node": "Mark prospect drafted",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark prospect drafted": {
      "main": [
        [
          {
            "node": "Tell reps the batch is ready",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
