A local-first feed reader for email and MCP. Antenna pulls RSS / Atom / JSON Feed, stores posts in SQLite with full-text search, emails you what's new, and exposes the same index to AI agents over MCP.
Phase 0 is a single-user local release. No service, no account, no hosted anything — it's a Python package, a SQLite file, and a launchd agent. You own all of it.
Mainstream RSS-to-email services (you know which one) have drifted into loud, out-of-context ads and dated layouts. Meanwhile everyone's workflow now includes at least one AI agent. Antenna is what happens when you rebuild the feed-subscription backbone for a world where your inbox and your agent are both first-class readers:
- Email output — clean per-post or digest HTML, delivered via your own SMTP (Gmail App Password works fine).
- MCP output —
list_sources,search_posts,get_post, and friends, exposed over stdio so Claude Desktop or any MCP client can answer "what's new on Hackaday about Z80?" directly against the same SQLite.
Same subscriptions, same index, two outputs.
- 14 CLI subcommands covering the full loop (
init,add-feed,import-opml,fetch,sync,list-sources,recent-posts,search,doctor,render-digest,send-email,setup-email,test-email,serve-mcp). - SQLite schema with
sources,posts,subscriptions,deliveries— plus an FTS5 virtual table so search is fast. - Fetcher for RSS, Atom, and JSON Feed with ETag / Last-Modified conditional GET.
- Deduplication by stable entry id across polls, so you never get the same post twice.
- Jinja2 HTML email templates (per-post + digest), dark-mode friendly.
- SMTP sender with a
--dry-runmode that writes HTML tooutbox/for visual preview. - Rule engine: include / exclude terms per feed (or
*), plus analertflag that promotes a match to an immediate email. - MCP stdio server with six tools, pointed at the same SQLite file.
- OPML import so you can walk your existing Blogtrottr / Feedly / Inoreader subs over in one command.
Phase 0 is explicitly not: hosted, multi-tenant, a Slack/Discord router, or anything involving embeddings. Those are on the roadmap, not in the box.
- macOS (these instructions) or Linux. Windows should work but isn't what I'm testing.
- Python 3.12+.
- A Gmail account with 2FA, if you want email output. We'll generate an App Password in a minute.
git clone https://github.com/toddllm/antenna.git ~/code/antenna
cd ~/code/antennaIn these examples, the code checkout lives in ~/code/antenna. Your config,
database, and logs live separately in ~/antenna/, outside the repo.
If you'd rather pin a known snapshot than track main, check out v0.1.1
after cloning or download the tagged source release from GitHub. This is pure
Python; there is no native build step.
Pick one.
Editable install (recommended — gives you an antenna command on your PATH):
python3 -m venv .venv
source .venv/bin/activate
pip install -e .Or run it without installing — works fine, you just have to use python3 -m antenna.cli instead of antenna:
pip install -r requirements.txt
export PYTHONPATH="$(pwd)"From here on the docs show antenna …; substitute python3 -m antenna.cli … if you didn't install.
If you want a confidence check before wiring up your own feeds or SMTP:
bash scripts/smoke_test.shThat creates a throwaway config, fetches a couple of public feeds, renders both email modes, and exercises the MCP server over real stdio.
I keep mine at ~/antenna/. Nothing about that path is required; it's just a convenient place that isn't inside the repo.
mkdir -p ~/antenna/logs
cp antenna.example.yaml ~/antenna/antenna.yamlEither point every command at it with -c, or set it once:
export ANTENNA_CONFIG="$HOME/antenna/antenna.yaml"(I put that line in my shell rc.)
If you want inbox delivery, the easiest path is to let Antenna write the SMTP section for you and then send a fixed test email before your first real sync.
Gmail preset:
- Open https://myaccount.google.com/apppasswords. This requires 2FA on your Google account.
- Create a new App Password; name it
antenna. Copy the 16-character string. - Run:
antenna setup-email \
--provider gmail \
--gmail-address you@gmail.com \
--app-password "xxxx xxxx xxxx xxxx" \
--to-address you+antenna@gmail.com
antenna test-email --dry-run
antenna test-emailThat rewrites the smtp: and email: sections in ~/antenna/antenna.yaml
and saves a timestamped backup beside it before making any change.
Amazon SES preset:
antenna setup-email \
--provider ses \
--region us-east-1 \
--username YOUR_SMTP_USERNAME \
--password YOUR_SMTP_PASSWORD \
--from-address feeds@yourdomain.com \
--to-address you@yourdomain.com
antenna test-emailGeneric SMTP:
antenna setup-email \
--provider generic \
--host smtp.example.com \
--port 587 \
--username YOUR_SMTP_USERNAME \
--password YOUR_SMTP_PASSWORD \
--from-address you@example.com \
--to-address you@example.comIf you prefer to edit YAML directly, this is the Gmail equivalent:
smtp:
host: smtp.gmail.com
port: 587
username: you@gmail.com
password: "xxxx xxxx xxxx xxxx" # the App Password
use_tls: true
email:
from_address: you@gmail.com
from_name_template: "{feed_title}" # Gmail will group threads by sender
to_address: you+antenna@gmail.com # plus-tag → easy filter in GmailIf you're not sending email yet, leave the defaults and only run --dry-run commands until you're ready.
antenna.example.yaml is starter data, not a forever-maintained bundle of canonical feeds, so it's worth swapping in the feeds you actually care about before your first long-running sync.
antenna initThis creates ~/antenna/antenna.db (or wherever database: in the config points) and loads every feed from the feeds: section of the YAML.
antenna -v fetchExpected output: one line per feed, ✓ <N> new <title>. The first_run_entries setting (default 3) caps how many historical posts each feed gets on its very first poll, so you're not flooded. Later polls keep moving forward from there; they do not backfill the older history you intentionally skipped on first run.
If one feed is broken, rate-limited, or temporarily down, Antenna records that source's last_error, backs that source off before retrying it again, and still exits successfully by default so later steps like send-email can keep working for healthy feeds. Use antenna fetch --strict when you want any source error to fail the command, for example in CI or while debugging one specific feed.
If you want the full headless loop in one command, run:
antenna -v syncThat polls feeds and immediately delivers any pending email in one step. For humans and coding agents alike, sync is the safest default because it avoids leaving the system half-finished after a manual fetch.
antenna list-sources
antenna recent-posts --limit 20
antenna search "rust" --limit 5
antenna doctor
antenna test-email --dry-run
antenna sync --jsonSearch uses SQLite FTS5, so you get phrase match ("exact phrase"), boolean (rust AND lifetime), and prefix (anthropi*) for free.
antenna doctor is the fastest support check when a feed goes bad or you need to confirm whether email is backed up, the DB exists, or the outbox path is writable.
antenna test-email is the safest SMTP check because it sends a fixed probe before any feed traffic is involved.
antenna sync --json is the easiest one-shot entry point for coding agents because it returns one structured report for fetch plus delivery, including a degraded status when some feeds fail or are still in backoff while healthy ones continue to run.
antenna render-digest --since 24hWrites an HTML file to outbox/ with all posts from the last 24 hours grouped by feed. Open it in a browser (or just drag it into a Finder Quick Look) to see exactly what the email will look like.
antenna send-email --mode per_post --dry-run
antenna send-email --mode digest --dry-runNo network, no SMTP — just writes the rendered HTML to outbox/ and records dry-run deliveries so you don't re-preview the same posts next time.
antenna send-email --mode digestOr switch default_mode in the YAML and just run antenna send-email.
Most feed services export OPML. Grab the file and:
antenna import-opml ~/Downloads/feedly.opml
antenna -v fetchThat's the whole migration.
The repo ships a launchd plist template at scripts/com.antenna.fetch.plist. It fetches every 15 minutes and sends whatever's undelivered.
Install:
-
Copy the template and edit the
{{PLACEHOLDERS}}:cp scripts/com.antenna.fetch.plist ~/Library/LaunchAgents/com.antenna.fetch.plist # open ~/Library/LaunchAgents/com.antenna.fetch.plist in your editor
Replace:
{{PYTHON}}→/Users/YOURNAME/code/antenna/.venv/bin/python(or/opt/homebrew/bin/python3).{{ANTENNA_HOME}}→/Users/YOURNAME/antenna.{{CONFIG_PATH}}→/Users/YOURNAME/antenna/antenna.yaml.{{REPO_PATH}}→/Users/YOURNAME/code/antenna(or delete thePYTHONPATHkey entirely if you usedpip install -e .).
(launchd won't expand
~. Use absolute paths everywhere.) -
Load it:
launchctl load ~/Library/LaunchAgents/com.antenna.fetch.plist launchctl start com.antenna.fetch # optional: trigger one run now
-
Tail the logs:
tail -F ~/antenna/logs/fetch.out ~/antenna/logs/fetch.err
-
Uninstall:
launchctl unload ~/Library/LaunchAgents/com.antenna.fetch.plist rm ~/Library/LaunchAgents/com.antenna.fetch.plist
If you'd rather use cron — run crontab -e and add:
*/15 * * * * /Users/YOURNAME/code/antenna/.venv/bin/python -m antenna.cli -c /Users/YOURNAME/antenna/antenna.yaml sync >> /Users/YOURNAME/antenna/logs/cron.log 2>&1On macOS, cron needs Full Disk Access granted to /usr/sbin/cron in System Settings → Privacy & Security → Full Disk Access, otherwise it can silently fail to reach your home dir. launchd is easier.
Antenna exposes six MCP tools over stdio: list_sources, subscribe, unsubscribe, recent_posts, search_posts, get_post.
-
Find your Claude Desktop MCP config:
~/Library/Application Support/Claude/claude_desktop_config.json -
Add an entry under
mcpServers:{ "mcpServers": { "antenna": { "command": "/Users/YOURNAME/code/antenna/.venv/bin/python", "args": ["-m", "antenna.cli", "serve-mcp"], "env": { "ANTENNA_CONFIG": "/Users/YOURNAME/antenna/antenna.yaml" } } } }If you don't use a venv,
commandcan bepython3and you'll need"PYTHONPATH": "/path/to/antenna/repo"inenv. -
Restart Claude Desktop. Look for Antenna under the connected tools hammer icon.
Example prompt after it's wired up:
Using the antenna tools, search for posts about "tokenizer" from the last 7 days and summarize the three most interesting ones.
For feed-health aware agents, list_sources includes each source's last_error, consecutive_failures, next_poll_after, and a poll_status field (healthy, error, or backoff), so an agent can tell the difference between a stale feed and one that's intentionally cooling off after a failure.
If you build your own Python MCP client, prefer result.structuredContent when it is present. Antenna's MCP server can also return one TextContent block per item, so naïvely joining all text blocks and calling json.loads(...) can fail with JSONDecodeError: Extra data. scripts/smoke_test.sh includes the canonical parse_list() helper.
See antenna.example.yaml — it's commented inline. The fields that matter:
database/outbox— paths, relative to the config file (or absolute).smtp.*— SMTP host, port, username, password, TLS. Username/password can be left blank for a trusted relay that allows unauthenticated local delivery.email.from_address/email.to_address/email.from_name_template—{feed_title}in the name template makes Gmail thread by feed.default_mode—per_postordigest, used whensend-emailis called with no--mode.first_run_entries— cap on historical entries pulled on a feed's first poll.poll_delay_seconds— polite pause between feeds duringfetch.feeds:— list of{url, title?, tags?}.rules:— list of{match, include?, exclude?, alert?}.matchis a feed-URL glob or*.include/excludeterms can be plain substrings or/regex/.excludewins.alert: truesends a matching post as its own email even in digest mode.
antenna init # create DB, load feeds from config
antenna add-feed URL [--title T --tags a,b]
antenna import-opml PATH.opml
antenna fetch [--source-id N] [-v]
antenna sync [--source-id N] [--dry-run] [--json]
antenna list-sources [--json]
antenna recent-posts [--source-id N] [--since 24h] [--limit 20] [--json]
antenna search QUERY [--source-id N,M] [--since 7d] [--limit 20] [--json]
antenna doctor [--recent-hours 24] [--json]
antenna render-digest [--since 24h] # preview digest to outbox/
antenna send-email --mode per_post|digest [--dry-run] [--since 24h] [--source-id N]
antenna setup-email --provider gmail|ses|generic [...]
antenna test-email [--dry-run] [--to you@example.com]
antenna serve-mcp # stdio MCP server
--since accepts 24h, 7d, 30m, or an ISO 8601 timestamp.
Before trusting the scheduler, run:
bash scripts/smoke_test.shIt builds a throwaway config in /tmp, fetches two public feeds, exercises every CLI path, renders a digest, and drives the MCP server over real stdio with the mcp client library. If every stage prints a green check, Antenna is healthy on this machine.
The script prefers ./.venv/bin/python automatically when that venv exists. If your Antenna environment lives somewhere else, override it explicitly:
PYTHON=/absolute/path/to/venv/bin/python bash scripts/smoke_test.shPass KEEP=1 to keep the scratch dir for inspection.
If it exits early with a missing-dependencies message, install the project into the venv you want to test:
.venv/bin/pip install -e .For a richer real-world parser check before handing Antenna to early adopters, run:
bash scripts/live_feed_matrix.shThat script stays fully headless and exercises verified public feeds across RSS, Atom, Substack RSS, and JSON Feed using sync --dry-run.
antenna: command not found— either activate the venv (source .venv/bin/activate) or usepython3 -m antenna.cli.Config file not found— setANTENNA_CONFIGor pass-c /path/to/antenna.yaml.- SMTP auth fails on Gmail — you need an App Password, not your normal password, and 2FA must be on.
- A feed shows
ERROR:inlist-sources— that feed's publisher is either down, 403'ing our User-Agent, or returning malformed XML. Antenna records the error per-source, backs that source off, and keeps polling/sending for the rest; usefetch --strictif you want that warning to become a hard failure. - You ran
fetchmanually and expected email right away — useantenna syncfor one-step fetch plus delivery, or runantenna send-emailafter the fetch. sync --jsonreturnsdegraded— at least one feed failed during fetch or is still in backoff, but healthy feeds still finished and delivery still ran. Checkwarnings,fetch.per_feed, orantenna doctorto see which source needs attention.- You need a quick support snapshot — run
antenna doctor. It prints config/db/outbox health, failing feeds, recent delivery counts, and the next operator actions to take. - Search returns nothing for obvious terms — did
fetchactually bring any posts in? Runantenna recent-posts --limit 5first. - launchd agent doesn't run — check
~/antenna/logs/fetch.err. Most failures are a bad path in the plist. - MCP client can't see the server — confirm
python3 -m antenna.cli serve-mcpexits cleanly with Ctrl-C and thatANTENNA_CONFIGis correct.
antenna/
├── pyproject.toml
├── requirements.txt
├── antenna.example.yaml
├── README.md
├── scripts/
│ ├── smoke_test.sh
│ └── com.antenna.fetch.plist
└── antenna/
├── __init__.py
├── __main__.py
├── cli.py # 14 subcommands
├── config.py # YAML config dataclasses
├── db.py # SQLite schema + helpers, FTS5 triggers
├── fetcher.py # RSS / Atom / JSON Feed + ETag conditional GET
├── mcp_server.py # FastMCP stdio server, 6 tools
├── opml.py # OPML import
├── renderer.py # Jinja2 rendering for per-post + digest
├── rules.py # include / exclude / alert
├── sender.py # SMTP + dry-run outbox
└── templates/
├── base.html
├── post.html
└── digest.html
- Phase 0.5 — webhook output adapter; per-feed rule UI instead of YAML; alert throttling.
- Phase 1 — hosted multi-user at antennafeed.com; OAuth; shared OPML import.
- Phase 2 — broader adapters (Slack, Discord, Matrix, HTTP push to Zapier); optional semantic search.
Phase 0 is today, and it's the thing that replaces your current RSS-to-email service end-to-end.
MIT — see LICENSE. Copyright © 2026 Todd Deshane.