Build a Cold Outreach Email Personalizer From a CSV Using OpenRouter Free Models
What We're Building
We are building a command-line tool that turns a dry CSV of prospects into a batch of highly relevant, personalized cold outreach email drafts. The script reads each row, automatically scrapes the prospect's company website for recent news, product language, and tone, then feeds that context to a free large language model to generate a tailored email.
Core features:
- CSV ingestion: Reads standard
Name,Company,Role,LinkedIncolumns. - Automated context scraping: Uses Firecrawl's free tier to extract clean markdown from the prospect's company homepage.
- LLM-driven personalization: Calls OpenRouter's free models (e.g., Gemini Flash, Llama 3) to draft a concise, non-salesy cold email that references real company details.
- Output to CSV or Markdown: Appends the generated email draft to the original row and saves a new file ready for review.
This is not a mass-mailer. It is an intelligence augmentation tool. You still review every draft before hitting send. The machine does the tedious research; you apply human judgment.
Architecture and Data Flow
The system is a linear pipeline with three stages: parse, scrape, generate. Each stage is idempotent so you can re-run individual rows without re-scraping everything.
The scraper hits the company URL, extracts the main page content as markdown, and saves it to a local JSON cache keyed by domain. The generator constructs a prompt containing the prospect's name, role, and the scraped company context, then sends it to OpenRouter. The response is appended as a new column in the output CSV.
Prerequisites and Free-Tier Setup
You need three free accounts. No credit card required for any of them.
| Service | Purpose | Free Tier Limits | Sign-Up Link |
|---|---|---|---|
| OpenRouter | LLM API gateway for free models | ~200 requests/day on free models | openrouter.ai |
| Firecrawl | Web scraping to markdown | 500 credits/month (1 credit per page) | firecrawl.dev |
| Python 3.10+ | Runtime | N/A | python.org |
OpenRouter setup:
- Create an account at openrouter.ai.
- Go to openrouter.ai/keys and create an API key.
- The free models we will use:
google/gemini-flash-1.5(fast, good at following instructions) andmeta-llama/llama-3-8b-instruct:free(strong reasoning). Both are unlimited on free tier but rate-limited.
Firecrawl setup:
- Sign up at firecrawl.dev.
- Grab your API key from the dashboard.
- Test it with a curl one-liner:
curl -X POST https://api.firecrawl.dev/v1/scrape -H 'Authorization: Bearer YOUR_KEY' -H 'Content-Type: application/json' -d '{"url":"https://example.com"}'
Step 1: Setting Up the Python Environment
Create a project directory and a virtual environment. Install only two dependencies.
mkdir cold-outreach-personalizer && cd cold-outreach-personalizer
python3 -m venv .venv && source .venv/bin/activate
pip install requests python-dotenv
Create a .env file to hold your API keys. Never hardcode secrets.
echo 'OPENROUTER_API_KEY=sk-or-v1-your-key-here' >> .env
echo 'FIRECRAWL_API_KEY=fc-your-key-here' >> .env
Create an empty cache/ directory for scraped content.
mkdir cache
Step 2: Parsing the Prospect CSV
Your input CSV must have at minimum these columns: Name, Company, Role, LinkedIn. The Company column should contain the company's domain (e.g., acme.com) or a full URL. We will normalize it.
Create parser.py:
import csv
import os
from urllib.parse import urlparse
def normalize_domain(raw: str) -> str:
"""Convert 'https://www.acme.com/about' to 'acme.com'"""
if not raw.startswith('http'):
raw = 'https://' + raw
domain = urlparse(raw).netloc
return domain.replace('www.', '')
def load_prospects(path: str) -> list[dict]:
"""Read CSV and return list of dicts with normalized domain."""
prospects = []
with open(path, newline='', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
row['domain'] = normalize_domain(row['Company'])
prospects.append(row)
return prospects
Create a sample prospects.csv:
Name,Company,Role,LinkedIn
Jane Doe,acme.com,VP Engineering,https://linkedin.com/in/janedoe
John Smith,example.io,CTO,https://linkedin.com/in/johnsmith
Step 3: Scraping Context with Firecrawl
Firecrawl's /v1/scrape endpoint returns clean markdown. We only need the first 2000 characters—enough to capture the company's value proposition, recent blog posts, and tone.
Create scraper.py:
import json
import os
import time
import requests
from dotenv import load_dotenv
load_dotenv()
FIRECRAWL_KEY = os.getenv('FIRECRAWL_API_KEY')
CACHE_DIR = 'cache'
def scrape_company(domain: str) -> str:
"""Scrape company homepage, cache result, return markdown snippet."""
cache_path = os.path.join(CACHE_DIR, f"{domain}.json")
# Return cached result if it exists
if os.path.exists(cache_path):
with open(cache_path, 'r') as f:
return json.load(f)['markdown'][:2000]
url = f"https://{domain}"
headers = {'Authorization': f'Bearer {FIRECRAWL_KEY}', 'Content-Type': 'application/json'}
payload = {'url': url, 'formats': ['markdown']}
resp = requests.post('https://api.firecrawl.dev/v1/scrape', headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
if not data.get('success'):
raise RuntimeError(f"Firecrawl failed for {domain}: {data}")
markdown = data['data']['markdown']
# Cache the full result
with open(cache_path, 'w') as f:
json.dump({'markdown': markdown, 'scraped_at': time.time()}, f)
return markdown[:2000]
The cache is critical. Firecrawl's free tier gives you 500 credits per month. Re-scraping the same domain on every test run burns credits fast. The cache also speeds up iteration.
Step 4: Generating Personalized Emails via OpenRouter
OpenRouter exposes a unified OpenAI-compatible chat completions endpoint. Free models have rate limits, so we add a small delay between calls.
Create generator.py:
import os
import time
import requests
from dotenv import load_dotenv
load_dotenv()
OPENROUTER_KEY = os.getenv('OPENROUTER_API_KEY')
def generate_email(name: str, role: str, company_domain: str, context: str) -> str:
"""Generate a personalized cold email using OpenRouter's free model."""
prompt = f"""You are an executive coach helping a founder write a concise, respectful cold outreach email.
Prospect: {name}, {role} at {company_domain}.
Company context (scraped from their website):
{context}
Write a 3-4 sentence email that:
- Opens with a specific, genuine observation about their company (use the context above).
- Connects it to a relevant challenge or opportunity their role likely faces.
- Proposes a 15-minute call to share a specific insight, not a sales pitch.
- Uses a warm, peer-to-peer tone. No "Dear Sir/Madam," no "I hope this email finds you well."
Return ONLY the email body. No subject line, no signature."""
headers = {
'Authorization': f'Bearer {OPENROUTER_KEY}',
'Content-Type': 'application/json',
}
payload = {
'model': 'google/gemini-flash-1.5', # Free, fast, good instruction following
'messages': [{'role': 'user', 'content': prompt}],
'max_tokens': 300,
'temperature': 0.7,
}
resp = requests.post(
'https://openrouter.ai/api/v1/chat/completions',
headers=headers,
json=payload,
)
if resp.status_code == 429:
print(f"Rate limited. Waiting 10 seconds...")
time.sleep(10)
return generate_email(name, role, company_domain, context)
resp.raise_for_status()
data = resp.json()
return data['choices'][0]['message']['content'].strip()
Why Gemini Flash over Llama 3 for this task? Gemini Flash has a larger free-tier context window and follows structured output instructions more reliably. But you can swap the model string to meta-llama/llama-3-8b-instruct:free if you prefer.
Step 5: Assembling the Main Orchestration Script
Create main.py that ties everything together and writes the output CSV.
import csv
from parser import load_prospects
from scraper import scrape_company
from generator import generate_email
def main(input_csv: str = 'prospects.csv', output_csv: str = 'outreach_output.csv'):
prospects = load_prospects(input_csv)
results = []
for i, p in enumerate(prospects):
print(f"[{i+1}/{len(prospects)}] Processing {p['Name']} at {p['domain']}...")
try:
context = scrape_company(p['domain'])
except Exception as e:
print(f" Scrape failed: {e}. Using fallback.")
context = f"{p['Company']} is a technology company."
email = generate_email(p['Name'], p['Role'], p['domain'], context)
p['GeneratedEmail'] = email
p['ScrapedContextSnippet'] = context[:200]
results.append(p)
print(f" Done.")
# Write output CSV
fieldnames = list(prospects[0].keys()) if prospects else []
with open(output_csv, 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(results)
print(f"\nOutput written to {output_csv}")
if __name__ == '__main__':
main()
Running the Tool
source .venv/bin/activate
python main.py
Expected output:
[1/2] Processing Jane Doe at acme.com...
Done.
[2/2] Processing John Smith at example.io...
Done.
Output written to outreach_output.csv
Open outreach_output.csv and you will see the original columns plus GeneratedEmail and ScrapedContextSnippet. Each email will reference real details from the company's website.
Sensible Extensions
Once the core pipeline works, extend it in high-leverage directions:
- LinkedIn profile scraping: Use a tool like Proxycurl's free tier (10 credits) to pull the prospect's recent posts or about section. Feed that into the prompt for hyper-personalization.
- A/B prompt variants: Generate two emails per prospect with different tones (e.g., "direct and data-driven" vs "curious and collaborative") and let the user pick.
- Subject line generation: Add a second OpenRouter call that generates a subject line based on the email body. Subject lines are high-leverage—worth the extra API call.
- CRM integration: Instead of CSV output, push drafts directly to HubSpot or Salesforce using their free developer sandboxes. This turns the script into a real workflow tool.
- Confidence scoring: Ask the model to self-rate how specific the email is to the company context on a 1-5 scale. Flag low-confidence drafts for manual rewrite.
If you want to go deeper on agentic workflows that chain multiple LLM calls with structured outputs, our guide on Domain-Driven Agents: Bounded Contexts for Reliable AI Workflows covers the architectural patterns that make pipelines like this production-grade.
Common Pitfalls and How to Avoid Them
Pitfall 1: Burning Firecrawl credits on every test run. Always use the file-based cache from Step 3. During development, pre-scrape a few domains and commit the cache files so you are not hitting the API on every invocation.
Pitfall 2: OpenRouter 429 rate limits.
Free models are shared resources. If you hit a 429, the script already retries after 10 seconds. For larger batches, add a 1-second delay between calls with time.sleep(1) inside the loop.
Pitfall 3: Generic-sounding emails because the scraped context is thin.
Many company homepages are vague ("We empower enterprises with AI-driven solutions"). If the scraped markdown is under 500 characters, the model has nothing specific to latch onto. In that case, fall back to scraping the company's /blog or /about page instead of the root.
Pitfall 4: Not reviewing generated drafts. LLMs hallucinate. They might invent a product feature or misstate the company's industry. Always read every draft before sending. This tool replaces research time, not judgment.
Pitfall 5: Hardcoding API keys.
Use the .env file and python-dotenv as shown. If you commit code to a public repo, add .env and cache/ to .gitignore.
FAQ
Q: Is this tool compliant with CAN-SPAM and GDPR? The tool generates drafts. You are responsible for ensuring your actual sends comply with regulations. It does not send emails, store prospect data in a cloud database, or track opens. The CSV stays on your machine.
Q: Can I use a different free LLM?
Yes. Swap the model field in generator.py to any model tagged :free on OpenRouter. Good alternatives: meta-llama/llama-3-8b-instruct:free, mistralai/mistral-7b-instruct:free.
Q: What if Firecrawl can't scrape a site due to bot protection?
Some sites block automated scrapers. Firecrawl handles basic JS-rendered pages but not aggressive Cloudflare challenges. If scraping fails, the script falls back to a generic context string. For high-value prospects, manually copy-paste their about page text into a cache/domain.com.json file.
Q: How many prospects can I process on the free tier? Firecrawl: 500 unique domains per month. OpenRouter: roughly 200 free model requests per day. For a batch of 100 prospects, you will be well within limits.
Q: Can I run this on a schedule?
Wrap main.py in a cron job or GitHub Action. Just ensure your .env and cache/ are available in the execution environment. For a deeper dive on building reliable, scheduled AI workflows, see our WhatsApp Customer-Support Agent guide which covers similar patterns with n8n.
Q: How do I improve email quality? The single biggest lever is the prompt. Add examples of your best cold emails as few-shot examples in the system message. If you are scaling outreach as part of an enterprise AI rollout, understanding customer health signals will help you calibrate the right tone and timing.
Want to build like a Forward Deployed Engineer?
FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.
Explore the program