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.

Jun 2026 → present Python · vanilla JS · SQLite Claude CLI GitHub Pages source ↗

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

pipeline
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:

The generalisation: if your LLM step does not need per-request context, it does not belong in the request path. Push it to a scheduled job, commit the output, and let a CDN do the serving. You trade freshness you did not need for an operational surface of approximately zero.

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:

ArtefactPurpose
data/feed.jsonEvery categorised story + the canonical category list
data/source-<id>.jsonPer-source snapshot, so source tabs work statically
stories/<category>/<slug>.mdOne Markdown file per story, with front matter
data/feed.dbSQLite mirror — for me, not the app
data/leaderboard.jsonTop-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.

The pattern in one line: a static site can absolutely talk to authenticated services — as long as the credential lives in a tiny server-side shim you own, and the page holds nothing but an endpoint.

What it costs, honestly

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.