project · static site · llm pipeline
AI Feed — a static site with a nightly LLM pipeline behind it
Seven research sources, an LLM that reads all of them at 7 PM, and a website whose entire backend is a cron job that commits JSON.
I follow more AI and science sources than I can actually read: Nature, DeepMind's blog, Hugging Face's daily papers, arXiv cs.LG, Latent Space, Simon Willison. Between them they produce a few hundred items a week, of which perhaps ten matter to me. Every reader app I tried solved the aggregation problem and left the triage problem entirely to me.
So AI Feed does the triage — and does it at build time, which is the only interesting decision in the project.
The whole architecture
7 PM (launchd) → sync.py
├─ fetch all sources
├─ categorise + score with the claude CLI
├─ write data/*.json · stories/*.md · data/feed.db
└─ git push
GitHub Actions → deploys the committed tree to Pages
Browser → downloads static JSON, filters client-side
(no API, no server, no build step)
That is the entire system. There is no API to keep up, no database to back up, no container to patch, no cold start. The site is files. If every machine I own dies tonight, the site keeps serving yesterday's digest indefinitely.
Why the intelligence lives at build time
The scoring is done by an LLM, and the constraint that shaped everything is that it runs through my local claude CLI — which is authenticated against my own login and exists only on my Mac. It cannot run in CI, and there is no API key to hand a server.
That sounds like a limitation. It turned out to be the design:
- No key in the browser, because the browser never calls a model. There is nothing to leak and no per-visitor cost.
- No latency budget. The pipeline can take two minutes writing careful summaries at 7 PM. A reader at 8 AM waits for a JSON download.
- The output is auditable. Every run's decisions are committed.
git logis the observability story — I can diff two days and see exactly what the model changed its mind about. - Degradation is graceful by default. If the job fails, the site serves yesterday. A runtime-LLM design fails by showing an error page.
What the nightly job actually does
sync.py fetches each source, then sends every story to the Claude CLI headless (claude -p … --strict-mcp-config) in chunks of sixty, asking for one small object per item: a category from a fixed list of ten, and a 0–100 significance score.
| Categories |
|---|
| AI Research · Nature & Ecology · Health & Medicine · Genetics & Biology · Neuroscience & Mind |
| Physics & Space · Climate & Energy · Robotics & Engineering · Science & Society · Chemistry & Materials |
Constraining the model to a closed set and a bounded integer is what makes a small, cheap pass reliable — the output is validated against the category list and anything unrecognised is dropped rather than trusted. The top few stories per category, plus the overall top twenty-odd by score, then get a second pass: a 60–160 word plain-language briefing, written from the source abstract and explicitly instructed not to invent figures. Depth tracks the source — arXiv and Hugging Face give full abstracts, so those briefings are richer; Nature and DeepMind teasers are short, so those stay short.
The job writes several artefacts, all committed:
| Artefact | Purpose |
|---|---|
data/feed.json | Every categorised story + the canonical category list |
data/source-<id>.json | Per-source snapshot, so source tabs work statically |
stories/<category>/<slug>.md | One Markdown file per story, with front matter |
data/feed.db | SQLite mirror — for me, not the app |
data/leaderboard.json | Top-10 trending Hugging Face models |
The browser reads feed.json, filters by the categories you follow, and remembers that choice in localStorage. Client-side filtering on a static file is not a compromise here — at this volume it is simply faster than any query API would be.
Failure is a first-class path
The leaderboard step is the clearest example of how the job is written. It hits Hugging Face's trending list, fetches each model's detail and README excerpt, and asks the CLI for a grounded one-line summary. Any of those can fail. When they do, the step logs and continues — the daily run never fails on it — and the tab falls back to always-live canonical leaderboard links.
The same principle runs through the reader: uncurated cards show the raw feed summary rather than nothing, and if curation is unavailable entirely the tab shows all items with a notice. A personal tool that breaks loudly on a bad night is a tool you stop opening.
Two security decisions worth stealing
The dev server is not an open proxy
Running locally, server.py serves the page and proxies each upstream feed server-side at /feed?src=<id>, which sidesteps browser CORS. The obvious naive version — /fetch?url=… — is a textbook SSRF hole. Instead, only sources in an explicit FEEDS allowlist are fetchable, keyed by id rather than URL. The thumbnail proxy is host-allowlisted the same way. Adding a source means editing two allowlists on purpose; there is no path where a caller supplies an arbitrary URL.
The Notion token never touches the browser
Each card has a Save to Notion button. The naive version puts a Notion integration token in the page, which publishes it to anyone who opens devtools. Instead the request goes to a Google Apps Script Web App that holds the token in Script Properties; the app only ever stores that deployment's /exec URL. The browser posts a story to a URL. The credential lives somewhere the browser cannot read.
What it costs, honestly
- It is only as live as last night. Freshness is capped at 24 hours by construction. For research reading that is fine; for news it would not be.
- The pipeline needs my laptop awake at 7 PM. A launchd agent runs it in my user session so the CLI picks up my login. If the Mac is asleep, the run is skipped and the site serves the previous day.
- Curation takes one to two minutes per full batch. Invisible to readers, occasionally annoying when I want to force a run.
- Every run commits. The repo grows a daily digest commit forever. I consider the history a feature; a purist would call it churn.
Where it stands
It has run nightly since June 2026 and is how I actually read papers now. The parts I keep coming back to are not the LLM bits — they are the boring ones: an allowlist instead of a proxy, a fallback instead of an error, and a deploy step that is just git push.