{
  "name": "Lodge Daily Send Engine",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 9,
              "triggerAtMinute": 0
            }
          ]
        }
      },
      "id": "schedule",
      "name": "9am ET Mon-Fri",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        200,
        300
      ],
      "notes": "Fires at 9am ET. Set n8n instance timezone to America/New_York. Workflow paused in dry-run mode."
    },
    {
      "parameters": {
        "values": {
          "number": [
            {
              "name": "daily_cap",
              "value": 10
            },
            {
              "name": "week_number",
              "value": "={{ Math.ceil((Date.now() - new Date('2026-04-30').getTime()) / (7 * 24 * 60 * 60 * 1000)) }}"
            }
          ],
          "boolean": [
            {
              "name": "dry_run",
              "value": true
            }
          ]
        },
        "options": {}
      },
      "id": "config",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3,
      "position": [
        420,
        300
      ],
      "notes": "daily_cap starts at 10. Set dry_run=false when domain is warm (~2026-04-30). Increase daily_cap by 10 each week."
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $env.SUPABASE_URL }}/rest/v1/prospects",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "vertical",
              "value": "eq.Hospitality"
            },
            {
              "name": "status",
              "value": "eq.prospected"
            },
            {
              "name": "email",
              "value": "not.is.null"
            },
            {
              "name": "select",
              "value": "id,business_name,contact_name,email,city,state,notes,website_notes"
            },
            {
              "name": "limit",
              "value": "={{ $json.daily_cap }}"
            },
            {
              "name": "order",
              "value": "created_at.asc"
            }
          ]
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "apikey",
              "value": "={{ $env.SUPABASE_SERVICE_KEY }}"
            }
          ]
        },
        "options": {}
      },
      "id": "fetch_leads",
      "name": "Fetch Unsent Leads",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        640,
        300
      ],
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "fieldToSplitOut": "={{ $json }}",
        "options": {}
      },
      "id": "split_leads",
      "name": "Split Leads",
      "type": "n8n-nodes-base.splitOut",
      "typeVersion": 1,
      "position": [
        860,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Build the Claude prompt for this lead.\n// We embed the critical skill rules directly since n8n can't load .md files.\nconst lead = $json;\n\nlet notes = {};\ntry { notes = JSON.parse(lead.notes || '{}'); } catch {}\n\nconst personalization_line = notes.personalization_line || '';\nconst website = notes.website || lead.website_notes || '';\n\nconst systemPrompt = `You are writing a cold email for Isaac at Twenty1 Media.\n\nIMPORTANT RULES (non-negotiable):\n- ZERO em dashes anywhere in the body. Only on signoff line. Use commas/periods instead.\n- NEVER guarantee marketing outcomes (bookings, leads, traffic, revenue). Isaac sells a TOOL (website + booking engine), NOT marketing.\n- NEVER fabricate personal history. Isaac is a bass fisherman in Indiana. NOT walleye, NOT Minnesota.\n- NEVER include links, prices, or corporate signatures.\n- Use \"I\" not \"we\". Sign off as \"\u2014 Isaac\".\n- Under 150 words. 4 paragraphs max.\n- One specific CTA with a proposed day/time.\n- No words: leverage, streamline, empower, unlock, elevate, seamless, robust, cutting-edge, delve, landscape, realm.\n- Write like a text message to a friend who runs a lodge.\n\n4-STEP FRAMEWORK:\n1. Personalization (1-2 sentences): cold reading or specific observation + voluntary disclosure about Isaac (bass fisherman in Indiana)\n2. Who am I (1-2 sentences): \"I'm Isaac, I run Twenty1 Media\" + one named client (Waterway Inn, Papin's Resort, IVR906)\n3. Offer (1-2 sentences): \"I'll build you [specific deliverable] in [14 days]. You don't pay until it's live in your hands.\"\n4. CTA (1 sentence): specific day/time for a quick call\n\nISAAC'S VOICE:\n- Openers: \"Hey [name],\" \u2014 simple\n- Words he uses: \"you guys\", \"let's face it\", \"taken care of\", \"get you rolling\", \"quick call\"\n- Comfortable with fragments, run-ons, contractions always\n- PS with real scarcity is fine (\"only taking 3 lodges this month\")\n- Match tone of texting a buddy`;\n\nconst userPrompt = `Write a cold email to:\nName: ${lead.contact_name || 'the owner'}\nBusiness: ${lead.business_name}\nLocation: ${[lead.city, lead.state].filter(Boolean).join(', ') || 'unknown'}\nWebsite: ${website}\nExisting personalization note: ${personalization_line}\n\nOutput ONLY the email in this format:\nSubject: <lowercase, under 50 chars, plausible deniability>\n\n<email body>\n\n\u2014 Isaac`;\n\nreturn {\n  json: {\n    ...lead,\n    system_prompt: systemPrompt,\n    user_prompt: userPrompt,\n    website,\n    personalization_line\n  }\n};"
      },
      "id": "build_prompt",
      "name": "Build Claude Prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1080,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "content-type",
              "value": "application/json"
            },
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"model\": \"claude-sonnet-4-6\",\n  \"max_tokens\": 500,\n  \"system\": {{ JSON.stringify($json.system_prompt) }},\n  \"messages\": [{\"role\": \"user\", \"content\": {{ JSON.stringify($json.user_prompt) }}}]\n}",
        "options": {}
      },
      "id": "claude_generate",
      "name": "Claude: Generate Email",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1300,
        300
      ],
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "notes": "Using HTTP Request instead of LangChain node for more control over the payload."
    },
    {
      "parameters": {
        "jsCode": "// Extract subject + body from Claude's response and validate\nconst lead = $('Build Claude Prompt').item.json;\nconst raw = $json.content?.[0]?.text || '';\nconst config = $('Config').item.json;\n\n// Parse subject and body\nconst subjectMatch = raw.match(/^Subject:\\s*(.+)/im);\nconst subject = subjectMatch ? subjectMatch[1].trim() : 'quick question';\n\n// Body = everything after \"Subject: ...\" line, trimmed\nlet body = raw.replace(/^Subject:\\s*.+\\n*/im, '').trim();\n\n// Validation checks\nconst issues = [];\nif (body.includes('\u2014') && !body.match(/^\u2014 Isaac$/m)) {\n  // Has em dash NOT on signoff line\n  const cleaned = body.replace(/\u2014(?!\\s*Isaac\\s*$)/gm, ',');\n  body = cleaned;\n  issues.push('removed em dashes');\n}\nif (body.split(/\\s+/).length > 160) issues.push('over 150 words');\nif (/https?:\\/\\//i.test(body)) issues.push('contains link');\nif (/\\$\\d/.test(body)) issues.push('contains price');\n\nreturn {\n  json: {\n    prospect_id: lead.id,\n    business_name: lead.business_name,\n    email: lead.email,\n    contact_name: lead.contact_name,\n    subject,\n    body,\n    validation_issues: issues,\n    dry_run: config.dry_run,\n    sent_by: 'isaac'\n  }\n};"
      },
      "id": "parse_validate",
      "name": "Parse + Validate Email",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1520,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.dry_run }}",
              "value2": false
            }
          ]
        }
      },
      "id": "check_dry_run",
      "name": "Dry Run?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        1740,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.instantly.ai/api/v1/lead/add",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"api_key\": \"{{ $env.INSTANTLY_API_KEY }}\",\n  \"campaign_id\": \"REPLACE_WITH_CAMPAIGN_ID\",\n  \"skip_if_in_workspace\": true,\n  \"lead_list\": [{\n    \"email\": {{ JSON.stringify($json.email) }},\n    \"first_name\": {{ JSON.stringify(($json.contact_name || '').split(' ')[0] || '') }},\n    \"last_name\": {{ JSON.stringify(($json.contact_name || '').split(' ').slice(1).join(' ') || '') }},\n    \"company_name\": {{ JSON.stringify($json.business_name) }},\n    \"custom_variables\": {\n      \"email_subject\": {{ JSON.stringify($json.subject) }},\n      \"email_body\": {{ JSON.stringify($json.body) }}\n    }\n  }]\n}",
        "options": {}
      },
      "id": "add_to_instantly",
      "name": "Add Lead to Instantly",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1960,
        240
      ],
      "notes": "REPLACE_WITH_CAMPAIGN_ID \u2014 create the campaign in Instantly first, then paste its ID here. Campaign template should use {{email_subject}} and {{email_body}} custom variables."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.SUPABASE_URL }}/rest/v1/emails",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "apikey",
              "value": "={{ $env.SUPABASE_SERVICE_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Prefer",
              "value": "return=minimal"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"prospect_id\": {{ $json.prospect_id }},\n  \"type\": \"cold\",\n  \"subject\": {{ JSON.stringify($json.subject) }},\n  \"body\": {{ JSON.stringify($json.body) }},\n  \"sent_by\": {{ JSON.stringify($json.sent_by) }},\n  \"status\": \"sent\",\n  \"sent_at\": \"{{ new Date().toISOString() }}\",\n  \"batch_name\": \"lodge-auto-{{ new Date().toISOString().split('T')[0] }}\"\n}",
        "options": {}
      },
      "id": "log_email",
      "name": "Log Email to CRM",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2180,
        240
      ],
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "method": "PATCH",
        "url": "={{ $env.SUPABASE_URL }}/rest/v1/prospects?id=eq.{{ $('Parse + Validate Email').item.json.prospect_id }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "apikey",
              "value": "={{ $env.SUPABASE_SERVICE_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Prefer",
              "value": "return=minimal"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={ \"status\": \"followed_up\", \"sent_by\": \"isaac\" }",
        "options": {}
      },
      "id": "update_status",
      "name": "Update Prospect Status",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2400,
        240
      ],
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Dry run \u2014 log what WOULD have been sent without actually sending\nconst email = $json;\nconsole.log(`[DRY RUN] Would send to ${email.email}:`);\nconsole.log(`  Subject: ${email.subject}`);\nconsole.log(`  Body: ${email.body.substring(0, 100)}...`);\nconsole.log(`  Validation issues: ${email.validation_issues.join(', ') || 'none'}`);\nreturn { json: { ...email, status: 'dry_run_logged' } };"
      },
      "id": "dry_run_log",
      "name": "Dry Run Log",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1960,
        400
      ],
      "notes": "In dry-run mode: logs the generated email but does NOT send via Instantly or update CRM status. Review output to validate email quality before going live."
    }
  ],
  "connections": {
    "9am ET Mon-Fri": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Fetch Unsent Leads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Unsent Leads": {
      "main": [
        [
          {
            "node": "Split Leads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Leads": {
      "main": [
        [
          {
            "node": "Build Claude Prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Claude Prompt": {
      "main": [
        [
          {
            "node": "Claude: Generate Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude: Generate Email": {
      "main": [
        [
          {
            "node": "Parse + Validate Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse + Validate Email": {
      "main": [
        [
          {
            "node": "Dry Run?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dry Run?": {
      "main": [
        [
          {
            "node": "Add Lead to Instantly",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Dry Run Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add Lead to Instantly": {
      "main": [
        [
          {
            "node": "Log Email to CRM",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log Email to CRM": {
      "main": [
        [
          {
            "node": "Update Prospect Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}