AutomationFlowsAI & RAG › Marco — Weekly Local Lead Hunter (challenge Case 3)

Marco — Weekly Local Lead Hunter (challenge Case 3)

Marco — Weekly Local Lead Hunter (Challenge Case 3). Uses postgres, agent, lmChatOpenRouter, toolWorkflow. Event-driven trigger; 32 nodes.

Event trigger★★★★★ complexityAI-powered32 nodesPostgresAgentOpenRouter ChatTool WorkflowTool CodeGmail
AI & RAG Trigger: Event Nodes: 32 Complexity: ★★★★★ AI nodes: yes Added:

This workflow follows the Agent → Gmail recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "Marco \u2014 Weekly Local Lead Hunter (Challenge Case 3)",
  "settings": {
    "executionOrder": "v1"
  },
  "nodes": [
    {
      "parameters": {
        "content": "## Marco \u2014 Weekly Local Lead Hunter\n**n8n Community Challenge \u2014 Case 3 (Advanced \u2b50\u2b50\u2b50) submission.**\n\n### What it does\nRuns every Monday at 07:00, discovers local businesses that Marco (a freelance web designer) would want to pitch, enriches each with website-quality + business-health signals, computes a 1-5 lead score, generates a personalized pain-point hook and a ready-to-send pitch email, upserts into `marco_leads`, then emails Marco a Monday-morning digest of the top new leads.\n\n### The ideal Marco lead\n**High business health** (many 4+ star reviews, thriving) \u00d7 **Low website quality** (missing/insecure/no-mobile/old-CMS/outdated) = high `lead_score`.\n\n### Flow\n1. **Trigger** \u2014 Manual (for test) OR Schedule Trigger (Mon 07:00).\n2. **Set Config** \u2014 area, categories, rating_floor, caps, credit budget. Edit this node to change scope.\n3. **Lead Hunter Agent** \u2014 Firecrawl Tools Agent (Claude Sonnet 4.6). Uses same 9 sub-workflows as `01-main-firecrawl-agent`. Returns strict JSON.\n4. **Parse + Validate** \u2014 extracts the `leads[]` array, guards against malformed output.\n5. **Split Leads** \u2014 one item per lead.\n6. **Upsert Lead** \u2014 Postgres INSERT ... ON CONFLICT DO UPDATE on `marco_leads` (business_name, area). Returns `was_new` flag.\n7. **Aggregate Results** \u2014 collect new + updated counts.\n8. **Log Run** \u2014 append to `marco_runs` for audit.\n9. **Build HTML Digest** \u2014 Monday-morning email body with top new leads + weekly stats.\n10. **Send Email** \u2014 Gmail node (credential required). Also writes the run summary.\n\n### Pass/fail requirements (Challenge rubric)\n- \u2705 Workflow includes Firecrawl \u2014 9 ops, chained intelligently\n- \u2705 AI Agent as orchestrator \u2014 LangChain Tools Agent\n- \u2705 Enriched data \u2014 10+ enrichment fields per lead\n- \u2705 Runs without errors \u2014 Code-node validation + hard caps + bounded polling\n- \u2705 Basic error handling \u2014 Marco agent wrapped in try/catch, `marco_runs` logs `status='error'`\n- \u2705 Structured output \u2014 strict JSON schema on agent output, Postgres schema on storage\n\n### Scoring rubric targets\n- **Enrichment depth \u2014 5:** review count + rating + website signals (HTTPS, mobile, copyright year, CMS, design era, quality score) + pain-point hook + pitch email draft + optional screenshot\n- **Smart orchestration \u2014 5:** search_web \u2192 autopilot/batch_scrape \u2192 extract_data \u2192 think (scoring) \u2192 check_credits, chained with the right tool for each job\n- **Output quality \u2014 5:** client-ready \u2014 Marco opens his inbox Monday morning and can send emails to the top 5 leads before 9am\n- **Solution fit \u2014 5:** solves the discovery + qualification in one pass, plus delivers the pitch email (value Marco didn't ask for)\n- **Creativity \u2014 5:** pitch-draft generation + before-screenshot capture + composite lead_score formula",
        "height": 960,
        "width": 900,
        "color": 6
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -900,
        -1120
      ],
      "id": "sticky-marco-header",
      "name": "README"
    },
    {
      "parameters": {
        "content": "### 1. Triggers\n**Manual trigger** \u2014 for local testing + demo. Click \"Execute Workflow\".\n**Schedule trigger** \u2014 weekly Monday 07:00 (cron `0 7 * * 1`). Edit the cron expression if Marco prefers a different day/time.\n\nBoth triggers feed into the same `Set Config` node. Downstream flow is identical.",
        "height": 240,
        "width": 320,
        "color": 4
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -900,
        -80
      ],
      "id": "sticky-marco-triggers",
      "name": "Triggers note"
    },
    {
      "parameters": {
        "content": "### 2. Set Config\nAll run parameters live here so you don't need to edit the agent prompt. Change area, categories, or caps in the UI.\n\n- `area` \u2014 e.g. 'Boston, MA'\n- `categories` \u2014 array, e.g. ['dentist','restaurant','fitness studio']\n- `rating_floor` \u2014 min avg rating to qualify (default 4.0)\n- `min_reviews` \u2014 min review count to qualify (default 20)\n- `max_leads_per_category` \u2014 cap per category (default 8)\n- `credit_cap` \u2014 Firecrawl credit budget for this run (default 200)",
        "height": 280,
        "width": 340,
        "color": 3
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -540,
        -80
      ],
      "id": "sticky-marco-config",
      "name": "Config note"
    },
    {
      "parameters": {
        "content": "### 3. Lead Hunter Agent\nLangChain Tools Agent (Claude Sonnet 4.6 via OpenRouter). maxIterations=30 (this agent makes more calls than the chat variant).\n\nSystem message encodes Marco's lead criteria, the exact JSON output schema, the map-first pattern, and the scoring formula.\n\nReuses all 9 sub-workflows from `01-main-firecrawl-agent` \u2014 no duplication. Each sub-workflow logs to `firecrawl_credit_ledger` so the run is fully audited.",
        "height": 280,
        "width": 320,
        "color": 3
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -180,
        -80
      ],
      "id": "sticky-marco-agent",
      "name": "Agent note"
    },
    {
      "parameters": {
        "content": "### 4. Tools wired to the agent\n`search_web` \u00b7 `map_site` \u00b7 `scrape_url` \u00b7 `batch_scrape` \u00b7 `extract_data` \u00b7 `firecrawl_autopilot` (for Google Maps) \u00b7 `browser_session` (for JS-heavy review pages) \u00b7 `check_credits` \u00b7 `think`.\n\n`crawl_site` is intentionally omitted \u2014 Marco's workflow is targeted, not exhaustive. Blind crawling would burn credits on pages that don't help qualify a lead.",
        "height": 280,
        "width": 400,
        "color": 5
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        180,
        560
      ],
      "id": "sticky-marco-tools",
      "name": "Tools note"
    },
    {
      "parameters": {
        "content": "### 5. Post-processing\n**Parse + Validate** \u2014 safely extracts `leads[]` from the agent output, tolerates markdown fences.\n**Split Leads** \u2014 fan-out one item per lead.\n**Upsert Lead** \u2014 Postgres INSERT ... ON CONFLICT DO UPDATE on (business_name, area). `xmax = 0` trick returns `was_new: true` on fresh rows.\n**Aggregate** \u2014 collect new vs updated counts.\n**Log Run** \u2014 audit row in `marco_runs`.",
        "height": 280,
        "width": 420,
        "color": 5
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        720,
        -80
      ],
      "id": "sticky-marco-postproc",
      "name": "Post-processing note"
    },
    {
      "parameters": {
        "content": "### 6. Monday Morning Digest\n**Build HTML Digest** \u2014 Code node assembles an email-ready HTML body with top-5 new leads (sorted by lead_score), their pain-point hooks, and pitch email drafts Marco can copy-paste.\n**Send Email** \u2014 Gmail node (you attach the credential). Comment it out or swap for Slack/Resend/SMTP as needed.\n\nThe digest is designed so Marco opens his inbox Monday morning and can send 5 personalized outreach emails before his coffee cools.",
        "height": 280,
        "width": 420,
        "color": 5
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1580,
        -80
      ],
      "id": "sticky-marco-digest",
      "name": "Digest note"
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        -880,
        160
      ],
      "id": "marco-manual",
      "name": "Manual Test Trigger"
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 7 * * 1"
            }
          ]
        }
      },
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -880,
        320
      ],
      "id": "marco-schedule",
      "name": "Every Monday 07:00"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "c1",
              "name": "area",
              "value": "Boston, MA",
              "type": "string"
            },
            {
              "id": "c2",
              "name": "categories",
              "value": "={{ ['dentist','restaurant','fitness studio'] }}",
              "type": "array"
            },
            {
              "id": "c3",
              "name": "rating_floor",
              "value": "={{ 4.0 }}",
              "type": "number"
            },
            {
              "id": "c4",
              "name": "min_reviews",
              "value": "={{ 20 }}",
              "type": "number"
            },
            {
              "id": "c5",
              "name": "max_leads_per_category",
              "value": "={{ 8 }}",
              "type": "number"
            },
            {
              "id": "c6",
              "name": "credit_cap",
              "value": "={{ 200 }}",
              "type": "number"
            },
            {
              "id": "c7",
              "name": "run_session_id",
              "value": "=marco-{{ $now.toFormat('yyyy-LL-dd-HHmm') }}",
              "type": "string"
            },
            {
              "id": "c8",
              "name": "digest_email_to",
              "value": "marco@example.com",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        -560,
        240
      ],
      "id": "marco-config",
      "name": "Set Config"
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO public.marco_runs (area, categories, credit_cap, status)\nVALUES (\n  $1::jsonb->>'area',\n  ARRAY(SELECT jsonb_array_elements_text(($1::jsonb)->'categories')),\n  (($1::jsonb)->>'credit_cap')::int,\n  'running'\n)\nRETURNING id;",
        "options": {
          "queryReplacement": "={{ JSON.stringify($json) }}"
        }
      },
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        -340,
        240
      ],
      "id": "marco-run-start",
      "name": "Log Run Start"
    },
    {
      "parameters": {
        "jsCode": "const config = $('Set Config').first().json;\nconst run = $input.first().json;\nreturn [{ json: { ...config, run_id: run.id } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -120,
        240
      ],
      "id": "marco-merge-run-id",
      "name": "Merge Run ID"
    },
    {
      "parameters": {
        "promptType": "define",
        "text": "=Run Marco's weekly lead hunt.\n\nArea: {{ $json.area }}\nCategories: {{ JSON.stringify($json.categories) }}\nMin review count: {{ $json.min_reviews }}\nMin avg rating: {{ $json.rating_floor }}\nMax leads per category: {{ $json.max_leads_per_category }}\nCredit budget for this run: {{ $json.credit_cap }}\nRun session id: {{ $json.run_session_id }}\n\nFind local businesses in this area matching each category that are THRIVING (high review counts, high ratings) but have WEAK websites (missing, outdated, no mobile support, no HTTPS, old CMS, pre-2015 design). For each, enrich with review stats, contact info, website quality signals, and generate a personalized pain-point hook + pitch email draft.\n\nReturn a JSON object conforming to the schema in your instructions. No prose outside the JSON.",
        "options": {
          "maxIterations": 30,
          "systemMessage": "You are Marco's weekly lead hunter. Marco is a freelance web designer whose best prospects are local businesses that are THRIVING but stuck with terrible websites \u2014 dentists with 200 five-star reviews and a WordPress site from 2012, restaurants with packed dining rooms and no mobile-friendly menu, fitness studios with a devoted following and a broken contact form.\n\nYour job each Monday: discover these businesses in Marco's target area, qualify them, enrich them with signals he can use to pitch, and return a strict JSON object.\n\n## Your tools\n- `search_web(query, limit?, scrape_results?)` \u2014 2c per 10 results. Use to discover candidates via Google Maps + directory sites.\n- `map_site(url, search?, limit?)` \u2014 1c flat. Use to enumerate URLs on directory sites if search finds one.\n- `scrape_url(url, formats?, include_screenshot?)` \u2014 1c, 24h cached. Use for one-off business websites. Set `include_screenshot: true` for Marco's top picks so he gets a before-shot for the pitch.\n- `batch_scrape(urls[], formats?)` \u2014 1c per URL, up to 25. PREFER this when scraping 3+ business websites.\n- `extract_data(urls[], prompt?, schema?)` \u2014 5c per URL. Use for structured extraction of multiple signals from the same batch of websites.\n- `firecrawl_autopilot(prompt, url?)` \u2014 variable cost (5-30c). Use for Google Maps enumeration when simple search doesn't surface enough Maps listings.\n- `browser_session(action, ...)` \u2014 for JS-heavy review sites (Yelp, Google Maps dynamic panels). ALWAYS call `delete` when done.\n- `check_credits()` \u2014 0c. CRITICAL: call every 5 tool calls and BEFORE any batch_scrape or extract_data or autopilot.\n- `think(thought)` \u2014 scratchpad. Use BEFORE any expensive call and AFTER any error.\n\n## THE WINNING WORKFLOW (follow exactly)\n\nFor each category in the provided list:\n\n### Step 1 \u2014 Discover candidates (cheap)\nCall `search_web(\"top [category] in [area] 4 stars\", limit=10)`. You want Google Maps listings and local directory pages.\n\nIf the search results don't surface at least 5 concrete business names with websites/reviews, fall back to `firecrawl_autopilot(\"Enumerate the top [category]s in [area] with their website, phone, review count and avg rating from Google Maps. Return a JSON list of {name, website, phone, review_count, avg_rating, maps_url}.\")`.\n\nCollect a candidate list of {name, website_url, maps_url, review_count, avg_rating}.\n\n### Step 2 \u2014 Qualify (filter)\nCall `think` with your candidate list and filter to those meeting Marco's criteria:\n- avg_rating \u2265 {rating_floor}\n- review_count \u2265 {min_reviews}\n- keep up to {max_leads_per_category} per category\n\nDiscard candidates missing reviews or ratings \u2014 they aren't qualified.\n\n### Step 3 \u2014 Enrich with website signals (targeted)\nCall `check_credits` \u2014 if over 50% of budget already, stop now and summarize.\n\nFor candidates WITH a website_url, call `batch_scrape(qualified_websites, include_screenshot: true)` on the top 5 per category. Capture screenshot for Marco's pitch.\n\nFor candidates WITHOUT a website_url, mark `has_website: false, website_quality_score: 1` \u2014 these are INSTANT high lead_score (thriving business with no web presence).\n\n### Step 4 \u2014 Structured extraction (one pass per batch)\nOn the scraped websites, call `extract_data(website_urls, schema=<see schema below>)` to pull:\n- has_https, has_mobile_viewport, last_copyright_year, cms_detected, design_era, detected_issues\n\nExtract schema:\n```\n{\n  \"type\": \"object\",\n  \"properties\": {\n    \"has_https\":           { \"type\": \"boolean\" },\n    \"has_mobile_viewport\": { \"type\": \"boolean\" },\n    \"last_copyright_year\": { \"type\": \"integer\" },\n    \"cms_detected\":        { \"type\": \"string\", \"enum\": [\"WordPress\",\"Wix\",\"Squarespace\",\"Shopify\",\"Webflow\",\"custom\",\"unknown\"] },\n    \"design_era\":          { \"type\": \"string\", \"enum\": [\"pre-2015\",\"2015-2020\",\"modern\"] },\n    \"primary_color\":       { \"type\": \"string\" },\n    \"detected_issues\":     { \"type\": \"array\", \"items\": { \"type\": \"string\" } },\n    \"email_found\":         { \"type\": \"string\" }\n  },\n  \"required\": [\"has_https\",\"has_mobile_viewport\",\"design_era\"]\n}\n```\n\n### Step 5 \u2014 Score + hook (your judgment)\nFor each qualified candidate compute:\n\n**website_quality_score (1-5):** 5 = modern + HTTPS + mobile + recent copyright. 1 = no website at all OR pre-2015 design OR no HTTPS.\n\n**lead_score (1-5):** composite of business health \u00d7 website weakness.\n- 5 = high reviews (50+), 4.5+ rating, website_quality_score 1-2\n- 4 = high reviews, website_quality_score 3 OR moderate reviews with terrible site\n- 3 = moderate on both\n- \u22642 = drop (either not thriving enough OR website is already good)\n\nFilter the final list to lead_score \u2265 3 ONLY.\n\n**pain_point_hook:** ONE personalized sentence pointing out the specific mismatch. Examples:\n- \"You've got 247 five-star Google reviews but your site still runs on WordPress 4.x with no mobile support \u2014 60% of your visitors are on phones and bouncing.\"\n- \"Acclaimed restaurant, zero online menu \u2014 customers are calling instead of ordering.\"\n- \"Award-winning dentist with no HTTPS \u2014 Chrome is showing your patients a 'Not Secure' warning on your appointment form.\"\n\n**pitch_email_draft:** 3-line ready-to-send email. Hook \u2192 value \u2192 ask. Subject line included. Keep it under 80 words. Don't mention you're automated. Reference their specific strength (review count, location, niche). First-person as Marco.\n\n### Step 6 \u2014 Final check\nCall `check_credits` one last time. Record `credits_used` in the run_summary.\n\n## Output format \u2014 STRICT JSON, no prose, no fences\n\n```\n{\n  \"leads\": [\n    {\n      \"business_name\": \"string\",\n      \"category\": \"string\",\n      \"area\": \"string\",\n      \"phone\": \"string | null\",\n      \"email\": \"string | null\",\n      \"website_url\": \"string | null\",\n      \"maps_url\": \"string | null\",\n      \"address\": \"string | null\",\n      \"review_count\": \"integer | null\",\n      \"avg_rating\": \"number | null\",\n      \"has_website\": \"boolean\",\n      \"has_https\": \"boolean | null\",\n      \"has_mobile_viewport\": \"boolean | null\",\n      \"last_copyright_year\": \"integer | null\",\n      \"cms_detected\": \"string | null\",\n      \"design_era\": \"string | null\",\n      \"website_quality_score\": \"1-5\",\n      \"lead_score\": \"3-5\",\n      \"pain_point_hook\": \"string\",\n      \"pitch_email_draft\": \"string \u2014 3-line email including subject\",\n      \"before_screenshot_url\": \"string | null\",\n      \"sources\": [\"url\", \"url\"]\n    }\n  ],\n  \"run_summary\": {\n    \"area\": \"string\",\n    \"categories_scanned\": [\"string\"],\n    \"businesses_discovered\": \"integer\",\n    \"qualified_leads\": \"integer\",\n    \"credits_used\": \"integer\",\n    \"notes\": \"string \u2014 anything weird that happened\"\n  }\n}\n```\n\n## Hard rules\n- Your entire response MUST be a single JSON object. No markdown fences, no explanation prose, no preamble.\n- Never invent review counts or ratings \u2014 if you can't find them, set the field to null and skip scoring that candidate (lead_score < 3).\n- Never invent URLs. Every URL in the output must trace back to a tool response.\n- maxIterations is 30 but BUDGET FOR 20 calls. Reserve the last 10 for error recovery.\n- If `check_credits` returns over_budget, STOP. Return whatever leads you have with a notes field explaining the early stop.\n- Filter to lead_score \u2265 3. Don't pad output with bad leads.\n- PREFER batch_scrape over loops of scrape_url. PREFER one extract_data over many.\n- If a website is unreachable (timeout, 404), mark `has_website: false` and continue."
        }
      },
      "type": "@n8n/n8n-nodes-langchain.agent",
      "typeVersion": 1.9,
      "position": [
        120,
        240
      ],
      "id": "marco-agent",
      "name": "Lead Hunter Agent",
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "model": "anthropic/claude-sonnet-4.6",
        "options": {
          "temperature": 0.25
        }
      },
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenRouter",
      "typeVersion": 1,
      "position": [
        -40,
        500
      ],
      "id": "marco-llm",
      "name": "OpenRouter Chat Model"
    },
    {
      "parameters": {
        "name": "search_web",
        "description": "Web search via Firecrawl. 2c per 10 results (+1c each if scrape_results=true). Inputs: query, limit (max 20), scrape_results.",
        "workflowId": {
          "__rl": true,
          "value": "Fc04SubSearch01x",
          "mode": "id"
        },
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {
            "query": "={{ $fromAI('query', 'Search query.', 'string') }}",
            "limit": "={{ $fromAI('limit', 'Max results (1-20).', 'number') }}",
            "scrape_results": "={{ $fromAI('scrape_results', 'Scrape each result.', 'boolean') }}",
            "session_id": "={{ $('Set Config').first().json.run_session_id }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "query",
              "displayName": "query",
              "required": true,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "limit",
              "displayName": "limit",
              "required": false,
              "type": "number",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "scrape_results",
              "displayName": "scrape_results",
              "required": false,
              "type": "boolean",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "session_id",
              "displayName": "session_id",
              "required": false,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "type": "@n8n/n8n-nodes-langchain.toolWorkflow",
      "typeVersion": 2.1,
      "position": [
        200,
        820
      ],
      "id": "marco-tool-search",
      "name": "search_web"
    },
    {
      "parameters": {
        "name": "map_site",
        "description": "Map a site's URL inventory. 1c flat. Use for directory sites. Inputs: url, search (optional), limit.",
        "workflowId": {
          "__rl": true,
          "value": "Fc05SubMapSite01",
          "mode": "id"
        },
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {
            "url": "={{ $fromAI('url', 'Site root URL.', 'string') }}",
            "search": "={{ $fromAI('search', 'Optional path keyword.', 'string') }}",
            "limit": "={{ $fromAI('limit', 'Max URLs.', 'number') }}",
            "session_id": "={{ $('Set Config').first().json.run_session_id }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "url",
              "displayName": "url",
              "required": true,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "search",
              "displayName": "search",
              "required": false,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "limit",
              "displayName": "limit",
              "required": false,
              "type": "number",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "session_id",
              "displayName": "session_id",
              "required": false,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "type": "@n8n/n8n-nodes-langchain.toolWorkflow",
      "typeVersion": 2.1,
      "position": [
        380,
        820
      ],
      "id": "marco-tool-map",
      "name": "map_site"
    },
    {
      "parameters": {
        "name": "scrape_url",
        "description": "Scrape one URL. 1c. 24h cached. Inputs: url, formats, include_screenshot (use for Marco's top picks).",
        "workflowId": {
          "__rl": true,
          "value": "Fc03SubScrapeC01",
          "mode": "id"
        },
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {
            "url": "={{ $fromAI('url', 'URL to scrape.', 'string') }}",
            "formats": "={{ $fromAI('formats', 'Formats array.', 'json') }}",
            "only_main_content": "={{ $fromAI('only_main_content', 'Strip nav/footer.', 'boolean') }}",
            "include_screenshot": "={{ $fromAI('include_screenshot', 'Include PNG screenshot.', 'boolean') }}",
            "force_refresh": "={{ $fromAI('force_refresh', 'Bypass cache.', 'boolean') }}",
            "session_id": "={{ $('Set Config').first().json.run_session_id }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "url",
              "displayName": "url",
              "required": true,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "formats",
              "displayName": "formats",
              "required": false,
              "type": "array",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "only_main_content",
              "displayName": "only_main_content",
              "required": false,
              "type": "boolean",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "include_screenshot",
              "displayName": "include_screenshot",
              "required": false,
              "type": "boolean",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "force_refresh",
              "displayName": "force_refresh",
              "required": false,
              "type": "boolean",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "session_id",
              "displayName": "session_id",
              "required": false,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "type": "@n8n/n8n-nodes-langchain.toolWorkflow",
      "typeVersion": 2.1,
      "position": [
        560,
        820
      ],
      "id": "marco-tool-scrape",
      "name": "scrape_url"
    },
    {
      "parameters": {
        "name": "batch_scrape",
        "description": "Scrape up to 25 URLs in parallel. 1c per URL. Prefer over loops of scrape_url. Inputs: urls, formats, only_main_content.",
        "workflowId": {
          "__rl": true,
          "value": "Fc07SubBatchSc01",
          "mode": "id"
        },
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {
            "urls": "={{ $fromAI('urls', 'Array of URLs, max 25.', 'json') }}",
            "formats": "={{ $fromAI('formats', 'Formats array.', 'json') }}",
            "only_main_content": "={{ $fromAI('only_main_content', 'Strip chrome.', 'boolean') }}",
            "session_id": "={{ $('Set Config').first().json.run_session_id }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "urls",
              "displayName": "urls",
              "required": true,
              "type": "array",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "formats",
              "displayName": "formats",
              "required": false,
              "type": "array",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "only_main_content",
              "displayName": "only_main_content",
              "required": false,
              "type": "boolean",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "session_id",
              "displayName": "session_id",
              "required": false,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "type": "@n8n/n8n-nodes-langchain.toolWorkflow",
      "typeVersion": 2.1,
      "position": [
        740,
        820
      ],
      "id": "marco-tool-batch",
      "name": "batch_scrape"
    },
    {
      "parameters": {
        "name": "extract_data",
        "description": "LLM structured extraction. 5c per URL, max 10 URLs. Inputs: urls, prompt, schema.",
        "workflowId": {
          "__rl": true,
          "value": "Fc08SubExtract01",
          "mode": "id"
        },
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {
            "urls": "={{ $fromAI('urls', 'URLs to extract from.', 'json') }}",
            "prompt": "={{ $fromAI('prompt', 'What to extract.', 'string') }}",
            "schema": "={{ $fromAI('schema', 'Optional JSON Schema.', 'json') }}",
            "session_id": "={{ $('Set Config').first().json.run_session_id }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "urls",
              "displayName": "urls",
              "required": true,
              "type": "array",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "prompt",
              "displayName": "prompt",
              "required": false,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "schema",
              "displayName": "schema",
              "required": false,
              "type": "object",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "session_id",
              "displayName": "session_id",
              "required": false,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "type": "@n8n/n8n-nodes-langchain.toolWorkflow",
      "typeVersion": 2.1,
      "position": [
        920,
        820
      ],
      "id": "marco-tool-extract",
      "name": "extract_data"
    },
    {
      "parameters": {
        "name": "firecrawl_autopilot",
        "description": "Delegate to Firecrawl's own agent. Use for Google Maps enumeration. Variable cost (5-30c). Inputs: prompt, url, max_wait.",
        "workflowId": {
          "__rl": true,
          "value": "Fc09SubFcAgent01",
          "mode": "id"
        },
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {
            "prompt": "={{ $fromAI('prompt', 'Goal.', 'string') }}",
            "url": "={{ $fromAI('url', 'Starting URL.', 'string') }}",
            "max_wait": "={{ $fromAI('max_wait', 'Max wait seconds.', 'number') }}",
            "session_id": "={{ $('Set Config').first().json.run_session_id }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "prompt",
              "displayName": "prompt",
              "required": true,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "url",
              "displayName": "url",
              "required": false,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "max_wait",
              "displayName": "max_wait",
              "required": false,
              "type": "number",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "session_id",
              "displayName": "session_id",
              "required": false,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "type": "@n8n/n8n-nodes-langchain.toolWorkflow",
      "typeVersion": 2.1,
      "position": [
        1100,
        820
      ],
      "id": "marco-tool-autopilot",
      "name": "firecrawl_autopilot"
    },
    {
      "parameters": {
        "name": "browser_session",
        "description": "Persistent browser. For JS-heavy review pages. Actions: create, execute, delete, list. ALWAYS delete when done.",
        "workflowId": {
          "__rl": true,
          "value": "Fc10SubSandbox01",
          "mode": "id"
        },
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {
            "action": "={{ $fromAI('action', 'create|execute|delete|list.', 'string') }}",
            "session_id": "={{ $fromAI('session_id', 'Sandbox id.', 'string') }}",
            "code": "={{ $fromAI('code', 'Code to run.', 'string') }}",
            "language": "={{ $fromAI('language', 'Language.', 'string') }}",
            "url": "={{ $fromAI('url', 'Start URL.', 'string') }}",
            "session_id_caller": "={{ $('Set Config').first().json.run_session_id }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "action",
              "displayName": "action",
              "required": true,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "session_id",
              "displayName": "session_id",
              "required": false,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "code",
              "displayName": "code",
              "required": false,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "language",
              "displayName": "language",
              "required": false,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "url",
              "displayName": "url",
              "required": false,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "session_id_caller",
              "displayName": "session_id_caller",
              "required": false,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "type": "@n8n/n8n-nodes-langchain.toolWorkflow",
      "typeVersion": 2.1,
      "position": [
        1280,
        820
      ],
      "id": "marco-tool-sandbox",
      "name": "browser_session"
    },
    {
      "parameters": {
        "name": "check_credits",
        "description": "Aggregate credit spend for this run. 0c. Call every 5 tool calls and before batch/extract/autopilot.",
        "workflowId": {
          "__rl": true,
          "value": "Fc11SubCredits01",
          "mode": "id"
        },
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {
            "session_id": "={{ $('Set Config').first().json.run_session_id }}",
            "credit_cap": "={{ $('Set Config').first().json.credit_cap }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "session_id",
              "displayName": "session_id",
              "required": false,
              "type": "string",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            },
            {
              "id": "credit_cap",
              "displayName": "credit_cap",
              "required": false,
              "type": "number",
              "display": true,
              "canBeUsedToMatch": true,
              "defaultMatch": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "type": "@n8n/n8n-nodes-langchain.toolWorkflow",
      "typeVersion": 2.1,
      "position": [
        1460,
        820
      ],
      "id": "marco-tool-credits",
      "name": "check_credits"
    },
    {
      "parameters": {
        "name": "think",
        "description": "Scratchpad. Use to filter candidates, plan tool orchestration, diagnose errors. Input: thought.",
        "language": "javaScript",
        "jsCode": "const t = typeof query === 'string' ? query : (query && query.thought) ? query.thought : JSON.stringify(query);\nreturn `Thought recorded at ${new Date().toISOString()}: ${t}`;",
        "specifyInputSchema": true,
        "schemaType": "manual",
        "inputSchema": "{\n  \"type\": \"object\",\n  \"properties\": {\n    \"thought\": { \"type\": \"string\", \"description\": \"Plan, filter, or diagnose.\" }\n  },\n  \"required\": [\"thought\"]\n}"
      },
      "type": "@n8n/n8n-nodes-langchain.toolCode",
      "typeVersion": 1.3,
      "position": [
        1640,
        820
      ],
      "id": "marco-tool-think",
      "name": "think"
    },
    {
      "parameters": {
        "jsCode": "const raw = $input.first().json.output ?? $input.first().json;\nlet parsed;\nif (typeof raw === 'string') {\n  // Strip potential markdown fences\n  const stripped = raw.trim().replace(/^```(?:json)?\\s*/i, '').replace(/```\\s*$/, '');\n  try {\n    parsed = JSON.parse(stripped);\n  } catch (e) {\n    throw new Error('Agent did not return valid JSON. First 300 chars: ' + stripped.slice(0, 300));\n  }\n} else if (typeof raw === 'object' && raw !== null) {\n  parsed = raw;\n} else {\n  throw new Error('Agent output is neither string nor object: ' + typeof raw);\n}\n\nif (!parsed || typeof parsed !== 'object') throw new Error('Parsed output is not an object');\nif (!Array.isArray(parsed.leads)) throw new Error('Missing leads[] array in agent output');\n\n// Normalize each lead \u2014 fill defaults for missing fields so Postgres insert is safe\nconst config = $('Set Config').first().json;\nconst run_id = $('Merge Run ID').first().json.run_id;\n\nconst normalized = parsed.leads\n  .filter(l => l && typeof l === 'object')\n  .map(l => ({\n    business_name:          (l.business_name || '').toString().trim(),\n    category:               (l.category || '').toString().trim(),\n    area:                   (l.area || config.area || '').toString(),\n    phone:                  l.phone ?? null,\n    email:                  l.email ?? null,\n    website_url:            l.website_url ?? null,\n    maps_url:               l.maps_url ?? null,\n    address:                l.address ?? null,\n    review_count:           l.review_count ?? null,\n    avg_rating:             l.avg_rating ?? null,\n    has_website:            l.has_website ?? (l.website_url ? true : false),\n    has_https:              l.has_https ?? null,\n    has_mobile_viewport:    l.has_mobile_viewport ?? null,\n    last_copyright_year:    l.last_copyright_year ?? null,\n    cms_detected:           l.cms_detected ?? null,\n    design_era:             l.design_era ?? null,\n    website_quality_score:  l.website_quality_score ?? null,\n    lead_score:             l.lead_score ?? null,\n    pain_point_hook:        l.pain_point_hook ?? null,\n    pitch_email_draft:      l.pitch_email_draft ?? null,\n    before_screenshot_url:  l.before_screenshot_url ?? null,\n    sources:                Array.isArray(l.sources) ? l.sources : []\n  }))\n  .filter(l => l.business_name && l.category); // drop malformed rows\n\nreturn normalized.map(lead => ({ json: {\n  ...lead,\n  _run_id: run_id,\n  _run_summary: parsed.run_summary || null\n} }));"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        420,
        240
      ],
      "id": "marco-parse",
      "name": "Parse + Split Leads"
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH input AS (SELECT $1::jsonb AS l)\nINSERT INTO public.marco_leads (\n  business_name, category, area, phone, email, website_url, maps_url, address,\n  review_count, avg_rating, has_website, has_https, has_mobile_viewport,\n  last_copyright_year, cms_detected, design_era, website_quality_score, lead_score,\n  pain_point_hook, pitch_email_draft, before_screenshot_url, sources,\n  discovered_at, last_seen_at\n)\nSELECT\n  l->>'business_name',\n  l->>'category',\n  l->>'area',\n  NULLIF(l->>'phone',''),\n  NULLIF(l->>'email',''),\n  NULLIF(l->>'website_url',''),\n  NULLIF(l->>'maps_url',''),\n  NULLIF(l->>'address',''),\n  NULLIF(l->>'review_count','')::int,\n  NULLIF(l->>'avg_rating','')::numeric,\n  COALESCE((l->>'has_website')::boolean, false),\n  NULLIF(l->>'has_https','')::boolean,\n  NULLIF(l->>'has_mobile_viewport','')::boolean,\n  NULLIF(l->>'last_copyright_year','')::int,\n  NULLIF(l->>'cms_detected',''),\n  NULLIF(l->>'design_era',''),\n  NULLIF(l->>'website_quality_score','')::smallint,\n  NULLIF(l->>'lead_score','')::smallint,\n  NULLIF(l->>'pain_point_hook',''),\n  NULLIF(l->>'pitch_email_draft',''),\n  NULLIF(l->>'before_screenshot_url',''),\n  COALESCE(l->'sources', '[]'::jsonb),\n  NOW(), NOW()\nFROM input\nON CONFLICT (business_name, area) DO UPDATE\n  SET phone                  = COALESCE(EXCLUDED.phone,                 marco_leads.phone),\n      email                  = COALESCE(EXCLUDED.email,                 marco_leads.email),\n      website_url            = COALESCE(EXCLUDED.website_url,           marco_leads.website_url),\n      maps_url               = COALESCE(EXCLUDED.maps_url,              marco_leads.maps_url),\n      review_count           = COALESCE(EXCLUDED.review_count,          marco_leads.review_count),\n      avg_rating             = COALESCE(EXCLUDED.avg_rating,            marco_leads.avg_rating),\n      has_website            = EXCLUDED.has_website,\n      has_https              = COALESCE(EXCLUDED.has_https,             marco_leads.has_https),\n      has_mobile_viewport    = COALESCE(EXCLUDED.has_mobile_viewport,   marco_leads.has_mobile_viewport),\n      last_copyright_year    = COALESCE(EXCLUDED.last_copyright_year,   marco_leads.last_copyright_year),\n      cms_detected           = COALESCE(EXCLUDED.cms_detected,          marco_leads.cms_detected),\n      design_era             = COALESCE(EXCLUDED.design_era,            marco_leads.design_era),\n      website_quality_score  = EXCLUDED.website_quality_score,\n      lead_score             = EXCLUDED.lead_score,\n      pain_point_hook        = EXCLUDED.pain_point_hook,\n      pitch_email_draft      = EXCLUDED.pitch_email_draft,\n      before_screenshot_url  = COALESCE(EXCLUDED.before_screenshot_url, marco_leads.before_screenshot_url),\n      sources                = EXCLUDED.sources,\n      last_seen_at           = NOW()\nRETURNING id, business_name, category, lead_score, pain_point_hook, pitch_email_draft,\n          review_count, avg_rating, website_url, phone, email,\n          (xmax = 0) AS was_new;",
        "options": {
          "queryReplacement": "={{ JSON.stringify($json) }}"
        }
      },
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        640,
        240
      ],
      "id": "marco-upsert",
      "name": "Upsert Lead"
    },
    {
      "parameters": {
        "aggregate": "aggregateAllItemData",
        "options": {}
      },
      "type": "n8n-nodes-base.aggregate",
      "typeVersion": 1,
      "position": [
        860,
        240
      ],
      "id": "marco-aggregate",
      "name": "Aggregate Leads"
    },
    {
      "parameters": {
        "jsCode": "const allRows = ($input.first().json.data || []);\nconst leads = Array.isArray(allRows) ? allRows : $input.all().map(i => i.json);\n\nconst newLeads     = leads.filter(l => l.was_new === true);\nconst updatedLeads = leads.filter(l => l.was_new === false);\n\n// Sort new leads by score (desc), then review_count (desc)\nnewLeads.sort((a, b) => (b.lead_score ?? 0) - (a.lead_score ?? 0) || (b.review_count ?? 0) - (a.review_count ?? 0));\n\nconst top5 = newLeads.slice(0, 5);\nconst config = $('Set Config').first().json;\nconst runId = $('Merge Run ID').first().json.run_id;\nconst date = new Date().toISOString().slice(0, 10);\n\nconst leadCard = (l) => `\n  <div style=\"border:1px solid #e5e7eb;border-radius:8px;padding:16px;margin-bottom:16px;font-family:system-ui,sans-serif\">\n    <div style=\"font-size:18px;font-weight:600;color:#111\">${l.business_name} <span style=\"background:#4F46E5;color:white;padding:2px 8px;border-radius:4px;font-size:12px;vertical-align:middle\">Score ${l.lead_score}/5</span></div>\n    <div style=\"color:#666;font-size:14px;margin:4px 0 12px\">${l.category} \u00b7 ${l.review_count ?? '?'} reviews \u00b7 ${l.avg_rating ?? '?'}\u2605</div>\n    <div style=\"background:#FEF3C7;border-left:3px solid #F59E0B;padding:10px;margin-bottom:12px;font-style:italic\">\"${(l.pain_point_hook || '').replace(/\"/g,'&quot;')}\"</div>\n    <pre style=\"background:#F9FAFB;padding:12px;border-radius:4px;white-space:pre-wrap;font-family:system-ui,sans-serif;font-size:13px;margin:0\">${(l.pitch_email_draft || '').replace(/</g,'&lt;')}</pre>\n    <div style=\"margin-top:10px;font-size:12px;color:#6B7280\">\n      ${l.website_url ? `<a href=\"${l.website_url}\" style=\"color:#4F46E5\">Current site</a>` : '<b style=\"color:#DC2626\">No website</b>'}\n      ${l.phone ? ` \u00b7 ${l.phone}` : ''}\n      ${l.email ? ` \u00b7 <a href=\"mailto:${l.email}\" style=\"color:#4F46E5\">${l.email}</a>` : ''}\n    </div>\n  </div>`;\n\nconst html = `<!DOCTYPE html>\n<html><body style=\"background:#F3F4F6;padding:24px;font-family:system-ui,sans-serif\">\n  <div style=\"max-width:680px;margin:0 auto;background:white;padding:32px;border-radius:12px\">\n    <h1 style=\"margin:0 0 6px;font-size:24px;color:#111\">\ud83c\udfaf Marco's Monday Lead Digest</h1>\n    <div style=\"color:#6B7280;margin-bottom:24px\">${date} \u00b7 ${config.area} \u00b7 ${(config.categories||[]).join(', ')}</div>\n\n    <div style=\"background:#EEF2FF;padding:16px;border-radius:8px;margin-bottom:24px;font-size:14px\">\n      <b style=\"color:#4F46E5\">${newLeads.length}</b> new leads this week.\n      <b style=\"color:#10B981\">${updatedLeads.length}</b> existing leads re-verified.\n      ${top5.length > 0 ? ` Top ${top5.length} below are pre-drafted and ready to send.` : ''}\n    </div>\n\n    <h2 style=\"font-size:18px;color:#111;margin-top:24px\">\ud83d\udd25 Top ${top5.length} leads to contact this week</h2>\n    ${top5.map(leadCard).join('')}\n\n    ${newLeads.length > 5 ? `<h2 style=\"font-size:16px;color:#111;margin-top:32px\">Also new this week</h2><ul style=\"font-family:system-ui;font-size:14px\">${newLeads.slice(5).map(l => `<li>${l.business_name} (${l.category}, score ${l.lead_score})</li>`).join('')}</ul>` : ''}\n\n    <hr style=\"margin:32px 0;border:none;border-top:1px solid #E5E7EB\"/>\n    <div style=\"color:#6B7280;font-size:12px\">\n      Generated by the Firecrawl Lead Hunter workflow \u00b7 Run ID ${runId}<br/>\n      Edit area/categories/cadence in the n8n workflow's Set Config node.\n    </div>\n  </div>\n</body></html>`;\n\nconst text = `Marco's Monday Lead Digest \u2014 ${date}\\n\\n${newLeads.length} new leads, ${updatedLeads.length} re-verified.\\n\\n` +\n  top5.map((l, i) => `${i+1}. ${l.business_name} (${l.category}) \u2014 score ${l.lead_score}/5\\n   ${l.pain_point_hook || ''}\\n   Contact: ${l.phone || '\u2014'} / ${l.email || '\u2014'}\\n`).join('\\n');\n\nreturn [{ json: {\n  run_id: runId,\n  area: config.area,\n  categories: config.categories,\n  leads_new: newLeads.length,\n  leads_updated: updatedLeads.length,\n  top5,\n  all_new: newLeads,\n  html,\n  text,\n  subject: `\ud83c\udfaf Marco's Monday leads \u2014 ${newLeads.length} new in ${config.area}`,\n  to: config.digest_email_to\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1080,
        240
      ],
      "id": "marco-build-digest",
      "name": "Build HTML Digest"
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE public.marco_runs\n   SET leads_discovered = $2,\n       leads_new        = $3,\n       leads_updated    = $4,\n       status           = 'ok',\n       completed_at     = NOW(),\n       notes            = $5\n WHERE id = $1;",
        "options": {
          "queryReplacement": "={{ $json.run_id }}, {{ $json.leads_new + $json.leads_updated }}, {{ $json.leads_new }}, {{ $json.leads_updated }}, {{ 'Top 5 leads: ' + ($json.top5 || []).map(l => l.business_name).join(', ') }}"
        }
      },
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        1300,
        240
      ],
      "id": "marco-log-finish",
      "name": "Log Run Complete"
    },
    {
      "parameters": {
        "sendTo": "={{ $('Build HTML Digest').first().json.to }}",
        "subject": "={{ $('Build HTML Digest').first().json.subject }}",
        "emailType": "html",
        "message": "={{ $('Build HTML Digest').first().json.html }}",
        "options": {}
      },
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1580,
        240
      ],
      "id": "marco-send-email",
      "name": "Send Monday Digest",
      "notesInFlow": true,
      "notes": "Attach a Gmail OAuth2 credential. Disable this node if you prefer Slack/SMTP/Resend \u2014 the Build HTML Digest node's output contains everything you need."
    },
    {
      "parameters": {
        "content": "### \u26a0\ufe0f Error path\nThe Lead Hunter Agent has `onError: continueErrorOutput`. When the agent fails (max iterations, Firecrawl outage, invalid JSON output), execution forks to this branch instead of stopping the workflow.\n\n**Mark Run Error** UPDATEs the `marco_runs` row from status='running' to status='error' and records the error message so the next run's log has context. Without this branch, a failed run would leave a stuck 'running' row in the audit table.\n\n**Send Failure Alert** (optional) sends Marco an email so he knows Monday's digest didn't arrive by 08:00. Disable if you have separate n8n alerting.",
        "height": 280,
        "width": 420,
        "color": 2
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        720,
        500
      ],
      "id": "sticky-marco-error",
      "name": "Error path note"
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE public.marco_runs\n   SET status        = 'error',\n       completed_at  = NOW(),\n       notes         = $1::jsonb->>'error_message'\n WHERE id = ($1::jsonb->>'run_id')::bigint;",
        "options": {
          "queryReplacement": "={{ JSON.stringify({ run_id: $('Merge Run ID').first().json.run_id, error_message: ($json.error && $json.error.message) || ($json.message) || JSON.stringify($json).slice(0, 400) }) }}"
        }
      },
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        640,
        540
      ],
      "id": "marco-log-error",
      "name": "Mark Run Error"
    },
    {
      "parameters": {
        "sendTo": "={{ $('Set Config').first().json.digest_email_to }}",
        "subject": "=\u26a0\ufe0f Marco's Monday Lead Hunter failed (run #{{ $('Merge Run ID').first().json.run_id }})",
        "emailType": "html",
        "message": "=<h2 style=\"color:#DC2626;font-family:system-ui\">Weekly lead hunt failed</h2>\n<p><b>Run ID:</b> {{ $('Merge Run ID').first().json.run_id }}</p>\n<p><b>Area:</b> {{ $('Set Config').first().json.area }}</p>\n<p><b>Categories:</b> {{ JSON.stringify($('Set Config').first().json.categories) }}</p>\n<p><b>Error:</b></p>\n<pre style=\"background:#F9FAFB;padding:12px;border-radius:4px\">{{ ($json.error && $json.error.message) || $json.message || JSON.stringify($json).slice(0, 800) }}</pre>\n<p>The marco_runs.status for this run was marked 'error'. Check the n8n Executions panel for the full trace, then rerun manually or wait for next Monday.</p>",
        "options": {}
      },
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        900,
        540
      ],
      "id": "marco-alert-failure",
      "name": "Send Failure Alert",
      "notesInFlow": true,
      "notes": "Optional \u2014 tells Marco when a Monday run broke. Disable if you have separate alerting, or swap for Slack/SMTP/Resend."
    }
  ],
  "connections": {
    "Manual Test Trigger": {
      "main": [
        [
          {
            "node": "Set Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every Monday 07:00": {
      "main": [
        [
          {
            "node": "Set Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Config": {
      "main": [
        [
          {
            "node": "Log Run Start",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log Run Start": {
      "main": [
        [
          {
            "node": "Merge Run ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Run ID": {
      "main": [
        [
          {
            "node": "Lead Hunter Agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenRouter Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "Lead Hunter Agent",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "search_web": {
      "ai_tool": [
        [
          {
            "node": "Lead Hunter Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "map_site": {
      "ai_tool": [
        [
          {
            "node": "Lead Hunter Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "scrape_url": {
      "ai_tool": [
        [
          {
            "node": "Lead Hunter Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "batch_scrape": {
      "ai_tool": [
        [
          {
            "node": "Lead Hunter Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "extract_data": {
      "ai_tool": [
        [
          {
            "node": "Lead Hunter Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "firecrawl_autopilot": {
      "ai_tool": [
        [
          {
            "node": "Lead Hunter Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "browser_session": {
      "ai_tool": [
        [
          {
            "node": "Lead Hunter Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "check_credits": {
      "ai_tool": [
        [
          {
            "node": "Lead Hunter Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "think": {
      "ai_tool": [
        [
          {
            "node": "Lead Hunter Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Lead Hunter Agent": {
      "main": [
        [
          {
            "node": "Parse + Split Leads",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Mark Run Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark Run Error": {
      "main": [
        [
          {
            "node": "Send Failure Alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse + Split Leads": {
      "main": [
        [
          {
            "node": "Upsert Lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upsert Lead": {
      "main": [
        [
          {
            "node": "Aggregate Leads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate Leads": {
      "main": [
        [
          {
            "node": "Build HTML Digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build HTML Digest": {
      "main": [
        [
          {
            "node": "Log Run Complete",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log Run Complete": {
      "main": [
        [
          {
            "node": "Send Monday Digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

Marco — Weekly Local Lead Hunter (Challenge Case 3). Uses postgres, agent, lmChatOpenRouter, toolWorkflow. Event-driven trigger; 32 nodes.

Source: https://github.com/MinaSaad1/n8n-firecrawl-web-crawler-agent/blob/main/workflows/12-case-marco-lead-hunter.json — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

Template Nodes Example. Uses CUSTOM, formTrigger, executeWorkflowTrigger, chatTrigger. Event-driven trigger; 70 nodes.

Custom, Form Trigger, Execute Workflow Trigger +23
AI & RAG

Who is this for? Agencies, consultants, and service providers who conduct discovery calls and need to quickly turn conversations into professional proposals.

Tool Think, Tool Calculator, Agent Tool +18
AI & RAG

Transform your WhatsApp into a powerful personal finance command center. This AI-powered workflow converts natural language messages into structured financial data, automates record-keeping, and deliv

Tool Code, Output Parser Structured, OpenRouter Chat +6
AI & RAG

Mistral OCR is a cutting-edge document understanding API that improves how businesses extract and process information from complex documents. With top scores in benchmarks for accuracy and comprehensi

Agent, Chain Llm, Gmail +6
AI & RAG

Email-Triage. Uses lmChatOpenAi, outputParserStructured, agent, toolWorkflow. Event-driven trigger; 27 nodes.

OpenAI Chat, Output Parser Structured, Agent +5