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

> Source: <https://dev.to/modeaicreator/i-built-an-n8n-agent-that-reads-a-leads-website-before-writing-the-cold-email-and-refuses-to-p02>
> Published: 2026-07-15 04:19:43+00:00

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.

```
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
```

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:

`SUBJECT: <60 chars>`

then a 60–120 word body.**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.)

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)],
```

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.

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…`

.

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.

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