All articles
Build Guides

Build a Free AI Newsletter Agent with Groq, Supabase & GitHub Actions

FDE Coach EditorialJuly 20, 202610 min read

What We’re Building

We’re building an autonomous AI agent that wakes up on a cron schedule, ingests articles from your favorite RSS feeds, uses a large language model to rank them based on your personal interests, and compiles a clean, email-ready digest.

This isn't a generic feed reader. It's a personalized curation engine that filters noise and surfaces signal. By the end, you'll have a system that runs entirely on free-tier infrastructure and costs you zero dollars a month.

Core features:

  • Multi-source RSS ingestion with deduplication
  • Semantic relevance scoring against a user-defined interest profile
  • Persistent storage of processed items to avoid repeats
  • Automated daily/weekly email digests via Resend
  • Serverless orchestration through GitHub Actions

Architecture & Data Flow

Before we write code, let's nail the flow. A scheduled GitHub Action triggers a Python script. That script pulls raw XML from configured RSS feeds, parses out titles, links, and summaries, then checks a Supabase table for items we've already seen. Fresh articles are batched and sent to Groq’s API, where Llama 3.3 scores each one against your interest profile. The top N articles are formatted into an HTML email and dispatched through Resend’s free tier.

The deduplication step is critical. Without it, you'll email yourself the same article every time the job runs. We use Supabase as a lightweight state store, keyed by article URL.

Prerequisites (All Free Tier)

You need accounts on four platforms. Every one has a generous free tier that covers our use case easily.

  • GitHub Account: For repository hosting and Actions minutes (2,000 free minutes/month for private repos, unlimited for public).
  • Supabase: Free tier includes 500 MB database and 2 GB bandwidth. Sign up here.
  • Groq Cloud: Free API access to Llama 3.3 and other models with generous rate limits. Get an API key.
  • Resend: 100 emails/day free. Perfect for a personal digest. Create account.

You’ll also need Python 3.10+ locally for testing, but the production runner is GitHub Actions’ Ubuntu image.

Step 1: Setting Up the Supabase Backend

Head to your Supabase dashboard and create a new project. Once provisioned, open the SQL Editor and run this schema:

-- Table for deduplication and scoring history
CREATE TABLE processed_articles (
  id BIGSERIAL PRIMARY KEY,
  url TEXT UNIQUE NOT NULL,
  title TEXT,
  source_feed TEXT,
  relevance_score FLOAT,
  processed_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index for fast lookups by URL
CREATE INDEX idx_processed_articles_url ON processed_articles (url);

-- Optional: store your interest profile
CREATE TABLE user_profile (
  id INT PRIMARY KEY DEFAULT 1,
  interests TEXT NOT NULL,
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Seed your interests (edit this to your actual interests)
INSERT INTO user_profile (id, interests)
VALUES (1, 'I care deeply about: LLM inference optimization, Rust systems programming, forward deployed engineering patterns, open-source AI tools, and PostgreSQL internals. I ignore: crypto/NFT news, celebrity tech gossip, gadget reviews.');

Grab your project URL and anon/service_role key from Settings > API. We'll use the service_role key in our script because we're running server-side. Store it as a GitHub Actions secret—never hardcode it.

Step 2: Fetching and Parsing RSS Feeds

Create a new GitHub repository and add a file feeds.txt at the root. List one RSS feed URL per line:

https://simonwillison.net/atom/entries/
https://news.ycombinator.com/rss
https://www.anthropic.com/blog/feed.xml

Now write the core script. Create curate.py:

import os
import hashlib
import feedparser
import requests
from datetime import datetime, timezone
from supabase import create_client

# --- Config ---
SUPABASE_URL = os.environ["SUPABASE_URL"]
SUPABASE_KEY = os.environ["SUPABASE_SERVICE_ROLE_KEY"]
GROQ_API_KEY = os.environ["GROQ_API_KEY"]
RESEND_API_KEY = os.environ["RESEND_API_KEY"]
TO_EMAIL = os.environ["TO_EMAIL"]

supabase = create_client(SUPABASE_URL, SUPABASE_KEY)

# --- Fetch feeds ---
def fetch_all_feeds(feed_file="feeds.txt"):
    articles = []
    with open(feed_file) as f:
        urls = [line.strip() for line in f if line.strip()]
    
    for feed_url in urls:
        try:
            parsed = feedparser.parse(feed_url)
            for entry in parsed.entries:
                articles.append({
                    "title": entry.get("title", "Untitled"),
                    "url": entry.get("link", ""),
                    "summary": entry.get("summary", entry.get("description", "")),
                    "source": feed_url
                })
        except Exception as e:
            print(f"Failed to parse {feed_url}: {e}")
    return articles

# --- Deduplicate against Supabase ---
def filter_new_articles(articles):
    new_articles = []
    for article in articles:
        if not article["url"]:
            continue
        # Check if URL already processed
        existing = supabase.table("processed_articles") \
            .select("id") \
            .eq("url", article["url"]) \
            .execute()
        if not existing.data:
            new_articles.append(article)
    return new_articles

We use feedparser, a battle-tested library that handles malformed feeds gracefully. The dedup check queries Supabase by URL—fast, indexed, and reliable.

Step 3: Ranking Articles with Groq & Llama 3.3

This is where the agent gets smart. We send a batch of article summaries to Groq’s chat completion endpoint with a system prompt that encodes your interest profile. Llama 3.3 returns structured JSON scores.

import json
from groq import Groq

groq_client = Groq(api_key=GROQ_API_KEY)

def load_user_profile():
    result = supabase.table("user_profile").select("interests").eq("id", 1).execute()
    return result.data[0]["interests"] if result.data else ""

def rank_articles(articles, user_profile):
    if not articles:
        return []
    
    # Build a compact representation for the LLM
    article_list = []
    for i, art in enumerate(articles):
        article_list.append({
            "id": i,
            "title": art["title"],
            "summary": art["summary"][:300]  # Truncate to save tokens
        })
    
    prompt = f"""You are a personal research curator. Your user has the following interests:

{user_profile}

Below is a list of articles. Score each one from 0.0 to 1.0 based on how relevant it is to the user's interests. 
1.0 = must-read, core interest. 0.0 = completely irrelevant.

Return ONLY a valid JSON array of objects with keys "id" (int) and "score" (float). Do not include any other text.

Articles:
{json.dumps(article_list, indent=2)}"""

    response = groq_client.chat.completions.create(
        model="llama-3.3-70b-versatile",
        messages=[
            {"role": "system", "content": "You are a precise article relevance scorer. Output only valid JSON."},
            {"role": "user", "content": prompt}
        ],
        temperature=0.1,  # Low temp for consistent scoring
        max_tokens=4096
    )
    
    # Parse the JSON response
    raw = response.choices[0].message.content
    # Strip markdown fences if Groq wraps it
    if raw.startswith("```"):
        raw = raw.split("```")[1]
        if raw.startswith("json"):
            raw = raw[4:]
    
    scores = json.loads(raw)
    
    # Attach scores back to articles
    score_map = {s["id"]: s["score"] for s in scores}
    for i, art in enumerate(articles):
        art["score"] = score_map.get(i, 0.0)
    
    # Sort descending and return top 10
    ranked = sorted(articles, key=lambda x: x["score"], reverse=True)
    return ranked[:10]

A few design decisions here: we truncate summaries to 300 characters to stay within token limits and reduce latency. We use temperature=0.1 because we want deterministic, repeatable scoring—not creative flair. The JSON parsing includes a guard against markdown-wrapped responses, which Groq sometimes returns despite instructions.

Step 4: Scheduling with GitHub Actions

The whole pipeline runs on a cron trigger. Create .github/workflows/digest.yml:

name: Curate Newsletter Digest

on:
  schedule:
    - cron: '0 14 * * *'  # 9 AM EST daily
  workflow_dispatch:  # Manual trigger for testing

jobs:
  curate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      
      - name: Install dependencies
        run: pip install feedparser supabase groq requests
      
      - name: Run curation script
        env:
          SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
          SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
          GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
          RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
          TO_EMAIL: ${{ secrets.TO_EMAIL }}
        run: python curate.py

Add your secrets in the repository’s Settings > Secrets and variables > Actions. The workflow_dispatch trigger lets you run the job manually from the Actions tab—invaluable for debugging.

Step 5: Sending the Digest with Resend

Back in curate.py, add the email sending function. Resend’s API is straightforward: POST a JSON payload with from, to, subject, and HTML body.

def send_digest(ranked_articles):
    if not ranked_articles:
        print("No articles to send.")
        return
    
    # Build HTML email
    html_parts = ["<h2>Your Personalized Digest</h2><ul>"]
    for art in ranked_articles:
        html_parts.append(
            f'<li><a href="{art["url"]}">{art["title"]}</a> '
            f'(Score: {art["score"]:.2f})<br>'
            f'<small>{art["summary"][:150]}...</small></li>'
        )
    html_parts.append("</ul>")
    html_body = "".join(html_parts)
    
    payload = {
        "from": "Digest Agent <digest@yourdomain.com>",
        "to": [TO_EMAIL],
        "subject": f"Your Curated Digest — {datetime.now(timezone.utc).strftime('%Y-%m-%d')}",
        "html": html_body
    }
    
    response = requests.post(
        "https://api.resend.com/emails",
        headers={
            "Authorization": f"Bearer {RESEND_API_KEY}",
            "Content-Type": "application/json"
        },
        json=payload
    )
    
    if response.status_code == 200:
        print("Digest sent successfully.")
        # Mark articles as processed
        for art in ranked_articles:
            supabase.table("processed_articles").insert({
                "url": art["url"],
                "title": art["title"],
                "source_feed": art["source"],
                "relevance_score": art["score"]
            }).execute()
    else:
        print(f"Failed to send: {response.status_code} {response.text}")

# --- Main ---
if __name__ == "__main__":
    all_articles = fetch_all_feeds()
    new_articles = filter_new_articles(all_articles)
    print(f"Found {len(new_articles)} new articles out of {len(all_articles)} total.")
    
    profile = load_user_profile()
    top_articles = rank_articles(new_articles, profile)
    
    send_digest(top_articles)

For Resend’s free tier, you must verify your sending domain or use the test email address they provide during onboarding. The from address must be on a verified domain.

Extensions & Production Hardening

Once the core loop works, you can layer on sophistication without leaving the free tier:

  • Multi-user support: Replace the single user_profile row with a table keyed by email. Loop over users, score against each profile, and send individualized digests.
  • Sentiment and tone filtering: Extend the Groq prompt to classify articles as “technical deep-dive,” “news brief,” or “opinion piece,” and let users tune the mix.
  • Web dashboard: A lightweight Supabase Edge Function or a static page querying processed_articles can show your reading history and scoring trends.
  • Slack/Discord delivery: Swap Resend for a webhook if you prefer digesting in a chat app. The architecture is identical; only the delivery channel changes.

If you're interested in building more agentic workflows that reason over external tools, our guide on building a multi-agent research assistant with Groq and Serper extends these patterns into a full research pipeline.

Common Pitfalls and Debugging Tips

RSS feeds returning stale data. Some feeds (especially corporate blogs) cache aggressively. Use feedparser’s etag and modified parameters to make conditional requests and avoid re-processing.

Groq rate limits. The free tier allows roughly 30 requests per minute. If you’re processing many feeds, batch articles into fewer LLM calls. Our script sends all articles in one prompt, but you may need to chunk if you subscribe to dozens of high-volume feeds.

Supabase cold starts. On the free tier, your database may pause after inactivity. The first GitHub Action run after a pause might time out. Mitigate this by adding a retry with exponential backoff on the Supabase client initialization.

Email landing in spam. Resend’s shared IPs on the free tier can trigger spam filters. Use a custom domain with proper SPF/DKIM records (Resend guides you through this) and keep your HTML simple.

GitHub Actions cron drift. Scheduled workflows can be delayed by up to 15 minutes during peak load. Don’t rely on second-level precision.

FAQ

Q: How much does this cost to run? A: Zero. Groq’s API is currently free, Supabase’s free tier covers 500 MB, GitHub Actions gives 2,000 minutes/month (this job uses ~2 minutes), and Resend allows 100 emails/day. You’ll hit limits only if you’re curating for a large team.

Q: Can I use a different LLM? A: Yes. Swap the Groq client for OpenAI, Anthropic, or a local Ollama instance. The prompt structure remains the same. Groq is chosen here for speed and free-tier generosity.

Q: How do I tune the relevance scoring? A: Edit the interests text in your user_profile row. Be specific and include both positive signals (“I love reading about X”) and negative signals (“I ignore Y”). The LLM uses this as its scoring rubric.

Q: What if an article appears in multiple feeds? A: Our dedup key is the URL. The first feed processed wins; subsequent appearances are skipped. If different URLs point to the same content (common with tracking parameters), normalize URLs by stripping query strings before inserting.

Q: I want to build more complex AI pipelines. Where should I go next? A: The patterns here—scheduled triggers, LLM-as-judge, persistent state in Postgres—are foundational to forward deployed engineering. If you’re preparing for roles that demand this skillset, understanding the FDE interview loop and what to expect in 2025 will help you position these projects effectively.

Q: Can I deploy this without GitHub Actions? A: Absolutely. The script is just Python. Run it on a Raspberry Pi cron, an AWS Lambda free tier, or a Cloudflare Worker (with a Python-to-JS translation). The architecture is decoupled from the scheduler.

#rss#personalization#cron#llama3#supabase

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