---
title: Books (built-in plugin)
---

# Books (built-in plugin)

## What it is

**Books** is a personal reader for **EPUB and PDF** books you own. Import a book from
your computer and it lands on a shelf; open it to read. EPUBs get a paginated reader
with a table of contents and **reading settings** (font, size, line spacing, margins,
justify, and a light/sepia/dark theme); PDFs get a page-by-page pdf.js reader. Either
way it remembers your place per book. Highlights, notes and AI study tools are
**planned (Phases 3–4), not yet built**. It shows up in the sidebar as **"Books"** once
the app loads.

### It is a plugin — but a bundled built-in, not Marketplace-only

Books is a **first-party, sandboxed plugin** living at `src/plugins/books/`, but
unlike a Marketplace-only plugin (e.g. Reading Queue) it is **bundled into the
app**: it is not in `plugin-loader.ts`'s `BUILTIN_EXCLUDE`, so it ships and loads as a
built-in with no separate install.

- **Its UI is hand-authored vanilla JS** served into an isolated webview — no React, no
  bundler, no build step, and no access to the app's component kit. It reaches the host
  only through the `window.AgentMC` bridge (its `db` namespace); it cannot import
  `src/renderer/**` or `@shared/**`.
- **Strict CSP.** The webview runs under `script-src 'self'` with no `unsafe-inline` —
  every handler is attached with `addEventListener` in `plugin.js`, so no inline handler
  is ever needed (deliberately stricter than its sibling plugins).

## Where to find it

**Books** appears as its own entry in the sidebar once the app loads — there is nothing to enable
and no separate install, because it ships inside the app as a built-in plugin. Open it to see your
shelf, import a book from your computer, then click a book to start reading.

## How it behaves

### Reading a book (the reader engine)

Opening a book mounts a **vendored [foliate-js](../../src/plugins/books/ui/vendor/foliate-js/VENDOR.md)
fork** (7 modules, pinned by SHA) in the webview. The subtle part is HOW chapters are
served — same-origin, never `blob:`:

- The main process already unpacked the EPUB at import and serves its files under a
  scoped `@data` path (`/plugin/books/@data/<dir>/<href>`), each response carrying a
  strict `script-src 'none'` CSP.
- foliate is driven over those **same-origin** files: `reader-engine.js` uses foliate
  only to PARSE (spine, table of contents, CFI) via a `fetch`-backed loader, then
  overrides each `section.load()` to return the same-origin `@data` URL. foliate's
  native loader would mint a `blob:` URL per chapter, which the plugin CSP floor forbids
  (`frame-src 'self'`) — and a blob doc carries no response CSP, so the same-origin path
  is also the stricter one.
- Your place is saved as `{ sectionIndex, progression, cfi }` in the book's `position`
  column (throttled, and when you leave), and restored on open (precise CFI first, then
  a clamped section index). Fixed-layout EPUBs are refused with a message (that renderer
  is not vendored).
- **Reading settings** (the "Aa" panel) — font, size, line spacing, margins, justify,
  and a light/sepia/dark theme — become a stylesheet (`lib/typography.js` `cssFromPrefs`,
  html/body `!important` so a book's own CSS can't win) applied via
  `view.renderer.setStyles`, which foliate re-applies on every chapter load. They persist
  in the plugin's KV storage (`amc.storage`, the `storage` permission) and are normalized
  on read, so a stale value can never break the reader.

**PDFs take a separate path** — they bypass foliate entirely. The importer stores the
file as `book.pdf` and validates it in the main process via **pdf.js** (page count +
metadata, with scripting and eval off); a password-protected, empty, or corrupt PDF is
refused with a friendly message and no half-imported book. The reader **fetches** those
bytes same-origin from the `@data` route (never navigating to them) and draws the
current page onto a canvas with
[vendored pdf.js](../../src/plugins/books/ui/vendor/pdfjs/VENDOR.md) — one visible page
at a time, fit to width, at a capped resolution so a very large page can't exhaust
memory. Page turns use the same arrow / on-screen controls, your page is saved as
`{ pdfPage }` (in the same `position` column EPUBs use for their CFI), and the
EPUB-only contents + "Aa" controls are hidden. A PDF shows a placeholder cover on the
shelf (page-1 thumbnails are a later polish). Because Books is a **built-in** plugin its
CSP already allows what pdf.js needs (the same-origin worker + eval), so no CSP change
was required.

Full invariants + the tests that lock them:
[books-plugin-contract.md](../../.claude/memory/contracts/books-plugin-contract.md).

### How it stores data

The plugin owns a `books` collection declared in its manifest (`storage.collections`)
— the host creates and evolves the table. Each row records the book's `title`,
`author`, `format`, on-disk `dir`, `cover_path`, `spine` (JSON), `added_at`, a
`progress` fraction, the exact reading `position` (JSON — `{ sectionIndex, progression,
cfi }` for an EPUB, `{ pdfPage }` for a PDF), and `last_opened_at`. No PDF-specific
column was needed (the page count is read live from the document at open). New fields are added **additively** — a manifest column
plus a `plugin.version` bump, and the host runs `ALTER TABLE ADD COLUMN` on upgrade
(existing books untouched). Highlights and notes get their own collections in a later
phase.

### Usage telemetry

Books is tracked in `FEATURE_REGISTRY` (id `books`, status `instrumented`) — importing
a book emits an `imported` `feature_events` datum so the feature's usage is counted,
like every other tracked surface. Only the anonymous action is recorded; no book
content or personal data is sent.

## For agents

### How the pieces fit

| Concern        | Where                                                         |
| -------------- | ------------------------------------------------------------- |
| Manifest       | `src/plugins/books/manifest.json`                             |
| UI shell       | `ui/index.html`, `ui/plugin.js`, `ui/styles.css`              |
| Shelf view     | `ui/views/shelf.js`                                           |
| Reader view    | `ui/views/reader.js` (chrome + table of contents)             |
| Reader engine  | `ui/lib/reader-engine.js` (foliate glue + position)           |
| PDF reader     | `ui/lib/pdf-reader.js` (pure page/scale) + `plugin.js` render |
| PDF engine     | `ui/vendor/pdfjs/` (pinned pdf.js + `VENDOR.md`)              |
| Book-data URLs | `ui/lib/urls.js` (validated `@data` builder)                  |
| Reading engine | `ui/vendor/foliate-js/` (pinned fork + `VENDOR.md`)           |
| Import (main)  | `src/main/services/books/import-book.ts` (EPUB + PDF)         |
| Data layer     | `ui/lib/data.js` (over `AgentMC.db`)                          |
| Escaping       | `ui/lib/escape.js`                                            |
| Serving + MIME | `src/main/services/plugin/plugin-server.ts`                   |

### Changing it

Books is a sandboxed webview plugin: new capability goes in its `ui/`, never in core
`src/renderer/**` or `src/main/**` (the main-process touchpoints the reader needs are
the `.xhtml`/`.pdf` content-type map in `plugin-server.ts` and the import parse in
`import-book.ts`). The pure logic + engine mapping
have node unit tests (`tests/unit/plugins/books-*.test.ts`); the full
open→render→navigate→resume loop is covered by `tests/e2e/ui/books-reader.spec.ts`, and
otherwise verify UI behavior by hand in the dev instance. Because Books is bundled (not
Marketplace-only), it must **not** be added to `BUILTIN_EXCLUDE`, or it would stop
shipping inside the app. Invariants are locked in
[books-plugin-contract.md](../../.claude/memory/contracts/books-plugin-contract.md).

## Related

- [reading-queue.md](reading-queue.md) — a sibling reading plugin, but Marketplace-only
