FeatherReader πͺΆ
A minimalist, atproto-native RSS/Atom reader β written in Rust.
FeatherReader is a calm, typography-first feed reader for people who left algorithmic feeds on purpose. Its defining idea: your subscriptions, folders, stars, and read-state live as records in your own atproto PDS β not in the app's database. You sign in with your atproto identity, and your reading list follows you across any reader that speaks the same open lexicon. You own your data; the app just holds a cache and a login session.
Hosted at feather-reader.com, and trivial to self-host.
Status: experimental / pre-1.0. The core is built and usable, but the project is early, the on-disk formats and the lexicon may still change, and a closed invite-beta is planned before any wider launch. Treat it as something to try, not something to depend on.
Why FeatherReader
- Own your data β as an open standard. Subscriptions, folders, saved items,
and read-state are written as
community.lexicon.rss.*records in your PDS. There's no signup and no password database: your atproto handle is your account. Because the records use a shared, vendor-neutral schema, your feed list is portable across readers, not just across FeatherReader instances. - Minimalist by design. A single sorted list, a distraction-free reading view, keyboard flow, dark mode. No ads, no tracking, no telemetry, no algorithm, no "discover" tab. Every feature has to earn its place against "does this make the calm reading experience better, or just bigger?"
- Single binary, self-hostable. Rust + an embedded SQLite cache (no Postgres to run). Since 0.3.0 the atproto OAuth client is built in, so a self-host can be one process β or keep the Node sidecar if you prefer. Easy to run yourself either way.
The community.lexicon.rss.* standard
Most readers own your account and your export format. FeatherReader holds
neither. Your data is stored under a neutral, community-owned lexicon that any
atproto RSS reader can adopt β the same way community.lexicon.calendar.event
lets any atproto calendar app read the same events. Log in anywhere with your
handle and your feeds are already there. If you switch readers, there's nothing
to export: the records are a shared standard.
The record types:
community.lexicon.rss.subscriptionβ a subscribed feedcommunity.lexicon.rss.folderβ a lightweight groupingcommunity.lexicon.rss.savedβ a starred / saved itemcommunity.lexicon.rss.readStateβ a compact per-feed read cursor
Architecture
Two views β where your data lives and how the running system is wired. Both diagrams adapt to your light/dark theme.
Data ownership β your PDS is the source of truth
Your subscriptions and read-state are records in your PDS, so the local cache is throwaway and your reading list follows you to any reader that speaks the same lexicon.
Runtime β one container, three processes
Caddy fronts everything on a single port β routing /oauth/* to whichever
process owns the OAuth flow and the rest to the Rust server. The SQLite cache on
the mounted volume is disposable; all durable state lives in your PDS. An
optional followβinvite bot runs outside this container
and reaches the app over POST /bot/claims; it isn't part of the core app.
The dashed links are the Node sidecar, used only on the default sidecar
backend. On FEATHERREADER_REPO_BACKEND=rust the app owns the OAuth flow itself,
those links do not exist, and the :8081 process is not started β see
Choosing an OAuth backend.
Diagram sources + rendered images live in design/architecture/.
Features
- A clean list + a distraction-free reader view β the headline feature.
- Star / save-for-later and folders for lightweight organisation.
- OPML import / export β the migration on-ramp and off-ramp. Import creates a subscription record per feed in your PDS; export reads them back out.
- Subscribe by URL β paste a feed URL or a site URL and autodiscovery finds the feed.
- Keyboard navigation β
j/kmove,o/Enter open,mtoggle read,sstar,Amark-all-read,?for the shortcuts overlay,Escto close. - Dark mode β system-preference-aware, with a manual toggle.
- No-JS friendly β server-rendered HTML with a dash of htmx; every action also works as a plain form POST.
- Polite fetching β conditional GET (ETag / Last-Modified), backoff, and an SSRF guard on every feed and identity fetch.
Known limitation: private / paid feeds
FeatherReader stores your subscriptions in your public PDS. Because those records are public, a secret-bearing feed URL (private Substack, Patreon, private podcast feeds, etc.) would leak its secret if written there. So for now FeatherReader supports public feeds only β a private/paid feed's URL is never saved, fetched, or sent anywhere; it is refused at submission with a clear message. Private-feed support is deliberately deferred until atproto's permissioned ("private") records ship.
Build & run
FeatherReader runs as one or two processes, depending on which OAuth backend you choose (see Choosing an OAuth backend):
sidecar(the default) β the Rust server plus a small Node OAuth sidecar that owns the atproto OAuth flow, so the Rust side never holds PDS tokens.rustβ the Rust server alone, using its own built-in atproto OAuth client. No Node.
Prerequisites: a recent stable Rust toolchain (see rust-version in
Cargo.toml), plus Node.js 24 or newer only if you run the sidecar backend
(it uses the built-in node:sqlite, which is stable and flagless from 24).
# 1. Build the server (always)
# 2. Build the OAuth sidecar (only for FEATHERREADER_REPO_BACKEND=sidecar)
Both processes are configured entirely through environment variables β there is no config file. Every knob has a sensible default, so a bare run boots and works.
- The server reads
FEATHERREADER_*variables (bind address, database path, poll interval, β¦), plus theSIDECAR_*URL and shared-secret pair it needs to reach the sidecar. See the table at the top ofsrc/config.rs. - The sidecar reads
SIDECAR_*variables (its public URL, storage path, the at-rest token-encryption key, the shared internal secret, β¦). Seeoauth-sidecar/.env.example. Not used on therustbackend.
In production the sidecar requires a real at-rest encryption key and a strong shared internal secret, and refuses to boot without them. Never commit secret values β the example files ship placeholders only.
Choosing an OAuth backend
FEATHERREADER_REPO_BACKEND selects which implementation performs the atproto
OAuth handshake and every com.atproto.repo.* call:
sidecar (default) |
rust |
|
|---|---|---|
| Processes | Rust server + Node sidecar | Rust server only |
| OAuth client | @atproto/oauth-client-node |
built in |
| Runtime deps | Node.js | none |
| Needs | SIDECAR_* |
FEATHERREADER_OAUTH_ENCRYPTION_KEY |
Upgrading to 0.3.0 changes nothing. The default is sidecar, so an existing
deployment keeps the topology it already has until you choose otherwise.
Switching
FEATHERREADER_REPO_BACKEND=rust
FEATHERREADER_OAUTH_ENCRYPTION_KEY=<random, >=32
The server refuses to start if you select rust on a production-like
instance without an encryption key. That table holds every user's access token,
refresh token and DPoP private key; without a key they would sit in plaintext in
SQLite, on the same volume as the feed cache and in every backup of it. An
unrecognised backend name is also a startup failure rather than a silent
fallback.
Put the signing key somewhere persistent. FEATHERREADER_OAUTH_KEY_PATH
defaults to the relative oauth-signing-key.json, which is fine for a local
run and a trap in a container: the key lands in the working directory, is lost on
every redeploy, and a new one is generated in its place. Your published JWKS then
changes on each deploy, which breaks private_key_jwt against any authorization
server still holding the old one. The supplied image already points it at the
persistent volume; a custom image or bare-binary deployment must do the same:
FEATHERREADER_OAUTH_KEY_PATH=/data/oauth-signing-key.json
What switching costs
- Everyone signs in again β in both directions. The two backends keep
separate session stores, and separate is literal: nothing under
src/readsSIDECAR_DB, because the Rust backend keeps its ownoauth_sessiontable insideFEATHERREADER_DB, apart from the sidecar's own database. No access token, refresh token or DPoP key crosses the flip, so every signed-in reader is logged out by it β and rolling back logs them out a second time, off a sidecar store that has gone stale in the meantime. Browser sessions are in-memory and already end on restart, so nothing is lost; it is one login per flip, which is worth timing for low traffic and telling people about. - Unverified, but worth knowing: the two backends publish JWKS from different signing keys, so if a PDS caches our JWKS across the flip, the first login after it may fail for that reason rather than because of a bug in the new path. This has not been observed or reproduced β it is a thing to rule out before concluding the backend is broken.
/oauth/*routing must match the backend. The two cannot share/oauth/callback: your PDS redirects there with identical?code=&state=&iss=in both cases, so nothing in the request distinguishes them and one process has to own the path. The supplied container handles this β the entrypoint installs the matching Caddy routing from the same environment variable. A bare-binary deployment must route/oauth/*itself: to the app onrust, to the sidecar onsidecar.- Rolling back is unsetting the variable and restarting. Nothing is migrated or destroyed by the switch, and both Caddy routings ship in every image, so a rollback needs no rebuild. Cheap operationally β but not free for your readers, who log in again (see above).
Which should you run?
sidecar is the default and the option with production time behind it β it is
what the hosted instance runs today. rust shipped in 0.3.0, but it has no
production time at all yet: its login path is now covered by tests (the code
exchange itself, and each of the security guards around it, are pinned by them),
and that is a different claim from having been exercised against real PDSes under
real traffic. private_key_jwt client authentication and RFC 7009 revocation in
particular are implemented and unit-tested on the Rust path but have not run
against a production PDS.
So: if you want the smaller deployment β one process, no Node β and are content
to be early, start fresh on rust and watch your logs through the first logins.
Otherwise run the default. If you already have a working sidecar deployment,
there is no urgency to move.
The choice is transitional. Maintaining two implementations of the same
surface has a real cost, and the intent is to remove the sidecar in a later
release once the Rust path has enough production time. sidecar will be
announced as deprecated before it is removed.
Self-hosting
FeatherReader is designed to be run by anyone: a single static Rust binary
(optionally plus the Node sidecar), an embedded SQLite cache, and no external
database. Front it with your own reverse proxy / TLS. Teardown and
data-ownership notes live in deploy/.
GET /health is the unauthenticated liveness endpoint, and it reports machine
facts only β no user counts, no DIDs, no feed URLs. The first token of the body
is the state: ok, unknown or FAIL. Only a measured database failure is a
failure (FAIL, HTTP 503); unknown means no probe has completed yet, which
happens briefly at boot and is not an outage β so match the state token, not just
the status code. The remaining lines (db:, uptime:, poller:,
polling-paused:, backend:, oauth-runtime:) never change the status code, on
the grounds that a stale poller can still serve pages while an unreadable
database cannot. Alert on the body if you want to hear about those.
Invite bot (optional)
The repo also ships a small, optional followβinvite bot in bot/ β a
tool for running a closed invite-beta, not needed to self-host the reader. It's
a standalone Rust crate (its own workspace, deliberately not built by the app's
cargo build) that watches an atproto account's followers and, for each new one,
calls the app's POST /bot/claims to mint a single-use invite, then posts a public
claim link. That endpoint stays disabled unless FEATHERREADER_BOT_SECRET is set,
so the core app runs fine without the bot. Details + configuration in
bot/README.md.
Contributing
Contributions are welcome. Please read CONTRIBUTING.md for how
to build, run the checks (./scripts/ci.sh), and open a pull request. Bug reports
and design discussion via issues are equally welcome.
License
AGPL-3.0-only. The AGPL is deliberate: it keeps hosted forks open, so improvements to a network-served reader flow back to everyone.