frostread is a tailnet-only feed reader and read-later list. It runs behind
lodge, frostyard's tailnet-only web
shell, as app read.
- Sources. RSS, Atom and JSON Feed, by URL or from a page's
<link rel="alternate">, imported and exported as OPML. Each source polls every 20 minutes or so, with conditional GET and a per-source backoff. - Read later. Save a link from the app, from
readctl save <url>, or from an agent with aread;writetoken. - From your phone. A bookmarklet opens Read's save page with the page's address and title. Read shows both and saves only when you tap Save, so it works in iOS Safari and needs no token.
- Articles. The main content is extracted, sanitized, and its images are fetched into a content-addressed store and inlined, so the browser loads nothing remote. The original bytes are kept as sent.
- Reading. Unread, starred and archived views, full-text search, and
permalinks at
/read/item/<id>. - Save as a note. A reader button writes the article into notes under
Reading/, through notes' MCP on lodge's agent socket. - Agents. Porter and any other agent reach it at
/mcp/readwith a scoped token.
Status: pre-release, 2026-10-22. The repository is public since
2026-10-23. The daemon, its API, readctl, MCP, save-as-note, the web
app, save from the phone and the blob sweep are built. It runs on lodge's
kit v0.4.0, and the bundle on @frostyard/lodge-client
client/v0.3.2 and @frostyard/lodge-ui ui/v0.6.0. See the
roadmap.
make check # tidy, vet, gofmt, lint and tests
make build # build/readd and build/readctl
make lodge-package # stage them for lodge under build/lodge-root
make dev-lodge # lodge's development build on loopback, with Read and Notesmake dev-lodge starts lodge's development build with frostread and
frostnotes in it, generated feeds and a generated vault, and frostread's
scoped notes token, so save-as-note works end to end. It reads ../lodge
and ../frostnotes (LODGE_DIR, NOTES_DIR) and changes neither. See
scripts/dev-lodge.sh.
The web bundle builds only in an nsl machine with Node, never on the host:
make web-buildlodge owns every network listener. readd serves:
| Socket or route | What |
|---|---|
/run/lodge/apps/read.sock |
lodge's app socket: /health, /api, /content/, /mcp |
/run/frostread/readctl.sock |
readctl, the CLI |
https://CONTENT/read/<path> |
an article's content page, sandboxed |
It runs as user frostread, in groups lodge-apps and lodge-agents,
and listens on no network. Its state is /var/lib/frostread, and its
configuration is /etc/frostread/frostread.json
(docs/specs/api.md).
frostread runs as lodge's read app, beside lodge and notes, on the same
host. LODGE below is lodge's address, lodge.<tailnet>.ts.net.
-
Build the bundle and stage the app. The web bundle builds in an nsl machine.
lodge-packagestagesreadd,readctl, the manifest, the bundle, the units and the sysusers entry underbuild/lodge-root; it refuses a whole root, so copy the stagedusr/over the host's, with the app's own directory replaced whole. The staging writes only underusr/:readdandreadctlland in/usr/bin, and the unit'sExecStart=is/usr/bin/readd(core ADR-0058).make web-build # in nsl make lodge-package # stages build/lodge-root sudo rm -rf /usr/share/lodge/apps/read # only Read's own directory sudo cp -r build/lodge-root/usr/. /usr/ # not -a: the files must be root's
Once, on a host that installed an earlier version (which put the binaries in
/usr/local/bin): remove the old copies, because/usr/local/bincomes before/usr/binonPATHand a stalereadctlthere would shadow the new one.sudo rm -f /usr/local/bin/readd /usr/local/bin/readctl command -v readctl # expect /usr/bin/readctl systemctl cat frostread.service # expect ExecStart=/usr/bin/readd
The steps below copy the unit to
/usr/lib/systemd/system/, so a full copy of it in/etc/systemd/system/frostread.servicewould shadow the new one and still start/usr/local/bin/readd. These steps make only the drop-intz.conf(step 2), which is safe; remove any full copy. -
Create the user and start the socket. The sysusers entry makes
frostread, with its home at/var/lib/frostread, in grouplodge-appsand in grouplodge-agents(the agent socket), and userlodge, with lodge's own line byte for byte, because readd'slodgeapp.LoadEnvlooks that user up.sudo systemd-sysusers /usr/lib/sysusers.d/frostread.conf id frostread # expect lodge-apps and lodge-agents sudo systemctl daemon-reload sudo systemctl enable --now frostread.socket frostread.service
On an upgrade, do not run these commands as they are: follow Update, which stops frostread before the copy.
The service is enabled too, so the poller runs from boot, not only after lodge's first request. On a host whose clock is UTC, give readd the owner's zone, as notes' unit does: a drop-in
/etc/systemd/system/frostread.service.d/tz.confwith[Service]andEnvironment=TZ=America/New_York. Saved notes are dated, and article pages show their day, in that zone. -
Write the configuration,
/etc/frostread/frostread.json, owned by root, 0644: no secret is in it.notes_vaultis the id of the vault notes serves.{ "poll_interval": "20m", "lodge_url": "https://LODGE", "notes_folder": "Reading/", "notes_vault": 1 } -
Let frostread reach notes. Add
frostreadtoagentsin/etc/lodge/config.json(the list is at most 16 system users), then reload lodge, which also picks up Read's manifest:{ "agents": ["frostread"] }sudo lodgectl reload sudo lodgectl status # the agent socket, and frostread among its agents -
Issue Read's notes token, scoped to one folder, for the agent socket. It is shown once; pipe it straight into
readctl, which writes it to/var/lib/frostread/tokens/notes(0600) and printsthe notes token is set.sudo lodgectl token issue frostread-notes --app notes \ --scope 'vault=1;write=Reading/' --agent frostread | sudo -u frostread readctl notes-token
readctlspeaks readd's private socket, which admits only frostread's own processes; everyreadctlstep runs assudo -u frostread. -
Subscribe to feeds from an OPML file, and check the app:
sudo -u frostread readctl import feeds.opml sudo -u frostread readctl status # save as a note: yes, vault 1, folder Reading/ sudo -u frostread readctl sourcesThe web app is at
https://LODGE/read/, and save-as-note writes into notes underReading/.
An update installs a release's .deb,
frostyard-readd_X.Y.Z_amd64.deb (see Release). lodge's host
has no apt source and no sysext for it (2026-11-03), so it goes in by hand
with dpkg -i, as v0.3.0 (2026-10-30) and v0.4.0 (2026-11-03) did. The
package has no maintainer scripts: it starts, stops and reloads nothing,
so the steps below do that. While frostread is stopped, the Read tab does
not work, the poller does not run, and lodge shows the app as down.
-
Get the package and check it, on a computer with
gh. Continue only when the hash is the release asset's digest and the attestation's workflow isrelease.ymlatrefs/tags/vX.Y.Z. Then copy the package to the host's/root, and keep it there: it is the next roll back.gh release download vX.Y.Z -R frostyard/frostread -p 'frostyard-readd_*_amd64.deb' sha256sum frostyard-readd_X.Y.Z_amd64.deb gh release view vX.Y.Z -R frostyard/frostread --json assets --jq '.assets[].digest' gh attestation verify frostyard-readd_X.Y.Z_amd64.deb -R frostyard/frostread
-
Note the old version, and stop frostread. Stop the socket too: it starts the service again on the next connection.
sudo -u frostread readctl status # prints: readd <version> sudo systemctl stop frostread.socket frostread.service -
Take a rollback archive. Keep it root-only: the notes token is a secret. Name it with the old version and a UTC time. The binaries are in
/usr/bin(builds from frostread#20 on) or in/usr/local/bin(earlier builds), so the script lists only the paths that exist. Every other path must exist, ortarfails.sudo sh -c 'umask 077 a=/root/frostread-rollback-OLDVERSION-$(date -u +%Y%m%dT%H%M%SZ).tar.gz bins=$(ls -d /usr/bin/readd /usr/bin/readctl \ /usr/local/bin/readd /usr/local/bin/readctl 2>/dev/null) tar -czpf "$a" /var/lib/frostread /etc/frostread \ /usr/share/lodge/apps/read $bins \ /usr/lib/systemd/system/frostread.socket \ /usr/lib/systemd/system/frostread.service \ /usr/lib/sysusers.d/frostread.conf && echo "$a"'
Continue only when
tarexits 0 and prints the archive name. Check that the archive holds the state:sudo tar -tzf ARCHIVE var/lib/frostread >/dev/null -
Install, reload and start. readd migrates its database at start (
internal/store, byuser_version); the release notes name a migration, for example v0.4.0's source folders.sudo dpkg -i /root/frostyard-readd_X.Y.Z_amd64.deb sudo systemctl daemon-reload sudo systemctl start frostread.socket frostread.service sudo lodgectl reload
Once, on a host whose files were copied by hand (before v0.3.0): remove
/usr/share/lodge/apps/readand any/usr/local/bin/readdandreadctlbeforedpkg -i, so that no file the package does not own is left behind (Install on lodge's host, step 1). -
Check.
readctl statusprints the new version,readis up in lodge's list with the new version, and the journal since the start has no warning:sudo -u frostread readctl status sudo lodgectl apps sudo journalctl -u frostread.service -p warning --since -10min
A commit with no release builds and copies as
Install on lodge's host, step 1 does, in place
of step 4's dpkg -i. On a host that has the package, that copy leaves
dpkg's record wrong: install a release again soon after.
Roll back to the release you ran before.
-
Stop frostread, as in step 2 of "Update".
-
Put the earlier release back with its
.deb, which step 1 of "Update" kept in/root.dpkgaccepts the older version:sudo dpkg -i /root/frostyard-readd_OLD_amd64.deb
For a version that was not a package, extract the installed files from the rollback archive instead, after
sudo dpkg -r frostyard-readdand after you remove/usr/share/lodge/apps/read. This leaves the state and the configuration as they are:sudo tar -xzpf ARCHIVE -C / --exclude=var/lib/frostread --exclude=etc/frostread
-
Reload and start, as in step 4 of "Update":
sudo systemctl daemon-reload sudo systemctl start frostread.socket frostread.service sudo lodgectl reload
-
Read the journal:
sudo journalctl -u frostread -n 50
If it says
database schema N is newer than this readd, the newer readd migrated the database, and the older readd refuses it. Stop frostread, restore/var/lib/frostreadfrom the archive, and start again:sudo systemctl stop frostread.socket frostread.service sudo rm -rf /var/lib/frostread sudo tar -xzpf ARCHIVE -C / var/lib/frostread sudo systemctl start frostread.socket frostread.service
The restore loses what changed after the archive: new items, reads and saves. The archive also holds the blobs and the notes token.
-
Check, as in step 5 of "Update".
The sysext readd is built and published for Snosi bootc images (Floe).
snosi#1169 added the
first two suite sysexts (notesd, maild).
snosi#1173 added lodge,
readd and porterd.
snosi#1175 pinned readd
0.2.0 and porterd 0.1.1 (lodge stays 0.1.0). Updex downloads the image
from https://repository.frostyard.org/ext/readd/ and checks its
signature. lodge's own host stays hand-installed (core Plan 0010 Phase 4
is deferred, 2026-10-23), so the steps above stay valid for that host.
The full steps for an image are on
the site page.
Back up the state before you update. The units are upheld
(Upholds=), so a plain systemctl stop does not hold: mask them. The
state is not in the sysext. It holds the notes token, so keep the
archive root-only:
sudo systemctl mask --now frostread.socket frostread.service
sudo sh -c 'umask 077; tar -czpf /root/frostread-state-$(date -u +%Y%m%dT%H%MZ).tar.gz /var/lib/frostread'
sudo systemctl unmask frostread.socket frostread.service
sudo systemctl start frostread.socket frostread.serviceUpdate on an image:
sudo updex features update --component=readd
sudo systemctl restart frostread.socket frostread.service
sudo lodgectl reloadWith Updex v2.1.3 and later
(ADR-0019),
the update is a live refresh. The new files merge at once,
systemd-sysusers and systemd-tmpfiles --create run, and systemd
reloads its unit files. Updex does not restart units, so the running
readd keeps its old binary until you restart it. No reboot is necessary.
lodgectl reload makes lodge read the new manifest.
Reboot only when the update waits for the next boot: you passed
--no-refresh, the automatic update timer installed it, or the output
says refresh skipped. With an Updex older than v2.1.3
(updex --version shows it), run sudo systemd-sysusers,
sudo systemd-tmpfiles --create and
sudo systemctl daemon-reload first, or reboot.
Check the version:
sudo -u frostread readctl status
sudo lodgectl appsreadctl status prints readd <version>. lodgectl apps lists read
up at the new version. lodge polls every 15 seconds, so if it shows
down at once, run it again after 15 seconds.
Roll back:
sudo updex features rollback readdThe rollback takes effect at the next boot. Add --now to apply it at
once, then restart and reload as above. A rollback does not undo a
database migration. If the journal of the older readd
(journalctl -u frostread.service -n 50) shows
database schema N is newer than this readd, restore the state from
the backup. Replace ARCHIVE with its path:
sudo systemctl mask --now frostread.socket frostread.service
sudo rm -rf /var/lib/frostread
sudo tar -xzpf ARCHIVE -C / var/lib/frostread
sudo systemctl unmask frostread.socket frostread.service
sudo systemctl start frostread.socket frostread.serviceThe restore loses what changed after the backup: new items, reads and saves.
A release is a tag, and make bump is the only way to make one
(core ADR-0012).
It checks the pinned svu, that you are on main at origin/main with a
clean tree, and that make check passes, then tags svu next and pushes
the tag. The tag starts
.github/workflows/release.yml: it builds
frostyard-readd_X.Y.Z_amd64.deb with make deb, attests its provenance, and
attaches it to the GitHub release.
make deb builds the same package on a developer's machine, after the web
bundle: make web-build (nsl), then make deb. It writes
dist/frostyard-readd_0.0.0-<commit>-snapshot_amd64.deb, and make deb-check
(packaging/deb-check.py) reads the archive's exact member names. It fails unless
the control fields are right (Depends: systemd, no maintainer scripts), and
the package holds only root-owned, non-writable directories and regular files (no links)
under /usr, with the binaries (mode 0755), the units, the sysusers.d entry and the web bundle's index.html, and a manifest whose version is the package's
version. internal/packagecheck feeds it bad packages, and each must fail.
CI builds a snapshot on every pull request (job deb).
Verify a downloaded package:
gh attestation verify frostyard-readd_X.Y.Z_amd64.deb --repo frostyard/frostreadSnosi pins each release's .deb in a reviewed checksum PR, then builds
and publishes the sysext readd
(snosi#1173). See
After the sysexts.
- docs/README.md: the index.
- docs/design/overview.md: the parts and how they fit.
- docs/specs/api.md: the API,
readctl, the config and the state layout. - AGENTS.md: the rules for every agent that works here.