A daily news digest for the reMarkable 2, delivered as an EPUB every morning by a Raspberry Pi.
Pipeline: configurable RSS feeds → in-process article extraction (feedparser for RSS parsing; trafilatura as the primary article-body extractor with readability-lxml as a secondary fallback) → ebooklib EPUB assembly → rmapi upload to your reMarkable cloud → systemd user timer schedules the daily run.
Important
Built with substantial AI assistance. This project's requirements, design, implementation, and tests were produced through close collaboration with large language models under a spec-driven workflow (.kiro/specs/). Every change is human-reviewed before commit, but the code reflects that pairing — expect more LLM fingerprints than in a typical hand-written codebase. If that disqualifies the project for your use case, that is a valid call; if it doesn't, you are warmly welcome.
- Core pipeline (build + upload + scheduled run + pairing) is implemented and unit-tested.
- Bootstrap script (
scripts/install-pi.sh) provisions apt prerequisites, a project-local venv, and a pinnedrmapibinary on Raspberry Pi OS 64-bit (Bullseye/Python 3.9 and Bookworm/Python 3.11 both supported). - Config loader, logging with credential redaction, per-feed retry/backoff,
systemd --usertimer install/uninstall, and end-to-endtest-pipelineare all wired. - Rendering is a clean reflowable EPUB (one chapter per article, embedded images); device-specific page layout is delegated to the reMarkable EPUB reader.
- Cross-day deduplication and any monitoring dashboard are explicit non-goals.
- A reMarkable 2 (or Paper Pro) with a logged-in reMarkable cloud account on
my.remarkable.com. - A Raspberry Pi running Raspberry Pi OS 64-bit (
aarch64/arm64), Bullseye or Bookworm. The system Python must be ≥ 3.9 (Bullseye ships 3.9, Bookworm ships 3.11; both are fine andinstall-pi.shchecks). 32-bit Pi OS (armv7l/armhf) is not supported — there is no prebuiltrmapibinary for 32-bit ARM andinstall-pi.shrefuses to run. - The Pi has outbound internet, an accurate clock (default
systemd-timesyncdis fine), and is kept powered on. - A development machine (macOS is what this project is built against) with
gitand SSH access to the Pi.
Renewsable supports two install paths. The Raspberry Pi path is the production path: a Pi runs a systemd --user timer that fires the daily build on schedule, so you wake up to a fresh paper already on the tablet. The macOS path is a manual-mode parallel: an Apple Silicon or Intel Mac runs the same pipeline, but only when you invoke renewsable run yourself — there is no scheduler on Darwin by design. Pick the Pi runbook below if you want "wakes up to a fresh paper" semantics; pick the macOS section after it if you prefer "I run it when I want it" semantics.
Run these steps in order on the Pi, from a fresh checkout of this repo.
Either clone directly on the Pi, or clone on macOS and scp -r it over. A conventional location is ~/renewsable:
cd ~
git clone <your-renewsable-remote> renewsable
cd renewsable./scripts/install-pi.shThis creates a project-local venv at .venv/, installs renewsable in editable mode with dev extras (pip install -e ".[dev]"), and downloads the pinned ddvk/rmapi Linux arm64 release tarball with SHA-256 verification into .venv/bin/rmapi. It is idempotent: re-running it is safe.
source .venv/bin/activate
renewsable pairOpen https://my.remarkable.com/device/desktop/connect in a browser that is logged into the same reMarkable account, copy the 8-character one-time code, and paste it into the rmapi prompt that renewsable pair spawns. The device token persists at ~/.config/rmapi/rmapi.conf; all later runs are headless.
renewsable --config config/config.example.json test-pipelineThis runs the full build + upload end-to-end against the shipped example config (six international feeds, /News folder on the tablet, 05:30 fire time). On success, an EPUB appears at ~/.local/state/renewsable/out/renewsable-YYYY-MM-DD.epub and in the /News/ folder on your reMarkable.
mkdir -p ~/.config/renewsable
cp config/config.example.json ~/.config/renewsable/config.json
$EDITOR ~/.config/renewsable/config.jsonEdit the stories list and schedule_time to taste. See the Configuration reference below and config/README.md for the authoritative field docs. If you skip this step, keep passing --config config/config.example.json on every invocation, including to install-schedule — see below.
renewsable install-scheduleAdd --config <path> if you are using a non-default config location. install-schedule pins whatever path you supply into the rendered ExecStart=, so the scheduled fire uses the same config the operator vetted at install time — copying config.example.json into ~/.config/renewsable/config.json is genuinely optional. This writes renewsable.service and renewsable.timer into ~/.config/systemd/user/, reloads systemctl --user, and enables the timer.
sudo loginctl enable-linger $USERWithout this, the user-level systemd instance stops when your SSH session ends and the timer never fires. This step is manual because it requires sudo and is a one-time system-policy change — the bootstrap script refuses to touch it on your behalf.
You are done. The Pi will build and upload a dated EPUB every day at the configured time.
The parallel install path for an Apple Silicon or Intel Mac (Darwin only). This is a manual-mode-only path: there is no scheduler equivalent on macOS, and the operator invokes renewsable run themselves whenever they want a digest. Run these steps in order from a fresh checkout of this repo.
./scripts/install-mac.shThis is idempotent: it sanity-checks the host (Darwin, arm64 or x86_64), creates a project-local venv at .venv/, installs renewsable in editable mode with dev extras, downloads the latest ddvk/rmapi macOS release matching the host architecture into .venv/bin/rmapi, and clears the macOS quarantine xattr defensively. Re-running on a fully bootstrapped host completes without re-downloading the archive or recreating the venv.
source .venv/bin/activate
renewsable pairSame flow as on the Pi: open https://my.remarkable.com/device/desktop/connect in a browser logged into the same reMarkable account, copy the 8-character one-time code, and paste it into the rmapi prompt. The token persists at ~/.config/rmapi/rmapi.conf; subsequent commands run headlessly.
renewsable --config config/config.example.json test-pipelineThis builds a dated EPUB and uploads it to the configured reMarkable folder end-to-end against the shipped example config — no schedule fire time involved.
renewsable runInvoke this whenever you want a fresh digest. There is no timer that does it for you.
Identical to the Pi runbook's "Customise the config" step above — copy config/config.example.json to ~/.config/renewsable/config.json and edit stories and schedule_time to taste. (Note: schedule_time has no behavioural effect on macOS, since there is no scheduler reading it; treat it as documentation of the intended fire time if you ever migrate this config to a Pi.) See the Configuration reference below and config/README.md for the authoritative field docs.
Important
Scheduling is not supported on macOS. renewsable install-schedule and renewsable uninstall-schedule exit non-zero on Darwin by design and never touch host scheduler state. There is no launchd or cron integration provided; the operator must invoke renewsable run themselves when they want a digest. If you need a scheduled daily paper, use the Pi runbook above instead.
Day-to-day, changes flow via git. From the macOS dev box:
# On macOS
git add -A
git commit -m "..."
git pushThen on the Pi:
# On the Pi
cd ~/renewsable
git pull
.venv/bin/pip install -e .
# Only if schedule_time changed in the config:
.venv/bin/renewsable install-scheduleNotes:
pip install -e .is cheap when nothing changed; run it unconditionally after every pull to keep the editable install's metadata in sync (e.g. a new dependency added topyproject.toml).renewsable install-scheduleis idempotent: re-running it overwrites the unit files and reloadssystemctl --user. Re-running when nothing changed is a no-op you can treat as safe.- If you only edited feeds in your config, nothing further is needed — the next scheduled fire picks up the change (every
Config.loadis a fresh file read, no caching). - To roll back,
git checkout <ref>on the Pi and re-runpip install -e .. The config, logs, and output EPUBs live outside the repo under~/.config/renewsable/and~/.local/state/renewsable/.
The config is a single JSON object. Unknown top-level keys are rejected to catch typos. Required fields have no default; the loader raises ConfigError naming the field and file path if they are missing or malformed. See src/renewsable/config.py for the source of truth and config/README.md for extended per-field prose.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
schedule_time |
string "HH:MM" (24h) |
yes | "05:30" (dataclass default; example config sets it explicitly) |
Local wall-clock time when the daily build fires. |
remarkable_folder |
string starting with / |
yes | "/News" |
Destination folder on the reMarkable cloud. |
stories |
list of {provider, config} objects |
yes | — (must be non-empty) | RSS feed sources. Each entry is exactly {"provider": "rss", "config": {"rss_path": "<http(s) URL>", "limit": <optional positive int>}}. Unknown keys at either level are rejected. |
output_dir |
string path (supports ~) |
no | $XDG_STATE_HOME/renewsable/out (else ~/.local/state/renewsable/out) |
Where built EPUBs are written locally. |
log_dir |
string path (supports ~) |
no | $XDG_STATE_HOME/renewsable/logs (else ~/.local/state/renewsable/logs) |
Where the rotating plain-text log file lands. |
user_agent |
string | no | "renewsable/0.1 (+https://github.com/kbence/renewsable)" |
User-Agent sent on feed and article fetches. |
rmapi_bin |
string | no | "rmapi" |
Command name or absolute path to the rmapi executable. Pi installs drop it at .venv/bin/rmapi. |
feed_fetch_retries |
integer > 0 | no | 3 |
Attempts per feed before giving up (the feed is then skipped). |
feed_fetch_backoff_s |
number > 0 | no | 1.0 |
Base seconds between feed-fetch retries. |
upload_retries |
integer > 0 | no | 3 |
Attempts per rmapi upload before raising UploadError. |
upload_backoff_s |
number > 0 | no | 2.0 |
Base seconds between upload retries. |
The example at config/config.example.json is deliberately minimal: it sets only the required fields plus six pre-configured RSS feeds, and relies on defaults for everything else.
Every build writes a single file at <output_dir>/renewsable-YYYY-MM-DD.epub. Re-running on the same date overwrites the previous file in place; the upload uses rmapi put --force so the cloud copy is replaced too.
Nothing. The systemd --user timer fires at schedule_time, Builder fetches feeds, extracts articles, and assembles the EPUB; Uploader invokes rmapi put --force, and the digest shows up on the tablet.
To look at what happened:
# Last run's journald output (service logs)
journalctl --user -u renewsable.service --since today
# Rotating plain-text log file
less ~/.local/state/renewsable/logs/renewsable.log
# Timer state and next fire time
systemctl --user status renewsable.timer
systemctl --user list-timers | grep renewsableTo trigger a one-off run on demand:
renewsable run # quiet, same as the scheduled invocation
renewsable test-pipeline # verbose (at least INFO), prints progressFor exercising the full build pipeline in a Linux sandbox. The image
deliberately omits rmapi and systemd; it is not a production runtime.
The Pi deployment story (bootstrap + systemd user timer) remains the
supported path.
docker build -t renewsable:dev .
docker run --rm \
-v "$PWD/config:/config:ro" \
-v "$HOME/.local/state/renewsable:/state" \
renewsable:dev \
--config /config/config.example.json buildThe produced EPUB lands at $HOME/.local/state/renewsable/renewsable/out/renewsable-YYYY-MM-DD.epub
on the host via the bind-mount (the inner renewsable/ suffix is XDG's
per-app namespace under $XDG_STATE_HOME). Logs land in
$HOME/.local/state/renewsable/renewsable/logs/renewsable.log.
-
Token loss / "paired device was removed from my reMarkable cloud" — the
rmapi.confat~/.config/rmapi/rmapi.confis stale or missing. Re-pair:renewsable pair --force
Open https://my.remarkable.com/device/desktop/connect, type the fresh 8-character one-time code into the
rmapiprompt. Subsequent runs go back to headless. -
Timer did not fire /
list-timersshowsn/aforrenewsable.timer— either lingering is off, or the timer is not enabled.systemctl --user status renewsable.timer systemctl --user list-timers journalctl --user -u renewsable.service --since today loginctl show-user $USER | grep Linger # Linger=yes required sudo loginctl enable-linger $USER # fix if Linger=no
-
One feed broke the whole build (shouldn't happen) — by design, per-feed failures are caught and logged; the paper is still built from the surviving feeds. Find the offending feed:
journalctl --user -u renewsable.service --since today | grep -i 'skip\|fail'
Remove the dead feed from your config and commit. If every feed failed,
BuilderraisesBuildErrorandrenewsable runexits non-zero before callingrmapi. -
renewsable paircan't findrmapi— the shipped install puts it at.venv/bin/rmapi. Make sure you activated the venv (source .venv/bin/activate) or setrmapi_binin your config to the absolute path. -
journalctl --user -u renewsable.serviceis the primary inspection command; pair it with--since todayor-ffor a live tail. The plain-text log at~/.local/state/renewsable/logs/renewsable.logcarries the same records (with credential redaction already applied). -
apt prerequisites are missing after an OS upgrade — re-run
./scripts/install-pi.sh; it is idempotent and will top up any missing packages. -
Remove the schedule entirely:
renewsable uninstall-schedule
This disables and stops the timer, deletes the unit files, and reloads
systemctl --user. Safe to run even when no timer is installed. -
Wrong config path /
ConfigError: config file not found—renewsablereads$XDG_CONFIG_HOME/renewsable/config.json(falling back to~/.config/renewsable/config.json) by default. Override with--config <path>. Errors always name the exact file path and the offending field. -
End-to-end verification on real hardware — see
PI_VERIFICATION.mdfor the full copy-pasteable checklist (pair, build, upload,test-pipeline, schedule fire-test, linger check) used to accept the Pi install.
Single-process Python orchestrator: Config → Builder (in-process article extraction + ebooklib EPUB assembly) → Uploader (rmapi subprocess), scheduled by a systemd --user timer whose unit files are rendered and installed by the Scheduler component. Full design in .kiro/specs/epub-output/design.md; config schema authority in src/renewsable/config.py and config/README.md.
MIT — see LICENSE.