Build a Cold-Outreach Personalizer with Groq + Cloudflare Workers
What We're Building
A single Cloudflare Worker endpoint that takes a CSV of prospects (name, company, website, LinkedIn) and returns an enriched CSV with a personalized cold-outreach one-liner for each row. No databases, no queues — just a stateless edge function that chains Playwright scraping with Groq's fast inference to generate context-aware hooks.
Feature list:
- File upload endpoint accepting multipart CSV
- Smart fallback scraping: tries LinkedIn first, then the company website
- Groq-powered one-liner generation with structured JSON output
- Rate limiting and timeouts baked in to stay within free tiers
- Returns downloadable CSV with the new
personalized_linecolumn
This is the exact pattern FDEs use when a customer says "we have 500 leads and no time to research them." You ship a working prototype in an afternoon, then iterate based on what actually converts.
Architecture Overview
The flow is linear but with parallel scraping per row to keep latency acceptable. Playwright runs inside Cloudflare's Browser Rendering service (free tier includes 500 sessions/month). Groq's API is called for each prospect with a carefully engineered prompt that forces JSON output and prevents hallucination.
Prerequisites (All Free Tier)
Before writing a single line, sign up for these — all have generous free tiers:
| Service | Free Tier Limit | Signup Link |
|---|---|---|
| Cloudflare Workers | 100k requests/day | https://workers.cloudflare.com |
| Cloudflare Browser Rendering | 500 browser sessions/month | Included with Workers Paid Plan (free tier sufficient for testing) |
| Groq Cloud | 30 requests/minute, multiple models | https://console.groq.com |
| GitHub (for wrangler deploy) | Unlimited public repos | https://github.com |
You'll also need Node.js 18+ and npm installed locally. The entire stack costs zero dollars to build and test.
Step 1: Scaffold the Worker with Hono
Hono gives us a fast, Express-like routing layer that runs natively on Workers. Create a new project:
npm create cloudflare@latest outreach-personalizer -- --type hono
cd outreach-personalizer
npm install hono papaparse @cloudflare/ai
Replace src/index.ts with the skeleton:
import { Hono } from 'hono'
import { cors } from 'hono/cors'
const app = new Hono()
app.use('/enrich', cors())
app.post('/enrich', async (c) => {
// We'll fill this in
return c.json({ status: 'processing' })
})
export default app
Test it works:
npx wrangler dev
curl -X POST http://localhost:8787/enrich
You should see {"status":"processing"}. The worker is alive.
Step 2: Parse the CSV Upload
The endpoint receives a multipart/form-data POST with a file field named file. We'll use Papa Parse to convert it to an array of objects.
import Papa from 'papaparse'
app.post('/enrich', async (c) => {
const formData = await c.req.formData()
const file = formData.get('file') as File
if (!file) {
return c.json({ error: 'No CSV file provided' }, 400)
}
const csvText = await file.text()
const parsed = Papa.parse(csvText, {
header: true,
skipEmptyLines: true,
dynamicTyping: false
})
if (parsed.errors.length > 0) {
return c.json({ error: 'CSV parse failed', details: parsed.errors }, 422)
}
const prospects = parsed.data as Record<string, string>[]
// Validate required columns exist
const requiredCols = ['name', 'company', 'website', 'linkedin']
const missing = requiredCols.filter(col => !(col in prospects[0]))
if (missing.length > 0) {
return c.json({ error: `Missing columns: ${missing.join(', ')}` }, 422)
}
// Store for next steps — we'll process in Step 3
c.set('prospects', prospects)
return c.json({ count: prospects.length, status: 'parsed' })
})
CSV format expected:
name,company,website,linkedin
Jane Smith,Acme Corp,https://acme.com,https://linkedin.com/in/janesmith
Papa Parse handles quoted fields, escaped commas, and line breaks inside cells — edge cases that would break a naive split(',') approach.
Step 3: Scrape Prospect Data with Playwright
Cloudflare's Browser Rendering gives us a headless Chromium instance on the edge. We'll create a helper that tries LinkedIn first (for professional context), then falls back to the company website.
Add this to your worker (requires @cloudflare/ai for the browser binding):
interface Prospect {
name: string
company: string
website: string
linkedin: string
}
async function scrapeProspect(prospect: Prospect, env: Env): Promise<string> {
const browser = await env.BROWSER.launch()
const page = await browser.newPage()
let pageText = ''
// Attempt 1: LinkedIn profile
try {
await page.goto(prospect.linkedin, {
waitUntil: 'domcontentloaded',
timeout: 8000
})
// Wait for the profile section to load
await page.waitForSelector('.pv-top-card', { timeout: 5000 })
pageText = await page.evaluate(() => document.body.innerText)
} catch {
// LinkedIn may block or require login — fallback to website
console.log(`LinkedIn failed for ${prospect.name}, trying website`)
}
// Attempt 2: Company website (if LinkedIn failed or returned minimal text)
if (pageText.length < 200) {
try {
await page.goto(prospect.website, {
waitUntil: 'domcontentloaded',
timeout: 8000
})
pageText = await page.evaluate(() => document.body.innerText)
} catch {
pageText = `${prospect.name} works at ${prospect.company}`
}
}
await browser.close()
// Trim to first 3000 characters to stay within Groq context limits
return pageText.substring(0, 3000)
}
Critical details:
- We set aggressive timeouts (8 seconds) because free-tier browser sessions are precious
- The
waitForSelectoron.pv-top-cardconfirms we got a real LinkedIn profile page, not a login wall - Fallback chain ensures we always return something, even if it's just the name and company
Step 4: Generate One-Liners via Groq
Groq's API is OpenAI-compatible, so we can use the same SDK patterns. We'll use llama-3.3-70b-versatile for speed (200+ tokens/second).
Create a generateLine function:
async function generateLine(prospect: Prospect, contextText: string, groqKey: string): Promise<string> {
const prompt = `You are a cold outreach personalization engine.
Given a prospect's name, company, and scraped context about them, generate ONE personalized opening line for a cold email.
Rules:
- Maximum 25 words
- Reference something SPECIFIC from the context (recent post, project, role change, company news)
- Never use generic flattery like "I was impressed by your profile"
- If context is sparse, reference their role and company naturally
- Output ONLY the line, no quotes, no explanation
Prospect: ${prospect.name} at ${prospect.company}
Context: ${contextText}
Personalized line:`
const response = await fetch('https://api.groq.com/openai/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${groqKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'llama-3.3-70b-versatile',
messages: [{ role: 'user', content: prompt }],
max_tokens: 60,
temperature: 0.7
})
})
const data = await response.json() as any
return data.choices[0].message.content.trim()
}
Why this prompt works:
- Hard constraint on output format prevents rambling
- "Reference something SPECIFIC" forces the model to actually use the scraped context rather than hallucinating
- 60 max tokens is tight — roughly 40-50 words — which enforces conciseness
Step 5: Return Enriched CSV
Now we wire everything together. The full /enrich handler processes rows in batches of 3 to respect Groq's rate limit while keeping latency reasonable.
app.post('/enrich', async (c) => {
const formData = await c.req.formData()
const file = formData.get('file') as File
if (!file) return c.json({ error: 'No CSV file provided' }, 400)
const csvText = await file.text()
const parsed = Papa.parse(csvText, { header: true, skipEmptyLines: true })
const prospects = parsed.data as Prospect[]
const enriched: (Prospect & { personalized_line: string })[] = []
const groqKey = c.env.GROQ_API_KEY
// Process in batches of 3 to stay under Groq's 30 RPM free limit
for (let i = 0; i < prospects.length; i += 3) {
const batch = prospects.slice(i, i + 3)
const results = await Promise.all(
batch.map(async (p) => {
const context = await scrapeProspect(p, c.env)
const line = await generateLine(p, context, groqKey)
return { ...p, personalized_line: line }
})
)
enriched.push(...results)
// Small delay between batches to respect rate limits
if (i + 3 < prospects.length) {
await new Promise(resolve => setTimeout(resolve, 2000))
}
}
// Convert back to CSV
const outputCsv = Papa.unparse(enriched)
return new Response(outputCsv, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename="enriched_prospects.csv"'
}
})
})
Environment bindings in wrangler.toml:
name = "outreach-personalizer"
main = "src/index.ts"
compatibility_date = "2024-12-01"
[[browser]]
binding = "BROWSER"
[vars]
GROQ_API_KEY = "your-groq-key-here"
How to Run It
- Set your Groq key:
npx wrangler secret put GROQ_API_KEY
# Paste your key from https://console.groq.com/keys
- Deploy:
npx wrangler deploy
- Test with curl:
curl -X POST https://your-worker.workers.dev/enrich \
-F "file=@prospects.csv" \
-o enriched.csv
- Check the output:
head enriched.csv
You'll see the original columns plus personalized_line with context-aware hooks like "Saw your team just shipped the Q3 analytics dashboard — curious how you're handling real-time data ingestion."
Sensible Extensions
Once the core pipeline works, here's where to take it next:
- Add a web UI: A simple HTML form hosted on the same worker lets non-technical users upload CSVs. Use Hono's
serveStaticfor a single-page app. - Score and rank: Add a second Groq call that scores each line on "likely reply rate" (1-10) and sorts the output CSV by score.
- A/B variant generation: Generate two different lines per prospect (one professional, one casual) and let the user pick which to send.
- Webhook integration: POST the enriched CSV directly to a CRM or email sending service like SendGrid.
- Caching layer: Store scraped LinkedIn data in Cloudflare KV with a 7-day TTL so re-running the same prospect doesn't burn browser sessions.
For a deeper dive on building LLM-powered internal tools that actually ship to customers, see our breakdown of what a Forward Deployed Engineer actually does in a week — this personalizer is exactly the kind of prototype you'd build on day 2 of an engagement.
Common Pitfalls
LinkedIn blocks the Playwright session. LinkedIn aggressively detects headless browsers. If you see blank pages, add a User-Agent header and a random delay before navigation. The fallback to the company website handles this gracefully in the current implementation.
Groq rate limit 429 errors. The free tier allows 30 requests per minute. The batch delay of 2 seconds between groups of 3 keeps you at ~18 RPM. If you still hit limits, increase the delay or reduce batch size.
Browser Rendering quota exhaustion. Cloudflare's free tier includes 500 sessions/month. Each CSV row consumes one session (we close the browser after each prospect). For a 50-prospect CSV, that's 50 sessions — 10% of your monthly quota. Consider batching multiple prospects into a single browser session if you're processing large lists.
CSV encoding issues. Papa Parse handles UTF-8 by default. If your CSV comes from Excel, it may be UTF-16 or ISO-8859-1. Add encoding detection or instruct users to export as "CSV UTF-8."
Timeout on large CSVs. Cloudflare Workers have a 30-second CPU time limit on the free plan. A 10-prospect CSV with scraping takes roughly 20-30 seconds. For larger lists, split the work across multiple requests or use a queue-based pattern.
If you're curious how LLM pipelines like this fit into enterprise environments with strict security requirements, check out our case study on deploying LLM features at air-gapped customers.
FAQ
Q: Why not use a proper job queue like Cloudflare Queues? A: For small CSVs (under 20 rows), the synchronous approach keeps the architecture dead simple — one request, one response. Queues add deployment complexity and are overkill for a prototype. Scale it when you need to.
Q: What if the prospect doesn't have a LinkedIn URL? A: The scraping function falls through to the company website. If both fail, it returns a minimal context string with just the name and company. Groq will generate a line based on that — it won't be as personalized, but it won't break.
Q: Can I use a different LLM?
A: Yes. The Groq API is OpenAI-compatible, so you can swap the endpoint URL to OpenAI, Anthropic, or any provider that speaks the same protocol. Just update the model and Authorization header. Groq is chosen here because it's the fastest free-tier option.
Q: How do I handle GDPR / privacy concerns with scraping? A: This tool scrapes publicly available web pages. No data is stored — the worker is stateless. If you're deploying for production, add a data retention policy and consider whether you need consent for automated processing in your jurisdiction.
Q: The one-liners feel generic. How do I improve them? A: The quality ceiling is set by the scraped context. If LinkedIn returns a login wall, you're falling back to the company website, which may not have person-specific info. Consider adding a third fallback that searches for the person's name on the company blog or news section.
For more on building practical LLM tools that work within constraints, see our guide on building a codebase Q&A bot with Gemini RAG — similar engineering patterns, different use case.
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