bookfusion-api
Direct CLI access to the BookFusion mobile JSON API (https://www.bookfusion.com) — a fast, reliable
replacement for browser-based scraping. Unofficial: reverse-engineered from the Android app; the
server API is unversioned and may change; use may conflict with BookFusion's ToS. It identifies itself
via the User-Agent (bookfusion-api-skill/...) so usage is attributable to this skill.
Quick start
# Credentials come from env / file / sops-nix (see below). Log in once (token is cached):
${CLAUDE_SKILL_DIR}/scripts/bookfusion.sh login
# Read your library (SAFE, default-allowed):
${CLAUDE_SKILL_DIR}/scripts/bookfusion.sh searchUserBooks --data '{"query":"kubernetes","per_page":20}' --pretty
# List every command and its danger tier:
${CLAUDE_SKILL_DIR}/scripts/bookfusion.sh list
Command names are the API operationIds. Full list + bodies: references/endpoints.md.
Full request/response schemas: references/openapi.yaml.
Danger tiers (bookfusion list shows each command's tier)
- SAFE — reads/searches. Default-allowed. This is the scraping replacement.
- WRITE — non-destructive mutations (create/update, upload, reading position, kindle, borrow, join/leave). Default-allowed.
- DANGEROUS — all deletes and
updateUserBook(see "Editing book metadata" — it is the only path to change a book's title/tags/shelves/series, so even a routine edit needs--dangerous). Refused unless--dangerousis passed (exit code 4). - EXCLUDED —
createReaderSubscription(payment) anddisconnectFacebookare intentionally NOT wrapped. (No account-delete endpoint exists in the API.)
# refused without the flag:
${CLAUDE_SKILL_DIR}/scripts/bookfusion.sh deleteUserBook --id 12345
# proceed explicitly:
${CLAUDE_SKILL_DIR}/scripts/bookfusion.sh deleteUserBook --id 12345 --dangerous
Passing parameters
- Path params:
--<name> value—--id,--number,--slug,--book_id,--categoryId. - Query params:
--email a@b.c(used byauthChallenge). - Body:
--data '<json>',--data-file PATH, or--data-stdin. Missing body defaults to{}. - Multipart (
createHighlight,updateUserBook,finalizeBookUpload,updateUserProfile):--data= JSONpayloadpart,--file PATH= optional binary part (the CLI names the binary part correctly per endpoint —binaryforcreateHighlight,filefor the rest). - Book covers:
--cover PATHsends the image as thecoverpart (afile-part cover is ignored by the server).uploadBook --file book.pdfdoes init→S3→finalize and, by default, auto-renders + sets a first-page cover for cover-less formats (PDF/CHM/…; EPUB/MOBI/AZW3 auto-cover). Seereferences/covers.md. Opt out with--no-cover; override with--cover.
Editing book metadata (updateUserBook) — merge/replace semantics
updateUserBook is DANGEROUS and is the only way to change a book's metadata (title, summary, tags,
bookshelves, series, authors, publisher_name, published_at). Its update rules (verified live) matter:
- An omitted field is left unchanged — you cannot clear a field by omitting it.
- A sent scalar (
title/summary/language/publisher_name/published_at) overwrites the old value. (publisher_nameandpublished_atwritability was verified live — the Android app's own update model omits them, and even omitstags, yet the server accepts all three.) - A sent array (
tags,bookshelf_ids,authors,series) replaces the entire set. So{"bookshelf_ids":[5]}does not add shelf 5 — it removes the book from every other shelf. To add a shelf, read the book's currentbookshelves[].id, union your new id in, and send the full list. - There is no structured
isbnfield anywhere in this API — confirmed by decompiling the Android app (v2.23.8): noisbnliteral in any book model. The ISBN you see in the website editor is a web-only field this mobile API neither returns nor accepts, so it cannot be read or cleared from here. An ISBN that does show up in this API lives as free text intags(only editable via thetagsarray).creation_tokenforcreateSeries/createBookshelfis auto-generated when you omit it.
Bulk changes — batch (one login, one JVM)
Each CLI call cold-starts a JVM (~2 s), so many single writes are slow. For bulk work use batch, which
reads JSONL from stdin (one op per line), logs in once, and prints one compact result line per op:
printf '%s\n' \
'{"command":"updateUserBook","id":10798945,"data":{"tags":["css","debugging"]}}' \
'{"command":"updateUserBook","id":10798946,"data":{"bookshelf_ids":[96027]}}' \
| ${CLAUDE_SKILL_DIR}/scripts/bookfusion.sh batch --dangerous
# -> {"line":1,"command":"updateUserBook","id":"10798945","status":"ok","http":200}
# stderr: batch: 2 ops, 2 ok, 0 error
Response bodies are suppressed (results only). Default is continue-on-error (exit non-zero if any op
failed); use --stop-on-error to halt at the first failure. A per-line DANGEROUS op still requires the
batch-level --dangerous. Re-run failed lines to resume — the tool invents no server-side idempotency.
For single writes, --quiet likewise suppresses the ~1 KB response echo (prints only ok: … on stderr).
Request validation (fast feedback, before the round trip)
Every request body (and path/query params) is validated against references/openapi.yaml
before it is sent, so mistakes surface locally instead of as an opaque server error after a wasted call.
- Safe mistakes are auto-fixed and reported as
fix:lines on stderr: numeric-string → integer/number ("20"→20),"true"/"false"→ boolean, a scalar where an array is expected is wrapped ("5"→[5], then its element is coerced too), and enum values are snapped to the correct case ("Book"→"book"). The corrected value is what gets sent. - Unfixable mistakes are
error:lines and block the request (exit2) before any network call: a missing required field (reported with its expected type/enum — never invented) or a type that can't be safely coerced (e.g."abc"for an integer). Fix them in one shot from the messages. - Unknown fields and unknown enum values are
warn:only and still send — the spec is reverse-engineered and may be incomplete; a(did you mean 'query'?)hint is added when a close match exists.fix:notes never print secret values.
# Validate offline and print the effective request — never sends, never logs in:
${CLAUDE_SKILL_DIR}/scripts/bookfusion.sh searchHighlights --dry-run --data '{"book_id":"111","page":"2"}'
--dry-run— validate + print the request, do not send (exit2if it has hard errors, else0).--force— send even when validation found hard errors (still coerces and prints them).--no-validate— skip validation/coercion entirely (e.g. if the spec is wrong for a given endpoint).
Note: a bare {} body now fails fast for commands that have required fields (e.g. addBookBookmark,
updateBookReadingPosition) instead of round-tripping to a server error — supply the fields, or use --force/--no-validate.
Output & context economy
Designed to keep the agent context small and secret-free:
- Lists → TSV by default (
--format auto), which is far more compact than JSON. Use--format jsonlfor line-filterable JSON, or--format json(with--pretty) for full structure. - Large responses go to a temp file (over
--max-bytes, default 32 KiB): stdout then carries only the file path, with a short redacted preview +records=/bytes=summary on stderr.Read/rg/awkthe file when you need the data.--stdoutforces small results inline;--out PATHpicks the file. - Credentials never reach the context. Any response containing a
token/password/… is always written to a0600file (full value there) and only a redacted preview is shown. Sensitive keys are masked as***REDACTED***everywhere they would otherwise print.
Credentials (never printed; first non-empty wins) — details in references/auth-and-secrets.md
| Credential | Order |
|---|---|
| username | --username-file → $BOOKFUSION_USERNAME → $BOOKFUSION_USERNAME_FILE → ~/.config/sops-nix/secrets/bookfusion_username → --username (warns) |
| password | same order with bookfusion_password |
| token | $BOOKFUSION_TOKEN → --token-file → $BOOKFUSION_TOKEN_FILE → on-disk cache (auto-login otherwise) |
Do not paste passwords inline in a shared shell — prefer env, a 0600 file, or sops-nix.
bookfusion login refreshes the cached token; bookfusion logout clears it.
Configuration (env or flag)
| Setting | Flag | Env | Default |
|---|---|---|---|
| API base URL | --base-url | BOOKFUSION_BASE_URL | https://www.bookfusion.com |
| Rate limit (req/s) | --rate | BOOKFUSION_RATE_LIMIT | 5 |
| Output format | --format | BOOKFUSION_FORMAT | auto (TSV for lists) |
| Inline size cap (bytes) | --max-bytes | BOOKFUSION_OUTPUT_MAX_BYTES | 32768 |
Rate limiting is enforced before every request via a file-locked timestamp, so it holds across
separate CLI invocations too (default ≈ one request per 200 ms). Set a lower --base-url for tests.
Throughput note: sequential single-command runs are bounded by the ~2 s JVM cold-start per invocation,
not by the rate limit (so the effective rate is ~0.5 req/s, well under the 5 req/s cap). For many writes,
use batch (one JVM + one login) — that is where the rate limit actually governs pace.
Exit codes
0 ok · 2 usage error (incl. validation hard-fail / --dry-run with errors) · 3 auth failure · 4 blocked (DANGEROUS without --dangerous, or EXCLUDED) · 5 HTTP error (status + body printed) · 6 I/O / network error.
First run
The very first invocation (cold cache) bootstraps the Kotlin toolchain via nix and resolves the two
Maven dependencies, printing JVM / dependency-resolver / nix noise to stderr. That is normal one-time
setup, not an error — judge success by the exit code, not by stderr volume.
Editing this skill
The installed files under ~/.claude/skills/bookfusion-api/ are read-only symlinks into the Nix store
(home-manager). Do not edit them in place. Make changes in the source at
~/.config/nix-darwin/modules/ai/_files/skills/bookfusion-api/, then re-activate with just switch.
Tests
bash ${CLAUDE_SKILL_DIR}/tests/integration_test.sh runs the client against a local stdlib mock
(--base-url http://127.0.0.1:PORT) — no real BookFusion account needed. The mock's canned responses double
as documentation of the real API's behavior (see the # REAL BEHAVIOR: comments in tests/mock_server.py).