Architecture
Agent / user → [ zotero skill | zot CLI ] ─┬─ read → local HTTP API /api/… (GET, fast) └─ write → POST /zotero-agent (bridge plugin, arbitrary JS)Three layers, each doing the one thing it is good at.
Why the write path is a plugin
Section titled “Why the write path is a plugin”Zotero ships a local HTTP API at http://localhost:23119/api/… that mirrors
the Web API. On Zotero 7 / 9.x it is read-only by design: POST /items or
POST /collections returns 400 "Endpoint does not support method", and there
is no preference to enable writing. So the API is perfect for fast reads and
useless for writes.
Other options were evaluated and rejected:
- Better BibTeX JSON-RPC is alive but writes almost nothing (only
autoexport.add,collection.scanAUX). Fine for search/export, not for editing the library. - The BBT debug-bridge that older automation relied on no longer exists in current Better BibTeX (9.x).
The only complete local write path is code running inside Zotero’s
privileged context, calling Zotero.Item…saveTx() and friends directly. That is
exactly what a bootstrap plugin can do. The bridge is a ~200-line plugin that
registers one endpoint, POST /zotero-agent, which runs a supplied async JS body
in-process and returns the result as JSON. This mirrors the well-known
zotero-api-endpoint plugin pattern.
The three layers
Section titled “The three layers”Layer 0 — the bridge plugin
Section titled “Layer 0 — the bridge plugin”plugin/zotero-agent-bridge/: the endpoint. Token-protected, origin-guarded,
loopback-only. Fully general — every recipe in the
JS reference works over it unchanged.
Requires Zotero 7+ (tested through 9.x).
Layer 1 — the zot CLI
Section titled “Layer 1 — the zot CLI”Stdlib-only Python. Reads hit the fast local API (search, get, cite, pdf,
collections, tags, recent, bib, with --all pagination). Higher-level
operations are built cleanly on the Zotero JS API (export, missing, author,
stats, annotations, related, lint) — as are the write/edit verbs (add,
dedupe, tag, set, move, collection, note), each safe-by-default
(refusing non-interactive writes without --yes). exec is the raw escape hatch
(with --dry-run); backup snapshots the DB; ping/init set up.
Layer 1b — the PDF engine
Section titled “Layer 1b — the PDF engine”src/zotero_agent/pdf/: the only part that opens a file rather than talking to
Zotero, behind the [toc] extra and used by toc. PyMuPDF is reached solely
through require_pymupdf(), so the rest of the package stays importable without
it. Inside, pagemap is deliberately pure — the printed-page-to-physical-page
arithmetic is the subtle part, and keeping it free of the engine is what makes it
testable without a fixture PDF — while scan reads a document and outline
reads, validates and writes the tree.
Config precedence: flags > ZOTERO_AGENT_* env >
~/.config/zotero-agent/config.json — see
Configuration.
Layer 2 — the agent skill
Section titled “Layer 2 — the agent skill”skill/: teaches an agent to drive zot, including the safe workflow for
bulk/destructive operations. The same operations are also available over MCP via
zot mcp — see AI agents.
Anti-duplication
Section titled “Anti-duplication”The canonical sources live once: the plugin in plugin/, the CLI in src/, the JS
recipe book in skill/references/recipes.md — docs link to it rather than restating
it. The MCP tools call the same command functions the CLI does, so the two surfaces
cannot diverge.
Distribution follows the same rule: one artifact per surface, one version for all.
The CLI, the MCP server and the skill ship in the PyPI package (zot skill install
unpacks the skill), while the plugin XPI is published only as a GitHub Release
asset. Zotero keeps it current from updates.json, which the release workflow
generates from the tag; the build stamps the package version into the plugin, so a
released XPI can never disagree with the CLI it answers — and zot ping prints both
versions. See Install.
Homebrew adds an install route without adding an artifact. The formula is
generated from the sdist a release publishes to PyPI — scripts/gen_homebrew_formula.py
fills in the url, the sha256 and a hash-pinned lock of the optional extras. The
tap therefore mirrors PyPI instead of building anything of its own, and no hash is
ever edited by hand: that manual bump is precisely what made the project’s first
tap unmaintainable.