Cruxwire documentation
The complete guide to installing, configuring, and running Cruxwire, your self-hosted, local-AI news reader.
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.
/digest.json, /state,
/settings, /feeds, /status, /refresh,
/search, and /tldr.
digest.json.
state.json, feeds.json,
digest.json, settings.json, and more.
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:
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.
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:
| Model | Why |
|---|---|
| qwen3:8b recommended | What 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-nemo | Other 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.
| Model | Why |
|---|---|
| nomic-embed-text recommended | Small, fast, and the model every threshold is tuned for. Stay here unless you have a specific reason to move. |
| mxbai-embed-large | A larger, higher-quality embedder. Better clustering and search at a higher cost; re-tune the thresholds after switching. |
| bge-m3 | Reach 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.
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:
| Tab | What it holds |
|---|---|
| Home | The magazine, your ranked digest for today and the past week. Badge shows unread count. |
| Read Later | Articles you saved, each with an on-demand TL;DR. Badge shows saved count. Never auto-expires. |
| History | Everything you opened, within the retention window. Badge shows count. |
| Settings | Feeds, 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:
| Region | Contents |
|---|---|
| Hero band | The single highest-ranked lead story (large image, title, summary, source · time) plus four secondary "mini" cards alongside it. |
| Latest grid | An 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 rail | A 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:
| Action | What 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 / Later | Saves 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. |
| Done | Dismisses 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).
Semantic search #
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:
| Row | Meaning |
|---|---|
| Base | The model's raw 0–10 relevance score for the story. |
| Coverage | The boost added when several outlets cover the same story (clustering). |
| For you | The taste boost for stories close in meaning to what you save and open. |
| Source | Your per-source affinity multiplier (0.5×–2.0×). |
| Score | The 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.
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.
| Tier | Where | Takes effect | Examples |
|---|---|---|---|
| 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.
| Knob | Default | What it does |
|---|---|---|
Chat modelollama_model | qwen3:8b | Scores, summarises, and categorises each article (and writes TL;DRs). Must be pulled on your Ollama host. |
Embedding modelembed_model | nomic-embed-text | Embeds 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 #
| Knob | Default | Range | What it does |
|---|---|---|---|
Merge similaritysim_threshold | 0.74 | 0.5–0.99 | Cosine 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 capboost_cap | 1.5 | 0–5 | Maximum rank points a story can gain from cross-source coverage. |
Boost strengthboost_k | 0.6 | 0–5 | boost = 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 strengthtaste_weight | 1.0 | 0–3 | Max 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 #
| Knob | Default | Range | What it does |
|---|---|---|---|
Lookback (hours)lookback_hours | 36 | 1–168 | How 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 / runmax_articles | 1024 | 16–5000 | Cap on fresh articles scored per run (newest first). A throughput/cost guard, rarely hit. |
Scoring workersscore_concurrency | 4 | 1–32 | Parallel Ollama scoring requests. Raise to speed up runs if your Ollama host has headroom; lower if it serialises or runs out of memory. |
Schedule #
| Knob | Default | Range | What it does |
|---|---|---|---|
Active start houractive_start_hour | 6 | 0–23 | Earliest local hour a scheduled run fires. |
Active end houractive_end_hour | 22 | 0–23 | Latest local hour a scheduled run fires. |
Interval (hours)interval_hours | 2 | 1–24 | Hours 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.)
| Knob | Default | Range | What it does |
|---|---|---|---|
Keep at least (stories)retain_floor | 25 | 0–500 | Floor. 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 | 60 | 1–1000 | Ceiling. 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 | 24 | 1–720 | Lifespan of the lowest-ranked story before it can fall off. |
Max story lifespan (hours)retain_max_ttl_hours | 72 | 1–720 | Lifespan of the highest-ranked story. Lifespan scales with rank between min and max. |
Absolute max age (hours)retain_hard_max_age_hours | 120 | 1–1440 | Hard cutoff. Nothing older than this is ever kept, even to meet the floor. Stops stale news lingering. |
History retention (days)history_retention_days | 3 | 1–60 | Keep 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.
| Knob | Default | What it does |
|---|---|---|
Blocked keywordsblock_keywords | empty | Drop 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 | empty | Short 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 sensitivityblock_topic_threshold | 0.50 | Cosine 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
| Constant | Default | Effect |
|---|---|---|
WEIGHT_OPEN | 0.10 | Rank lift per open. |
WEIGHT_SAVE | 0.20 | Rank lift per Read-Later save (counts double an open). |
WEIGHT_DISMISS | 0.05 | Rank penalty per dismiss (gentler than positive signals). |
AFFINITY_MIN / AFFINITY_MAX | 0.5 / 2.0 | Clamp, a source can never fully vanish or dominate. |
LEARNING_DECAY_DAYS | 7 | Apply decay at most once per this many days. |
LEARNING_DECAY_FACTOR | 0.97 | Multiply every counter by this on decay (slow forgetting). |
Layout balance digest.html
| Constant | Default | Effect |
|---|---|---|
FRONT_MIN | 11 | Fewest 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_MIN | 6 | Floor on the "Earlier this week" rail length when the grid is short. |
Taste vector pipeline.py
| Constant | Default | Effect |
|---|---|---|
TASTE_MAX_ITEMS | 40 | How 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_WEIGHT | 2.0 | How 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.
| Variable | Default | Purpose |
|---|---|---|
OLLAMA_HOST | http://localhost:11434 | Ollama base URL (chat + embeddings). |
OLLAMA_MODEL / EMBED_MODEL | qwen3:8b / nomic-embed-text | Seed the initial models on a fresh volume (the sample .env sets these); the Models UI takes over after. |
PORT | 8090 | Container HTTP port. |
TZ | America/Los_Angeles | Timezone 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/*.json | Data paths on the volume. |
STATIC_DIR | /app | Where digest.html and icons are served from. |
SEED_FEEDS / SEED_CATEGORIES | sample files | Lists to seed on first run if the volume copies are missing. |
FEED_TIMEOUT | 12 | Per-feed fetch timeout (s). |
PAGE_TIMEOUT | 8 | Per-article page fetch timeout (s) for og:image + excerpt. |
OLLAMA_TIMEOUT | 120 | Per-request Ollama timeout (s). |
SEARCH_THRESHOLD | 0.5 | Minimum cosine similarity for a semantic-search match. |
HTTP API #
Everything the UI does is plain HTTP against the same server. There's no authentication, see Security.
| Method | Path | Purpose |
|---|---|---|
| GET | / | The dashboard (digest.html). |
| GET | /digest.json | Current ranked digest (empty shape if no run yet). |
| GET PUT | /state | Read / save user state (readIds, later, history, sourceStats, learning, viewState). |
| GET PUT | /settings | Read schema + values + deploy info / save runtime settings. |
| GET PUT | /categories | Read / replace the category list (key / label / color / interest). |
| GET POST DEL | /feeds | List / add / remove feeds. |
| GET | /feeds/check?url= | Validate a feed URL before adding (returns title + kind). |
| GET | /feeds.opml | Export feeds as OPML. |
| POST | /feeds/import | Batch-add mapped feeds (OPML import); returns an add/skip/fail summary. |
| GET | /models | Ollama models installed on the host (for the model pickers). |
| GET | /status | Live pipeline run status (drives the "Updating…" pill). |
| GET | /runs | Recent pipeline run log (powers Settings → Runs). |
| GET | /search?q=&threshold= | Semantic search, ranked digest matches by embedding similarity. |
| POST | /tldr | Generate a TL;DR (bullets + bottom line) for a saved article URL. |
| POST | /refresh | Trigger 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):
| File | Holds |
|---|---|
state.json | Your read IDs, Read Later, History, source stats, and learning timestamps. |
feeds.json | Your configured feed list. |
digest.json | The current ranked digest (rewritten each run). |
settings.json | Your runtime-settings overrides (only the keys you changed). |
categories.json | Your categories (label / color / interest). |
runs.json | The recent run log shown in Settings → Runs. |
embeddings.json | Article 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 weekends | Raise 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 inbox | Lower 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 cards | Lower sim_threshold (0.74 → 0.70) so near-duplicates merge. If unrelated stories start merging, nudge it back up. |
| More of what you actually read | Raise 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 entirely | Add 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 hours | Set interval_hours (e.g. 1) and active_start_hour / active_end_hour. |
| Faster runs | Raise 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 #
| Symptom | Likely cause & fix |
|---|---|
| Digest stays empty after first run | The 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;DR | The 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 instead | Cruxwire 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-bound | The 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 back | It 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 / floods | Tune retention, see the recipes above. |
| Changed a setting but nothing happened | Settings 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.
| File | Role |
|---|---|
server.py | HTTP server: UI, state/settings/feeds API, digest serving, state pruning. |
pipeline.py | Ingestion pipeline + scheduler: fetch → carry-forward → score → cluster → retain. |
settings.py | Runtime-settings schema (drives the Settings form), validation, persistence. |
digest.html | Single-file frontend: views, ranking display, affinity learning, layout. |
TUNING.md | Every adjustable knob, how they interact, and recipes. |