I built an n8n agent that reads a lead's website before writing the cold email — and refuses to write when it can't A developer built an n8n workflow that reads a lead's website before writing a cold email, and refuses to write when it cannot extract sufficient evidence. The system uses HTTP fetching, HTML extraction, and an LLM to produce grounded drafts, with a refusal mechanism for thin or error pages. The developer emphasizes that teaching the model to refuse was more valuable than teaching it to write, and that evals need debugging too. 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.