SyncShelf is a self-hosted Python service for a two-person household. It is:
- a remote MCP server that Claude connects to over OAuth, with tools that answer reading-history questions, edit the reading log, run and inspect the syncs, and request books from Chaptarr;
- a set of scheduled one-way syncs that keep Hardcover as the single reading log, fed by Goodreads and Audiobookshelf;
- a Chaptarr client that adds and searches one book in one format on request.
The build spec is docs/SPEC.md.
Phase 2 (front door and profiles), released as v0.2.0. uv run python -m syncshelf
starts the service on 0.0.0.0:8000 (set by server.listen), with an MCP endpoint and a
/healthz liveness check. /mcp needs a bearer token, which Claude gets by signing in
through SyncShelf's own OAuth server in oauth mode, or which a local client sends as the
static dev token in dev mode. Its data lives in /data, or the existing directory named by
SYNCSHELF_DATA_DIR. Phases are listed in SPEC §3.
You need uv. It installs Python 3.14 for you.
uv sync # create .venv with the locked dependencies
uv run pre-commit install # gitleaks and the private denylist on every commit| What | Command |
|---|---|
| Lint and format | uv run ruff check . and uv run ruff format --check . |
| Types | uv run pyright |
| Tests (offline, as CI runs them) | uv run pytest |
| Tests while iterating | uv run pytest <path> --no-cov -x |
| Live tests (opt-in, never in CI) | SYNCSHELF_LIVE=1 uv run pytest --live |
| Live write tests (test account only, never in CI) | SYNCSHELF_LIVE=1 SYNCSHELF_LIVE_WRITE=1 uv run pytest --live tests/live/test_writes_live.py |
| Record fixtures (live reads) | uv run python tests/live/record.py hardcover --token-var HARDCOVER_TOKEN_ALICE |
| Record Goodreads RSS fixtures (live reads) | uv run python tests/live/record.py goodreads_rss --user-var GOODREADS_USER_ID_ALICE |
| Record the Goodreads CSV fixture (from a downloaded export) | uv run python tests/live/record.py goodreads_csv --file local/goodreads_library_export.csv |
Calibrate the matcher (live reads; pairs to label in local/) |
uv run python tests/live/calibrate_matcher.py --token-var HARDCOVER_TOKEN_ALICE |
| Attended Goodreads sign-in findings (phase 4b opening test) | uv run python tests/live/goodreads_signin.py --email-var GOODREADS_EMAIL_ALICE --password-var GOODREADS_PASSWORD_ALICE --ws ws://localhost:3000/ |
| All pre-commit hooks | uv run pre-commit run --all-files |
| Check changelog fragments | uv run python scripts/changelog.py check |
| Assemble the changelog (release-prep PR only) | uv run python scripts/changelog.py build --version X.Y.Z --date YYYY-MM-DD --title "Phase N: …" |
Tests block outbound network access. Live tests read credentials from the git-ignored
repo-root .env (or the file in SYNCSHELF_LIVE_ENV, for a worktree); see .env.example.
Live write tests use only HARDCOVER_TOKEN_TEST, a throwaway Hardcover account. They refuse
to run if it's the same account as a profile's token, and take off whatever they shelve.
To stop real values (your domain, emails, LAN hostnames) ever being committed, list them one per
line in the git-ignored local/denylist.txt. The pre-commit hook then refuses any commit that
contains one.
Contributing rules, for people and agents alike, are in AGENTS.md and SPEC §2.10.
Configuration goes in profiles.yaml (git-ignored), modelled on
profiles.example.yaml. SyncShelf reads it from the path in
SYNCSHELF_CONFIG, default /config/profiles.yaml. Secrets never go in the file: each is a
${VAR} reference to the environment (see .env.example) or a *_file path. Startup stops on
bad config with a message naming the key. From phase 3, services.hardcover_url and each
profile's hardcover_token (the Hardcover API token, without its Bearer prefix) are
required. cadence.hardcover_daily_reserve (default 1000) is how many of each profile's 5000
daily Hardcover requests the syncs leave for Claude, and each profile's hardcover_privacy
(public, followers or private, the default) is who sees books SyncShelf adds to its shelves.
From phase 4a, a profile with a goodreads section is read from its Goodreads shelf RSS feed:
goodreads.user_id (the number in goodreads.com/user/show/<id>, quoted) and
goodreads.shelf_map (each exclusive shelf's Hardcover status, 1 to 5) are both required in
it, and rss_key is needed only if the profile isn't public. A profile without the section has
no Goodreads sync. From phase 4b, goodreads.email and goodreads.password let SyncShelf sign
in and run the library export itself. They come as a pair, and need the browser sidecar at
services.browser_ws, so startup refuses credentials without it. With no browser_ws
configured the session export is off for the whole stack, which startup says once, and
Goodreads syncs through RSS and the upload endpoint only. A profile with no password has session
state disabled; giving it credentials moves it to needs_sign_in at the next start, and
taking them away moves it back. A profile's optional ingest_secret lets it upload a Goodreads library
export by hand (below); each profile needs its own, since the secret says whose export it is. The library export runs on its own cadence: cadence.csv_interval_days (default 5) after the
last CSV success, in the next cadence.csv_window (default 02:00-05:00) in that profile's own
timezone, so the slowest Goodreads call happens overnight. A dated upload to the ingest endpoint
counts as a success and pushes the date out. Each profile's feed is read every
cadence.rss_interval (default 60m),
the profiles half an interval apart; a failed read backs off from 1 hour to at most 6, and a
restart picks the schedule up where it left off. The file is the only source of profiles: at
each start, a profile removed from it is marked removed and its Claude connections are revoked,
and a name added back is restored.
Signing in to Goodreads, once per profile (phase 4b). With credentials and a sidecar
configured, ask Claude to run goodreads_sign_in; it acts on your own profile only. If Amazon
asks for a one-time code, Claude asks you for it and passes it to goodreads_verify; if it asks
you to approve the sign-in on a device, approve it there and check sync_status. SyncShelf
drives a plain browser and never works around a bot check: if one appears, it stops and says so,
and you export your library by hand to the ingest endpoint below. A bot check or a rejected
password stays stuck until you run goodreads_sign_in again, which is also how you clear it.
Otherwise SyncShelf signs in again on its own when a scheduled export finds the session expired,
at most once a day per profile, and only while no prompt appears. sync_status shows where the
session stands under goodreads_session, and /healthz carries the same state as an attention
code.
Every /mcp request needs a bearer token; without one it gets 401. server.auth_mode says
where tokens come from:
oauth, the production mode. Claude connects toserver.public_url(for examplehttps://shelf.example.com, served through the reverse proxy) and signs in with SyncShelf as its OAuth server: discovery, the Claude client documents (CIMD), PKCE, and a consent page at/authorize. Cloudflare Access does the login there, so an Access app must cover/authorize, both GET and POST, and pass on itsCf-Access-Jwt-Assertionheader. The mode needsserver.access.team_domain(justhttps://and the team's host),server.access.aud(ACCESS_AUD, the Access app's AUD tag) andserver.consent_hmac_key(CONSENT_HMAC_KEY), and startup names whichever is missing. Each person signs in with the email of their profile; any other email is refused.my_connectionsandrevoke_connectionlist and end a profile's Claude connections. Known issue: browsers may sendOrigin: nullwhen Allow is pressed, which the consent check refuses (#114).dev, for a local instance./mcpaccepts the single static token inserver.dev.token(fromSYNCSHELF_DEV_TOKEN) as the profileserver.dev.profile, so Claude Code or MCP Inspector can connect by sendingAuthorization: Bearer <token>. The token must be at least 32 characters (openssl rand -hex 32). Dev mode refuses to start unlessserver.public_urlis onlocalhostor127.0.0.1, serves no OAuth routes, and logs a warning at every start. That guards only the configured name, so keep a dev instance off the network: publish the port on loopback only (e.g.127.0.0.1:8000:8000), or setserver.listento127.0.0.1:8000.profiles.example.yamlshows how to switch to it.- Absent: no token is accepted, and
server.public_urlmust be onlocalhostor127.0.0.1(#73).
The full deployment guide is docs/runbook.md, which arrives in phase 7. Until then, this
section carries the deployment notes.
Each release tag publishes the image ghcr.io/<owner>/syncshelf:<version> (and :latest)
for amd64 and arm64, built from the Dockerfile. To run it:
-
/dataholds the database, itspre-migration-NNNN.dbsnapshots (taken before each upgrade, none on a fresh install) andgoodreads/, where each profile's Goodreads session lives as<profile>.cookies.jsonat mode 0600 — never in the database, so a copy of the database carries no live session, and nowhere else, since the sidecar keeps no state.SYNCSHELF_DATA_DIRnames a different directory, which is useful outside a container. The container runs as10001:10001, so a bind-mounted host directory must exist and be owned by that user (chown 10001:10001 <dir>), or startup stops naming the unwritable path. A named volume gets the right owner from the image. Startup also stops if the database is newer than the image (an older release started on it). -
/healthz(keep it on the LAN) answers 200 whenever the database does, with"status": "ok"or"attention"and each profile's attention codes (quarantined,hardcover_bug,source_stale, and from phase 4bgoodreads_sessionwhen a profile needs to sign in to Goodreads again), for Uptime Kuma or Home Assistant to alert on. It's 503 only when the database fails, so use it for the container's liveness: a Goodreads CAPTCHA never restarts the service. SyncShelf sends no notifications itself. -
/ingest/goodreads-csv(keep it on the LAN too) takes a Goodreads library export ("Import and export" on Goodreads) for the profile whoseingest_secretit's sent with, and answers 202 at once; the profile's next sync applies it. Sendgenerated_at, when the export was made, or the upload only fills in what SyncShelf hasn't seen yet:curl -H "Authorization: Bearer <ingest secret>" \ -F file=@goodreads_library_export.csv -F generated_at=2026-10-15T08:00:00Z \ http://<lan-host>:8000/ingest/goodreads-csv
-
/config/profiles.yamlis the config. Mount it read-only (:ro); SyncShelf never writes it. Pass the secrets it references as environment variables or*_filepaths. -
The browser sidecar (phase 4b), needed only by a profile with Goodreads credentials. Run the official Playwright image as
playwright run-serveron an internal network: no published ports, no reverse-proxy labels and no volume, since it keeps no state. Pointservices.browser_wsat it, matchingrun-server's default path (/). Keep the image tag in lockstep with theplaywrightversion inpyproject.toml, which is how the client and the browser stay compatible:docker run -d --name browser --network syncshelf --restart unless-stopped \ mcr.microsoft.com/playwright:v1.64.1-noble \ npx -y playwright@1.64.1 run-server --host 0.0.0.0 --port 3000
Then set
services.browser_ws: ws://browser:3000/and sign in once per profile (above). With nobrowser_wsconfigured there is no sidecar to run: Goodreads syncs through RSS and the ingest endpoint, and nothing about the export reachessync_statusor/healthz. -
Listening. The service listens on
server.listen, default0.0.0.0:8000, so Traefik or a published port (-p 8000:8000) reaches it. Keep port 8000 in the container: the image's health check probes127.0.0.1:8000. In oauth mode, attach the container to the reverse proxy's network rather than publishing the port, setserver.forwarded_allow_ipsto that network, and passACCESS_AUDandCONSENT_HMAC_KEY(-e). In dev mode, publish the port on loopback only and passSYNCSHELF_DEV_TOKEN, as in this local example:
docker run -d --name syncshelf \
-p 127.0.0.1:8000:8000 \
-e SYNCSHELF_DEV_TOKEN=<token> \
-v <data-dir>:/data \
-v <config-dir>/profiles.yaml:/config/profiles.yaml:ro \
ghcr.io/<owner>/syncshelf:<version>