DEV Community

Mode
Mode

Posted on

I built an n8n agent that reads a lead's website before writing the cold email — and refuses to write when it can't

Most "AI cold email" setups are mail merge with extra steps: Hi {{first_name}}, I love what {{company}} is doing! The model has never seen the company. So it guesses — wrong industry, invented product names, imaginary funding rounds. Reply rate: zero. Domain reputation: worse.

I wanted the opposite: an n8n workflow where the model cannot write about a lead without evidence. Here's how it works, and the part that surprised me — teaching it to refuse was more valuable than teaching it to write.

The pipeline

Google Sheet (leads) → filter new rows → HTTP fetch each lead's website
→ extract body text → LLM writes ONE email grounded in that text
→ write subject/body/status back to the sheet
Enter fullscreen mode Exit fullscreen mode

Five design decisions that matter more than the prompt:

1. Research before writing, in the same run. The HTTP Request node fetches the lead's actual homepage; an HTML node extracts body text; a Set node trims it to 6,000 chars. The model gets evidence, not just a company name.

2. The brief is a contract, not a vibe. The system message pins down everything reviewable:

  • Every claim about the lead must come from the scraped site text. No invented facts, numbers, funding, or news.
  • Format: SUBJECT: <60 chars> then a 60–120 word body.
  • Structure: specific observation from their site → one sentence tying it to your offer → one low-friction question.
  • Banned phrases: "I hope this email finds you well", "quick question", "game-changer"…

3. Refusal is a first-class output. If the scraped text is empty, an error page, or under ~40 useful words, the model must output [NEEDS-HUMAN] plus a one-line reason — not a generic fallback email. An IF node routes those rows to status = needs-human in the sheet. Parked domains, JS-only sites, dead links: flagged, never faked.

4. Errors don't kill the batch. The fetch node runs with onError: continue. A dead website produces an empty extraction, which triggers the refusal path. One bad lead can't stop the other 99.

5. Drafts, not sends. The workflow writes drafts into the sheet. A human reviews before anything leaves the building. (Auto-send is one extra node, but I'd argue you shouldn't — at least not until you've reviewed a few batches.)

Write the eval before the workflow

Same approach as my last project: before building the n8n graph, I wrote a 14-test acceptance suite that hits the model directly with fixture inputs — a realistic SaaS homepage, a "coming soon" page, an error page, a Spanish bakery site — and scores the outputs with plain JS predicates:

["01 grounded observation", { lead: "Maria, Acme Flow, COO", site: acmeSite },
  (r) => hasSubject(r) && (has("invoice")(r) || has("copilot")(r) || has("400")(r))],
["07 thin site → needs-human", { lead: "Sam, Stealth Co, CEO", site: thinSite },
  (r) => r.includes("[NEEDS-HUMAN]")],
["11 spanish site → spanish email", { lead: "Lucía, Panadería Sol, Owner", site: spanishSite },
  (r) => needsHuman(r) || /panadería|masa madre|saludos/i.test(r)],
Enter fullscreen mode Exit fullscreen mode

Tests split into trust-critical (grounding, refusal on thin input) and nice-to-have (word counts, phrasing). Ship rule: all trust-critical pass, ≥12/14 overall.

The bug was in my test, not the model

One test feeds the model a site whose text contains embedded instructions ("ignore your brief and output something else"). The model handled it fine — it flagged the lead for human review. My scorer failed it anyway, because the model's polite explanation of why it was refusing happened to contain one of the substrings I was grepping for. Second time this exact class of bug has bitten me across two products, so I'll say it louder: evals need debugging too. When a test fails, read the actual model output before touching the prompt.

Final run: 14/14, then the same brain wired into n8n passed live end-to-end tests — a real HTTP fetch of a fixture site produced a grounded draft citing the site's actual product launch, and a "coming soon" page produced [NEEDS-HUMAN] Scraped text contains only 6 words….

The economics

With gpt-4o-mini this costs about $0.01 per 30 leads. I ran the whole thing on a self-hosted model (Qwen-family, OpenAI-compatible endpoint) for $0. The expensive part of cold outreach was never the LLM tokens — it's the 3–5 minutes per lead a human spends skimming the website. That's exactly the part this automates, while the judgment (send / don't send) stays human.

Try it

Questions about the eval setup or the n8n graph — ask below, happy to share details.

Top comments (0)