pressbox
Source: github.com/red-oxide/pressbox
A self-hosted RSS/Atom/JSON Feed aggregator. You declare feeds (and the people using them) in your NixOS configuration, a small Rust server polls them, and terminal and desktop clients on NixOS, an iPhone, iPad and Mac app and an Android app share read and starred state through its API. Each client is paired with a short code; there is no website and no shared secret. Birdland's bar can show the unread count.
pressbox is in its early days. Expect rough edges, and configuration and behavior that may change between versions.
NixOS config ──▶ pressbox.toml ──▶ pressboxd ──▶ SQLite (items, search index, devices)
(services.pressbox.feeds) │ HTTP API, one token per device
┌───────────────┼────────────────┐
TUI, desktop Birdland bar Apple, Android apps
pressboxctl (control socket, on the server)
| Part | Path | What it is |
|---|---|---|
pressboxd | crates/pressboxd | Server: polling, storage, search, API |
pressboxctl | crates/pressboxd/src/bin | Pairing, devices, backups, OPML |
pressbox-api | crates/pressbox-api | JSON types shared by the Rust crates |
pressbox | clients/tui | Terminal client (ratatui) |
pressbox-iced | clients/iced | Desktop client (iced) |
| Apple app | pressbox-darwin | SwiftUI client (iPhone, iPad, Mac), in its own repo |
| Android app | pressbox-android | Jetpack Compose client, in its own repo |
| NixOS module | modules/nixos.nix | services.pressbox |
| Home Manager module | modules/home-manager.nix | programs.pressbox |
Documentation
- Server (NixOS): installing, feeds, users, options, how feeds are fetched, systemd
- Pairing devices: giving each client its own token
- pressboxctl: the admin CLI on the server
- Clients (Home Manager): the terminal and desktop clients and the Birdland bar
- API: the HTTP API clients use
Apple and Android apps
The mobile and Mac apps are in their own repos, with their own releases, and talk to the server through API v1; see their READMEs for building them and what they can do.
- pressbox-darwin (formerly pressbox-ios): iPhone and iPad on iOS 18, and Mac on macOS 15.
- pressbox-android: Android 12 or later.
Server (NixOS)
Add the flake to your system configuration:
{
inputs.pressbox = {
url = "github:chrisppy/pressbox";
inputs.nixpkgs.follows = "nixpkgs";
};
}
Then import inputs.pressbox.nixosModules.default and declare your feeds:
{
services.pressbox = {
enable = true;
refreshInterval = 1800; # seconds, per feed
feeds = {
lwn = "https://lwn.net/headlines/rss"; # shorthand for { url = ...; }
nixos = {
url = "https://nixos.org/blog/announcements-rss.xml";
title = "NixOS";
tags = [ "nix" ];
refreshInterval = 3600; # this feed only
};
};
};
}
Users
For a household or friends, declare users. Everyone gets the shared feeds plus their own, with their own read and starred state, devices and pairings. Nobody sees another user's feeds or state, and a URL several people follow is fetched once.
{
services.pressbox.users = {
chris = {
admin = true; # can approve devices for anyone
feeds.hn = "https://news.ycombinator.com/rss";
};
anna.feeds.recipes = "https://example.com/recipes.xml";
};
}
Without users there is a single admin user named default. When you first declare users, the existing data (read state, stars, devices) goes to the first admin. Removing a user deletes their devices and state. Users come only from the config; nobody signs up.
Moving from another reader? Export OPML from it and run pressboxctl opml-to-nix subscriptions.opml > feeds.nix, then services.pressbox.feeds = import ./feeds.nix;. To add a site whose feed URL you don't know, pressboxctl discover https://example.org finds its feeds and prints them as Nix.
Some feeds carry only a summary or the first paragraph. For those, set fullText = true on the feed: pressbox fetches each new item's page and keeps its main content (the article, without navigation, ads or comments), sanitized like feed HTML. Clients show it in place of the feed's text, and search covers it. Pages without enough text to be an article keep the feed's own text.
Feed ids (the attribute names) may contain letters, digits, - and _. The generated config is checked by pressboxd --check at build time, so a bad feed fails nixos-rebuild, not the service.
Removing a feed (for everyone, or from one user's feeds) deletes its items for those users, except starred ones: the feed stays under "removed" in their clients until they unstar them. Adding it back resumes it.
| Option | Default | Notes |
|---|---|---|
address | 127.0.0.1 | 0.0.0.0 or :: to listen on all interfaces |
port | 8470 | |
openFirewall | false | |
refreshInterval | 1800 | Seconds, 60–86400; feeds can set their own |
maxItemsPerFeed | 500 | Starred items are never pruned |
maxAgeDays | null | Remove items older than this (starred ones stay) |
behindProxy | false | Behind a reverse proxy or tailscale serve; see Pairing |
backup.enable | true | Daily copy to /var/lib/private/pressbox/backups |
backup.schedule | daily | systemd OnCalendar |
backup.keep | 14 | |
watchdogSec | 60 | 0 turns the watchdog off |
socketActivation | false | Feeds refresh only once the first client connects |
users | {} | <name>.admin, <name>.feeds; see Users |
tokenFile | null | Optional static token; acts as the first admin |
configFile | read-only | The generated pressbox.toml, for inspection |
For the Apple app, serve pressbox over HTTPS. iOS and macOS only allow plain HTTP to local network addresses; the Android app allows it but warns outside the local network or a tailnet. tailscale serve --bg 8470 is the least work; a reverse proxy with ACME works too.
How feeds are fetched
Each feed has its own schedule. Fetches are conditional (ETag and Last-Modified), and a server's Cache-Control: max-age can stretch the wait but never shorten it below the interval. A failing feed backs off: the delay doubles with each failure, up to a day, and Retry-After on a 429 or 503 is honored. pressboxctl status and the clients show failing feeds.
Feed HTML is cleaned when it is stored: scripts, styles, iframes, forms and event handlers are dropped, links get rel="noopener noreferrer", relative URLs are made absolute, and tracking pixels (1×1 images and known tracker hosts) are removed. Every item's text is indexed for full-text search.
Each feed keeps its newest maxItemsPerFeed items. With maxAgeDays set, items published longer ago are removed too, and not stored when a feed still lists them. Starred items are kept either way.
systemd
pressboxd integrates with systemd directly:
- Readiness and status. It is a
Type=notifyservice, andsystemctl status pressboxshows its own status line, e.g.12 feeds, 2 users, 3 new at 14:30, 1 failing. - Watchdog. It pings systemd's watchdog only while its database answers, so a wedged server is restarted automatically.
- Credentials.
tokenFile, if set, reaches it throughLoadCredential, so the secret can stay readable by root only. - Journal. Logs go straight to the journal with proper priorities:
journalctl -t pressboxd -p warning. - Backups.
pressbox-backup.timerrunspressboxctl backupon schedule. - Socket activation (optional). With
socketActivation = true, systemd owns the port, so it stays open across restarts.
Outside systemd all of this is skipped and pressboxd runs the same by hand.
Pairing devices
Every client gets its own token, and only its SHA-256 is stored. To pair:
- On the new client, start pairing:
pressbox pair, or "Pair this device" in the Apple or Android app. It shows a code likeK7W-4QX. - Approve it on the server with
sudo pressboxctl approve K7W-4QX --user anna(--useris needed once there is more than one user), or from a client that is already paired:pressbox approve K7W-4QX, or Settings in the apps. A device approved from a client goes to that client's user; admins can name another (pressbox approve CODE --user anna, or the user field in the apps' Settings). - The client picks up its token on its own. Codes expire after ten minutes.
pressboxctl devices lists devices with their user and when each was last seen; pressboxctl revoke <name> locks one out. For a client that cannot pair (a script, say), pressboxctl add-device <name> --user <user> prints a token. pressbox whoami shows which user and device a machine is signed in as, and the server's version.
Anyone who can reach the server can ask to pair, so requests are limited: 3 waiting per client address and 50 in all. Behind a reverse proxy or tailscale serve, every request comes from the proxy; set behindProxy = true so the address the proxy puts in X-Forwarded-For counts instead. Leave it off otherwise, since clients could then send any address they like.
pressboxctl
Runs as root on the server and talks to pressboxd over /run/pressbox/control.sock, which only root and the service can open.
| Command | What it does |
|---|---|
status | Feeds (failing, removed), items, users, devices, pairings |
users | Users, admins, and their feed and device counts |
pairings | Pending pairing requests |
approve CODE [--user U] / deny CODE | Answer one |
devices [--user U] | Paired devices, their users, when each was last seen |
revoke NAME | Revoke a device's token |
add-device NAME [--user U] | Create a device and print its token |
backup [--keep N] | Back up now (the timer runs this daily) |
refresh | Fetch every feed now |
opml-export [--user U] | Print a user's feeds as OPML |
opml-to-nix FILE | Convert OPML to a feeds attrset (offline) |
discover URL | Find a site's feeds and print them as Nix (no root needed) |
--user is required when there is more than one user.
Clients (Home Manager)
Import inputs.pressbox.homeManagerModules.default:
{
programs.pressbox = {
enable = true;
server = "https://pressbox.your-tailnet.ts.net";
};
}
This writes ~/.config/pressbox/client.json and installs pressbox. Pairing saves the token to $XDG_STATE_HOME/pressbox/token; set tokenFile only to use a token from somewhere else. Both clients read these files, so pairing one pairs both.
Set desktop.enable = true to also install pressbox-iced, the desktop client, and tui.enable = false to leave out the terminal one.
pressbox (terminal client)
pressbox [--server URL] [--token-file PATH] [--config PATH]. Flags and the PRESSBOX_SERVER / PRESSBOX_TOKEN_FILE environment variables override client.json. pressbox pair [--name NAME] pairs this machine; pressbox approve [CODE] [--user U] approves (or lists) other devices' requests, and pressbox whoami shows the signed-in user and device.
| Key | Action |
|---|---|
j k / arrows | Move (scrolls in the reader) |
Enter / l / Tab | Open feed → item list → reader |
Esc / h | Back (clears a search first) |
/ | Search titles and text, including read items |
m | Toggle read |
s | Toggle star |
o | Open in browser (marks read) |
A | Mark the selected feed (or all feeds) read |
M | Mark the selected item and all below it read |
u | Cycle unread / all / read items |
r / R | Reload from server / ask server to refetch |
p | Play the episode (or pause / resume it) |
V | Play the episode's video |
x | Stop playing (saves where you were) |
, / . | Back 10s / forward 30s |
[ / ] | Previous / next chapter |
- / + | Slower / faster |
t | Show the episode's transcript (in the reader) |
q | Quit |
Podcast episodes (items with audio or video) are marked ♪ in the list, ◐ once started and ▶ while playing. They play in mpv, which must be installed (or set player in client.json, or programs.pressbox.player in Home Manager): audio plays in the background, video in mpv's window. The line above the status shows the time, chapter and speed. The position is saved on the server every 15 seconds, on pause and on stop, so playing resumes where you left off on any client; an episode played to the end (or stopped in its last 30 seconds) is marked read. Opening an episode doesn't mark it read. The reader lists its chapters, the current one highlighted while it plays, and t swaps the text for the transcript.
pressbox-iced (desktop client)
A window with three panes: sources (Unread, All items, Starred, tags and feeds, with unread counts), the item list with search, and the article. Opening an item marks it read. Its desktop entry is named "pressbox", and its Wayland app ID is pressbox-iced, for window rules.
On a machine that isn't paired yet it asks for the server and a device name and shows a code to approve, as pressbox pair does; it then saves the token where the TUI looks, and writes client.json only if that has no server (so a file Home Manager manages is left alone). The sidebar can approve another device's code, ask the server to refetch every feed, and sign out, which deletes the token.
| Key | Action |
|---|---|
j / ↓ | Next item |
k / ↑ | Previous item |
s | Toggle star |
m | Toggle read |
o | Open in browser |
p / Space | Play the episode (or pause / resume it) |
V | Watch the episode's video |
x | Stop playing |
, / . | Back 10s / forward 30s |
[ / ] | Previous / next chapter |
t | Show the transcript |
Esc | Dismiss a message |
Podcast episodes are marked ♪ in the list (◐ once started, ▶ while playing), and their article has Play, Watch video and Transcript buttons, the duration and season/episode, and the chapters. Audio plays in the background through GStreamer; video plays above the article. A bar along the bottom shows the episode and chapter, a progress slider, chapter and skip buttons, and the speed (click to step through 1×, 1.25×, 1.5×, 1.75×, 2× and 0.75×, without raising the pitch). While playing, clicking a chapter or a line of the transcript jumps there, and the line being heard is highlighted.
The position is saved on the server every 15 seconds and on pause and stop, so playback resumes where you left off on any client, and an episode played to the end (or stopped in its last 30 seconds) is marked read. Opening an episode doesn't mark it read.
The player shows up in the system's media controls (MPRIS on Linux, Now Playing on macOS), so media keys, headphone buttons and Birdland's bar can play, pause and seek it; "next" and "previous" skip 30 seconds forward and 10 back.
Colors are Birdland, and Birdland Light when the system is in light mode.
In the Birdland bar
Birdland's bar, bullpen, has a feeds module showing the unread count. Click opens pressbox in your terminal, right click asks the server to fetch every feed, and new items send a notification. It reads the same client.json and token as pressbox, so pairing the TUI is all it needs:
programs.birdland.bullpen.layout.right = [ "feeds" "clock" ]; # among your modules
Its options (programs.birdland.bullpen.feeds.command, interval and notifications) are in Birdland's configuration options.
API
The API is versioned: everything lives under /api/v1/, except /health. Pairing routes and version need no token (pairing is how a client gets one); every other route needs Authorization: Bearer <token>, a paired device's token or the static tokenFile token. A token belongs to one user, and every answer is about that user's feeds and state. Timestamps are Unix seconds.
| Method | Path | Body / query | Response |
|---|---|---|---|
| GET | /health | ok | |
| GET | /api/v1/version | {version, api}, e.g. {"version": "2026.11.0", "api": 1} | |
| POST | /api/v1/pair | {"name": str} | {code, poll_id, expires_in}; 429 when too many wait |
| GET | /api/v1/pair/{poll_id} | 202 pending; {token, name} once; 404 | |
| GET | /api/v1/me | {user, admin, device} | |
| GET | /api/v1/feeds | [Feed] | |
| GET | /api/v1/items | feed, tag, unread, starred, media, in_progress, q, limit, cursor | {items, next_cursor} |
| GET | /api/v1/items/{id} | Item with content | |
| PUT | /api/v1/items/{id}/read | {"value": bool} | 204 |
| PUT | /api/v1/items/{id}/starred | {"value": bool} | 204 |
| PUT | /api/v1/items/{id}/position | {"position": seconds} | 204 |
| GET | /api/v1/items/{id}/chapters | {chapters: [Chapter]}; 404 if none; 502 if unreadable | |
| GET | /api/v1/items/{id}/transcript | {segments: [Segment]}; 404 if none; 502 if unreadable | |
| POST | /api/v1/feeds/{id}/read-all | {"updated": n} | |
| POST | /api/v1/items/read-all | the list's filters, optional from item id | {"updated": n} |
| POST | /api/v1/refresh | 202 | |
| GET | /api/v1/pairings | [{code, name, requested}] | |
| POST | /api/v1/pairings/{code}/approve | optional {"user": str} (admins only) | {name, user} |
| DELETE | /api/v1/pairings/{code} | 204 |
Item lists come newest first. To get the next page, pass the previous page's next_cursor as cursor with the same filters; it is absent on the last page. A cursor is opaque, and a malformed one is a 400. The older offset parameter (answered with next_offset) still works, but gets slower the further into a list it goes.
unread=false lists only read items. POST /api/v1/items/read-all marks read every item a list shows, however many pages it has: its body takes the same filters as GET /api/v1/items (feed, tag, unread, starred, q), and with "from": id only that item and the older ones after it. updated counts the items that were unread.
Responses are compressed (gzip or brotli) when the request's Accept-Encoding allows it.
q matches every word as a prefix, in titles and text. With q, unread is usually left out so read items are found too. Breaking changes will go to a new version (/api/v2/), served alongside v1 for a while.
Podcasts
An item with audio or video has media: its enclosures (the feed's own <enclosure> first, then any Podcasting 2.0 podcast:alternateEnclosures, which is where video and other bitrates come from), and, when the feed says, duration (seconds), image, season, episode, chapters and transcripts. RSS <enclosure>, Atom rel="enclosure" links and JSON Feed attachments all count. Every URL is absolute http(s). A feed has image (its artwork) and medium (podcast:medium) when it says.
position is where this user stopped playing, in seconds; it is left out when 0. Clients save it with PUT /api/v1/items/{id}/position every so often while playing and when pausing or stopping, and the last write wins. When an episode is finished, clients mark it read and set its position to 0. in_progress=true lists unread items with a saved position (started, not finished); media=true lists only items with media.
chapters and transcript read the episode's chapters file and its best transcript (JSON, then WebVTT, SRT, HTML and plain text) so clients don't have to: chapters come as {start, end?, title?, image?, url?, hidden?} sorted by start (hidden chapters only change the artwork), and transcripts as {start, end, speaker?, text} segments, with runs of very short cues joined into sentences. Files are fetched once and kept for a week.