Build a Cold Outreach Email Personalizer from a CSV of Prospects
What We're Building
A Python pipeline that takes a CSV of prospects, scrapes the open web for recent context about each person or their company, and feeds that context to Groq's Llama 3.1 70B model to generate a hyper-specific opening line that doesn't sound like AI slop.
Feature list:
- Reads a CSV with
name,company,rolecolumns - Uses Playwright to extract recent news, blog posts, or LinkedIn activity
- Cleans and truncates scraped text to fit within Groq's free-tier context window
- Calls Groq's chat completions API with a structured prompt that forces output into a JSON object containing
subject_lineandopening_line - Writes a new CSV with the original data plus the generated fields
- Runs entirely on free-tier services — no API keys that require a credit card beyond Groq's generous free tier
By the end, you'll have a script you can point at any prospect list and get back first drafts that actually reference something real about the recipient.
Architecture Overview
The flow is linear: CSV in, scrape per prospect, clean, prompt Groq, parse the response, write CSV out. No vector databases, no embedding steps, no complex orchestration. This keeps the free-tier usage predictable and the code easy to debug.
Prerequisites
All free. No credit card required for anything here.
- Python 3.10+ — python.org/downloads
- Groq API key — console.groq.com/keys (free tier gives you ~30 requests/minute on Llama 3.1 70B, more than enough for a few hundred prospects)
- Playwright —
pip install playwrightthenplaywright install chromium. Uses the bundled Chromium, no license fees. - A CSV file with at minimum
name,company,rolecolumns. More columns are fine; the script ignores extras.
If you haven't built with Groq before, check out our Gmail AI Triage Agent guide for a deeper dive into Groq's API patterns. Same exact client library, same fast inference.
Step 1: Project Setup and Dependencies
Create a new directory and a virtual environment:
mkdir cold-outreach-personalizer
cd cold-outreach-personalizer
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
Install the required packages:
pip install groq playwright pandas python-dotenv
playwright install chromium
Create a .env file:
GROQ_API_KEY=gsk_your_key_here
Create personalize.py — we'll build it piece by piece.
Step 2: Reading and Validating the CSV
We use pandas for CSV I/O because it handles edge cases (quoted fields, missing columns) without drama. Create a sample prospects.csv first:
name,company,role
Jane Doe,Acme Corp,VP Engineering
John Smith,TechStart Inc,CTO
Now the reader:
import pandas as pd
import sys
def load_prospects(path: str) -> pd.DataFrame:
df = pd.read_csv(path)
required = {"name", "company", "role"}
if not required.issubset(set(df.columns)):
missing = required - set(df.columns)
print(f"Missing required columns: {missing}")
sys.exit(1)
# Strip whitespace from string columns
for col in required:
df[col] = df[col].astype(str).str.strip()
return df
No magic here. The script fails fast if the CSV is malformed, which is exactly what you want in a pipeline you might run unattended.
Step 3: Scraping the Web for Context
This is the hardest part to make reliable on free tools. We're not using any paid enrichment APIs — just raw Playwright against public web pages. The strategy:
- Search for
"{name}" "{company}" recent newson DuckDuckGo (no API key needed) - Click the first 3 results
- Extract visible text from each page
- Concatenate and truncate to ~2000 characters
from playwright.sync_api import sync_playwright
import re
def scrape_context(name: str, company: str) -> str:
query = f'"{name}" "{company}" recent news'
search_url = f"https://duckduckgo.com/?q={query.replace(' ', '+')}"
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(search_url, timeout=15000)
# DuckDuckGo renders results as <a> tags with data-testid="result-title-a"
page.wait_for_selector('[data-testid="result-title-a"]', timeout=10000)
result_links = page.query_selector_all('[data-testid="result-title-a"]')
texts = []
for link in result_links[:3]:
try:
href = link.get_attribute("href")
if not href:
continue
new_page = browser.new_page()
new_page.goto(href, timeout=10000)
body_text = new_page.inner_text("body")
# Collapse whitespace
cleaned = re.sub(r'\s+', ' ', body_text).strip()
texts.append(cleaned[:1500])
new_page.close()
except Exception:
continue
browser.close()
combined = " ".join(texts)
return combined[:2000]
A few engineering notes:
- The DuckDuckGo CSS selector
[data-testid="result-title-a"]is stable as of mid-2025 but could break. If it does, inspect the page and update the selector. This is the cost of free. - We cap each page at 1500 chars and the combined text at 2000 chars. Groq's free tier has a 6k token context window, and we need room for the system prompt plus the output.
headless=Truekeeps this running in the background. Set toFalseif you want to watch it work.- Error handling is deliberately broad — if a page fails to load, we skip it and move on. Better to have less context than to crash the whole run.
Step 4: Generating Personalized Lines with Groq
Now the fun part. We'll use Groq's chat completions endpoint with a system prompt that enforces structured output. The model is llama-3.1-70b-versatile — fast enough that you won't feel the latency even on the free tier.
import os
import json
from groq import Groq
from dotenv import load_dotenv
load_dotenv()
client = Groq(api_key=os.getenv("GROQ_API_KEY"))
def generate_personalization(name: str, company: str, role: str, context: str) -> dict:
system_prompt = """You are an expert cold outreach copywriter. Given context about a prospect, generate a personalized email opening.
Rules:
- Reference something specific from the context (a recent achievement, news mention, blog post, or project).
- Do NOT use generic flattery like "I saw you're doing great things."
- Keep the opening line under 40 words.
- Keep the subject line under 8 words.
- Output ONLY valid JSON with keys "subject_line" and "opening_line". No markdown, no explanation."""
user_prompt = f"""Prospect:
Name: {name}
Company: {company}
Role: {role}
Context scraped from the web:
{context if context else "No context found. Use the prospect's role and company to craft a relevant but generic opening."}"""
response = client.chat.completions.create(
model="llama-3.1-70b-versatile",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
temperature=0.7,
max_tokens=200
)
raw = response.choices[0].message.content.strip()
# Groq sometimes wraps JSON in ```json fences
if raw.startswith("```"):
raw = raw.split("```")[1]
if raw.startswith("json"):
raw = raw[4:]
try:
return json.loads(raw)
except json.JSONDecodeError:
return {"subject_line": f"Quick question, {name}", "opening_line": f"I've been following {company}'s work in {role.split()[-1]} and wanted to reach out."}
Key decisions:
temperature=0.7gives enough creativity to avoid repetitive phrasing while staying grounded in the context.- The fallback JSON is deliberately safe — if Groq returns something unparseable, we don't lose the row. You can flag these for manual review later.
- The system prompt is strict about output format. Llama 3.1 70B follows JSON instructions well, but the
json.loadstry/except is cheap insurance.
Step 5: Assembling the Final Output
Tie everything together in a main function that processes the CSV row by row and writes results incrementally (so you don't lose work if it crashes at row 47).
def main():
input_path = "prospects.csv"
output_path = "prospects_personalized.csv"
df = load_prospects(input_path)
results = []
for idx, row in df.iterrows():
name, company, role = row["name"], row["company"], row["role"]
print(f"Processing {idx+1}/{len(df)}: {name} at {company}")
context = scrape_context(name, company)
personalization = generate_personalization(name, company, role, context)
results.append({
"name": name,
"company": company,
"role": role,
"subject_line": personalization["subject_line"],
"opening_line": personalization["opening_line"],
"context_snippet": context[:200] # for manual review
})
# Write incrementally
pd.DataFrame(results).to_csv(output_path, index=False)
print(f"Done. Wrote {len(results)} rows to {output_path}")
if __name__ == "__main__":
main()
Running the Pipeline
python personalize.py
Expected output:
Processing 1/2: Jane Doe at Acme Corp
Processing 2/2: John Smith at TechStart Inc
Done. Wrote 2 rows to prospects_personalized.csv
The output CSV will have all original columns plus subject_line, opening_line, and context_snippet. The snippet lets you spot-check whether the scraper found anything useful before you hit send.
Rate limits: Groq's free tier allows ~30 requests per minute. If you have more than 30 prospects, add a time.sleep(2) between iterations. The script will take a few minutes but won't hit rate limits.
Extensions
Once the basic pipeline works, here's where you can take it:
- LinkedIn-specific scraping: If your prospects are active on LinkedIn, modify the DuckDuckGo query to
site:linkedin.com/in "{name}"to target their profile directly. You'll need to handle LinkedIn's bot detection — adding apage.wait_for_timeout(3000)after navigation helps. - Company news only: For C-level prospects, sometimes company news is more relevant than personal mentions. Change the query to
"{company}" announcement funding product launch. - Multi-stage pipeline: Use the output CSV as input to a second script that actually sends the emails. Our Calendar Negotiation Agent guide shows how to wire Groq output into n8n for email automation.
- Quality scoring: Add a second Groq call that scores the generated opening line on a 1-5 scale for specificity. Rows scoring below 3 get flagged for manual rewrite.
- Parallel scraping: Use Python's
concurrent.futuresto scrape multiple prospects simultaneously. Playwright supports multiple browser contexts out of the box.
Common Pitfalls
DuckDuckGo blocks automated requests. If you get empty results, add a page.wait_for_timeout(2000) after page.goto() and set a realistic User-Agent header:
page.set_extra_http_headers({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
Groq returns unparseable JSON. This happens maybe 5% of the time with Llama 3.1 70B. The fallback in our code handles it gracefully. If you see it frequently, lower temperature to 0.3.
Scraping is slow. Each prospect makes up to 4 page loads (search + 3 results). At 2 seconds per page, that's ~8 seconds per prospect. For large lists, consider running overnight or using the parallel extension mentioned above.
Context is irrelevant. The scraper sometimes pulls in navigation menus, cookie banners, and footer text. The re.sub(r'\s+', ' ', body_text) helps, but you might want to add a cleaning step that strips lines shorter than 50 characters — those are almost always UI chrome.
FAQ
Why not use a paid enrichment API like Clearbit or Apollo?
Because this guide is about building with free tools. If you have budget, those APIs are faster and more reliable. But the skills you learn here — Playwright scraping, prompt engineering for structured output, incremental CSV processing — transfer directly to enterprise pipelines where you're integrating 5 different APIs and need fallback paths when one fails.
Can I use this for hundreds of prospects?
Yes, with a time.sleep(2) between rows to respect Groq's free-tier rate limits. 200 prospects will take about 30-40 minutes. For thousands, you'll want to upgrade to Groq's paid tier ($0.59/million input tokens for Llama 3.1 70B) or batch prospects into groups and run them in parallel.
What if the prospect has no online presence?
The script falls back to a role-and-company-based opening line. It won't be as compelling, but it won't crash. You can improve the fallback by adding industry-specific templates in the except json.JSONDecodeError block.
How do I integrate this with my actual email sending?
Write the output CSV to a format your email tool accepts. Most tools (GMass, Mailchimp, HubSpot) accept CSV imports with custom columns. For a fully automated pipeline, see our On-Call Incident Summarizer guide — same Groq + Python pattern, different use case, but the integration principles are identical.
Is web scraping legal for this use case?
Scraping publicly available information for personal outreach is generally fine. Don't scrape gated content, don't violate robots.txt, and don't resell the data. If you're doing this at an enterprise scale, consult your legal team — the same patterns apply, but you'll want to add compliance checks.
How do I prep for FDE interviews with projects like this?
This project demonstrates several FDE core competencies: API integration, prompt engineering, error handling, and building pipelines that fail gracefully. If you're preparing for forward-deployed roles, check out our FDE Portfolio guide for what hiring managers actually look for in take-home projects.
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