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)
PartPathWhat it is
pressboxdcrates/pressboxdServer: polling, storage, search, API
pressboxctlcrates/pressboxd/src/binPairing, devices, backups, OPML
pressbox-apicrates/pressbox-apiJSON types shared by the Rust crates
pressboxclients/tuiTerminal client (ratatui)
pressbox-icedclients/icedDesktop client (iced)
Apple apppressbox-darwinSwiftUI client (iPhone, iPad, Mac), in its own repo
Android apppressbox-androidJetpack Compose client, in its own repo
NixOS modulemodules/nixos.nixservices.pressbox
Home Manager modulemodules/home-manager.nixprograms.pressbox

Documentation

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.

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.

OptionDefaultNotes
address127.0.0.10.0.0.0 or :: to listen on all interfaces
port8470
openFirewallfalse
refreshInterval1800Seconds, 60–86400; feeds can set their own
maxItemsPerFeed500Starred items are never pruned
maxAgeDaysnullRemove items older than this (starred ones stay)
behindProxyfalseBehind a reverse proxy or tailscale serve; see Pairing
backup.enabletrueDaily copy to /var/lib/private/pressbox/backups
backup.scheduledailysystemd OnCalendar
backup.keep14
watchdogSec600 turns the watchdog off
socketActivationfalseFeeds refresh only once the first client connects
users{}<name>.admin, <name>.feeds; see Users
tokenFilenullOptional static token; acts as the first admin
configFileread-onlyThe 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=notify service, and systemctl status pressbox shows 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 through LoadCredential, 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.timer runs pressboxctl backup on 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:

  1. On the new client, start pairing: pressbox pair, or "Pair this device" in the Apple or Android app. It shows a code like K7W-4QX.
  2. Approve it on the server with sudo pressboxctl approve K7W-4QX --user anna (--user is 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).
  3. 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.

CommandWhat it does
statusFeeds (failing, removed), items, users, devices, pairings
usersUsers, admins, and their feed and device counts
pairingsPending pairing requests
approve CODE [--user U] / deny CODEAnswer one
devices [--user U]Paired devices, their users, when each was last seen
revoke NAMERevoke 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)
refreshFetch every feed now
opml-export [--user U]Print a user's feeds as OPML
opml-to-nix FILEConvert OPML to a feeds attrset (offline)
discover URLFind 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.

KeyAction
j k / arrowsMove (scrolls in the reader)
Enter / l / TabOpen feed → item list → reader
Esc / hBack (clears a search first)
/Search titles and text, including read items
mToggle read
sToggle star
oOpen in browser (marks read)
AMark the selected feed (or all feeds) read
MMark the selected item and all below it read
uCycle unread / all / read items
r / RReload from server / ask server to refetch
pPlay the episode (or pause / resume it)
VPlay the episode's video
xStop playing (saves where you were)
, / .Back 10s / forward 30s
[ / ]Previous / next chapter
- / +Slower / faster
tShow the episode's transcript (in the reader)
qQuit

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.

KeyAction
j / ↓Next item
k / ↑Previous item
sToggle star
mToggle read
oOpen in browser
p / SpacePlay the episode (or pause / resume it)
VWatch the episode's video
xStop playing
, / .Back 10s / forward 30s
[ / ]Previous / next chapter
tShow the transcript
EscDismiss 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.

MethodPathBody / queryResponse
GET/healthok
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/itemsfeed, 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-allthe list's filters, optional from item id{"updated": n}
POST/api/v1/refresh202
GET/api/v1/pairings[{code, name, requested}]
POST/api/v1/pairings/{code}/approveoptional {"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.