All articles
Build Guides

Build an AI Cron Job: RSS to Personalized Morning Newsletter

FDE Coach EditorialAugust 22, 20269 min read

What We're Building

We're creating a fully automated morning newsletter engine. A cron job fires at 7 AM, pulls items from your favorite RSS feeds, uses an LLM to keep only the articles matching your interests (e.g., "rust compiler internals" or "enterprise AI adoption"), summarizes each in 2-3 sentences, and emails you a clean HTML digest.

Feature list:

  • Multi-source RSS ingestion with rss-parser
  • Configurable topic list for filtering
  • Groq-hosted Llama 3.3 70B for near-instant, free summarization
  • Resend for transactional email delivery
  • Cloudflare Workers cron trigger for zero-cost scheduling
  • Plain-text fallback for email clients that block HTML

Architecture

The Worker wakes up on schedule, fetches raw XML from each feed URL, parses entries, sends batches of titles and descriptions to Groq's API with a strict system prompt, assembles the returned summaries into a responsive HTML template, and fires it off through Resend.

Prerequisites (All Free Tier)

  • Cloudflare account – Workers free tier gives 100k requests/day and 10ms CPU per invocation (plenty for this). Sign up at dash.cloudflare.com.
  • Node.js 20+ and npm for local development with wrangler.
  • Groq API key – Free tier allows 30 requests/min, 14,400 requests/day. Grab one at console.groq.com.
  • Resend API key – 100 emails/day free. Register at resend.com. Verify your sending domain (or use the test domain resend.dev for development).

Step 1: Scaffold the Cloudflare Worker

Create a new project using wrangler:

npm create cloudflare@latest rss-newsletter -- --type hello-world
cd rss-newsletter
npm install rss-parser resend

Replace wrangler.toml with:

name = "rss-newsletter"
main = "src/index.js"
compatibility_date = "2025-03-21"

[triggers]
crons = ["0 7 * * *"]

Set your secrets:

npx wrangler secret put GROQ_API_KEY
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put TO_EMAIL

Step 2: Fetch and Parse RSS Feeds

Add your feed list as a constant at the top of src/index.js. We'll use rss-parser which works in Cloudflare Workers with a small polyfill for DOMParser (the library handles it internally, but we need to pass a custom request function since Workers lack fetch on the global http module).

import Parser from 'rss-parser';

const FEEDS = [
  'https://simonwillison.net/atom/everything/',
  'https://www.anthropic.com/blog/rss.xml',
  'https://engineering.fb.com/feed/',
];

const parser = new Parser({
  customFields: { item: ['summary', 'content:encoded'] },
  timeout: 8000,
});

async function fetchAllFeeds() {
  const allItems = [];
  for (const url of FEEDS) {
    try {
      const feed = await parser.parseURL(url);
      const items = feed.items.slice(0, 15).map(item => ({
        title: item.title,
        link: item.link,
        contentSnippet: item.contentSnippet || item.summary || '',
        pubDate: item.pubDate,
        source: feed.title || new URL(url).hostname,
      }));
      allItems.push(...items);
    } catch (e) {
      console.error(`Failed to fetch ${url}: ${e.message}`);
    }
  }
  return allItems;
}

Step 3: Filter and Summarize with Groq LLM

Define your interests. The prompt instructs the model to return a strict JSON array so we can parse it programmatically.

const USER_TOPICS = [
  'AI engineering, LLM ops, prompt engineering',
  'Enterprise software deployment patterns',
  'Rust systems programming',
];

async function filterAndSummarize(items) {
  if (items.length === 0) return [];

  const prompt = `You are a personal newsletter curator. The user is interested in these topics:
${USER_TOPICS.map((t, i) => `${i + 1}. ${t}`).join('\n')}

Here are recent RSS items:
${items.map((item, i) => `[${i}] Title: ${item.title}\nSource: ${item.source}\nSnippet: ${item.contentSnippet.substring(0, 300)}`).join('\n\n')}

Instructions:
- Keep ONLY items directly relevant to the user's topics. Discard the rest.
- For each kept item, write a 2-3 sentence summary that captures the key point.
- Return a JSON array of objects with keys: "index" (number matching the item index), "summary" (string).
- Return ONLY the JSON array, no other text.`;

  const response = await fetch('https://api.groq.com/openai/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${GROQ_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'llama-3.3-70b-versatile',
      messages: [{ role: 'user', content: prompt }],
      temperature: 0.3,
      max_tokens: 2048,
    }),
  });

  const data = await response.json();
  const raw = data.choices[0].message.content;
  // Strip potential markdown fences
  const jsonStr = raw.replace(/```json|```/g, '').trim();
  const summaries = JSON.parse(jsonStr);

  // Merge summaries back into original items
  const summaryMap = new Map(summaries.map(s => [s.index, s.summary]));
  return items
    .filter((_, i) => summaryMap.has(i))
    .map((item, i) => ({ ...item, summary: summaryMap.get(i) }));
}

Why Llama 3.3 70B? It’s fast (sub-second for this prompt size), free on Groq, and follows JSON-output instructions reliably. If you hit rate limits, drop to llama-3.1-8b-instant.

Step 4: Compile the Newsletter HTML

Build a responsive template that works in Gmail, Apple Mail, and Outlook.

function buildHtml(dateStr, items) {
  const itemsHtml = items.map(item => `
    <div style="margin-bottom: 24px; border-bottom: 1px solid #eaeaea; padding-bottom: 16px;">
      <a href="${item.link}" style="color: #1a0dab; font-size: 18px; font-weight: 600; text-decoration: none;">${item.title}</a>
      <p style="color: #666; font-size: 13px; margin: 4px 0;">${item.source} · ${new Date(item.pubDate).toLocaleDateString()}</p>
      <p style="color: #333; font-size: 15px; line-height: 1.5;">${item.summary}</p>
    </div>
  `).join('');

  return `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; max-width: 640px; margin: 0 auto; padding: 20px;">
  <h1 style="font-size: 24px; margin-bottom: 4px;">Your Morning Digest</h1>
  <p style="color: #888; font-size: 14px; margin-top: 0;">${dateStr} · ${items.length} stories</p>
  ${itemsHtml}
  <p style="color: #aaa; font-size: 12px; margin-top: 32px;">Curated by your AI newsletter bot. <a href="https://github.com/your/repo">Unsubscribe or adjust topics</a>.</p>
</body>
</html>`;
}

Step 5: Send Email via Resend

Initialize the Resend client and send. The free tier requires a verified domain for production; during dev, set from to onboarding@resend.dev.

import { Resend } from 'resend';

async function sendEmail(html, textFallback) {
  const resend = new Resend(RESEND_API_KEY);
  const { data, error } = await resend.emails.send({
    from: 'newsletter@yourdomain.com',
    to: [TO_EMAIL],
    subject: `Your Morning Digest – ${new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })}`,
    html,
    text: textFallback,
  });
  if (error) throw new Error(`Resend error: ${error.message}`);
  return data;
}

function buildTextFallback(items) {
  return items.map(item => `${item.title}\n${item.source} · ${new Date(item.pubDate).toLocaleDateString()}\n${item.summary}\n${item.link}\n`).join('\n---\n');
}

Step 6: Schedule with a Cron Trigger

Wire everything into the Worker's scheduled handler. The fetch handler is optional—useful for manual testing via curl.

export default {
  async scheduled(event, env, ctx) {
    ctx.waitUntil(runPipeline(env));
  },
  async fetch(request, env) {
    await runPipeline(env);
    return new Response('Newsletter sent.', { status: 200 });
  },
};

async function runPipeline(env) {
  // Bind secrets from env
  globalThis.GROQ_API_KEY = env.GROQ_API_KEY;
  globalThis.RESEND_API_KEY = env.RESEND_API_KEY;
  globalThis.TO_EMAIL = env.TO_EMAIL;

  console.log('Fetching feeds...');
  const allItems = await fetchAllFeeds();
  console.log(`Fetched ${allItems.length} items.`);

  const relevant = await filterAndSummarize(allItems);
  console.log(`Kept ${relevant.length} relevant items.`);

  const dateStr = new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' });
  const html = buildHtml(dateStr, relevant);
  const text = buildTextFallback(relevant);

  await sendEmail(html, text);
  console.log('Email sent.');
}

How to Run It

  1. Local dev test: npx wrangler dev then hit http://localhost:8787 to trigger manually. Check the console for logs.
  2. Deploy: npx wrangler deploy. The cron in wrangler.toml activates automatically.
  3. Verify cron: In the Cloudflare dashboard, navigate to Workers & Pages → your worker → Triggers. You'll see the cron schedule listed.
  4. Debugging: Use npx wrangler tail to stream live logs from production.

Sensible Extensions

  • Multi-recipient with per-user topics: Store user configs in Cloudflare KV or D1. Loop through users, run the filter/summarize step per-user, and mail merge.
  • Deduplication across days: Keep a KV store of sent item GUIDs. Skip anything seen in the last 7 days.
  • Reply to adjust topics: Parse inbound email replies via a catch-all address to add/remove topics dynamically.
  • Sentiment or priority scoring: Add a second LLM call that scores items 1-10, sort the digest by priority.
  • Slack/Teams delivery: Swap Resend for a Slack webhook if your team prefers chat-based digests.

Common Pitfalls

  • RSS feed timeouts: Some feeds are slow. The 8-second timeout in the parser config prevents hanging. Add retry logic with exponential backoff if needed.
  • Groq JSON parsing failures: Occasionally the model wraps JSON in markdown or adds trailing text. The replace(/```json|```/g, '') handles most cases. For production, add a try/catch with a fallback that returns all items with a generic summary.
  • Worker CPU limits: Free-tier Workers have a 10ms CPU time limit. LLM calls are I/O-bound (fetch), so they don't consume CPU. But heavy HTML templating or large feed parsing can. Keep feeds under 100 items total.
  • Resend domain verification: You can't send from arbitrary addresses on the free tier. Verify your domain (DNS TXT record) or use onboarding@resend.dev for testing.
  • Cron timezone: Cloudflare cron triggers use UTC. 0 7 * * * fires at 7 AM UTC. Adjust to your timezone or use a timezone-aware library.

FAQ

Q: How much does this cost to run? A: Zero dollars. Cloudflare Workers free tier covers the invocations, Groq's free API covers the LLM calls, and Resend's free tier covers 100 emails/day. You'll only pay if you scale beyond those limits.

Q: Can I use a different LLM provider? A: Absolutely. Swap the fetch URL and model name for OpenAI, Anthropic, or any OpenAI-compatible endpoint. Groq is chosen here for speed and generous free tier.

Q: What if an RSS feed has no items matching my topics? A: The script sends an email with zero stories and a "No matching stories today" message. Add a conditional in buildHtml to handle this gracefully.

Q: How do I add more feeds? A: Append URLs to the FEEDS array. Stick to 5-10 feeds to stay within Worker memory limits and Groq context windows.

Q: Is the LLM filtering reliable? A: For broad topics, yes. For niche technical terms, you may get false positives. Tune the prompt or add a second verification pass. For a deeper dive on how LLM outputs can be unpredictable and how to sanitize them, check out our piece on Sanitizing LLM Code Output: How 'Vomit' Cleans Up Claude's Token Stream.

Q: Can I run this as a Forward Deployed Engineer for a client? A: This exact pattern—scheduled AI pipelines that turn raw data into structured, actionable summaries—is a bread-and-butter FDE deliverable. If you're preparing for stakeholder rounds where you'd whiteboard something like this, the FDE Interview Loop and How to Prepare for the Technical and Stakeholder Rounds breaks down what interviewers look for.

Q: What's the next step after mastering this pattern? A: Extending this to a full user-facing product with per-user state, authentication, and multi-channel delivery is the natural progression. The skill of shipping AI features under real constraints is exactly why Demand for Forward Deployed Engineers: Why This Role Is Booming continues to accelerate.

#rss#newsletter#cron#groq#cloudflare-workers

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