zotio gives humans, scripts, and MCP agents local-first search
and auditing, preview-first writes, and bounded, provenance-tagged context
for Zotero libraries.
**Fast proof:** check a bundled library before trying zotio on your own data.
```bash
brew install orgmentem/tap/zotio # or grab a signed binary from Releases
zotio demo # bundled library: 34 classic papers, one genuinely retracted
ZOTIO_DEMO=1 zotio --data-source local items retract-check # check every DOI against Retraction Watch data
```
```console
KEY STATUS TYPE NOTICE DOI DATE SOURCE TITLE
WAKEFLD98 correction correction 10.1016/s0140-6736(04)15715-2 2004-03-06 retraction-watch Ileal-Lymphoid-Nodular Hyperplasia, Non-Speci...
WAKEFLD98 retracted retraction 10.1016/s0140-6736(10)60175-4 2010-02-06 retraction-watch Ileal-Lymphoid-Nodular Hyperplasia, Non-Speci...
FAIR002016 correction addendum 10.1038/s41597-019-0009-6 2019-03-19 publisher The FAIR Guiding Principles for Scientific Da...
Checked 21 DOI-bearing items; 3 update notices flagged; 2 DOI(s) not registered with CrossRef.
```
That is the bundled library catching the retracted Wakefield 1998 MMR paper,
with no Zotero install and no API key (the check queries Crossref live).
`zotio demo --reset` deletes and re-seeds the demo library, restoring it to its original state. To remove it, delete the `demo.db` file whose path the demo report prints as `db_path`.
Zotero itself red-flags a retracted item when you open it, from the same
Retraction Watch data. `zotio` adds what the desktop app does not: corrections
and expressions of concern, a report across a whole library that scripts can
consume, and an exit code CI can fail on.
**Point it at your real library:**
```bash
zotio init # guided setup: detect Zotero, key, first sync, health check
zotio items retract-check # the same check, against your real library
zotio library health --for citation --fail-on high # fit to cite? (exit 11 if not)
zotio items bibcheck thesis.tex --fail-on-unknown # every \cite{} resolves? (exit 11 if not)
```
---
## Why zotio
Zotero's GUI is great for reading and citing. It is painful the moment you need to *operate* on a library at scale: find every article missing a PDF, catch duplicate `\cite{}` keys before a submission, export a week of highlights, keep an Obsidian vault in sync, or hand an AI agent trustworthy context. Existing CLIs and `pyzotero` give you raw API access — then you write the glue, and you own the risk.
`zotio` provides that glue with these properties:
- **Reads are local.** Point `zotio` at your running desktop app: no API key and no cloud round-trip, and it works offline against a synced mirror.
- **Writes are preview-first.** Every mutation shows a plan before it touches anything. Gates cap blast radius; irreversible ops require an explicit opt-in; an append-only journal lets you undo the reversible ones.
- **Context is bounded and provenance-tagged.** Every result says where it came from and how fresh it is, so a human or an agent knows whether to trust it. `zotio` **never calls an LLM**; it assembles and budgets the context, then hands it to the model.
It is the tool you reach for when the GUI gets too manual: **find the problems that bite downstream, fix them safely, ingest with review, and give agents a surface they can trust.**
---
## How it works
Reads stay on your machine. Writes split by intent: **creating a new item (with its attachments/PDFs) prefers the local desktop connector** (`localhost:23119`, no key — the same channel the browser "Save to Zotero" button uses), while **field edits, deletes, enrichment, tag ops, moves, and `collections` create/update route to the Zotero Web API** and need a configured key. The connector path is a preference: `--via auto` uses it only on a personal library with the desktop running, and falls back to the Web API otherwise (group libraries always go to the cloud). For `attachments add` on an existing item, `--via connector` is an explicit desktop route that still needs a Web API key to move the file to its target. Writes are preview-first; item update reads its version from the Web write target at apply time. Supported applied changes update an existing local mirror so a follow-up read can see them without another sync.

| Plane | Backend | Needs a key? |
|---|---|---|
| **Read** | Local Zotero API (`localhost:23119`) + synced SQLite mirror | No |
| **Write — new item** | Local desktop connector (`localhost:23119`) when personal + desktop up; else Web API. New items, attachments, PDFs. | No (connector path) |
| **Write — other items and vault notes** | Zotero Web API (`api.zotero.org`) — edits, deletes, enrich, tags, moves, `collections` create/update, vault write-back | Yes — configured once |
| **External** | CrossRef · OpenAlex · Semantic Scholar · Unpaywall · OpenCitations | No (feeds enrich/import) |
| **Local-only** | Files, desktop launch, vault file sync, introspection | No |
Run `zotio doctor` any time to see connectivity, cache freshness, and a `writes:` line telling you whether write-back is available or read-only.
---
## The flagship: `library health`
One command that answers a real question — *"is this library fit for the next thing I'm going to do with it?"* — in place of six separate audits.

`library health` composes the checks that already exist (citekey conflicts, duplicates, missing metadata, tag drift, broken attachments) into **one ranked, finding-typed report**. You pick what "ready" means with `--for`:
| `--for` | Prepares for | Checks |
|---|---|---|
| `quick` *(default)* | anything obviously broken | citekey conflicts, duplicates, broken attachments |
| `citation` | a manuscript bibliography | missing/duplicate citekeys, citation-core fields, duplicates |
| `systematic-review` | a PRISMA screening corpus | duplicates, screenable metadata (title/abstract), full-text PDFs |
| `vault` | a trustworthy Obsidian/PKM vault sync | missing/duplicate citekeys (note filenames), citation-core fields |
| `all` | a full sweep | every registered check |
```console
$ zotio library health --for quick
Health: needs attention
Scope: library · 846 top-level items (2103 mirrored rows) · source local · synced 1d ago · preset quick
High (13)
[duplicate_candidates] doi="10.1002/bdm.2118" (2 items)
[duplicate_candidates] title="Social psychology" (3 items)
... 11 more
Skipped (precondition unmet)
broken_attachment_file — live check (needs Zotero desktop running); off by default.
Fix: zotio library health --for quick --verify-files
Remediation plan (preview-first)
duplicate_candidates — zotio items duplicates resolve --doi (preview first; add --yes after review)
```
Three properties make it suitable for gating:
- **It gates CI.** `--fail-on critical|high|info|any|none` exits **`11`** when the bar isn't met — drop it in a pre-submission hook. `--require-fresh 24h` exits **`12`** if your local mirror is stale.
- **It reports what it could not check.** A check that needs the desktop app (broken attachments) becomes a **loud skip with a remedy**. If that skip is gate-relevant, the run exits **`9`** (setup required) instead of passing.
- **It points at the real fixer.** Findings carry a `recommended_action` naming the exact existing command (`items enrich`, `items duplicates resolve`, `tags audit fix`). `library health` reports problems; the named commands fix them.
### CI for your bibliography
`--badge` renders any health run as a [shields.io endpoint](https://shields.io/badges/endpoint-badge) JSON artifact — `healthy` green, findings yellow, gate-failure red, `setup required` orange:
```yaml
# .github/workflows/bibliography.yml (excerpt)
- run: zotio sync
- run: zotio library health --for citation --fail-on high --badge > badge.json
# exit 11 fails the job when the bar isn't met; badge.json says why
# publish badge.json anywhere shields can reach (gh-pages, gist, artifact host), then:
# https://img.shields.io/endpoint?url=https://.github.io//badge.json
```
Your thesis or review repo gets a live `bibliography | healthy` badge — and a failing build the moment a citekey conflict or duplicate slips in. Add `--check-retractions` to extend the gate to **retracted papers** (Crossref's Retraction Watch data), and gate the manuscript itself with `zotio items bibcheck paper.tex --fail-on-unknown`. The [zotio-action](https://github.com/marketplace/actions/zotio-bibliography-health-for-zotero) packages this — install, sync, gate, and diff against a baseline so it fails only on *new* problems ([guide](https://orgmentem.github.io/zotio/guide/ci/)).
See it running for real: zotio's own docs deploy publishes a [live badge](https://orgmentem.github.io/zotio/bibliography-badge.json) off the maintainer's real Zotero library ([workflow](https://github.com/OrgMentem/zotio/blob/main/.github/workflows/docs.yml), [guide](https://orgmentem.github.io/zotio/guide/ci/)).
---
## Safe by default: the write engine
Mutation-envelope writes — `items enrich`, `tags audit fix`, `items duplicates resolve`, `items preprint-check fix`, `items create/update/move/delete/add-to-collection`, `collections create/update/delete/move`, and `import apply` — share one plan and result format.

- **Preview is the default.** You get a plan/result envelope with zero changes. `--yes` applies; `--dry-run` always wins.
- **A preview predicts the apply.** It runs the same reads and evaluates the same gates (`--max-changes`, `--allow-destructive`, local input checks) as `--yes`. A preview that apply would refuse prints `ok:false` with `error:{code,message}` and exits with the code apply would use. A destructive preview shows `ok:true` only with `--allow-destructive`; a conflict exits `1` in preview and apply alike.
- **`--dry-run` writes nothing at all.** No Zotero write, no local file or `.lock` sibling, no config save, no mirror write-through. Reads still run, and `--deliver` still routes the command's output. `sync`, `watch`, and `init` cannot preview and reject `--dry-run` with exit 2.
- **`--agent` does *not* auto-apply.** Agent mode sets JSON + non-interactive defaults, but a write still needs an explicit `--yes`.
- **Gates cap the blast radius.** `--max-changes` defaults to 500 (50 under `--agent`); irreversible ops (merge, permanent delete, collection delete, `creators rename`, `creators audit fix`, `vault resolve --keep-vault|--keep-remote`) refuse to run without `--allow-destructive`.
- **Read-your-writes.** Supported mutation-envelope writes update an existing local mirror when they apply; a follow-up mirror read can see the change without another `sync`.
- **Journaled + reversible.** Each applied mutation-envelope run is recorded append-only (`journal list` / `journal show`). `journal undo ` reverses the reversible ops (tag renames, collection membership, `items enrich` DOI/abstract/citation-field/tag writes — which also takes the provenance line back out of Extra — and creates, reversed by trashing the created item; `items delete` trashes and `items restore` runs are reversed by the opposite toggle) and **loudly refuses** the rest (merges, permanent deletes, other field overwrites) rather than guessing. Vault writes use the same `--yes` approval gate but have a separate per-note report and no journal entry.
- **Local-state commands.** Commands that destroy local state preview by default and apply with `--yes`: `auth logout` and `profile delete`. Commands that only create local state apply directly: `auth set-token` and `profile save`.
---
## Import without making a mess
Bulk-import references through a review checkpoint. `import apply` writes only after you review and, if needed, edit the manifest.

```bash
zotio import scan ~/Downloads/papers # read-only: triage new vs duplicate vs attach-candidate
zotio import resolve ~/Downloads/papers > manifest.json # resolve DOIs → editable manifest
# ... review and edit manifest.json ...
zotio import apply manifest.json --dry-run # preview the writes
zotio import apply manifest.json --yes # schema-valid creation via the Web API
```
- **Scan** classifies a folder of PDFs against your existing library — extracting DOIs from filenames or the PDF bytes — so you never re-import what you already have.
- **Resolve** turns findings into an editable JSON manifest, enriching create-entries from CrossRef. This is the human touchpoint.
- **Apply** creates schema-valid items, preview-first, with an explicit `--attach-mode none|linked-file|stored` contract. `stored` uploads the file bytes: `--via connector` hands the item and its file to Zotero desktop in one session, while the direct Web API route is guarded by the file-storage precondition (`--allow-zotero-cloud` to override).
`resolve` fetches metadata by DOI (CrossRef, with an arXiv-to-DOI lookup for preprint PDFs); it does not resolve PMIDs or ISBNs. The one-shot importers take those identifiers directly: `import doi|pmid|arxiv|isbn|url|file|pdf`. They take the destination collection key with `--to`.
---
## Conflict-safe vault round-trip
Keep an Obsidian or Logseq vault in step with Zotero **in both directions** — without ever clobbering your prose.

```bash
zotio vault sync # preview: shows the notes it would write
zotio vault sync --yes # Zotero → one Markdown note per item (idempotent)
zotio vault push --dry-run # your ## Notes region → a managed Zotero child note
zotio vault pull --dry-run # remote note edits → your ## Notes region (fast-forward only)
```
Each note has a **managed region** (frontmatter plus fenced title, abstract, and annotations blocks, refreshed on every sync) and **your region** (`## Notes`, prose preserved untouched). Write-back is fast-forward only: if both sides changed, `zotio` never merges blindly — it writes a **reviewable conflict artifact** under `_vault-zotero-conflicts/` and reports it, so divergence becomes something you resolve on purpose (`vault resolve --keep-vault | --keep-remote | --recreate`), never a silent overwrite. Run `vault audit` for a read-only preflight before any push.
Configure the vault once in `~/.config/zotio/config.toml` (the `--vault` flag overrides it):
```toml
[vault]
root = "~/Vaults/dev" # ~ is expanded; base output dir
notes_dir = "Zotero" # notes land in /
format = "obsidian" # or "logseq"
```
---
## More that the GUI and `pyzotero` don't give you
### Library hygiene, integrity & analytics
- **`items retract-check`** — check every DOI against **Crossref's Retraction Watch data**: retractions, expressions of concern, and corrections, with notice DOIs and dates. Opt into the `library health` gate with `--check-retractions`. *(This one reads the network.)*
- **`collections gaps`** — citation-graph gap analysis: rank the papers your collection cites most that are **missing from your library** (OpenCitations + Semantic Scholar), then `import doi` them. *(Network too.)*
- **`items bibcheck `** — parse `\cite{}`/`@citekey` from `.tex` or pandoc Markdown, or read the Zotero citation fields in `.docx` and `.odt` files, and resolve every key against your library — unknown and ambiguous keys flagged, and an unknown key carries the closest keys in your library as an advisory suggestion when one is close enough to name, so a one-character typo is a named fix rather than a hand-grep of the `.bib`. `--fail-on-unknown` exits 11 for CI. `--check-retractions` also looks up the DOI of each cited item at CrossRef (network) and reports retractions (critical) and expressions of concern (high) at the citing line; a lookup that fails is reported as skipped and exits 9 under `--fail-on`.
- **`tags audit`** — group tags that differ only by case or variant, with item counts and **ready-to-run merge commands**. On a real 840-tag library it surfaced 53 duplicate groups in one pass.
- **`library stats`** — a one-command dashboard: items by type and year, top venues, PDF coverage, and optional intake buckets with `--added-by month|year`.
- **`items audit`** — count and list items missing PDFs, abstracts, DOIs, tags, or citation-core fields; `--verify-files` checks PDFs actually exist on disk.
- **`items duplicates`** — detect likely duplicates by DOI or title (attachments/notes excluded), then `duplicates resolve` to merge safely.
- **`items citekey-conflicts`** — find missing or duplicate Better BibTeX keys before they break a LaTeX build.
- **`items find`** — resolve a DOI, ISBN, PMID, arXiv ID, citation key, URL, OpenAlex work ID, or exact title against the local mirror before importing a duplicate. A title matches whatever its case, whitespace, quote and dash styling, or trailing full stop, so a title pasted from a reference list resolves. When the lookup as a whole matches nothing, the closest titles or keys in the library are listed separately with a score, so a typo is distinguishable from an absent paper; they are suggestions to confirm, never matches. The selectors are OR-ed, so a run that matched on another selector reports no near rows for the one that missed.
### Reading & synthesis
- **`items summarize`** — assemble a bounded, synthesis-ready bundle for an item or collection (citation + abstract + your annotations + a capped fulltext excerpt + known metadata gaps + a synthesis prompt) and hand it to any LLM. `zotio` does the budgeting; it never calls the model. Ask a question with `--focus "TERMS"` and each item instead carries the ranked PDF-text passages and annotations that match, with item and attachment keys — `--max-chars` caps the passages and `--max-annotations` the annotations — so a finding on page 30 beats the introduction. An item with no synced PDF text says so rather than reporting "no match".
- **`annotations export` · `annotations timeline` · `annotations search`** — pull highlights and notes as Markdown or JSON (`annotations export --output|-o` writes a file), ordered by date or searched by text.
- **`reading-list`** — a `to-read` tag queue with an `add` → `start` → `done` lifecycle for triaging what to read next.
- **`items note-template`** — generate a pre-filled Obsidian/Logseq reading note for an item.
- **`items open`** — print or launch a `zotero://` deep link to an item, collection, or PDF (cross-platform).
- **`library wrapped`** — your Zotero year in review: hero counts, monthly rhythm with peak highlight, a type-mix ratio bar, highlights (busiest day, longest streak, deep cut, most-annotated paper, top tag), top venues and full author names, PDF coverage — plus a shareable SVG card in four layouts via `--card-style overview|rhythm|picks|cycle` (the card below is `cycle`, crossfading through all three):
### Enrichment (reads external APIs, writes Zotero)
- **`items enrich`** — fill missing DOIs and abstracts from **CrossRef → OpenAlex → Semantic Scholar**; fill missing citation fields from **CrossRef**; attach open-access PDFs from **Unpaywall**; and record provenance in each item's Extra field. `--missing-citation` fills only blank fields supported by the item type, including provider-backed volume, issue, and pages. `--missing-mesh` proposes PubMed MeSH descriptors as automatic `mesh/` tags for items with a PMID or a DOI that PubMed knows, previews by default, and reports a PubMed outage as an error. `--validate` reports title, year, and render-field discrepancies against CrossRef, plus DOI registration failures from OpenCitations, without writing.
- **`items preprint-check`** — find arXiv preprints that now have a published CrossRef record; `preprint-check fix` upgrades them with the journal DOI — preview-first, journaled, and it never overwrites a conflicting DOI.
### Export & reproducibility
- **`items bibliography`** — render a shared scope with a CSL style, or export it as CSL-JSON, BibTeX, BibLaTeX, or RIS. CSL-JSON uses unique Better BibTeX citation keys for Pandoc and Quarto. `--manuscript paper.tex` (or a `.docx`/`.odt` written with the Zotero plugin) exports only the items a manuscript cites — in first-citation order for the export formats, while a CSL-rendered `bib` keeps its style's sort — and refuses (exit 11) on unknown or ambiguous citekeys. `--scope` accepts AND-joined cohorts such as `collection:KEY AND tag:NAME`.
- **`collections export`** — a whole collection and all its subcollections as one BibTeX, RIS, or CSL-JSON file. Items from the subtree are included recursively; the file does not encode collection hierarchy.
- **`export snapshot`** — a reproducible, resumable, fully paginated export (JSONL by default; `--format bibtex|ris|csljson`) with a `