Cruxwire docs

Cruxwire documentation

The complete guide to installing, configuring, and running Cruxwire, your self-hosted, local-AI news reader.

Single container No Python dependencies Local LLM via Ollama No cloud, no tracking MIT licensed
🛈

New to Cruxwire? The overview and feature tour on the site cover what it does and why. This guide is the hands-on reference: install it, configure it, and understand how it behaves.

How it works #

Cruxwire ships as a single Docker container with no external services beyond an Ollama server you point it at: the Python is pure standard library (no pip install), the frontend is one self-contained vanilla-JS file, and all your data lives on one Docker volume you can back up.

One process does everything. server.py serves the dashboard and the state/settings/feeds API; pipeline.py runs a cron-like background scheduler that produces the digest. They share a local data volume and talk to your Ollama server over HTTP.

single container
server.py HTTP server: the dashboard UI plus /digest.json, /state, /settings, /feeds, /status, /refresh, /search, and /tldr.
pipeline.py Background scheduler (cron-like): fetch feeds → carry forward unread → score + embed → cluster → retain → write digest.json.
/data Local volume: state.json, feeds.json, digest.json, settings.json, and more.
Ollama (OLLAMA_HOST)

What a pipeline run does

The pipeline runs on a schedule (by default every 2 hours between 06:00 and 22:00) and once on start. Each run moves through these stages:

1
Fetch
Pull every feed, parse RSS/Atom, drop items older than the lookback window and anything matching the blocklists, de-dupe by URL.
2
Carry forward
Re-include unread stories from the previous digest so a story you didn't read doesn't vanish when its feed rotates it out. Read ones are vacated.
3
Score & embed
Each fresh article is scored, summarised, and embedded via Ollama. Carried stories reuse their score and only re-embed.
4
Cluster
Collapse same-story coverage by cosine similarity and boost a story by how many sources cover it.
5
Retain
Prune the pool to a rank-weighted, floor/ceiling-banded keep set.
6
Write
Atomically write digest.json. The browser picks it up on the next poll.

The frontend (digest.html) is vanilla JS, it renders the magazine, applies the per-device source-affinity multiplier when ordering, and handles layout balance, backfill, and promotion. The server serves and prunes your state: dismissed stories stay dismissed, History ages out after the retention window, Read Later is never aged, and your learned source preferences persist independently of inbox hygiene.

Get started

Installation & first run #

Cruxwire runs with Docker Compose. Two steps:

cp .env.example .env
docker compose up -d --build

In the .env you just copied, the two values most people change are OLLAMA_HOST (point it at your Ollama; use host.docker.internal if Ollama runs on the same machine) and TZ (your timezone, for the scheduler's active-hours window). The chat model is already set to the recommended qwen3:8b. Everything else ships with sensible defaults; the full list is in Deploy-time environment.

Open http://<host>:8090/ in a browser.

⚠️

There is no login. Every endpoint, including the ones that change settings, feeds, and categories or trigger pipeline runs, is reachable by anyone who can reach the port. Keep Cruxwire on a trusted private network and never expose port 8090 directly to the internet. See Security & deployment.

On first run the app seeds its feed list from feeds.sample.json and its categories from categories.sample.json, then the pipeline generates the first digest, give it a minute. From there you manage everything in the UI's Settings → Feeds screen, or trigger a run on demand:

curl -X POST http://<host>:8090/refresh

Requirements

  • Docker, on your own Mac or PC (Docker Desktop bundles Compose) or any server.
  • An Ollama instance reachable over HTTP, on the same machine or your network, with the two models pulled:
ollama pull qwen3:8b            # chat: score / summarise / categorise
ollama pull nomic-embed-text    # embeddings: clustering / taste / semantic block

The sample .env already selects qwen3:8b as the chat model, so a default install uses it. To run a different one, change OLLAMA_MODEL in .env or pick it in Settings → Models. See Choosing your models for the trade-offs.

Running on a laptop with Docker Desktop

Cruxwire runs fine on a personal Mac or Windows machine. One gotcha: if Ollama runs natively on the same machine, set OLLAMA_HOST to http://host.docker.internal:11434, not localhost, inside the container localhost is the container itself, and host.docker.internal is how Docker Desktop reaches your host.

🛈

On Linux that hostname isn't automatic. Either add extra_hosts: ["host.docker.internal:host-gateway"] to the service, or point OLLAMA_HOST at the host's LAN IP.

On macOS, run the native Ollama app, Docker Desktop's Linux VM can't use the Mac GPU, so running Ollama inside Docker falls back to slow CPU. Cruxwire-in-Docker talking to a native Ollama is the right setup.

Make sure Ollama is listening for the container

Resolving host.docker.internal only gets the container to your host. Ollama also has to be listening on an interface the container can reach, and by default it binds to loopback (127.0.0.1) only. The trap: a localhost test on the host succeeds while the container's connection is still refused. Set Ollama's own bind address to 0.0.0.0:11434 and restart it.

# Windows (PowerShell), then quit Ollama from the tray and reopen it
setx OLLAMA_HOST "0.0.0.0:11434"

# macOS, then quit and reopen the Ollama app
launchctl setenv OLLAMA_HOST "0.0.0.0:11434"

# Linux (systemd): add an override, then restart
sudo systemctl edit ollama
#   [Service]
#   Environment="OLLAMA_HOST=0.0.0.0:11434"
sudo systemctl restart ollama
⚠️

OLLAMA_HOST means two different things. On the host it tells Ollama what to bind to (0.0.0.0:11434). In the Cruxwire container it tells Cruxwire where to find Ollama (http://host.docker.internal:11434). Same name, two processes, two jobs.

To confirm the container can actually reach Ollama, test from inside it. The image is pure standard library with no curl or wget, so use Python:

docker exec -it cruxwire python -c "import urllib.request; print(urllib.request.urlopen('http://host.docker.internal:11434/api/tags').read().decode())"

A list of models means you're set. Name or service not known means the container can't resolve the host (the host.docker.internal / extra_hosts issue above). Connection refused means it reached the host but Ollama is bound to loopback (set 0.0.0.0, above).

Choosing your models #

Cruxwire uses two models with very different jobs. The chat model does the reasoning-heavy work of scoring, summarising, and categorising; the embedding model handles clustering and search. You pull and select them separately, and the chat model is the one worth thinking hardest about.

Chat model scoring · summaries · TL;DR

It reads every fresh article and returns a 0 to 10 relevance score, a one or two sentence summary, and one of your category keys, and it writes the Read Later TL;DRs. It runs once per article, so both quality and speed scale with how many feeds you follow. What separates a good model here from a weak one isn't world knowledge, it's the discipline to follow a strict, terse output format. These are the ones worth running:

ModelWhy
qwen3:8b recommendedWhat this project's author currently runs. Excellent instruction-following, accurate category assignment, and clean summaries, at a size that fits an 8 GB GPU. If you pull one model, pull this one.
qwen2.5 (7b or 14b)The long-standing default and still very dependable. The 14b sharpens relevance and summaries when you have the VRAM; the 7b is the lighter option that shipped as the original default.
gemma2:9b, llama3.1:8b, mistral-nemoOther capable instruct models in this size class that also do the job. The Qwen line tends to hold the strict format most reliably, but any of these is reasonable if you already run it.
3b-class (qwen2.5:3b, llama3.2:3b)Only when hardware is tight. They run fast but score less consistently, and sometimes pick the wrong category or break the format.
🛈

qwen3 is a hybrid "thinking" model, but you don't need to configure anything for it. Cruxwire sends think: false (and asks for JSON-formatted output) on every scoring and TL;DR request, so qwen3 answers directly instead of spending time, and tokens, on visible chain-of-thought. The models to actually avoid are base (non-instruct) ones that ignore the output format. When a score can't be parsed, the pipeline falls back to a default, flagged in Settings → Runs.

Embedding model clustering · taste · search

The embedding model powers clustering, the taste vector, semantic blocking, and search. One thing to know before you change it: the similarity thresholds are calibrated to a specific vector space. sim_threshold (0.74), block_topic_threshold (0.50), and SEARCH_THRESHOLD (0.5) all assume nomic-embed-text, so swapping the embedder changes what those numbers mean.

ModelWhy
nomic-embed-text recommendedSmall, fast, and the model every threshold is tuned for. Stay here unless you have a specific reason to move.
mxbai-embed-largeA larger, higher-quality embedder. Better clustering and search at a higher cost; re-tune the thresholds after switching.
bge-m3Reach for this one if your feeds are multilingual. It also needs the thresholds re-tuned.

Hardware reality check

Scoring is the slow part of a run, one chat call per fresh article, so it lives or dies on whether the chat model fits your GPU. The Settings → Runs health banner shows average latency per story and a gpu_ratio (how much of the model is resident in VRAM). qwen3:8b and the other 7 to 9b models above sit comfortably on an 8 GB card; if runs drag, move to a smaller chat model or lower score_concurrency before blaming the feeds.

💡

Pull whatever you choose on your Ollama host, then select it in Settings → Models (no restart needed). Only the embedding choice affects the calibrated thresholds; swapping the chat model is free.


User experience

The interface at a glance #

The whole app is four views and a few inline controls. There's nothing to learn: open a story, save it for later, dismiss what you don't want, and the digest quietly tunes itself to you.

The masthead

At the top sits the Cruxwire wordmark, today's date with a count of unread stories ("N today"), a theme toggle (), and, when the pipeline is working, an "Updating…" status pill. When a fresh digest lands it becomes a clickable "↑ N new stories, refresh" prompt.

The view bar

Four tabs run across the top, each with a live count badge where relevant:

TabWhat it holds
HomeThe magazine, your ranked digest for today and the past week. Badge shows unread count.
Read LaterArticles you saved, each with an on-demand TL;DR. Badge shows saved count. Never auto-expires.
HistoryEverything you opened, within the retention window. Badge shows count.
SettingsFeeds, categories, filters, models, tuning, run log, and deployment info.

A semantic search box ("Search stories…") sits at the right end of the view bar on Home. Below the tabs is a scrollable row of category chips, an "All" chip plus one per category, each with a coloured dot and a count, that filter every view except Settings.

Home, the magazine #

Home is laid out like the front page of a magazine, in three balanced regions:

RegionContents
Hero bandThe single highest-ranked lead story (large image, title, summary, source · time) plus four secondary "mini" cards alongside it.
Latest gridAn auto-filling grid of the next-ranked stories. Each card shows a thumbnail, category kicker, title, optional summary, and "source · relative time".
Earlier this week railA single-column rail of older stories, grouped by day ("today", "yesterday", "2 days ago"…). It stays height-matched to the grid, with the overflow behind Show N more / Show less.

The layout self-balances. On a thin news day, the best earlier stories are promoted up so no section sits empty; when you dismiss a card, the vacated hero or grid slot is filled by the next-ranked story and the rail pulls its reserve up. Empty states read "No stories for this filter." and "All caught up."

Story cards & actions #

Every card carries the same controls, so the interactions are identical wherever a story appears:

ActionWhat it does
Open (click title/thumbnail)Opens the article in a new tab, records it as viewed (it appears in History), marks the story read, and fades the card out.
Read later / LaterSaves the story to your Read Later list and removes it from the digest view. A TL;DR begins generating in the background so it's ready when you return.
DoneDismisses the story, marks it read and fades it without opening anything. A dismiss is a gentle negative signal to the source-affinity learner.

Hovering a card lifts its title to the brand colour and gently zooms the image. Marking a story read or saved fades the card and promotes the next story into its place. Because read state is stored server-side, a story you dismiss on your phone is gone on your laptop too, and stays gone even after the pipeline vacates it from the digest.

Clustered coverage #

When several outlets cover the same story, Cruxwire shows one card for it, the best-ranked representative, and collapses the rest. The card's thumbnail gets a small "+N" badge, and a disclosure beneath it reads "Show N collapsed article(s)". Expanding it lists the other sources; clicking any of them opens that outlet's version, credits that source, and marks the whole cluster read as a unit. Cross-source coverage also boosts a story's rank, see the ranking model.

Category filters #

The chip row under the view bar filters the current view to a single category. "All" clears the filter. Each chip shows the category's colour dot, its label, and a count of matching unread stories. The same filter applies to Read Later and History, so you can, say, look only at unread Science or review the AI stories you saved. The categories themselves, their labels, colours, and the interest descriptions the scorer ranks against, are fully editable in Settings → Categories (see Categories configuration).

The "Search stories…" box on Home filters the digest to stories about a topic by meaning, not just keyword match, ask "did you hear about X?" and it surfaces the relevant coverage even when the words differ. It's a filter, like the category chips: no cloud, no generated answers.

  • Typing filters instantly by keyword, then runs a server-side semantic query (via local embeddings) after a short pause.
  • Results swap the magazine for a ranked list. Anything you've saved for later that matches is surfaced first, tagged ★ Saved.
  • While the query is in flight you'll see "Searching…"; with no matches, "No stories match ‘query’."
  • Clearing the box returns the magazine.

How it learns your taste #

Two learning signals quietly shape your digest, both fed by ordinary use:

  • Source affinity, a per-source multiplier that floats sources you open and save upward and sinks ones you dismiss. It's per device, lives in your synced state, and is bounded (0.5×–2.0×) so no source ever fully vanishes or dominates. You can see each source's current multiplier in Settings → Feeds (green above 1×, orange below), and reset every counter with Reset learning in Settings → About.
  • Taste boost, a centroid built from the embeddings of what you recently save and open; stories close to it in meaning get a rank bump. This one is computed server-side in the pipeline.

You never have to train anything. Give it a few days of normal reading and the digest visibly tilts toward what you actually engage with. The mechanics and the knobs are covered under The ranking model.

Ranking scores #

If you'd rather see the ranking than just feel it, turn on Settings → Display → "Show ranking scores." It's off by default, and it's a personal display preference, synced with the rest of your state, that only changes what you see: it never changes how stories are ranked or what anyone else sees.

With it on, each story on Home gets a small score chip in the top-right of its thumbnail. The number is the story's final, personalised score, the same value the feed sorts by, so the chip always agrees with the running order. Hovering it opens a breakdown of how that number is built:

RowMeaning
BaseThe model's raw 0–10 relevance score for the story.
CoverageThe boost added when several outlets cover the same story (clustering).
For youThe taste boost for stories close in meaning to what you save and open.
SourceYour per-source affinity multiplier (0.5×–2.0×).
ScoreThe final figure, (Base + Coverage + For you) × Source, used for ordering.

Because the chip shows the personalised score, a strong story from a source you've down-weighted can read lower than its raw quality, and a favourite source can ride higher, exactly the behaviour described under how it learns your taste and the ranking model. The chip rides the thumbnail, so it appears on the magazine and grid cards (the lead, the hero stories, and the Latest grid); the text-only Earlier rail and saved Read Later items don't carry it.

Read Later #

Saving a story to Read Later moves it into a curated list that never expires, it's yours until you remove it. The view has its own search box ("Search saved articles…"), a count with a Clear all action, and per-item Copy (title + URL, flashes "Copied!") and Remove buttons. Each item shows its source, title, URL, and when you saved it.

The headline feature here is the TL;DR. The local model fetches the full article page and distils it to a few bullet points plus a one-line Bottom line:. It's generated in the background the moment you save, so it's usually ready by the time you open the list.

  • A ▸ TL;DR / ▾ TL;DR toggle expands the panel; while it's working it reads "Summarizing…".
  • A regenerate control (↻) re-runs it anytime.
  • If generation fails (e.g. Ollama is unreachable) you'll see "Couldn't summarize." with a Retry.
  • TL;DR text is indexed, so saved-article search matches the summary too.

Each TL;DR also shows an "≈ N min read" estimate: the article's word count divided by about 200 words per minute. The count comes from the page's structured data when present, otherwise its paragraph text, so menus and related links don't inflate it. When no real article body can be extracted (a paywall, or a fully client-rendered page), the estimate is omitted rather than shown as a misleading number.

History #

History is an automatic log of everything you opened, newest first, within the retention window (today plus a configurable number of prior calendar days, three by default). It has its own search box and a Clear action; each entry shows source, title, URL, and "Viewed at …". Items currently in Read Later are kept out of History so they don't appear twice. History keeps its own self-contained copy of each story and is unaffected by inbox retention, read stories live on here even after they're vacated from the digest. Empty state: "No articles viewed in the last N days."

Theme, status & offline behaviour #

  • Light / dark theme, the button in the masthead toggles it, and your choice is remembered in the browser.
  • Live updates, the app polls run status and shows the "Updating…" pill while the pipeline works; when a new digest is ready it offers a one-click refresh rather than yanking the page out from under you.
  • Offline-friendly, state is mirrored to the browser's local storage, so the app stays usable from cache if the server is briefly unreachable, with a retry-with-backoff sync. A persistent sync failure shows a small "Sync failed, retrying" indicator.
  • Responsive, the hero collapses to a single column and the rail moves below the grid on narrow screens; the card actions are always visible (no hover required) for touch.

Configuration

Where settings live #

Cruxwire separates "what the digest shows you" from "how the container is wired up." The first is editable live in the app; the second is deploy-time plumbing. There are four tiers in all.

TierWhereTakes effectExamples
Settings UI Settings view → settings.json on the volume next pipeline run, no restart ranking, ingestion, schedule, retention, blocklist, models
Deploy env docker-compose.yaml / .env container restart OLLAMA_HOST, PORT, file paths, timeouts
Frontend constants edit digest.html, rebuild image rebuild + reload source-affinity weights, layout balance
Pipeline constants env or edit pipeline.py, restart restart taste-vector size/weight, built-in spam regex
💡

Environment variables only seed the defaults. Changing a value in the Settings UI stores an override in settings.json that wins over its env default until you clear it. So a clean .env deployment behaves exactly as documented, and the UI takes over from there.

Everything in the Settings view is generated from a schema (settings.py), with each knob's label, help text, and valid range defined in one place. Out-of-range values are clamped, not rejected, and a malformed settings.json can never crash a run, it falls back to env defaults. The sections below mirror the Settings sub-tabs.

Models #

Picks which Ollama models to use. The host itself stays in compose (it's deploy wiring); only the model choice lives here. When the app can reach the Ollama host, both fields become dropdowns populated from your installed models; if it can't, they fall back to free-text inputs with the error shown.

KnobDefaultWhat it does
Chat model
ollama_model
qwen3:8bScores, summarises, and categorises each article (and writes TL;DRs). Must be pulled on your Ollama host.
Embedding model
embed_model
nomic-embed-textEmbeds titles + summaries for clustering, taste, and semantic blocking.
⚠️

Changing the embedding model changes the vector space. sim_threshold and block_topic_threshold are calibrated to nomic-embed-text, re-tune them if you switch.

Clustering & ranking #

KnobDefaultRangeWhat it does
Merge similarity
sim_threshold
0.740.5–0.99Cosine similarity above which two articles collapse into one story (any source). Lower = merges more (fewer duplicate cards, but risks merging unrelated stories); higher is stricter.
Boost cap
boost_cap
1.50–5Maximum rank points a story can gain from cross-source coverage.
Boost strength
boost_k
0.60–5boost = min(cap, k · log2(sources)). Higher = breadth of coverage counts for more. With defaults: 2 sources → +0.6, 4 → +1.2, 8+ → capped at 1.5.
Personalization strength
taste_weight
1.00–3Max points a perfectly on-taste article gains. 0 disables personalization. Higher = your save/open history pulls matching stories up harder.
🛈

Scale context: base scores run 0–10, so a boost cap of 1.5 + taste 1.0 can move a story up to ~2.5 points, enough to lift a well-covered, on-taste 7 above a lone 9, but not enough to bury relevance entirely.

Ingestion #

KnobDefaultRangeWhat it does
Lookback (hours)
lookback_hours
361–168How far back a fresh feed item is first discovered. Does not control how long stories stay, that's retention. Items older than this are never pulled in the first place.
Max articles / run
max_articles
102416–5000Cap on fresh articles scored per run (newest first). A throughput/cost guard, rarely hit.
Scoring workers
score_concurrency
41–32Parallel Ollama scoring requests. Raise to speed up runs if your Ollama host has headroom; lower if it serialises or runs out of memory.

Schedule #

KnobDefaultRangeWhat it does
Active start hour
active_start_hour
60–23Earliest local hour a scheduled run fires.
Active end hour
active_end_hour
220–23Latest local hour a scheduled run fires.
Interval (hours)
interval_hours
21–24Hours between runs inside the active window.

Runs fire at minute 0 of every interval hours within [start, end] (defaults reproduce 0 6-22/2 * * *), plus once on container start. A run is skipped if one is already in progress.

🛈

The hours above are the container's local time, which is UTC unless you set it. Use the TZ env var (default America/Los_Angeles, see Deploy-time environment) so the window matches your wall clock. Left on UTC, a 6 to 22 window actually fires from 11 PM to 3 PM US Pacific.

Retention #

This is the inbox-stickiness model: unread stories are carried forward across runs and pruned by a rank-weighted lifespan held inside a [floor, ceiling] band. (A "story" here is a de-duplicated cluster.)

KnobDefaultRangeWhat it does
Keep at least (stories)
retain_floor
250–500Floor. While recent unread stories still exist, never let the inbox drop below this, even past their normal lifespan. Stops the app going dry on quiet days.
Keep at most (stories)
retain_ceiling
601–1000Ceiling. Never carry more than this many unread stories; over it, the lowest-ranked fall off first. Stops the inbox flooding.
Min story lifespan (hours)
retain_base_ttl_hours
241–720Lifespan of the lowest-ranked story before it can fall off.
Max story lifespan (hours)
retain_max_ttl_hours
721–720Lifespan of the highest-ranked story. Lifespan scales with rank between min and max.
Absolute max age (hours)
retain_hard_max_age_hours
1201–1440Hard cutoff. Nothing older than this is ever kept, even to meet the floor. Stops stale news lingering.
History retention (days)
history_retention_days
31–60Keep today + this many prior calendar days of viewed articles in History. (Read Later is curated and never aged out.)

How a story's lifespan is computed

Each run, every unread story gets a TTL scaled by its rank in the current pool:

ttl = base_ttl + (max_ttl − base_ttl) × rank_percentile

The best story lives max_ttl (72h by default), the worst base_ttl (24h), everyone in between. A story's age is its freshest coverage, so ongoing coverage of a developing story keeps it alive. Then the band clamps the survivor count: over the ceiling, drop the lowest-ranked; under the floor, add back the best expired-but-not-yet-stale stories; the hard cap overrides everything.

🛈

Precedence, highest wins: hard max age → ceiling → TTL → floor.

Read stories are vacated (removed from the carried pool) so they don't consume the band. They survive only in History, which keeps its own copy and is unaffected by retention.

Blocklist #

In the UI this is the Filters tab. You add blocked keywords and blocked topics as chips; each chip has a delete control and the lists show an "N active" count.

KnobDefaultWhat it does
Blocked keywords
block_keywords
emptyDrop any article whose title contains one of these (case-insensitive substring), on top of the built-in spam/deal filter. Fast and literal.
Blocked topics (semantic)
block_topics
emptyShort phrases ("celebrity gossip"). Drop articles whose meaning is close to any phrase, matches the concept, not just the words. Keep phrases specific; broad terms over-block.
Topic block sensitivity
block_topic_threshold
0.50Cosine similarity above which an article counts as matching a blocked topic (range 0.42–0.70). Lower = blocks more aggressively (more false blocks). Calibrated for nomic-embed-text.

Before your own keywords are applied, a built-in title regex drops obvious commercial spam so you don't have to maintain those patterns yourself. It matches titles with coupon and promo-code language, $N off / N% off price drops, "deal / sale of the day" phrasing, Prime Day, Black Friday, Cyber Monday, giveaways, and "sponsored" tags. It filters spam patterns only, no topics and no publishers. To change or disable it, edit TITLE_BLOCKLIST in pipeline.py and rebuild.

Categories, make it about your interests #

Categories are data, not code. Each entry is { "key", "label", "color", "interest" }, and they're listed in priority order. You can edit them live in Settings → Categories, colour picker, label, key, and an interest textarea per row, with up/down reordering, delete, and + Add category. They also live as categories.json on the data volume:

[ { "key": "cooking", "label": "Cooking", "color": "#ff8800",
    "interest": "Cooking -- recipes, technique, equipment, restaurants" } ]
💡

The interest line is the important part: it's woven directly into the LLM's scoring prompt. Write a real sentence about what you actually want in that bucket, that's what makes relevance scoring good. Keys must be lowercase alphanumeric (1–20 chars). Changes apply on the next run; the editor confirms with "Saved, applies on the next run."

The app always keeps at least one valid category, a save with none is rejected. A fresh deploy seeds the file from categories.sample.json; if the file is ever missing or malformed, the app falls back to its built-in defaults so a run can't break. The default sample set:

Tech AI Science Business World Gaming

Feeds & OPML #

Manage your sources in Settings → Feeds. To add one, paste a feed URL and click Validate, Cruxwire fetches it, confirms it's really RSS or Atom, and auto-fills the source name from the feed's title. Pick a category and click Add feed; new feeds appear in the digest after the next run.

The feeds table doubles as your learning dashboard. Per source it shows Opens, Saves, Dismisses, and the resulting Affinity multiplier (colour-coded up/down/neutral), and columns are sortable. Removing a feed leaves a restorable entry under "Recently removed, restorable until page reload"; a removed source's learned stats are wiped so the row disappears, but you can Restore within the session to bring both back.

Bulk import / export

  • Export OPML downloads cruxwire-feeds.opml, grouping feeds into folders by category in priority order.
  • Import OPML opens a mapping wizard: each OPML folder maps to an existing category, a + Create "Folder" new category, or Skip these. A "Validate each feed before adding" checkbox (on by default) drops dead URLs, and the import de-dupes against feeds you already have. A summary reports how many were added, how many duplicates were skipped, and any failures with reasons.

The ranking model #

Five stages decide where a story lands on screen, and whether it survives to the next run:

 1. base score        Ollama reads the article → 0.0–10.0 relevance
 2. + cluster boost    more sources covering the same story → higher (rep only)
 3. + taste boost      closeness to what you save/open → higher
 4. × source affinity  sources you engage with float up, dismissed ones sink   (per device)
 5. retention          which stories carry to the next run, and for how long

The pipeline (server side) computes stages 1–3 and stores them in digest.json. Stage 4 is applied in the browser because it's per-device and learned locally. The final on-screen order is:

effectiveScore = (base_score + cluster_boost + taste_boost) × source_affinity

Retention (stage 5) decides which stories exist in the digest at all. It ranks by the pipeline-side number only, without source affinity, since affinity lives on each device. So a story you'd never see ranked highly because you dismiss its source can still be retained server-side; affinity only reorders or sinks it in your view.

Source affinity, precisely

Your clicks teach the app which sources you trust. Counters live per device in sourceStats (persisted independently of read/later/history, so inbox hygiene never wipes preferences). Affinity is a multiplier on effectiveScore:

affinity(source) = clamp(1 + 0.10·opens + 0.20·saves − 0.05·dismisses,  0.5, 2.0)

A source you open and save floats toward 2× rank; one you repeatedly dismiss sinks toward 0.5×. Counters decay over time so stale habits fade. Reset learning (Settings → About) zeroes the counters and restores 1.00×.

Frontend & pipeline constants #

These aren't in the Settings UI, they're tuned by editing the source and rebuilding the image. Most people never touch them.

Source-affinity weights digest.html

ConstantDefaultEffect
WEIGHT_OPEN0.10Rank lift per open.
WEIGHT_SAVE0.20Rank lift per Read-Later save (counts double an open).
WEIGHT_DISMISS0.05Rank penalty per dismiss (gentler than positive signals).
AFFINITY_MIN / AFFINITY_MAX0.5 / 2.0Clamp, a source can never fully vanish or dominate.
LEARNING_DECAY_DAYS7Apply decay at most once per this many days.
LEARNING_DECAY_FACTOR0.97Multiply every counter by this on decay (slow forgetting).

Layout balance digest.html

ConstantDefaultEffect
FRONT_MIN11Fewest stories that make a proper front page (hero of 5 + ~2 grid rows). If today has fewer, the best earlier stories are promoted up so no section sits empty.
RAIL_MIN6Floor on the "Earlier this week" rail length when the grid is short.

Taste vector pipeline.py

ConstantDefaultEffect
TASTE_MAX_ITEMS40How many of your most-recent saved/opened items form the taste vector. More = smoother, slower-moving taste; fewer = snappier and more recency-driven.
TASTE_SAVE_WEIGHT2.0How much a saved item counts vs an opened one when averaging the taste centroid.

taste_weight (Settings) sets how hard the centroid pulls; these set what the centroid is.

Deploy-time environment #

Infrastructure wiring lives in docker-compose.yaml / .env and needs a container restart. These are shown read-only in Settings → About → Deployment. Every Settings-tier knob also has an env default (SIM_THRESHOLD, LOOKBACK_HOURS, RETAIN_*, …) that just seeds its starting value.

VariableDefaultPurpose
OLLAMA_HOSThttp://localhost:11434Ollama base URL (chat + embeddings).
OLLAMA_MODEL / EMBED_MODELqwen3:8b / nomic-embed-textSeed the initial models on a fresh volume (the sample .env sets these); the Models UI takes over after.
PORT8090Container HTTP port.
TZAmerica/Los_AngelesTimezone the scheduler's active-hours window is measured in. The container is UTC by default, so without this your active_start_hour / active_end_hour window is read in UTC and runs fire at the wrong local time. Named zones need tzdata in the image (included); rebuild after changing it. Log timestamps stay UTC by design.
STATE_FILE / DIGEST_FILE / FEEDS_FILE / SETTINGS_FILE / CATEGORIES_FILE/data/*.jsonData paths on the volume.
STATIC_DIR/appWhere digest.html and icons are served from.
SEED_FEEDS / SEED_CATEGORIESsample filesLists to seed on first run if the volume copies are missing.
FEED_TIMEOUT12Per-feed fetch timeout (s).
PAGE_TIMEOUT8Per-article page fetch timeout (s) for og:image + excerpt.
OLLAMA_TIMEOUT120Per-request Ollama timeout (s).
SEARCH_THRESHOLD0.5Minimum cosine similarity for a semantic-search match.

Reference

HTTP API #

Everything the UI does is plain HTTP against the same server. There's no authentication, see Security.

MethodPathPurpose
GET/The dashboard (digest.html).
GET/digest.jsonCurrent ranked digest (empty shape if no run yet).
GET PUT/stateRead / save user state (readIds, later, history, sourceStats, learning, viewState).
GET PUT/settingsRead schema + values + deploy info / save runtime settings.
GET PUT/categoriesRead / replace the category list (key / label / color / interest).
GET POST DEL/feedsList / add / remove feeds.
GET/feeds/check?url=Validate a feed URL before adding (returns title + kind).
GET/feeds.opmlExport feeds as OPML.
POST/feeds/importBatch-add mapped feeds (OPML import); returns an add/skip/fail summary.
GET/modelsOllama models installed on the host (for the model pickers).
GET/statusLive pipeline run status (drives the "Updating…" pill).
GET/runsRecent pipeline run log (powers Settings → Runs).
GET/search?q=&threshold=Semantic search, ranked digest matches by embedding similarity.
POST/tldrGenerate a TL;DR (bullets + bottom line) for a saved article URL.
POST/refreshTrigger a pipeline run now (returns immediately; the run is async).

Data & persistence #

All mutable data lives on the cruxwire-data Docker volume (mounted at /data):

FileHolds
state.jsonYour read IDs, Read Later, History, source stats, and learning timestamps.
feeds.jsonYour configured feed list.
digest.jsonThe current ranked digest (rewritten each run).
settings.jsonYour runtime-settings overrides (only the keys you changed).
categories.jsonYour categories (label / color / interest).
runs.jsonThe recent run log shown in Settings → Runs.
embeddings.jsonArticle vectors for semantic search (rewritten each run).

The app code is baked into the image, so rebuilds preserve your data, and new settings keys fall back to their defaults automatically. All writes are atomic (temp file + rename) so a crash mid-write can't corrupt a file. Back up the volume to preserve your feed list, read history, and learned preferences.

Security & deployment #

🔒

No authentication. Cruxwire is built for a single trusted user on a private network: a homelab LAN, a Tailscale/WireGuard tailnet, or localhost. Every endpoint, including the mutating ones, is reachable by anyone who can reach the port.

Do not expose port 8090 directly to the internet. For remote access, put it behind a reverse proxy that adds authentication, Caddy/nginx with basic auth, Authelia, Tailscale, Cloudflare Access, and the like.

Also worth knowing:

  • The pipeline fetches URLs you give it. It requests every feed you add and, for TL;DRs, fetches the linked article pages. Treat your feed list as trusted input, and don't give the container network access to internal services it has no reason to reach.
  • No rate limiting or CSRF protection on the mutating endpoints, the trust model is "the network is trusted," nothing more.
  • Runtime data is stored unencrypted on the volume (read history, Read Later, learned preferences). Back up, and protect, the volume accordingly.

Tuning recipes #

"I want X, change Y." Each of these is a Settings-UI tweak that applies on the next run.

You want…Do this
More stories sitting around on quiet weekendsRaise retain_floor (25 → 35) and/or retain_max_ttl_hours (72 → 96–120). The floor holds your best unread stories alive past their lifespan when little new is coming in.
Less clutter in the inboxLower retain_ceiling (60 → 40) and/or retain_base_ttl_hours (24 → 12) so weak stories die faster.
To stop seeing the same story as separate cardsLower sim_threshold (0.74 → 0.70) so near-duplicates merge. If unrelated stories start merging, nudge it back up.
More of what you actually readRaise taste_weight (1.0 → 1.5–2.0). Source affinity also handles this automatically as you open/save, give it a few days.
Cross-source coverage to matter more (or less)Raise/lower boost_k and boost_cap. Higher = a story carried by many outlets jumps higher.
To stop a topic entirelyAdd a specific phrase to Blocked topics for concepts, or a literal string to Blocked keywords for exact title matches. If too much slips through, lower block_topic_threshold slightly.
To refresh more often / only during work hoursSet interval_hours (e.g. 1) and active_start_hour / active_end_hour.
Faster runsRaise score_concurrency if your Ollama host can take it, or use a smaller chat model. Page-fetch + scoring per fresh article dominates run time; carried stories only re-embed.

Troubleshooting #

SymptomLikely cause & fix
Digest stays empty after first runThe pipeline can't reach Ollama, or the models aren't pulled. Check Settings → Runs for the Ollama health banner and failed runs. Test reachability from inside the container (no curl in the image, so use Python): docker exec -it cruxwire python -c "import urllib.request; print(urllib.request.urlopen('http://host.docker.internal:11434/api/tags').read().decode())". Name or service not known means it can't resolve the host (use host.docker.internal, not localhost; on Linux add extra_hosts). Connection refused means Ollama is bound to loopback, so set its OLLAMA_HOST to 0.0.0.0:11434 and restart it. See Running on a laptop.
"Couldn't summarize." on a TL;DRThe chat model is unreachable or the article page didn't fetch. Hit the regenerate (↻) control; check the Runs health banner.
Model dropdowns show text inputs insteadCruxwire couldn't list models from the host. The error is shown inline; verify the host URL and that Ollama is running.
Runs are slow or flagged CPU-boundThe Runs health banner detects slow / CPU-bound Ollama. On macOS, run the native Ollama app (the Docker VM can't use the Mac GPU). Otherwise lower score_concurrency or use a smaller model.
A dismissed story keeps coming backIt shouldn't, readIds persist server-side specifically to prevent this. If it recurs, the story's feed may be serving it under a changing URL.
Inbox goes dry / floodsTune retention, see the recipes above.
Changed a setting but nothing happenedSettings apply on the next pipeline run. Trigger one immediately with the refresh prompt or curl -X POST .../refresh.

Development #

No dependencies, run the server directly against local files:

OLLAMA_HOST=http://localhost:11434 \
DIGEST_FILE=./digest.json STATE_FILE=./state.json FEEDS_FILE=./feeds.json \
SETTINGS_FILE=./settings.json \
STATIC_DIR=. SEED_FEEDS=./feeds.sample.json \
python server.py

Then open http://localhost:8090/. To work on the layout without a live Ollama/feed setup, point FEEDS_FILE at a nonexistent path (the scheduled run no-ops) and drop a real digest.json in place.

FileRole
server.pyHTTP server: UI, state/settings/feeds API, digest serving, state pruning.
pipeline.pyIngestion pipeline + scheduler: fetch → carry-forward → score → cluster → retain.
settings.pyRuntime-settings schema (drives the Settings form), validation, persistence.
digest.htmlSingle-file frontend: views, ranking display, affinity learning, layout.
TUNING.mdEvery adjustable knob, how they interact, and recipes.