A self-hosted, single-user news dashboard whose content is curated by an AI assistant of your choice on a schedule you control. No ads. No tracking. No external telemetry. Runs as one tiny container on your laptop, a Pi, or a VPS.
The same dashboard is published twice: as a classic web UI over HTTP and as a capsule in Geminispace (the gemini:// protocol). Both are on by default; serve either one alone with a single env var.
Under the hood Neuz is just an authenticated ingest API (POST /api/items) plus a clean reader UI — it doesn't care what fills it. Claude Routines is the turnkey, scheduled curator (its prompts ship in the box); a local Qwen CLI lets you curate interactively on your laptop; a Open WebUI tool lets any tool-calling model — self-hosted (Qwen, Llama, …) or Claude — publish on demand from a chat; and you can always point your own script or cron at the API.
docker compose up -d
docker compose exec neuz bin/neuz setup # prints API key + Claude prompts
docker compose exec neuz bin/neuz acknowledge # after you've copied the key
That's the install: the CLI mints a single bearer API key on first boot, prints it once along with the two Claude prompts, and you acknowledge to delete the on-disk raw copy. From then on Claude pushes JSON news items to POST /api/items on whatever cadence you set.
Curation options
Neuz's only ingest contract is POST /api/items (bearer auth, JSON below). Pick whatever curator fits — they're not mutually exclusive:
| Curator | Cadence | Best for |
|---|---|---|
| Claude Routines / Cowork | scheduled (cron) | hands-off, always-on curation — prompts ship with Neuz (see How it works) |
| Open WebUI tool | on-demand or scheduled | steer a self-hosted model (or Claude) in a chat and say "publish to Neuz" — or run the same tool unattended on a cron-like schedule via Open WebUI Automations |
| Local Qwen CLI | interactive | curate from your laptop using Qwen's OpenAI-compatible API — see below |
| Your own script | anything | curl / cron / n8n / a routine in another tool — just POST the JSON shape below |
Using with a locally running Qwen CLI
You can run Qwen (Alibaba's qwen CLI, which speaks the OpenAI API) locally and point it at Neuz's ingest endpoint. This lets you curate interactively with qwen -p "<prompt>" or pipe content through it.
1. Start Neuz locally (or ensure it's running):
# Bare-metal: bundle exec puma -C config/puma.rb # default port 9292 # Docker: docker compose up -d
2. Get your API key:
bin/neuz setup # prints the key + prompts on first run # or if you've already acknowledged: bin/neuz prompts --key <your-key>
3. Point Qwen at Neuz and curate:
OPENAI_API_BASE=http://localhost:9292 \ OPENAI_API_KEY=<your-key> \ qwen -p "Research today's AI news and publish items to my Neuz dashboard. Use the Neuz ingest API: POST /api/items with Authorization: Bearer <key> and a JSON body with an 'items' array. Each item needs title, summary, source_url, published_at (ISO8601 UTC). Category, tags, and importance are optional."
Key flags:
| Flag | Purpose |
|---|---|
-p "<prompt>" |
Pass an inline prompt (non-interactive) |
-p (no value) |
Start interactive chat mode |
4. Docker compose (Qwen on the host, Neuz in a container):
OPENAI_API_BASE=http://host.docker.internal:9292 \ OPENAI_API_KEY=<your-key> \ qwen -p "..."
(host.docker.internal resolves to the Docker host from inside a container; from the host itself, use localhost.)
5. Recurring curation with Qwen:
Save your recurring prompt to a file and feed it:
qwen -p "$(cat neuz_recurring_prompt.txt)"Or schedule it with cron:
# crontab 0 8 * * * OPENAI_API_BASE=http://localhost:9292 OPENAI_API_KEY=<key> qwen -p "$(cat neuz_recurring_prompt.txt)" >> ~/neuz-cron.log 2>&1
Important notes:
- Qwen must support function calling / tool use for reliable multi-item publishes.
- The
OPENAI_API_BASEURL is Neuz's full base (e.g.http://localhost:9292), not the/api/itemspath — Qwen appends the path itself. - If Neuz is behind a reverse proxy with TLS, set
OPENAI_API_BASE=https://your-domain.com.
The rest of this README walks through the Claude Routines path (the turnkey option) and then the API contract every curator targets.
How it works
The Claude Routines path, step by step:
┌────────────────────┐ POST /api/items
│ Claude Routines ├───────────────────────────┐
│ / Cowork (cron) │ Authorization: Bearer │
└─────────▲──────────┘ │
│ ┌──────▼───────┐
│ recurring prompt │ │
│ (Neuz substitutes URL + key) │ Neuz (you) │
┌─────────┴──────────┐ │ Roda+SQLite │
│ Claude Code │ interview once │ │
│ (one-time) ├───────────────────►│ │
└────────────────────┘ │ │
│ / /day/ │
│ /month/... │
└──────────────┘
docker compose upboots Neuz. On first boot, it mints a single bearer API key (stored hashed; raw key cached atdata/first_boot_key.txt, chmod 600 in a 700 dir).- Run
docker compose exec neuz bin/neuz setup(or locallybin/neuz setup). The CLI prints the raw key and the interview prompt (the only prompt Neuz ships), with your URL and key already substituted. - Run
bin/neuz acknowledge(or pass--acknowledge/-ytosetup). Neuz deletes the raw-key file. From here on the raw key only exists wherever you pasted it. - Paste the interview prompt into Claude Code. Claude interviews you with
AskUserQuestion(4-7 questions about interests, sources, cadence, tone) and then prints a complete, personalized recurring prompt in the chat — that's the prompt you schedule. - Paste the recurring prompt into Claude Routines / Cowork (schedule whatever cadence you like — hourly, daily, etc.).
- Each run, Claude does the web research, picks items, dedupes against the trailing 14 days, and POSTs a JSON batch to
/api/items. - You read at
/(today) and/month/YYYY-MM(calendar) and/day/YYYY-MM-DD(a single day).
If you lose the key, bin/neuz rotate mints a new one and invalidates the old. The old Claude Routine will start returning 401 until you update the prompt with the new key.
API
This is the universal contract — Claude Routines, the Open WebUI tool, and any script you write all just POST this same shape.
POST /api/items — Authorization: Bearer <key>, JSON body:
{
"items": [
{
"title": "string, <=500 (required)",
"summary": "string, <=2000 (required)",
"source_url": "https://... (required)",
"published_at": "2026-05-22T13:00:00Z (required, ISO8601 UTC)",
"category": "lowercase, <=50 (optional)",
"tags": ["short","lowercase","tags"],
"body": "OPTIONAL Markdown",
"image_url": "OPTIONAL https://...",
"importance": 4,
"external_id": "stable id (optional but recommended)"
}
]
}Responses:
200 { accepted, updated, deduped, errors: [{index, field, code, message}], total }— partial success is fine.400 { error: "invalid_batch" }— whole body malformed.401 { error: "unauthorized" }— missing/bad bearer key.413 { error: "batch_too_large", limit: 500 }— too many items in one request.429 { error: "rate_limited", retry_after_seconds: N }— token-bucket exhausted (default 60/min, configurable).503 { error: "database_busy", retry_after_seconds: 5 }— DB locked or disk full.
GET /healthz returns { status, db, items_total, items_today, version }. No auth.
Routes
| Path | Purpose |
|---|---|
GET / |
Today's items (user-TZ via tz cookie) |
GET /day/:date |
Items for a specific YYYY-MM-DD |
GET /month/:ym |
Calendar grid for YYYY-MM (intensity-tinted) |
POST /api/items |
Ingest endpoint (bearer auth) |
GET /healthz |
Health JSON |
The Gemini capsule mirrors the three reader routes (/, /day/:date, /month/:ym) at gemini://your-host/ — see Gemini. Ingest and /healthz are HTTP-only.
There is intentionally no /setup, no /admin, no login form. Setup happens via the CLI on the host (or docker compose exec):
CLI (bin/neuz)
| Command | What it does |
|---|---|
bin/neuz setup |
First command. Print API key + Claude prompts. |
bin/neuz setup -y |
Same, then immediately delete the raw-key file. |
bin/neuz prompts |
Reprint the Claude prompts. Reads first_boot_key.txt if present; else --key KEY, --stdin, or NEUZ_KEY env (key is verified against the stored hash). |
bin/neuz rotate |
Mint a new key, invalidate the old one. |
bin/neuz acknowledge |
Delete the first-boot key file. |
bin/neuz status |
Instance metadata (URL, version, item counts, etc). |
bin/neuz migrate |
Apply Sequel migrations (mostly auto-run by entrypoint). |
bin/neuz gemini |
Serve the Gemini capsule in the foreground, no HTTP. The Docker entrypoint runs this when NEUZ_PROTOCOLS=gemini. |
bin/neuz fingerprint |
Print the SHA-256 fingerprint of the capsule's TLS certificate (to compare against what your Gemini client pinned). |
Configuration
All env vars are optional unless noted. Defaults shown.
| Var | Default | Purpose |
|---|---|---|
PORT |
9292 |
HTTP port |
NEUZ_PROTOCOLS |
http,gemini |
Front-ends to serve: http,gemini, http, or gemini; see Gemini |
NEUZ_GEMINI_PORT |
1965 |
Gemini TLS port |
NEUZ_GEMINI_HOST |
host of NEUZ_URL, else localhost |
Hostname of the capsule: certificate CN/SAN + printed gemini:// URL |
NEUZ_GEMINI_BIND |
0.0.0.0 |
Interface the Gemini listener binds |
NEUZ_GEMINI_CERT / NEUZ_GEMINI_KEY |
$DATA/gemini/cert.pem / key.pem |
Bring your own certificate; otherwise Neuz mints a self-signed one on first start |
NEUZ_GEMINI_TZ_OFFSET |
0 |
Minutes east of UTC for the capsule's "today" (Gemini has no cookies) |
NEUZ_DATA_DIR |
/app/data |
DB + first-boot key + session secret |
NEUZ_DB_PATH |
$DATA/neuz.db |
SQLite path |
NEUZ_URL |
(request URL) | Used in prompt substitution |
NEUZ_BRAND |
Neuz |
Brand text in header, <title>, footer |
NEUZ_TAGLINE |
(empty) | Optional small text after the brand |
NEUZ_REPO_URL |
github.com/vshvedov/neuz |
URL behind the footer "GitHub" link |
NEUZ_VERSION |
(built-in) | Overrides version in /healthz |
NEUZ_PRUNE_DAYS |
90 |
0 to disable auto-prune |
NEUZ_PRUNE_INTERVAL_SECONDS |
3600 |
Prune scan interval |
NEUZ_RATE_CAPACITY |
60 |
Tokens in bucket |
NEUZ_RATE_REFILL_PER_MIN |
60 |
Refill rate |
NEUZ_BATCH_LIMIT |
500 |
Max items per ingest |
NEUZ_JOURNAL_MODE |
WAL |
WAL or DELETE (NFS fallback) |
NEUZ_CACHE_SIZE |
-20000 |
SQLite cache_size pragma (KiB neg.) |
NEUZ_MMAP_SIZE |
67108864 |
SQLite mmap_size pragma |
NEUZ_WEB_WORKERS |
1 |
Puma workers (keep 1 with built-in prune) |
NEUZ_WEB_THREADS_MAX |
5 |
Puma threads |
NEUZ_LOG_MAX_SIZE |
10m |
Docker json-file max-size per log file (e.g. 1m, 100k) |
NEUZ_LOG_MAX_FILE |
3 |
How many rotated log files Docker keeps |
NEUZ_THEME |
default |
Color theme name; see Theming |
Theming
Neuz ships 8 light/dark theme pairs: default, solarized, gruvbox,
catppuccin, elflord, ayu, tokyo-night, one-dark. Pick one with the
NEUZ_THEME env var (default default):
# docker-compose.yml NEUZ_THEME: tokyo-night
The header's light / auto / dark toggle switches modes within the active theme.
You can also drop your own *.css theme into the neuz-data volume at
/app/data/themes/ (survives upgrades) and select it by name. See
themes/README.md for the variable contract and details.
Gemini
Neuz also publishes your dashboard as a capsule on the Gemini protocol — a small, text-first alternative to the web (TLS-only, one request per connection, no cookies, no scripts, no tracking by construction). Any Gemini client (Lagrange, Amfora, Kristall, Elaho on iOS, …) can read it:
gemini://your-host/ today's items
gemini://your-host/day/2026-08-23 one day
gemini://your-host/month/2026-08 days of a month that have items
It's the same content as the web UI, rendered as text/gemini from the same database: title, source link with host / age / category / importance, summary, the optional Markdown body (converted to gemtext — headings, lists, quotes, code blocks and links survive; inline emphasis is unwrapped), and tags. Gemtext has no layout, so the calendar becomes a list of days with counts, and category chips are omitted (each item still shows its category).
Choosing protocols
NEUZ_PROTOCOLS picks which front-ends run. Both are on by default.
| Value | What runs | Use when |
|---|---|---|
http,gemini (default) |
Puma on 9292 and the Gemini capsule on 1965, in one process |
you want both |
http |
Puma only | classic web dashboard, no Gemini listener at all |
gemini |
Gemini capsule only (bin/neuz gemini), no Puma |
a pure Geminispace capsule — note the ingest API is HTTP, so curate into the DB some other way (e.g. run http,gemini on a private host and gemini on the public one sharing the data volume, or flip protocols after ingesting) |
# docker-compose.yml NEUZ_PROTOCOLS: gemini NEUZ_GEMINI_HOST: news.example.com
# bare metal NEUZ_PROTOCOLS=gemini bin/dev # or: bin/neuz gemini NEUZ_PROTOCOLS=http bin/dev
Everything else — bin/neuz setup, the API key, prompts, pruning — works the same regardless of mode. bin/neuz status and the setup banner print the active protocols and the gemini:// URL; the web footer links to the capsule when it's enabled.
TLS and the certificate
Geminispace doesn't use certificate authorities; clients pin a server's certificate on first use (TOFU). On first start with Gemini enabled, Neuz mints a self-signed EC P-256 certificate valid for 10 years, with CN/SAN = NEUZ_GEMINI_HOST, into the data volume at /app/data/gemini/cert.pem + key.pem (key is chmod 600). It's reused across restarts and upgrades, so readers never see a "certificate changed" warning unless you delete it.
- Set
NEUZ_GEMINI_HOSTto the hostname people will type (it defaults to the host ofNEUZ_URL, thenlocalhost). An IP address works too and becomes anIP:SAN. - Bring your own certificate with
NEUZ_GEMINI_CERT+NEUZ_GEMINI_KEY(PEM). If those paths don't exist Neuz refuses to start rather than silently replacing your cert. bin/neuz fingerprintprints the SHA-256 fingerprint so you can check what your client pinned.- To rotate: stop Neuz, delete both files, start again. Every client will prompt to re-trust.
"Today" on the capsule
The web UI learns your timezone from a tz cookie; Gemini has no cookies. NEUZ_GEMINI_TZ_OFFSET (minutes east of UTC, e.g. 120 for UTC+2, -300 for UTC-5) decides where the capsule draws its day boundaries. Default 0 = UTC.
Status codes
20 success (text/gemini; charset=utf-8) · 51 not found (unknown path, impossible date) · 53 proxy request refused (non-gemini:// URL) · 59 bad request (malformed/oversized URL, userinfo) · 41 database busy (retry) · 40 other temporary failure.
Updating
Update the app (one-liner):
That runs git pull --ff-only → docker compose down → docker compose up -d --build, prints the new container status, and probes /healthz for liveness. Flags: --no-pull (skip git), --no-cache (force rebuild), --logs (tail logs after up), --force (ignore a dirty working tree).
The neuz-data volume / ./data dir survives. Your API key, item history, and existing Claude Routine compatibility are all preserved.
Manually (equivalent):
# Docker: git pull docker compose down # required because container_name is pinned docker compose up -d --build # rebuilds, runs migrations on boot # (or `docker compose pull && docker compose up -d` if you pull a pre-built image) # Bare-metal: git pull bundle install # if Gemfile.lock changed bin/dev # rebuilds CSS, migrates, boots Puma
The neuz-data volume / ./data dir survives. Your API key, item history, and Routine compatibility are preserved across updates.
Update the Claude prompt (e.g. you want different interests, or Neuz shipped a better template):
bin/neuz prompts # prints the interview + recurring prompts # → paste the INTERVIEW prompt into Claude Code # → walk through the AskUserQuestion sequence # → Claude prints a fresh RECURRING prompt # → replace the recurring prompt in your Claude Routine
If you've already run bin/neuz acknowledge, the raw-key file is gone, so pass the key explicitly:
bin/neuz prompts --key <your-key> # or echo "$NEUZ_KEY" | bin/neuz prompts --stdin # or NEUZ_KEY=<your-key> bin/neuz prompts
The CLI verifies the key against the stored SHA-256 before printing, so a typo refuses cleanly instead of shipping a broken prompt.
If you've lost the key entirely: bin/neuz rotate mints a new one (invalidates the old Routine — you'll need to update the prompt in Claude Routines with the new key, which the post-rotate prompt-reprint already gives you).
Self-hosting checklist
- Mount
/app/dataon a local filesystem — SQLite WAL does not work on NFS/CIFS. Neuz logs a warning if it detects a network mount. - If you must put data on a network share, set
NEUZ_JOURNAL_MODE=DELETE(slower, but safe). - Put a reverse proxy (Caddy, nginx) in front for TLS on the HTTP side. Neuz speaks plain HTTP there.
- The Gemini side needs no proxy — the protocol is TLS-only and Neuz terminates it itself on port 1965. Just forward that port. Don't put an HTTP reverse proxy in front of it.
- The Gemini certificate lives in the data volume (
/app/data/gemini/). Back it up along with the database: Gemini clients pin it (TOFU), so a lost certificate means every reader gets a "certificate changed" warning. - Backups: just snapshot
/app/data/neuz.db(plus its-wal/-shmsiblings) while the container is paused. - Logs: the app writes one common-log line per request plus warnings/errors to stdout/stderr. Docker captures both via its default
json-filedriver.docker-compose.ymlcaps log volume atNEUZ_LOG_MAX_SIZE(default10m) ×NEUZ_LOG_MAX_FILE(default3), so the running container can hold at most ~30 MiB of rotated logs at a time. Inspect withdocker compose logs neuzordocker compose logs -f neuz. No logrotate / cron config needed — Docker handles rotation.
Local development
bundle install bundle exec rake db:migrate bundle exec puma -C config/puma.rb # HTTP on 9292 + Gemini on 1965 NEUZ_PROTOCOLS=gemini bin/neuz gemini # Gemini only
Try the capsule without a Gemini client:
printf 'gemini://localhost/\r\n' | openssl s_client -connect 127.0.0.1:1965 -quiet -ign_eof
CSS:
# Build CSS once (download tailwindcss standalone for your arch first)
tailwindcss -c config/tailwind.config.js -i config/tailwind.input.css -o public/app.css --minifyTests:
Privacy posture
- No outbound network calls from the server. Item images are hotlinked from the source URL by your browser only.
- No third-party JS/CSS.
- Stdout logs HTTP method, path, status, ms — no IPs, no PII, no cookies. The Gemini server logs the same shape (
[neuz.gemini] /path 20 2ms). - The Gemini capsule sets nothing and asks for nothing: no client certificates, no input prompts (status
10), no query strings. - The only client cookie set is
tz(your offset in minutes — used to render "today" in your local time). No session cookie, no signing key, no CSRF token — there's no login surface, so none of that is needed. - Bearer keys are stored only as SHA-256 digests; the raw key exists on disk only between first-boot mint and
bin/neuz acknowledge.
License
MIT.