---
title: Reading Queue (Marketplace plugin — save links, auto-summary, read aloud)
---

# Reading Queue (Marketplace plugin)

## What it is

The **Reading Queue** is a place to save things to read later. Save a web link,
paste some text, or pick a PDF — it summarizes the content with AI so you can skim
the key points without reading the whole article. You can highlight passages, keep
notes, tag and search items, and have the summary or the full article **read aloud**,
one item or your whole unread list back-to-back.

It shows up in the sidebar as **"Reading"** once installed.

## Where to find it

You install it from the **Marketplace**; after that it appears as the **"Reading"** item in the sidebar, next to your other projects and plugins. Everything the plugin offers lives inside that screen — a list pane and a detail pane, the composer for adding an item, the summary card above the article body, the highlight and note tools, and the play-all control for reading your unread list aloud.

## How it behaves

### It is a plugin, not a built-in

This is the single most important fact for anyone changing it: **Reading Queue is a
first-party, Marketplace-only, sandboxed plugin** living at
`src/plugins/reading-queue/`. It is _not_ core app code.

- **Users install it from the Marketplace.** It is deliberately excluded from the app
  bundle (`BUILTIN_EXCLUDE` in `plugin-loader.ts` **and** `scripts/build-plugins.mjs`,
  which must mirror each other), so the core app stays lean.
- **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. Same model as
  Foundry (`prdstack`) and RepoGuard.
- **It reaches the host only through `window.AgentMC`** — the `db`, `ai`, `http`, `tts`,
  `toast`, `shell`, `settings`, and `theme` namespaces. It cannot import
  `src/renderer/**` or `@shared/**`.

It used to be a built-in "virtual project" (`__reading_queue__`) gated behind a
Settings → Lab toggle. That native implementation — the renderer feature, the
main-process backend, the `reading-queue:*` IPC channels, the CLI routes, and the
gating — **was deleted in full on 2026-07-22.** There is no fallback and no Lab
toggle any more. If you are looking for `ReadingQueueView`, `readingQueueEnabled`,
or `READING_QUEUE_PROJECT_ID`, they are gone on purpose.

### How it stores data

The plugin owns a `reading_items` collection declared in its manifest
(`storage.collections`) — the host creates and evolves the table. Deletes are **soft**
(`is_deleted` 0/1) so Undo is just flipping the flag back.

The old native table, `reading_queue_items`, is a **tombstone**: it still exists in the
database and its historical migrations are untouched, but nothing reads or writes it.
Existing rows were deliberately **not** migrated into the plugin — the migration was a
"start fresh" decision. The table is never dropped (the migration ledger is
additive-only, and dropping it would destroy user rows). It stays classified `'merge'`
in `all-table-merge.ts` so a backup still round-trips those old rows.

### Read-aloud and cost

Read-aloud runs on the plugin `tts` capability: a permission-gated bridge method whose
host-side implementation (`synthesizeForPlugin`) enforces a **per-plugin daily spend
cap** and books the cost under the plugin's own label. That cap is **$5 per plugin per
calendar day, shared with the plugin's AI calls** — read-aloud and generation draw on the same
five dollars — and it resets at local midnight; see
[Plugin Bridge Capabilities](plugin-bridge-capabilities.md) for the full rule. The plugin receives audio bytes
and plays them itself — it does not drive the app's `TTSPlayer`, and the old
`reading-item-<id>` pseudo-session coupling no longer exists.

### Behavior checklist — what "working" means

These are the acceptance criteria to re-check after any UI change.

They replace the old **FR-1..17** list, which lived on this page until the migration
retargeted it to the plugin. That list was written against the native React UI — it
named `SectionHeader`, `CenteredSpinner`, `Pill` and `max-w-prose`, none of which the
sandbox can import (`the-sandbox-may-not-import-core`) — so each item is restated here as **user-visible
behavior**. The original FR numbers are kept so older notes still resolve; the range
was never contiguous, and only 1, 2, 5, 6, 9, 10, 11, 12, 13, 14 and 15 ever existed.

| #     | Behavior                                                                                                                                                                                                    | Where                                |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| FR-1  | A bare domain (`theverge.com/article`) is normalized to `https://` before saving. Multi-line input always saves as text, never a link. A live hint reads "Saving as: Link" / "Saving as: Text" as you type. | `ui/lib/url-detect.js`               |
| FR-2  | For a link item, the source URL in the detail header opens in the system browser.                                                                                                                           | `ui/views/detail.js`                 |
| FR-5  | There is no permanent "summarized" pill — `ready` is the silent steady state. `pending` shows a "Summarizing" pill; `failed` shows a "Failed" pill beside a **Retry** button.                               | `ui/views/list.js`                   |
| FR-6  | Both panes show a loading state while fetching, never an empty state.                                                                                                                                       | `ui/views/app.js`, `views/detail.js` |
| FR-9  | The list header reads "Reading Queue" with an "N unread" subtitle.                                                                                                                                          | `ui/views/list.js`                   |
| FR-10 | With no API-key account configured, a notice points at Settings → Accounts.                                                                                                                                 | `ui/views/list.js`                   |
| FR-11 | Play-all reports live progress ("Stop, playing 2 of 5") and the playing row shows a pulsing dot.                                                                                                            | `ui/views/list.js`, `ui/styles.css`  |
| FR-12 | The summary renders as a hero card above the body; the full text is collapsible and starts collapsed when a ready summary exists.                                                                           | `ui/views/detail.js`                 |
| FR-13 | Delete happens immediately with an **Undo** toast — no confirm dialog.                                                                                                                                      | `ui/views/app.js`                    |
| FR-14 | Row actions reveal on keyboard focus as well as hover; Arrow Up / Arrow Down move focus between rows.                                                                                                       | `ui/views/list.js`, `ui/styles.css`  |
| FR-15 | On a narrow viewport the layout is a single-pane stack (list → tap → detail with Back), never a side-by-side split.                                                                                         | `ui/views/app.js`                    |
| —     | The composer shows "Add (Ctrl+Enter)" and a labelled "PDF" button, and the empty state explains what the queue is for.                                                                                      | `ui/views/list.js`                   |

**Two behaviors cannot be checked without spending money**: a summary actually
reaching `ready`, and read-aloud actually producing audio. Verify those by hand in a
dev instance, and note the summary path needs a **stored API-key account** — an OAuth
login does _not_ satisfy `ai.isConfigured()`, because the Messages API cannot use an
OAuth token.

## For agents

### How the pieces fit

| Concern    | Where                                                                              |
| ---------- | ---------------------------------------------------------------------------------- |
| Manifest   | `src/plugins/reading-queue/manifest.json`                                          |
| UI shell   | `ui/index.html`, `ui/plugin.js`, `ui/styles.css`, `ui/views/`                      |
| Data layer | `ui/lib/data.js` (over `AgentMC.db`)                                               |
| Ingest     | `ui/lib/ingest.js` — URL via `AgentMC.http`, text, PDF via vendored `pdf.min.mjs`  |
| Summaries  | `ui/lib/summarize.js` (`AgentMC.ai.generateStructured`)                            |
| Read-aloud | `ui/lib/audio.js` (`AgentMC.tts.synthesize`, played in the plugin's own `<audio>`) |
| Pure logic | `ui/lib/highlights.js`, `url-detect.js`, `search.js`, `playqueue.js`               |
| Tests      | `tests/unit/plugins/reading-queue/`                                                |

Notable consequences of the sandbox model:

- **Search runs in JS**, not SQL — the plugin `db` has no LIKE/FTS, so the UI fetches
  non-deleted rows and filters them in memory (`ui/lib/search.js`).
- **SSRF protection is the host's job.** `AgentMC.http.fetch` enforces the policy in
  main; the plugin only does a cheap `http:`/`https:` check for UX. That bridge returns
  a plain serializable object, never a `Response` (a `Response` cannot cross
  `contextBridge`).
- **PDFs are parsed in the webview** with a vendored pdf.js, not in main.
- **Summaries never throw** — a failure writes `summary_status: 'failed'` plus a
  humanized `summary_error`, so one bad item cannot break the list.
- **No credentials are reachable.** The plugin detects configuration via
  `AgentMC.ai.isConfigured()` / `AgentMC.tts.isAvailable()`, never by reading a key.

### Changing it

Read [reading-queue-contract.md](../../.claude/memory/contracts/reading-queue-contract.md)
first — it holds the invariants (`core-never-re-grows-a-reading-queue` … `stop-settles-the-in-flight-play`) and the tests that enforce them. The two
that catch the most mistakes:

- **`core-never-re-grows-a-reading-queue`** — core must never re-grow a Reading Queue. New capability goes in the
  plugin's `ui/`.
- **`marketplace-only-never-bundled`** — the bundle-exclude must stay declared in all three mirrored sites, or the
  plugin ships inside the app bundle.

There is no unit harness for the webview DOM; verify UI behavior by hand in the dev instance.
The pure logic and data layer are covered
by `tests/unit/plugins/reading-queue/`.

## Related

[plugin-marketplace.md](plugin-marketplace.md) covers the Marketplace this plugin is installed from and the sandbox model its UI runs inside, while [plugin-bridge-capabilities.md](plugin-bridge-capabilities.md) documents the host capabilities a sandboxed plugin may reach — the `AgentMC` namespaces this plugin's data, summary, HTTP and read-aloud layers are built on. The invariants a change to it must never break, together with the tests that enforce them, are locked in [reading-queue-contract.md](../../.claude/memory/contracts/reading-queue-contract.md).
