All articles
Build Guides

Build a Cold-Outreach Personalizer with Groq + Cloudflare Workers

FDE Coach EditorialAugust 13, 202611 min read

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_line column

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:

ServiceFree Tier LimitSignup Link
Cloudflare Workers100k requests/dayhttps://workers.cloudflare.com
Cloudflare Browser Rendering500 browser sessions/monthIncluded with Workers Paid Plan (free tier sufficient for testing)
Groq Cloud30 requests/minute, multiple modelshttps://console.groq.com
GitHub (for wrangler deploy)Unlimited public reposhttps://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 waitForSelector on .pv-top-card confirms 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

  1. Set your Groq key:
npx wrangler secret put GROQ_API_KEY
# Paste your key from https://console.groq.com/keys
  1. Deploy:
npx wrangler deploy
  1. Test with curl:
curl -X POST https://your-worker.workers.dev/enrich \
  -F "file=@prospects.csv" \
  -o enriched.csv
  1. 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 serveStatic for 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.

#cold-outreach#email#personalization#groq#serverless

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

More build guides

August 15 · 0d left
Enroll Now