Work / Case study
Gentle Stream
News feeds optimize for alarm. Gentle Stream is a feed of real, recent, uplifting stories, ranked per reader without an LLM call on every scroll.
At a glance
- Live at gentle-stream.org, deployed to Vercel through a gated GitHub Actions workflow with post-deploy smoke tests.
- Four puzzle types sit in the feed: sudoku, word search, killer sudoku, and nonogram.
- Feed requests read a pre-built article pool and never call a model.
- Three layers of deduplication stop the same story from entering the pool twice.
Challenge
Most news products are tuned for attention, which in practice means alarm. I wanted a reading experience built from stories that lift the spirit (no deaths, crimes, or disasters) and that still felt like a serious publication: a broadsheet, not a card grid.
The hard part is not the filter. It is keeping the feed fast and cheap while an LLM does the expensive work somewhere else, and keeping it from showing the same story twice.
Approach
Content generation and content consumption are decoupled. A background pipeline fills an article pool, and GET /api/feed only reads the pool and ranks it.
Three agents share the work:
| Agent | Runs | LLM | Job |
|---|---|---|---|
| Ingest | Scheduler cron, when stock for a category is low | Yes, with web search | Fetches real, recent articles one at a time and stores raw content |
| Tagger | Cron | Yes, no web search | Classifies tags, sentiment, emotion, locale, and a 0 to 1 quality score |
| Ranker | Every feed request | No | Scores the pool against the reader’s profile and returns the top N |
The ranker is plain arithmetic over fields the tagger already filled: quality score, the reader’s category weights, an emotion and locale boost, a freshness factor that decays linearly from 1.0 at day zero to 0.3 at day seven, and a novelty penalty for articles that have been shown a lot.
Deduplication has three layers. A headline fingerprint (normalized headline plus category) backed by a UNIQUE constraint. An overlap query over normalized source URLs stored in a TEXT[] column with a GIN index, which catches the same real article under a different generated headline. And a prompt avoid-list of recent headlines and URLs for the category.
The UI is the product’s other half: a blackletter masthead, rotating section layouts, drop caps and pull quotes on a warm newsprint palette. Auth, profiles, a saved library, and game stats run on Supabase with Postgres row-level security.
Role
Solo. I did the product thinking, the interface, the agents, the schema and migrations, the CI/CD, and the operational tooling, including ingest-run logs for finding out why a category ran low.
Tradeoffs
- A pool instead of live generation. Feeds answer in milliseconds and cost nothing per scroll, but a reader can see a story that is hours old, and the pool has to be kept stocked. Partial sections are returned when stock is low rather than blocking on a slow ingest.
- One article per LLM call. It costs more calls, but a truncated
max_tokensresponse can’t corrupt a JSON array of articles. - Arithmetic over a model for ranking. Personalization is explainable and testable. It can’t pick up subtle taste the tagger didn’t encode.
- Hero images without an image pipeline. Images come from Pollinations with a Picsum fallback and a staged
onErrorpath. That is cheap and degrades gracefully, and it is not editorial-grade imagery. - Known rough edges. Dark mode is experimental and its contrast is still being hardened. The cleanup cron is currently a no-op because TTL cleanup is disabled.
Lessons
- Keep the model off the hot path. Moving the LLM work to crons made the feed fast and the bill predictable.
- Put the uniqueness guarantee in the database. Application-level checks help, but the constraint is what makes duplicates impossible.
- Rate limits are a design input. Tracking real token usage against a per-minute budget and waiting out the window was simpler than retrying into errors.