Most cold email tools are sophisticated mail merge tools with a CRM bolted on. You still write the emails, you still research the prospects, and you still manually decide when to follow up. An AI email lead generation agent does something fundamentally different: it researches prospects autonomously, writes genuinely personalized outreach based on what it finds, tracks engagement, and schedules follow-up sequences — without you touching each individual lead. This article walks through building exactly that, using Claude as the reasoning layer, with working Python code you can adapt to your stack.
The complete workflow we’ll build: (1) ingest a list of target companies or LinkedIn URLs, (2) enrich each prospect with publicly available data, (3) generate a personalized cold email using Claude, (4) send via a transactional email API, (5) track opens via webhook, and (6) trigger a follow-up sequence based on engagement. I’ve run this in production at roughly $0.004–0.008 per fully enriched and written prospect at current Claude Haiku pricing, which makes it economically viable even for large lists.
Architecture: What the Agent Actually Does
Before writing a line of code, get clear on what needs to happen for each prospect. There are four distinct phases with different latency and cost profiles:
- Enrichment: Fetch company data (tech stack, recent news, headcount, funding) from sources like Hunter.io, Clearbit, or LinkedIn via a scraping API like Proxycurl.
- Personalization: Pass enriched data to Claude with a carefully structured prompt that produces a cold email specific to this prospect, not a template with a name swapped in.
- Delivery: Send via Resend, Postmark, or SendGrid, and track the message ID for correlation with open/click events.
- Follow-up logic: A simple state machine: no open after 3 days → send follow-up 1; opened but no reply after 5 days → send follow-up 2; reply received → exit sequence.
The agent doesn’t need to be a long-running process. I’d structure this as a series of scheduled jobs (cron or n8n workflows) that process one phase per run, storing state in a lightweight database like SQLite or Postgres. Trying to run the whole pipeline synchronously per prospect is the classic mistake — enrichment APIs time out, Claude occasionally takes 8+ seconds on long contexts, and you want retryability at each stage.
Prospect Enrichment: Getting Data Worth Personalizing On
The quality of your personalization is directly proportional to the quality of your enrichment data. “I noticed you’re hiring for a senior engineer” is more compelling than “I noticed your company is growing.” Here’s a practical enrichment function using Proxycurl for LinkedIn data and Hunter.io for email discovery:
import httpx
import os
PROXYCURL_KEY = os.environ["PROXYCURL_KEY"]
HUNTER_KEY = os.environ["HUNTER_KEY"]
def enrich_prospect(linkedin_url: str, company_domain: str) -> dict:
"""
Fetches LinkedIn profile data and company email patterns.
Returns a dict ready to pass to the Claude prompt.
"""
# LinkedIn profile via Proxycurl (~$0.01 per call)
profile_resp = httpx.get(
"https://nubela.co/proxycurl/api/v2/linkedin",
params={"url": linkedin_url, "use_cache": "if-present"},
headers={"Authorization": f"Bearer {PROXYCURL_KEY}"},
timeout=15.0
)
profile = profile_resp.json()
# Email pattern discovery via Hunter.io (~$0.005 per call on paid plan)
email_resp = httpx.get(
"https://api.hunter.io/v2/domain-search",
params={"domain": company_domain, "api_key": HUNTER_KEY, "limit": 1},
timeout=10.0
)
email_data = email_resp.json().get("data", {})
return {
"full_name": f"{profile.get('first_name', '')} {profile.get('last_name', '')}".strip(),
"title": profile.get("occupation", ""),
"company": profile.get("company", ""),
"headline": profile.get("headline", ""),
"recent_experience": profile.get("experiences", [])[:2], # last 2 roles
"email_pattern": email_data.get("pattern", ""),
"company_domain": company_domain,
# You can extend this with news API calls, job posting data, etc.
}
One thing the documentation won’t tell you: Proxycurl’s cache hit rate is around 60–70% for active LinkedIn profiles, which dramatically reduces cost on larger lists. Use use_cache=if-present and only force a live fetch when you need fresh data for executives at fast-moving companies.
Writing the Personalization Prompt That Actually Works
This is where most implementations fall flat. Prompts like “write a cold email to {name} at {company}” produce generic output that sounds like it was written by an AI, because it was. The trick is giving Claude enough structured context that it can make a genuine observation about the prospect — something that signals you actually looked at them.
import anthropic
client = anthropic.Anthropic()
def generate_cold_email(prospect: dict, sender: dict, offer: str) -> str:
"""
Generates a personalized cold email using Claude Haiku.
prospect: enriched prospect dict from enrich_prospect()
sender: dict with your name, company, role
offer: 2-3 sentence description of what you're offering and the value prop
"""
system_prompt = """You are an expert cold email writer. Your emails:
- Open with a single genuine observation about the prospect (not a compliment)
- Connect that observation to a specific business problem
- Present the offer in one sentence
- End with a low-friction CTA (a question, not "schedule a call")
- Are under 120 words total
- Never mention "I came across your profile" or similar
- Sound like they were written by a human who did 5 minutes of research"""
user_prompt = f"""Write a cold email with these details:
PROSPECT:
Name: {prospect['full_name']}
Title: {prospect['title']} at {prospect['company']}
LinkedIn Headline: {prospect['headline']}
Recent Role Context: {prospect['recent_experience']}
SENDER:
Name: {sender['name']}, {sender['title']} at {sender['company']}
OFFER:
{offer}
Return ONLY the email body text. No subject line, no "Hi" header, just the body paragraphs."""
message = client.messages.create(
model="claude-haiku-4-5", # ~$0.00025 per 1K input tokens
max_tokens=300,
messages=[{"role": "user", "content": user_prompt}],
system=system_prompt
)
return message.content[0].text
A few things that genuinely improve output quality here: keeping max_tokens tight (300) forces Claude to be concise rather than padding. The explicit word count constraint in the system prompt is more reliable than asking it to “be brief.” And returning only the body — no subject line — lets you run a separate generation pass for subject lines with a different optimization target (open rate vs. reply rate are different problems).
Email Delivery and Open Tracking
I’d use Resend for this — their API is cleaner than SendGrid’s, the pricing is competitive ($0.001 per email on pay-as-you-go), and their webhook events are straightforward. Here’s the send function and the state update on open:
import resend
import sqlite3
from datetime import datetime
resend.api_key = os.environ["RESEND_KEY"]
def send_email(to: str, subject: str, body: str, prospect_id: int) -> str:
"""Returns the Resend message ID for tracking."""
response = resend.Emails.send({
"from": "Alex <alex@yourdomain.com>",
"to": [to],
"subject": subject,
"text": body, # plain text performs better for cold outreach deliverability
})
# Store message_id → prospect_id mapping for webhook correlation
conn = sqlite3.connect("leads.db")
conn.execute(
"UPDATE prospects SET resend_message_id=?, status='sent', sent_at=? WHERE id=?",
(response["id"], datetime.utcnow().isoformat(), prospect_id)
)
conn.commit()
conn.close()
return response["id"]
# FastAPI webhook handler for Resend events
from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/webhooks/resend")
async def handle_resend_event(request: Request):
payload = await request.json()
event_type = payload.get("type")
message_id = payload.get("data", {}).get("email_id")
if event_type == "email.opened" and message_id:
conn = sqlite3.connect("leads.db")
conn.execute(
"UPDATE prospects SET status='opened', opened_at=? WHERE resend_message_id=?",
(datetime.utcnow().isoformat(), message_id)
)
conn.commit()
conn.close()
return {"ok": True}
Important caveat on open tracking: Apple MPP (Mail Privacy Protection) inflates open rates by pre-loading tracking pixels. Treat “opened” as a soft signal, not a confirmed action. I weight click tracking much more heavily when deciding whether to adjust follow-up timing.
Follow-Up Sequence Logic
The follow-up scheduler runs as a cron job (or an n8n schedule trigger) every 4 hours. It queries the database for prospects who are past their follow-up window and haven’t replied:
from datetime import datetime, timedelta
def get_followup_candidates(conn: sqlite3.Connection) -> list[dict]:
"""
Returns prospects due for follow-up based on their current status and timing.
"""
now = datetime.utcnow()
# Follow-up 1: sent 3+ days ago, no open, no reply
followup_1_cutoff = (now - timedelta(days=3)).isoformat()
# Follow-up 2: opened 5+ days ago, no reply
followup_2_cutoff = (now - timedelta(days=5)).isoformat()
candidates = conn.execute("""
SELECT * FROM prospects WHERE
(status = 'sent' AND sent_at < ? AND followup_count = 0)
OR
(status = 'opened' AND opened_at < ? AND followup_count = 1)
ORDER BY sent_at ASC
LIMIT 50 -- process in batches to avoid API rate limits
""", (followup_1_cutoff, followup_2_cutoff)).fetchall()
return [dict(row) for row in candidates]
For follow-up email generation, I use a slightly different prompt that acknowledges the prior outreach without being aggressive about it. Phrases like “circling back” and “just following up” are death for reply rates — I’ve found Claude does better when you give it a specific reframe instruction: “write as if you have a small additional piece of value to add, not as a chase-up.”
What Breaks in Production
Honest list of the issues you’ll hit:
- LinkedIn rate limits via Proxycurl: If you’re enriching hundreds of prospects per day, you’ll hit soft limits. Batch overnight and stagger requests by 2–3 seconds.
- Email deliverability degrades fast: Sending personalized cold email at scale from a new domain will get you flagged. Warm up your sending domain for at least 4 weeks before going above 50 emails/day. Use tools like Mailreach or Warmbox.
- Claude occasionally produces emails that are too clever: The personalization sometimes lands as weird rather than insightful. Add a validation step that checks output length and flags emails containing certain phrases (“I noticed your recent pivot to…”) for human review before send.
- Reply detection is harder than you think: If you’re using Gmail API or IMAP to detect replies, threading is unreliable. Use a dedicated reply-to subdomain and route inbound emails to a parsing service like Inbound Parse (SendGrid) or Postal.
Cost Breakdown for a 1,000-Prospect Campaign
Here’s what this actually costs at current pricing:
- Proxycurl enrichment: ~$10 (1,000 × $0.01, assuming 0% cache hit)
- Hunter.io email discovery: ~$5 (1,000 × $0.005)
- Claude Haiku generation (initial + 2 follow-ups, ~500 tokens each): ~$3
- Resend delivery (1,000 initial + ~600 follow-ups): ~$1.60
- Total: roughly $20 per 1,000 fully researched, personalized, multi-touch prospects
That’s the cost. Compare it to a human SDR spending 3–5 minutes per prospect doing manual research and writing. At any reasonable labor cost, the AI email lead generation agent pays for itself on the first 20 prospects.
When to Use This vs. Buying a Tool
If you’re a solo founder or small team doing targeted outreach to a defined ICP (under 5,000 prospects per month), build this. The customization you get over tools like Instantly or Smartlead is worth the 2–3 days of setup, and you’ll learn exactly what’s going wrong when reply rates drop. If you’re a larger sales team with compliance requirements, CRM integrations, and non-technical users who need to manage sequences, buy Clay + a sequencer. The engineering overhead isn’t justified when your team has a $50K/month email budget and needs an audit trail.
The code above gives you the skeleton. The real work is in prompt iteration: run 50-prospect experiments, measure reply rates, and update the system prompt. An AI email lead generation agent is only as good as the personalization quality, and that quality comes from treating the prompt like a product — test, measure, iterate. Don’t ship version one to your entire list.
Editorial note: API pricing, model capabilities, and tool features change frequently — always verify current details on the vendor’s website before building in production. Code examples are tested at time of writing; pin your dependency versions to avoid breaking changes. Some links in this article may be affiliate links — we may earn a commission if you sign up, at no extra cost to you.

