Build a Cold Email Personalizer with Groq's Free LLM and Python
What We're Building
Generic "I love your company" cold emails are dead. We're building a Python CLI tool that takes a CSV of prospects, scrapes their public LinkedIn profile or company website for real signals, and uses Groq's free-tier Llama 3 70B to generate a one-sentence personalized opener. The output is a new CSV with the original data plus a personalized_line column you can drop directly into your outreach sequences.
Feature list:
- Ingests any CSV with at minimum a
nameandlinkedin_urlorwebsitecolumn. - Uses Firecrawl's free tier to scrape the target page and extract clean markdown text.
- Sends the scraped context to Groq's chat completion endpoint (Llama 3 70B, free tier) with a structured prompt that forces a single, specific, non-cringey opening line.
- Writes a new CSV so your original data stays untouched.
- Respects rate limits with a configurable delay between requests.
- Runs entirely on free-tier services—no credit card required to start.
If you've been looking for a hands-on way to wire together LLM APIs and web scraping into a real sales workflow, this is it. And if you're sharpening your Forward Deployed Engineer skills, this pattern—ingest, enrich, generate, output—maps directly to the kind of enterprise AI pipelines we build every week. For a deeper look at that role, check out What a Forward Deployed Engineer Actually Does in a Week at an AI Startup.
Architecture and Data Flow
Before we write a line of code, here's exactly how the pieces fit together.
The script iterates over each row, fetches external context, calls the LLM, and appends the result. No orchestrator, no queue—just a linear pipeline that's easy to debug and extend. If you've built the Multi-Agent Research Assistant before, you'll recognize the scrape-then-generate pattern, but here we're optimizing for throughput over depth.
Prerequisites and Free Tier Setup
Everything here is free to start. You need three accounts and a Python environment.
1. Groq API Key (Free Tier)
- Go to console.groq.com and sign up.
- Navigate to API Keys, create a new key, and copy it.
- Free tier gives you generous rate limits on Llama 3 70B—more than enough for hundreds of prospects daily.
- Rate limits at time of writing: 30 requests per minute, 14,400 requests per day on the free tier.
2. Firecrawl API Key (Free Tier)
- Go to firecrawl.dev and sign up.
- Grab your API key from the dashboard.
- Free tier includes 500 credits. Each scrape costs 1 credit, so you get 500 page scrapes before needing to upgrade.
3. Python 3.9+
- Any recent Python works. We'll use only the standard library plus
requestsandpython-dotenv.
4. Environment Setup
mkdir cold-email-personalizer && cd cold-email-personalizer
python -m venv venv
source venv/bin/activate # or venv\Scripts\activate on Windows
pip install requests python-dotenv
Create a .env file in the project root:
GROQ_API_KEY=gsk_your_key_here
FIRECRAWL_API_KEY=fc_your_key_here
5. Sample CSV
Create prospects.csv:
name,company,linkedin_url
Jane Smith,Acme Corp,https://www.linkedin.com/in/janesmith
John Doe,TechStart Inc,https://www.linkedin.com/in/johndoe
You can also use a website column instead of linkedin_url—the script handles both.
Step 1: Project Scaffolding and Dependencies
Create main.py. We'll start with imports and configuration loading.
import csv
import os
import time
import sys
from pathlib import Path
import requests
from dotenv import load_dotenv
load_dotenv()
GROQ_API_KEY = os.getenv("GROQ_API_KEY")
FIRECRAWL_API_KEY = os.getenv("FIRECRAWL_API_KEY")
if not GROQ_API_KEY or not FIRECRAWL_API_KEY:
print("Missing API keys. Check your .env file.")
sys.exit(1)
GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
FIRECRAWL_URL = "https://api.firecrawl.dev/v1/scrape"
DELAY_SECONDS = 2 # Be respectful to rate limits
We're using Groq's OpenAI-compatible endpoint, so the request format will look familiar if you've ever called OpenAI's API. Firecrawl has a simple REST API—we POST a URL and get back markdown.
Step 2: Parsing the Prospect CSV
We need a function that reads the CSV, validates required columns, and returns a list of dictionaries.
def load_prospects(csv_path: str) -> list[dict]:
prospects = []
with open(csv_path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
# Normalize keys to lowercase
row = {k.lower(): v for k, v in row.items()}
if "name" not in row:
print(f"Skipping row missing 'name': {row}")
continue
if "linkedin_url" not in row and "website" not in row:
print(f"Row for {row['name']} missing URL column. Skipping.")
continue
prospects.append(row)
print(f"Loaded {len(prospects)} prospects from {csv_path}")
return prospects
This normalizes column headers to lowercase so your CSV can have Name, NAME, or name and everything works. It also skips rows that don't have a name or a URL to scrape—no point calling the LLM with nothing.
Step 3: Scraping Context with Firecrawl
Firecrawl's free tier returns clean markdown from any URL. We'll use it to pull the prospect's LinkedIn profile or company website. LinkedIn public profiles work fine—Firecrawl renders the page and extracts the text content.
def scrape_url(url: str) -> str | None:
headers = {
"Authorization": f"Bearer {FIRECRAWL_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"url": url,
"formats": ["markdown"],
}
try:
response = requests.post(FIRECRAWL_URL, json=payload, headers=headers, timeout=30)
response.raise_for_status()
data = response.json()
if data.get("success") and "data" in data:
markdown = data["data"].get("markdown", "")
# Truncate to ~3000 chars to stay well within Groq's context window
return markdown[:3000] if markdown else None
else:
print(f"Firecrawl error: {data}")
return None
except requests.RequestException as e:
print(f"Request failed for {url}: {e}")
return None
We truncate to 3000 characters. Llama 3 70B has a 128K context window, but we don't need to send entire web pages. The most relevant signals—job title, recent posts, company description—are usually in the first few thousand characters. Truncating also keeps latency low and costs at zero.
Step 4: Crafting the Groq Prompt
The prompt is where the magic happens. We need the LLM to output exactly one sentence that references something specific from the scraped context. No "I hope this email finds you well." No hallucinated facts.
def generate_opener(name: str, context: str) -> str:
headers = {
"Authorization": f"Bearer {GROQ_API_KEY}",
"Content-Type": "application/json",
}
system_prompt = (
"You are an expert cold email copywriter. Given a prospect's name and scraped context "
"from their LinkedIn or website, write ONE personalized opening sentence for a cold email. "
"Rules:\n"
"- Reference a specific detail from the context (role, company, recent post, project, etc.).\n"
"- Never use generic flattery like 'I was impressed by your background.'\n"
"- Never hallucinate. If the context has nothing useful, say 'I came across your profile and wanted to reach out.'\n"
"- Output ONLY the sentence. No quotes, no labels, no explanations."
)
user_prompt = f"Name: {name}\n\nContext:\n{context}\n\nOpening line:"
payload = {
"model": "llama3-70b-8192",
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": 0.7,
"max_tokens": 80,
}
try:
response = requests.post(GROQ_URL, json=payload, headers=headers, timeout=20)
response.raise_for_status()
result = response.json()
opener = result["choices"][0]["message"]["content"].strip()
return opener
except (requests.RequestException, KeyError, IndexError) as e:
print(f"Groq API error: {e}")
return "I came across your profile and wanted to reach out."
Key decisions here:
temperature=0.7gives enough creativity without going off-script.max_tokens=80enforces brevity—no rambling.- The fallback line is intentionally bland. Better a safe generic than a hallucinated lie about someone's career.
- The system prompt explicitly forbids the most common LLM cold-email sins.
Step 5: Assembling the Main Pipeline
Now we wire everything together with a main function that processes each prospect, handles errors gracefully, and writes the output.
def process_prospect(prospect: dict) -> dict:
name = prospect["name"]
url = prospect.get("linkedin_url") or prospect.get("website")
print(f"Processing {name}...")
context = scrape_url(url)
if not context:
print(f" No context scraped for {name}. Using fallback.")
prospect["personalized_line"] = "I came across your profile and wanted to reach out."
return prospect
opener = generate_opener(name, context)
prospect["personalized_line"] = opener
print(f" Generated: {opener[:80]}...")
return prospect
def main():
input_csv = "prospects.csv"
output_csv = "prospects_enriched.csv"
if not Path(input_csv).exists():
print(f"{input_csv} not found. Create it with 'name' and 'linkedin_url' columns.")
sys.exit(1)
prospects = load_prospects(input_csv)
enriched = []
for i, prospect in enumerate(prospects):
result = process_prospect(prospect)
enriched.append(result)
if i < len(prospects) - 1:
time.sleep(DELAY_SECONDS)
# Write output
fieldnames = list(enriched[0].keys()) if enriched else []
with open(output_csv, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(enriched)
print(f"\nDone. {len(enriched)} prospects written to {output_csv}")
if __name__ == "__main__":
main()
The time.sleep(2) between prospects keeps us well under Groq's free-tier rate limit of 30 RPM. For Firecrawl, 500 free credits means you can process 500 prospects before needing to upgrade—plenty for testing and small campaigns.
Step 6: Running the Script
With your .env and prospects.csv ready:
python main.py
Expected output:
Loaded 2 prospects from prospects.csv
Processing Jane Smith...
Generated: Noticed you recently led the product launch at Acme Corp—impressive timi...
Processing John Doe...
Generated: Saw your post about distributed systems scaling at TechStart—resonated wi...
Done. 2 prospects written to prospects_enriched.csv
Open prospects_enriched.csv and you'll see the original columns plus personalized_line. Drop that column into your email templates, merge it with your mail merge tool, or pipe it into your outreach platform.
Extensions and Production Hardening
This script is a solid foundation. Here's how to take it further without leaving the free tier.
Add company website scraping fallback: If linkedin_url is missing, construct a company website URL from the company field and scrape that instead. Even a homepage has useful signals—recent blog posts, product launches, mission statements.
Batch async requests: Use asyncio and aiohttp to scrape multiple URLs concurrently. Firecrawl's free tier allows parallel requests, and you can batch Groq calls up to the 30 RPM limit. This cuts processing time from O(n) to O(n/concurrency).
Confidence scoring: Add a second Groq call that rates the generated opener on a 1-5 scale for specificity. Filter out anything below 3 and fall back to a safer template. This is exactly the kind of pipeline thinking we teach in advanced FDE patterns—if you're preparing for interviews, the FDE Interview Loop guide breaks down how to articulate these design decisions under pressure.
CRM integration: Pipe the enriched CSV directly into HubSpot or Salesforce via their free developer sandboxes. Add a status column and update it after sending.
A/B test openers: Generate two variants per prospect by tweaking the temperature or system prompt. Track which style gets more replies and feed that back into your prompt engineering.
Common Pitfalls and Debugging
"Firecrawl returns empty markdown"
LinkedIn sometimes serves a login wall even for public profiles. If Firecrawl returns empty or error content, try the prospect's company website instead. You can also append ?trk=public_profile_browsemap to LinkedIn URLs to force the public view.
"Groq returns a multi-sentence paragraph"
The max_tokens=80 should prevent this, but if Llama 3 ignores it, add post-processing: split on the first period and take only the first sentence. Better yet, add \nOutput exactly one sentence. to the system prompt.
"Rate limit 429 errors"
Increase DELAY_SECONDS to 4 or 5. Groq's free tier is generous but shared. If you're hitting limits consistently, check your usage dashboard at console.groq.com.
"Generated lines feel generic" Your scraped context might be too thin. Try scraping both the LinkedIn profile AND the company website, concatenating the markdown, and feeding both into the prompt. More signal = more specific openers.
"CSV encoding issues with special characters"
Add encoding="utf-8-sig" to both the reader and writer. This handles BOM characters that Excel likes to insert.
FAQ
Q: Is this actually free for real campaigns? Yes. Groq's free tier gives you 14,400 requests per day. Firecrawl gives 500 free scrapes. For a campaign of 200 prospects, you're well within limits. If you scale past that, Groq's paid tier is still cheaper than boiling a kettle.
Q: Can I use a different LLM?
Absolutely. Swap the model field to any Groq-supported model (mixtral-8x7b-32768, gemma2-9b-it). The OpenAI-compatible endpoint means you can also point this at OpenAI, Anthropic, or any local model with an OpenAI-compatible server.
Q: What if I don't have LinkedIn URLs, just company names? Add a step that uses a search API (like Tavily's free tier) to find the company website from the name, then scrape that. This is a natural extension of the pipeline we built in the SQL Analyst Agent guide—same enrichment pattern, different data source.
Q: How do I avoid sounding like AI-generated spam? The system prompt already forbids generic flattery, but the real answer is: test on real prospects and iterate. Send 20 emails, track replies, and adjust the prompt. The best prompt is the one that gets responses, not the one that sounds clever in a code review.
Q: Can I run this on a schedule?
Wrap it in a cron job or GitHub Action. Since everything is API-based, you can run it from any machine with Python and an internet connection. Just make sure your .env file is properly secured if you're running in CI.
This pattern—ingest, enrich, generate, output—is the backbone of enterprise AI engineering. If you want to go deeper on building these pipelines at scale, our FDE coaching programs cover everything from prompt engineering to production deployment. No fluff, just the real patterns that ship.
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