zotgo documentation
zot is a single, zero-dependency Go binary that drives a running
Zotero 7+ through its own HTTP contracts. It never
opens zotero.sqlite.
- Getting started — install, enable the Local API, and
zot doctor. - Reading your library —
list,show,search,collections,stats,export. - Writing — create and edit items, collections, and tags.
- Endpoints & profiles — the local Zotero vs. the hosted Web API (
--web). - Machine-readable output —
--json/--jsonl/--rawfor scripts. - Zotero API reference — the verified HTTP contracts zotgo relies on.
Outstanding and planned work lives in the issue tracker; released
changes are in the changelog. Contribution conventions are in
AGENTS.md.
Getting started
Requirements
zotgo drives the running Zotero 7+ desktop app over HTTP. It has no
offline/SQLite mode by design — reaching into an application's database is the
architecture it exists to avoid. Reads go through Zotero's Local API, which
is off by default; zot doctor checks for it and tells you how to turn it on.
Reads and connector ingestion work on Zotero 7.0 and up. The write
commands need Zotero 10.0 or newer: the local write API arrived in 10.0, and
7, 8 and 9 do not have it in any form. On an older build zot doctor shows
write as unsupported and zot grant refuses with that reason, so the
limitation surfaces before you rely on it rather than partway through a write.
| Zotero | reads, export, connector ingestion | writes |
|---|---|---|
| 7.0 – 9.x | yes | no |
| 10.0+ | yes | yes |
Install
go install github.com/CameronBrooks11/zotgo/cmd/zot@latest
Or download a prebuilt binary for your platform from the Releases page — no runtime, no dependencies, just an executable.
Check your setup
zot doctor
zot doctor — checking the local endpoint at http://localhost:23119
✓ Zotero running (v9.0.4)
✓ Local API enabled (schema 42, API v3)
Capabilities:
✓ read
✗ write this Zotero build has no local write API (added upstream in zotero/zotero#5015; update Zotero once a release ships it)
✓ connector-ingest
✓ local-file-access
Libraries:
My Library files ✓
Biological Reactor files ✗ attachments not accepted
Ready. zotgo can read your library.
doctor reports what the endpoint can do, not merely whether it answers. Every
unsupported capability carries its reason, so there is always something to act on.
zot --json doctor reports the same under data.capabilities, and exits non-zero
when Zotero is unreachable — so a script can branch on the exit status without
parsing the payload.
The Libraries section (local endpoint only) reports, per library you can file
into, whether it accepts attachment files. This is independent of the
endpoint-level local-file-access capability: a group library can be writable yet
have file storage disabled, so local-file-access ✓ while a specific library is
files ✗. That is why a library can silently never gain attachments. It appears
under data.libraries in --json.
If the Local API is off, doctor prints the exact steps to enable it:
Zotero → Settings → Advanced → General → "Allow other applications on this
computer to communicate with Zotero".
Next steps
- Read your library — the everyday commands.
- Write to it — if your Zotero build has the write API.
- Point at a remote library with
--web.
Reading your library
All read commands work against either endpoint (local by default, or the Web API
under --web — see profiles).
zot list # top-level items (default 25)
zot list -c "Smart Grid" -n 50 # items in a collection, by name or key
zot list --tag ml --tag review # items with all the given tags
zot search "state estimation" # search by title/creator/year
zot search algae --everything # include full text and notes
zot show HRAC4E44 # one item with all direct attachments and notes
zot attachment show ABCD1234 # attachment metadata
zot note show NOTE1234 # one note with exact rich-text HTML
zot relation list HRAC4E44 # one item's outgoing relations
zot annotation list ABCD1234 # direct annotations on an attachment
zot collection path COLL1234 # root-to-leaf collection ancestry
zot collections # collections as a tree (--flat for a list)
zot stats # library-wide counts
Global flags: --library/-L selects a group library (by name or id; default is
My Library), and --url overrides the endpoint address.
Collection paths
zot collection path KEY... resolves one or more collection keys from root to
leaf, preserving argument order and duplicates. Stable machine output carries an
unambiguous path array of key/name segments. Human output joins names for
reading only. The command uses the complete paginated collection index and
rejects --raw because its result is derived from multiple records.
Annotations
zot annotation list ATTACHMENT_KEY lists every direct annotation child of one
attachment. Stable output is sorted in document order and carries the annotation
key, parent attachment, type, page label, color, sort index, and presence flags
for text and comments. It deliberately omits annotation bodies, position data,
and image bytes. Use --raw when you explicitly need Zotero's complete
annotation envelopes.
The command follows every child page before writing output, so a later-page failure cannot leave a partial result on stdout. It does not recursively scan a bibliographic item's attachments; pass the attachment key you want to inspect.
Attachments
zot attachment show ATTACHMENT_KEY reports the attachment's parent, title,
link mode, media and filename metadata, URL and dates, tags, nullable MD5/mtime,
and any enclosure Zotero advertised. It reads one item envelope; it does not
request file bytes, resolve a path, or open Zotero's database.
An enclosure is location and optional size metadata advertised by Zotero, not a
portable filesystem-existence check. Use --raw for Zotero's complete
single-item envelope.
Notes
zot note show NOTE_KEY reports one note's parent, dates, tags, and Zotero's
rich-text HTML exactly as stored. Standalone notes have no parent. The command
does not sanitize the HTML or derive plain text; use --raw for the complete
single-item envelope.
Relations
zot relation list ITEM_KEY reports the item's outgoing relation predicates and
complete target URIs. Stable machine records add targetKey only for strict
Zotero item URIs; the full URI remains authoritative because it identifies the
target library as well as the item. Use --raw for the complete source item
envelope.
Export
export hands off to Zotero's own translators, so no bibliography formatting is
reimplemented here:
zot export bibtex -c Polyhedra # BibTeX (from Zotero), scoped to a collection
zot export csljson -o refs.json # -o writes to a file (atomically) instead of stdout
zot export ris # ris, biblatex, csv, mods, tei, rdf_* …
zot export summary-md # zotgo's own summary shapes: json, summary-csv, summary-md
The Zotero translators are bibtex, biblatex, csljson, csv, mods, ris,
tei, and the rdf_* variants. zotgo shapes only json, summary-csv, and
summary-md itself.
mods, tei, and rdf_* wrap each page of results in a single XML root element,
so zotgo exports them only when the result fits in one page rather than emitting a
document with two roots; narrow the query with -c/-t if you hit that.
For scripting the output of any read command, see machine-readable output.
Writing
zot item, zot collection, and zot tag create and modify data on the
local endpoint. Writes need a Zotero build with the local write API
(zotero/zotero#5015); older builds
are read-only, and zot doctor reports which you have under the write
capability. On a build without it, a write fails immediately with that same
explanation rather than authorizing or reaching the server — but --dry-run
still works, so you can validate payloads against a read-only Zotero. Writes are
refused on --web.
Items
zot item template book # print a blank skeleton for an item type
zot item template book > b.json # …fill it in, then:
zot item create --file b.json # create (also reads JSON on stdin)
zot item patch KEY < patch.json # partial update; fields you omit are untouched
zot item replace KEY < full.json # full replace; fields you omit are reset
zot item delete KEY1 KEY2 # destructive; lists what it will remove first
create accepts a single item object or an array. patch takes a JSON object of
just the fields to change. replace takes a whole item object (it must
include itemType) and overwrites the item: any field you leave out is reset to
its default — with one exception, verified against Zotero, that an omitted tags
is preserved rather than cleared, so send "tags": [] to strip them. Prefer
patch unless you specifically want that reset behaviour.
For stored and embedded Zotero-managed attachments, use patch only for
non-storage metadata such as title or contentType. zotgo rejects filename,
path, and linkMode patches because Zotero's current generic item update
changes those storage fields without moving the managed file. Generic updates
also reject a resulting managed attachment, attachment storage-mode changes,
and conversion to or from the attachment item type. Full replace is unavailable
when either the current or resulting attachment is managed; file renaming and
attachment conversions need a dedicated supported operation.
Item writes support stable machine output:
zot --json item create --yes --file items.json
zot --jsonl item patch KEY --yes < patch.json
zot --json item delete KEY1 KEY2 --dry-run
JSON always returns an item-mutations array; JSONL emits one self-describing
item-mutation document per requested item. Records preserve request order and
report planned, successful, missing, unchanged, or failed outcomes without
exposing raw item input or Zotero versions. Non-dry-run machine writes require
--yes; machine dry runs do not authorize or write. --raw is unavailable for
item writes because their result is a zotgo-derived mutation report, not one raw
Zotero response.
Managed attachments
zot attachment import \
--parent ITEMKEY \
--path figure.png \
--title "Figure 1" \
--source-url https://example.org/figure.png
The binary flag is --path (--file is a deprecated alias).
attachment import attaches one non-empty regular file to an existing
bibliographic parent. It copies a stable snapshot into private staging, creates
imported_file metadata, uploads those exact bytes to Zotero, registers the
managed file, and verifies the parent, title, source URL, filename, media type,
MD5, and byte length. --source-url stores provenance only; zotgo never
downloads it.
The media type comes from the first 512 staged bytes, not the extension.
--content-type supplies a concrete MIME override, and --filename changes the
managed filename. Imports are capped at 128 MiB because Zotero's current Local
API receiver buffers each upload before staging it. Standard input, directories,
and special files are not accepted.
Before writing, zotgo checks every direct attachment child. An exact MD5 match
is a successful no-op unless --allow-duplicate is set. The check is best-effort
rather than atomic because Zotero has no create-unless-this-parent-has-no-
matching-checksum precondition.
Import has separate metadata, authorization, byte-upload, registration, and
verification phases. If a phase after metadata creation fails, stable output
reports the attachment key, last completed stage, and a bounded failure without
deleting anything automatically. --raw is unavailable because no single
Zotero response represents the operation.
Generic item create does not ingest local bytes. For a new imported_file
item it rejects local path and premature filename fields and directs the user
to attachment import; metadata-only creates produce a warning.
item create creates one item and never its children. Attachments and notes
hanging off a source item are not carried, and nothing in the result says so —
the new item simply has numChildren: 0. This matters most when composing a
copy out of show and create:
# Metadata only. The source's PDFs, snapshots and notes are NOT copied.
zot show KEY --raw --library "<source>" \
| jq '.item.data | del(.key, .version, .relations) | .collections = ["<TARGET>"]' \
| zot item create --library "<target>" --yes
That produces a correct metadata record and leaves the files behind. Recovering
them is a second pass: zot attachment show on each source child to locate the
file, then zot attachment import against the newly created item. Check
numChildren on the source first — if it is non-zero, plan for that second pass.
Note the pipeline reads .item.data from --raw, not .data from --json:
the DTO uses type where a write needs itemType, so the --json form does not
round-trip (see the issue tracker).
Collections
zot collection create "Smart Grid" -p PARENTKEY # -p/--parent is optional
zot collection rename KEY "New Name"
zot collection move KEY --to PARENTKEY # reparent; --to-top moves to the top level
zot collection delete KEY # the collection's items are kept
Collection writes support the same machine output as items: --json returns a
collection-mutations array, --jsonl one collection-mutation per line, with
statuses planned, created, renamed, moved, deleted, notFound, and
failed.
Tags
zot tag add ITEMKEY urgent todo # add tags to one item
zot tag remove ITEMKEY todo # remove tags from one item
zot tag purge urgent # remove a tag from EVERY item (library-wide)
tag add/remove edit one item's tags and preserve the rest; tag purge
strips a tag from the whole library. The item key is the first argument (--item
is a deprecated alias); tag purge was formerly tag delete, still accepted as
a deprecated alias.
Tag writes emit tag-mutations (--json) or tag-mutation lines (--jsonl),
one record per requested tag. add/remove carry the target item and report
added, removed, or unchanged (a tag already in the desired state triggers
no write); delete reports planned then deleted and omits item.
Safety and authorization
Every write surfaces the target library, shows what it will do, and asks to confirm:
--dry-runpreviews without writing (and without authorizing).--yesskips the confirmation prompt for scripts.
There are two ways a write is authorized, matched to who is driving it.
Interactive writes — a human at a terminal answering the confirmation prompt —
are their own authority. The first such write prompts for approval in Zotero.
Choosing Always Allow stores a local API key in ~/.config/zotgo/local-api-key
(mode 0600) so later interactive writes don't re-prompt; Allow grants a
single-use key. Interactive attachment import requires Always Allow, because
it performs several authenticated phases and a single-use key would be spent on the
first.
Non-interactive writes — anything with --yes, or machine output
(--json/--jsonl) — require a write lease, so an unattended writer's blast
radius (a script, a CI job, a cron sync, an AI agent) is bounded and auditable
instead of the whole library. A lease is a human-granted, time-boxed, scoped
capability; without one, a non-interactive write fails closed with
run 'zot grant'.
zot grant --library me # 30-min lease, all non-destructive ops, My Library
zot grant --library "Lab Group" # a group library, named explicitly
zot grant -L me --ttl 2h --operations item.patch # narrower scope and lifetime (max 720h)
zot grant -L me --ttl 168h # a week, for a recurring job (see below)
zot grant -L me --note "cleanup for project X" # recorded in the lease and audit log
zot grant status # show the active lease and its audit summary
zot grant revoke # end it early
Long-lived leases. A lease may run up to 30 days (--ttl 720h), so a
recurring job — a nightly sync, a weekly cleanup — can hold one lease across its
whole cycle instead of failing closed every day until you are back at a terminal.
There is still no unexpiring lease: what the design guarantees is that
authority ends on a date you set, whether or not anyone remembers to revoke it.
Anything above 24h is treated as long-lived — zot grant asks a second time and
names the concrete end date, and zot grant status marks it LONG-LIVED with the
time it has left for as long as it runs. Revoke it as soon as the job that needed
it is done.
The library is never inferred. --library (or ZOTGO_LIBRARY for the
session) is required for grant alone. Every other command defaults to My
Library harmlessly — a read reaches the wrong library and you see it. A lease
does not: an omitted selector would silently authorize writes across your
largest library, which after an afternoon in a group library is rarely the one
you meant.
zot grant is deliberately the inverse of every other write command: it must
run in an interactive terminal (a human approves Zotero's authorize modal and the
printed scope), so automation cannot mint its own lease and --yes does not apply.
The lease carries the write key it authorizes, so expiry and revoke actually
remove write ability. Every decision — allowed or refused — is appended to the
lease's audit log (~/.config/zotgo/audit/<id>.jsonl), summarized by
zot grant status. --dry-run still previews non-interactive writes without a
lease. Set ZOTGO_CONFIG_DIR to relocate the config directory.
The lease contains a rule-following caller — one that only invokes zot and
honors refusals — and gives you an audit trail. It is not a sandbox: on a
single-user machine any process running as you could write the lease file, read a
stored key, or call Zotero's local API directly, so the lease bounds accidental
blast radius, not a determined one. Revoking a lease removes zotgo's state only; if
you clicked Always Allow, revoke that key in Zotero's settings too, since the
Local API has no remote revoke. See
the design doc for the full model and threat
boundaries.
Writes carry Zotero's required Zotero-Server-ID and operation-specific
preconditions, so concurrent or mismatched writes are rejected and reported
rather than silently overwriting.
Endpoints & profiles
zotgo speaks one Zotero API-v3 client, pointed at one of two endpoints by an explicit profile. It never silently falls back from one to the other.
| Endpoint | Selected by | Auth | Notes |
|---|---|---|---|
| Local (default) | nothing / --url | none | a running desktop Zotero on this machine |
| Web | --web | ZOTGO_API_KEY | the hosted api.zotero.org |
Each endpoint is its own version and concurrency domain — an operation is never moved between them.
Local (default)
The desktop app must be running with its Local API enabled (see getting started). This is the default and the core of zotgo: fast, offline-of-the-internet, and the only endpoint that supports writes today.
Web API (--web)
--web points the same read commands and export at the hosted Web API —
useful for headless, CI, or remote use where no desktop Zotero is running.
export ZOTGO_API_KEY=… # from https://www.zotero.org/settings/keys
zot doctor --web # confirm the key and see what it grants
zot --web list # every read command works over the Web API
zot --web export bibtex -o refs.bib
The API key is read only from the ZOTGO_API_KEY environment variable, never a
flag, so it cannot leak into your shell history or ps output. A read-only key is
enough — zotgo issues no writes over --web.
Capabilities
doctor reports each endpoint's capabilities from a probe, not a guess:
- Local:
writeis derived from theZotero-Server-IDheader (present only on builds with the write API);connector-ingestandlocal-file-accessneed the desktop app. - Web:
read/writecome from the API key's own grants;connector-ingestandlocal-file-accesshave no Web API equivalent and are reported unsupported.
On the local endpoint, doctor also lists per-library file editability under
Libraries (data.libraries in --json). It is finer-grained than the
endpoint-level local-file-access: a writable group library can still refuse
attachment files, and only this per-library view surfaces it.
Machine-readable output
Every command speaks three mutually exclusive machine formats.
zot --json list # one versioned document
zot --jsonl list # one self-describing document per line
zot --raw list # Zotero's own response, untouched
--json
--json wraps stable zotgo DTOs in a versioned envelope. The shape is the same
for every command, so a script learns it once:
{
"schema": 3,
"kind": "items",
"library": { "type": "user", "id": 0, "name": "My Library" },
"data": [ { "key": "AAAA1111", "type": "journalArticle", "title": "Algae paper" } ],
"meta": { "shown": 25, "total": 312 }
}
The DTO is a read contract, not a write schema
--json output does not feed the write commands. Piping zot show --json
into zot item create produces an item Zotero will not accept, because the DTO
and Zotero's write vocabulary are different shapes:
| DTO | Zotero write field |
|---|---|
type | itemType |
creators[].type | creators[].creatorType |
tags[].name + automatic | tags[].tag + type |
parentKey | parentItem |
The DTO also carries fields derived for reading that a write never accepts —
parsedDate, creatorSummary, numChildren, children, dateAdded,
dateModified.
This is deliberate. The DTO exists to be stable and legible for scripts and humans; Zotero's write vocabulary exists to be validated by Zotero. Making the read shape double as a write schema would make every future DTO field addition a write-path question, which is a large commitment for a convenience.
To build a write payload from an existing item, use --raw, which is
Zotero's own object and round-trips by construction:
zot show KEY --raw | jq '.item.data | del(.key, .version, .relations)' \
| zot item create --library "<target>" --yes
Note that this copies metadata only — see the note on children under
item create in writing.md.
kind says what data holds: items, item, attachment,
attachment-import, annotations, annotation, note, relations,
relation, collections, collection, stats, health, or one of the mutation
kinds (item-mutations, collection-mutations, tag-mutations, and their
singular *-mutation forms
under --jsonl). A health document carries endpoint and capabilities, so a
script can check for write support rather than assume it. schema is bumped
only when a field changes meaning or disappears — new fields may appear at any
time, so ignore the ones you don't know.
Writes
Every write command emits a mutation document whose data is always an array,
even for a single object. Each record carries the request index, its
operation, and a status; dry runs use planned and perform no authorization
or write. Records preserve request order, and a partial batch emits every
outcome before the command exits with status 1. Mutation documents have no
pagination meta and never expose Zotero object versions or the raw request
body.
- Items —
zot item create,patch,replace,delete→item-mutations. Statuses:planned,created,unchanged,patched,replaced,deleted,notFound,failed. Context fieldskey,type,title, and sorted patchfieldsappear when known; a failed record carries a structuredfailure(see Failures). - Collections —
zot collection create,rename,move,delete→collection-mutations. Statuses:planned,created,renamed,moved,deleted,notFound,unchanged,failed. Records carrykey,name, andparentKeywhen known. - Tags —
zot tag add,remove,purge→tag-mutations. Each requested tag is one record with itstagname;add/removealso carry the targetitem, while the library-widepurgeomits it. Statuses:planned,added,removed,deleted,unchanged. A tag already in the desired state is reportedunchangedand triggers no write. (purgereportsoperationdelete: purge is the command name, delete is the operation.)
Non-dry-run machine writes require --yes with --json or --jsonl, so an
automation command never falls back to an interactive prompt.
Failures
A record with status: "failed" (or an attachment import's partial/failed)
carries a failure object with a stable shape:
{ "code": "precondition-failed", "httpStatus": 412, "message": "…" }
code is a documented string category you can branch on; httpStatus is
Zotero's exact status and is present only when the failure came from an HTTP
response; message is human-readable. Batch write failures (item/collection/tag)
use these category codes, derived from the status in httpStatus:
code | meaning | typical httpStatus |
|---|---|---|
invalid | malformed or rejected data | 400, 422 |
not-found | the target did not exist | 404 |
conflict | a conflicting object exists | 409 |
precondition-failed | the object changed concurrently | 412 |
too-large | the request exceeded a size limit | 413 |
rate-limited | Zotero throttled the request | 429, 503 |
server-error | Zotero failed internally | 5xx |
unknown | no HTTP status, or an unmapped one | — |
Attachment import uses phase codes instead — authorization-required,
staged-file-failed, metadata-create-failed, metadata-create-unknown,
upload-authorize-failed, upload-failed, register-failed,
verification-failed — naming the phase that failed, with httpStatus when the
phase failed on an HTTP response.
Show
zot --json show ITEM_KEY and --jsonl emit one stable item document. The
shaped item is .data; all of its shaped direct children are
.data.children, in Zotero's page order.
zot --raw show ITEM_KEY composes multiple Zotero responses as
{"item": <envelope>, "children": [<envelope>, ...]}. The wrapper and array
separators are synthesized by zotgo, while every embedded item envelope retains
Zotero's fields and scalar representations. Raw show validates and buffers all
pages before writing, so a malformed or failed later page produces no partial
stdout.
Relations
zot --json relation list ITEM_KEY emits a relations document in stable
predicate/target order; JSONL emits one relation document per edge. Every
record includes the source itemKey, predicate, and complete target URI. The
target URI is authoritative. targetKey is only a convenience and appears when
the target is a strict Zotero user, local-user, or group item URI.
--raw emits the complete source item envelope after validating its identity.
It does not shape the relations field, so malformed or future relation data
remains available through the raw escape hatch.
Collection paths
zot --json collection path KEY... emits a collections document in requested
key order. Each ordinary collection record gains a path array of {key,name}
segments from root to leaf; JSONL emits the same records individually with
kind: "collection". The segment array is the stable ancestry contract. Joined
names shown to humans are presentation only, since collection names can contain
path-like punctuation.
The command fetches the complete collection index once and follows normal API
pagination and backoff. It rejects --raw before making a request because a
resolved path is derived from multiple collection records rather than one Zotero
response.
Annotations
zot --json annotation list ATTACHMENT_KEY emits an annotations document in
document order; JSONL emits one annotation document per line. Each record has
exactly key, attachmentKey, type, pageLabel, color, sortIndex,
hasText, and hasComment. Text and comment bodies, position data, image bytes,
and Zotero versions are excluded from the stable listing contract.
The command buffers every child page before emission. --raw composes the
complete Zotero annotation envelopes into one array in server order, retaining
unknown fields and private bodies without adding a schema wrapper.
Attachments
zot --json attachment show ATTACHMENT_KEY emits one attachment record.
Its bounded fields cover identity and parent, title and link mode, content and
filename metadata, URL and dates, tags, nullable md5/mtime, and a nullable
enclosure. JSONL emits the same record on one self-describing line. An
enclosure carries the content type and optional size Zotero advertised for the
file; its download href is endpoint-scoped (it differs between the Local and Web
endpoints for the same attachment), so it is deliberately excluded from the
stable record — reach for --raw when you need it. None of the enclosure is a
portable filesystem-existence assertion.
--raw emits Zotero's complete single-item envelope after validating only the
response key and item type; changes to other Zotero-owned field shapes do not
block the raw escape hatch.
Attachment import
zot --json attachment import --parent KEY --path PATH --yes emits one
attachment-import record. It reports planned, duplicate, imported,
partial, or failed, the last completed stage, the nullable created key, the
staged filename/type/size/MD5, focused verification, and a bounded failure when
needed. verification.actualFilename is the filename read back from Zotero, so
scripts can distinguish the requested name from the stored result. Nullable
fields remain present as null in planned and partial records.
Import is a derived multi-response mutation, so JSONL emits the same single
self-describing record and --raw is unavailable. A failure after attachment
metadata exists emits the partial record before exiting with status 1; it never
silently rolls back the created item. Like other writes, a non-interactive import
requires a write lease permitting attachment.import.
Notes
zot --json note show NOTE_KEY emits one note record containing its key,
parent key, dates, tags, and Zotero's exact rich-text HTML string. An empty note
has "html": ""; standalone notes have "parentKey": "". JSONL emits the
same record on one self-describing line. The command never derives plain text or
rewrites the HTML.
--raw emits the complete single-item envelope after key and item-type
validation, even when fields outside that identity cannot be shaped as a stable
note record.
No version field
Items, collections, attachment, note, and annotation records carry
no version. A Zotero object version belongs to the endpoint that issued it,
and the Local API's has
no meaning zotgo can promise:
it is the server version, so it does not move when you edit an item locally
without syncing, and the local write API replaces it with an unrelated local
counter. Sending one to the Web API as a write precondition is a data-integrity
hazard. If you need Zotero's number anyway, take it from --raw, which is
explicitly outside this contract.
For change detection and recency, use the item's own timestamps instead: every
item record carries dateAdded and dateModified (Zotero's ISO-8601 values),
which are always present. A child item surfaced by list --all also carries
parentKey, the key of its parent; top-level items omit it.
--jsonl
--jsonl emits one document per line, each repeating schema, kind, and
library. Every line therefore stands alone, and a stream survives being
truncated, split, or concatenated with another. Write records use the singular
kind (item-mutation, collection-mutation, tag-mutation), one per line:
zot --jsonl list | jq -r '.data | "\(.key)\t\(.title)"'
--raw
--raw passes Zotero's API response straight through. It is an escape hatch for
fields zotgo does not model, and it is not covered by schema: its shape is
Zotero's and changes when Zotero changes. Commands backed by multiple requests,
such as show, may place complete Zotero envelopes inside a documented synthetic
wrapper. stats, doctor, and every write command (item, collection, and tag)
reject --raw, because their output is derived and is not a raw Zotero response.
Managed attachment import also rejects --raw because its result combines
metadata creation, upload, registration, and verification.
Zotero integration contracts (verified 2026-07-07)
zotgo talks to a running Zotero 7+ desktop app over two HTTP surfaces on
localhost:23119. It never touches zotero.sqlite. This file is the verified
reference for both surfaces; everything below was captured against a live
Zotero (client 9.0.4, Local API v3, schema 42, ~8784047 user, 13 groups,
2203 items in My Library).
The two surfaces:
| Surface | Path prefix | Purpose | Default state |
|---|---|---|---|
| Local API (read) | /api/* | read library data, server-side export | OFF by default (httpServer.localAPI.enabled) |
| Connector API (write) | /connector/* | create items/attachments, snapshots | on whenever Zotero runs |
Both require Zotero to be running. This is the central constraint: zotgo has no headless/offline mode.
1. Local API (read) — /api/*
Web-API-v3-compatible. Same JSON envelope and headers as api.zotero.org, so
the online API docs (_reference/zotero-upstream/zotero-docs/content/dev/web_api/)
apply. Served by server_localAPI.js.
Availability / probe
GET /api/→200 "Nothing to see here."when reachable. This no-op is the version-agnostic probe (it ignores API-version mismatch).- When the pref is off, every
/api/*route except/api/returns403 text/plain "Local API is not enabled". zotgo must detect this exact case and tell the user how to enable it (Settings → Advanced → "Allow other applications on this computer to communicate with Zotero" + Local API). - Connection refused → Zotero not running.
Response envelope
Every item/collection object:
{
"key": "HRAC4E44",
"version": 3579,
"library": { "type": "user", "id": 8784047, "name": "My Library", "links": {…} },
"links": { "self": {…}, "alternate": {…}, "attachment": { "href": ".../items/CWEW5DNC", "attachmentType": "application/pdf", "attachmentSize": 585858 } },
"meta": { "creatorSummary": "Posten", "parsedDate": "2009-06", "numChildren": 1 },
"data": { "key": "…", "version": 3579, "itemType": "journalArticle", "title": "…", "creators": [...], "tags": [...], "collections": [...] }
}
datais polymorphic byitemType. Decode as a typed envelope withData json.RawMessage; unmarshal fields on demand.metagives server-derived fields pyzot rebuilt by hand from SQL:creatorSummary,parsedDate,numChildren. Free.links.attachmentinlines the primary attachment (type + size) on a top-level item — no extra round trip to know it has a PDF.
Response headers (pagination + versioning)
Total-Results: 2203
Last-Modified-Version: 3579
Link: <…?limit=1&start=1>; rel="next", <…?start=2202>; rel="last"
Zotero-Schema-Version: 42
- Pagination: follow
Link rel="next"or page withstart/limit. - Counts are free:
GET …?limit=1and readTotal-Results(this is howzot statsshould count without pulling rows). Last-Modified-Versionenables cheap change-detection / caching later.
Route map (from server_localAPI.js)
Item routes exist under both /api/users/:userID/… and /api/groups/:groupID/…:
/api/users/:userID/items list all items
/api/users/:userID/items/top top-level only (parents)
/api/users/:userID/items/trash trashed
/api/users/:userID/items/tags tags in item set
/api/users/:userID/items/:itemKey one item
/api/users/:userID/items/:itemKey/children attachments + notes (NOT annotations — see below)
/api/users/:userID/items/:itemKey/file 302 redirect to the local file URL
/api/users/:userID/items/:itemKey/file/view/url local file URL
/api/users/:userID/items/:itemKey/fulltext full-text content (per item)
/api/users/:userID/collections[/top] collections (tree via parentCollection)
/api/users/:userID/collections/:key one collection
/api/users/:userID/collections/:key/items[/top] items in a collection
/api/users/:userID/collections/:key/collections subcollections
/api/users/:userID/searches/:searchKey/items saved-search results
/api/users/:userID/tags all tags
/api/users/:userID/groups groups (list)
/api/groups/:groupID one group
Schema surface (no library id needed):
/api/ no-op probe / version
/api/schema full Zotero schema (200)
/api/itemTypes [{itemType, localized}, …]
/api/itemFields
/api/itemTypeFields
/api/itemTypeCreatorTypes
/api/creatorFields
Query parameters
limit,start— pagination.q+qmode=titleCreatorYear|everything— search (verified:q=algae&itemType=journalArticle→ 3;q=photobioreactor&qmode=everything→ 2).itemType=journalArticle(supports||/-boolean per web API).itemKey=KEY1,KEY2— restrict to specific keys (verified live 2026-07-10). On/itemsZotero also returns the matched items' children; on/items/topit returns exactly the requested keys. An emptyitemKey=is ignored, not treated as "match nothing".format=json|bibtex|csljson|ris|biblatex|mods|tei|rdf_*|csv|…— server-side export via Zotero's translators (all verified 200 live: bibtex, csljson, ris, biblatex, mods, tei, rdf_bibliontology, Zotero-native csv). Expose these generically through oneformat=passthrough, not one method per format. Note the Zotero-nativecsvdiffers from zotgo's hand-rolled summary csv.include=bib,citationandstyle=also apply.sort,direction.
Export page-boundary behavior (verified live 2026-07-10, Zotero 9.0.4)
Confirmed against a real 1093-item library by forcing limit=1 over an
itemKey=-scoped query. Previously inferred from document structure only.
- bibtex / biblatex / ris — one record per entry; pages concatenate cleanly.
- csljson — each page is a JSON array; they splice into one array.
- Zotero-native
csv— the full header row is repeated on every page, so header-dedupe is correct. Each page is also prefixed with a UTF-8 BOM (EF BB BF). Go'sencoding/csvdoes not treat the BOM specially: it becomes part of the first field, and the quote that follows reads as a bare quote in an unquoted field, so parsing fails outright. Strip the BOM per page and re-emit one on the merged document (Zotero emits it so spreadsheets detect UTF-8). A BOM may also legitimately appear inside a field value — only strip the leading one. - mods / tei / rdf_* — each page is a complete document wrapping its records
in a single root (
<modsCollection>,<listBibl>,<rdf:RDF>); mods and tei additionally emit an<?xml?>declaration per page. Concatenation is therefore never valid; multi-page requests must be refused. - Zotero's CSV translator skips standalone attachments: a library with 1093 top-level items (19 of them standalone attachments) exports 1074 CSV rows. That omission is Zotero's, not a merge defect.
Annotation writes are field-order sensitive
Verified live 2026-09-12, Zotero 10.0.2 (schema 44). Creating an annotation
requires annotationType to arrive before the other annotation* fields.
Anything else first is rejected:
400 annotationType must be set before other annotation properties
This is a trap for Go in particular: encoding/json sorts map keys, and
annotationType sorts after annotationColor, annotationComment,
annotationPageLabel, annotationPosition and annotationSortIndex. A
map[string]any payload therefore always fails, while a struct — which
encodes in declaration order — always works. The ordering is a property of the
type, so encode annotations from a struct.
annotationPosition is also required. Omitting it fails a NOT NULL constraint in
Zotero's own schema, and the error arrives as raw SQL including the statement and
its bound parameters — which is not something to relay to a user.
Both facts were found by seeding a corpus, not by reading: the first attempt reported creating two annotations and created none, because a batch write reports per-object rejections inside a 200 response rather than as an error.
/children omits annotations unless you ask for them
Verified live 2026-09-12, Zotero 10.0.2 (schema 44). An unfiltered
/items/:key/children never includes annotations. They appear only with an
explicit itemType filter.
Annotations hang off an attachment, not off the item, so seeing this takes two levels. For a journal article with two attachments, one of them annotated:
GET /items/24PYAE9Q/children -> 2 items (both attachments)
GET /items/P2XTLVZU/children -> 0 items
GET /items/P2XTLVZU/children?itemType=annotation -> 1 item
Walking the item gives you two attachments and no indication that either carries an annotation. The annotation exists, belongs to that attachment, and returns 200 when fetched directly — it is absent only from the unfiltered listing.
AllRawAnnotations in internal/zotero/annotation.go already passes
ItemType: "annotation" and is therefore correct; the risk is any new caller
that enumerates children generically and assumes it has seen everything.
This matters most for anything that copies or mirrors an item: enumerate children unfiltered and every annotation is silently dropped, with the result looking complete. Ask for annotations explicitly, or state plainly that they are not carried.
The /file endpoint = a storage-path simplification, not byte streaming
GET /items/:key/file returns a 302 redirect to the attachment's local
file:// URL; GET /items/:key/file/view/url returns the same local file URL
as text/plain. zotgo does not need to know the Zotero data-dir /
storage/<key>/ layout that pyzot resolved by hand, but attachment access is
"ask Zotero for the file URL, then open/read that path" rather than a plain HTTP
byte stream. Do not assume a normal HTTP client following redirects will fetch
the bytes correctly.
Library-id / user-vs-group routing (correctness-critical)
userID = 0is accepted and resolved to the logged-in user (responses report the real id, e.g.8784047). Groups use their real numeric id.GET /api/users/0/groups→[{id, version, meta:{numItems}, data:{id,name,description}}].- Restriction (from source): "No access to user data for users other than the local logged-in user." Single-user only; you cannot read another user's personal library.
- zotgo must map its notion of "which library" → either
users/<uid>orgroups/<gid>. Getting this wrong reads the wrong library silently. This is the #1 correctness risk carried over from pyzot's issue-4 analysis.
Local write contract (released in Zotero 10.0)
zotero/zotero#5015 (commits 9dd17a2, 77f2432, a37a9e7; dstillman finished
AbeJellinek's branch) added the local write API, and it ships in Zotero 10.0.
zotgo's write support is live-verified against that build. Writes activate only
when the running Zotero exposes the write endpoints (the Zotero-Server-ID
header is the probe signal); on an older build, write commands fail fast with the
same explanation doctor gives, and --dry-run still works.
Endpoints & methods (mirror Web API v3 write semantics):
POST /api/local/authorize— obtain a local API key (local-only; no web analog). Body{"appName":"zotgo"}→ Zotero modal (Allow / Always Allow / Deny). 200{"key":"<key>","remember":<bool>}; 403{"denied":true}; 400 if appName blank; 429 +Retry-Afterif rate-limited. "Allow" keys are single-use (consumed by the first successful write) → always handle 401 by re-authorizing.- Batch create/delete on the collection routes:
POST/DELETE/…/items,/…/collections(DELETEmulti-key via query param, ≤50). - Per-object:
PUT/PATCH/DELETE/…/items/:key,/…/collections/:key. MAX_WRITE_OBJECTS = 50,MAX_DELETE_OBJECTS = 50.
Auth & headers:
- Writes require the local API key via
Zotero-API-Key(or?key=); missing/bad → 401. Zotero-Server-ID: a stable per-database id on every response. Optional on reads, required on writes (missing → 428 Precondition Required; mismatch → 412 Precondition Failed). Its presence on a read is the clean probe signal that this Zotero has the write API at all.If-Unmodified-Since-Versionguards updates and deletes against the library'sclientVersion(mismatch -> 412 with expected/found). New batch creates have no prior object to guard. File uploads instead useIf-None-Match: *.?since=andIf-Modified-Since-Versiongate reads; writes returnLast-Modified-Versionwhere applicable.
Response shapes (identical to Web API v3, so the parser is shared):
- Batch → 200
{"successful":{"<i>":<obj>}, "success":{…}, "unchanged":{"<i>":"<key>"}, "failed":{"<i>":{"key","code","message"}}}. - Single
PUT/PATCH/DELETE-> 204. File registration -> 204.
Managed attachment files use the API-v3 full-upload contract for an existing
imported_file attachment. zotgo first creates attachment metadata without
filename, path, md5, or mtime, then:
- Authenticated form POST to
/api/.../items/:key/filewith MD5, a bare filename, byte length, millisecond mtime, media type, andIf-None-Match: *.{"exists":1}means Zotero already has those bytes. - When upload is needed, stream the staged bytes without API credentials to the
returned exact same-origin
/api/local/uploads/:uploadKeyURL. The receiver verifies MD5 and returns 201. Redirects are not followed. - Authenticated form POST of
upload=:uploadKeyto the item file route; success is 204.
A focused item read then verifies the parent, imported_file link mode,
requested and actual filename, media type, MD5, and enclosure length. This is a
Local API workflow, not Connector ingestion: it targets an explicit existing
bibliographic parent. zotgo caps each import at 128 MiB while Zotero's Local API
receiver buffers the request before staging it.
Versioning is endpoint-scoped, in Zotero's own words: "Local API versions
have no relation to Web API versions, nor … to local API versions returned by
other Zotero instances." This is exactly zotgo's schema-2 decision to keep
version out of the DTOs — vindicated. clientVersion is a new per-library
counter, incremented once per library per transaction.
The already-shipped Web API provides official remote CRUD today (its writes use the same batch response shape). The durable axis is local vs remote endpoint, not read vs write.
2. Connector API (ingestion) — /connector/*
Served by server_connector.js. Same surface the browser extension uses; always
available when Zotero runs (no pref gate). Writes go through Zotero itself, so
zotero.sqlite integrity is Zotero's responsibility.
Ingestion adapter only, not a general write backend. saveItems writes to
getSaveTarget() — Zotero's currently-selected library/collection — and
silently redirects to My Library when that target is not editable
(verified in source). That nondeterminism disqualifies it for general
automation; use it only for app-mediated workflows (recognition, import,
snapshots). General resource writes belong on the official API write contract.
Registered endpoints (from server_connector.js)
/connector/ping liveness ("Zotero is running")
/connector/detect run detection on a URL
/connector/getTranslators list translators
/connector/getTranslatorCode
/connector/saveItems save translated item metadata → library
/connector/saveSnapshot save a web snapshot
/connector/saveSingleFile single-file snapshot
/connector/saveStandaloneAttachment upload a local file as standalone attachment
/connector/saveAttachment
/connector/saveAttachmentFromResolver
/connector/getRecognizedItem poll: did a saved PDF get recognized into a parent?
/connector/getSelectedCollection current target collection in the UI
/connector/updateSession attach tags/collection to a just-saved session
/connector/import import RIS / BibTeX / CSL text
/connector/installStyle
/connector/proxies /connector/delaySync /connector/getClientHostnames /connector/hasAttachmentResolvers
Write choreography (mined from pyzot's write/)
- Local file →
POST /connector/saveStandaloneAttachmentwith raw bytes,Content-Type, and anX-Metadataheader carrying{sessionID, title, url: file://…}. Response is{canRecognize}in current source; do not assume the new attachment key is returned. - Recognition (PDF → parent metadata): poll
POST /connector/getRecognizedItemwith the sessionID — this is the correct API replacement for pyzot's SQL polling hack (wait_for_recognized_parentreaching into the DB). Current response shape is recognized parent display data (title,itemType), not a Zotero item key. - Tags / collection →
POST /connector/updateSessionwith the sessionID after the save. - Collection/library target →
POST /connector/updateSessionexpects a Zotero tree target (L<libraryID>orC<collectionID>). Resolve explicit--collectionnames throughPOST /connector/getSelectedCollection, whosetargetsarray exposes those IDs for editable libraries/collections. Local API collection keys are not sufficient forupdateSession. - RIS / BibTeX / CSL file →
POST /connector/import(no local parsing). Observed contract below. - Identifier (DOI/arXiv/PMID/ISBN) → resolve identifier to item JSON, then
POST /connector/saveItems. The connector does not resolve identifiers for you headlessly; pyzot supplied its own resolvers. - Session model: every write flow generates a client
sessionIDand threads it through save → recognize → updateSession. - Existing-item attachments/collection assignment: connector attachment and session-update operations are session-bound. Attaching a file to an arbitrary pre-existing item, or assigning an arbitrary existing item to a collection, was handled in pyzot with direct SQLite writes. zotgo rejects that path; these capabilities are out of scope unless Zotero exposes an API for them.
Import contract (verified live 2026-09-11, Zotero 10.0.1, schema 44)
Re-probed on Zotero 10.0.2 (2026-09-11): the session parameter, the 500 on a
missing Content-Type, the silent duplicate creation, and the response undercount
are all unchanged. The remaining details below were recorded on 10.0.1 and were not
re-checked.
The bullet above — "POST /connector/import (no local parsing)" — was mined from
pyzot and was the whole of what we knew. Everything in this subsection was
observed by driving the endpoint against a running Zotero.
POST /connector/import?session=<unique-id>
Content-Type: application/x-bibtex
<the bibliography file as the raw request body>
session is a query parameter, and it is not sessionID. Every other
connector flow threads a sessionID through the JSON body; import takes
?session= on the query string, because its body is the file itself. A repeated
or omitted value fails with 409 {"error":"SESSION_EXISTS"}, so generate a
fresh id per call.
Content-Type must be present but need not be correct. Omitting the header
returns HTTP 500, not a 4xx. The value is not authoritative either: RIS sent
as text/plain imported correctly, so Zotero sniffs the content. Send a
plausible type; never rely on it being honoured.
BibTeX, RIS and CSL-JSON all import.
Status codes and response
| Outcome | Status | Body |
|---|---|---|
| Items imported | 201 | JSON array of the created top-level items |
| Parsed, nothing found | 201 | [] |
| Input unparseable | 400 | empty |
session reused or missing | 409 | {"error":"SESSION_EXISTS"} |
Content-Type absent | 500 | empty |
Three traps follow from that table:
201does not mean anything was imported. An empty file is a successful import of zero items, so a caller that checks only the status reports success.- A
400carries no message at all. There is nothing to relay; the caller has to supply the entire explanation itself. - The response under-reports what was created. A six-entry
.bibreturned six items and created seven — a BibTeXnote = {…}field becomes a childnoteitem, which does not appear in the response array. Counting the array is not counting the writes.
Returned items carry "version": 0. That is not a usable object version and must
not reach a DTO (see the endpoint-scoped versions rule in AGENTS.md).
Duplicates are not detected
Importing the identical file twice created a second complete set of items with
new keys — no dedup, no precondition, and nothing in the response indicating a
match. Any safety here has to be zotgo's own, as it already is for
attachment import.
Target
Created items come back carrying collections: ["<key>"] naming the collection
selected in the GUI, so the save target is observable in the result as well as in
advance through getSelectedCollection. Behaviour against a non-editable
target is untested — the probe profile had no group libraries — so the
redirect-to-My-Library claim in the section above remains source-derived rather
than observed.
Type mapping observed
| BibTeX | Zotero | Carried through |
|---|---|---|
@article | journalArticle | DOI, publicationTitle, volume, issue, pages |
@inproceedings | conferencePaper | proceedingsTitle, place, pages |
@book | book | ISBN, edition, publisher |
@patent | patent | patentNumber, country; creators become inventor |
@techreport | report | institution, reportType |
@misc | document | howpublished → extra: "Published: …" |
Braced TeX accents decode correctly (M{\"u}ller → Müller, Fern{\'a}ndez →
Fernández). A braced organisational author
({Department of Mechanical Engineering}) stays a single-field creator rather
than being split into first/last.
No publisher network I/O was observed for this fixture — nothing was fetched and no attachments were created. That is one fixture, not a guarantee.
3. Web API (remote reads) — api.zotero.org (verified live 2026-07-28)
The hosted Web API is API-v3 like the Local API, so the same semantic client serves it — but several contract details differ and were observed against a real key (user 8784047), not inferred:
- No
/apiprefix; real user id. Routes are/users/<id>/…and/groups/<id>/…. The<id>is the key owner's real numeric id (from/keys/current), not the Local API's0sentinel. - Auth.
Zotero-API-Key: <key>header (the documented current form; the?key=query param is deprecated and would leak the key into logs). GET /keys/currentreturns{"userID", "username", "access":{"user":{…}, "groups":{…}}}. Theaccessgrants (library,write,files,notes) are the endpoint's own statement of what the key may do — so web capabilities are probe-derived, unlike the local write capability, which no probe can determine. A 403/404 here means the key is missing/revoked/wrong.- csljson is wrapped differently. The Local API returns a bare array
[…]per page; the Web API returns{"items":[…]}. zotgo unwraps both to one bare CSL-JSON array, sozot export csljsonoutput is endpoint-neutral. This bit us: the httptest fakes served the local bare-array shape, so the merge passed every unit test and failed on the first real web export — the live web suite caught it (cf. the CSV-BOM finding). - Pagination differs for scoped exports. An
itemKey=-scopedformat=csljsonquery returns all matches in one page (limitis ignored;Total-Resultsequals the match count, noLinknext), where the Local API paginates the same query one item per page. The unwrap-and-splice merge handles both. - Rate limiting (Web-only):
429/503carryRetry-After: <seconds>(honored with bounded retries); aBackoff: <seconds>header asks the client to slow down (honored between paginated pages). The Local API never sends these.
4. What this buys zotgo over pyzot (concrete)
- No private-schema coupling (pyzot pinned SQLite schema v107).
- Reads while Zotero is running (pyzot's WAL lock effectively wanted it closed).
meta.*server-derived fields for free (creatorSummary/parsedDate/numChildren).- Server-side export (
format=bibtex|csljson) → nopybtexreimplementation. - Attachment file URLs via
/file→ no storage-dir path resolution. - Recognition via
/connector/getRecognizedItem→ no DB polling hack.
5. What it costs
- Zotero must be running for everything.
- Local API is off by default → first-run enablement friction.
- Single logged-in user only.
- Identifier→metadata resolution is not provided by the local surfaces.
Design: write authority
Status: Implemented. Phases 1–2 have shipped and are live-verified against a
Zotero build with the local write API: zot grant leases, the deny-by-default
authorizer at the write chokepoint, and attachment import are in the tool
today, and phase 4's user documentation is in writing.md. Phase 3 (--web
writes) remains a roadmap item — see the issue tracker. This document is kept as
the design of record; the phased plan at the end records what has landed.
This document defines how zotgo authorizes writes, so that a non-interactive caller — a script, a CI job, a cron sync, or an autonomous agent — can be given bounded, time-limited, auditable write access to a Zotero library instead of an all-or-nothing switch. It supersedes the earlier working rule of a hard "agents never write through this tool."
The design is framed throughout around an autonomous agent, because containing an agent that acts on your behalf without you watching is the sharpest version of the problem and the motivating use case. The mechanism is general, though: it keys off whether a human is present at the prompt, so every non-interactive writer is bounded the same way — an agent is simply the example that most needs it.
Why this exists
zotgo's original safety posture was a blanket boundary: writes were treated as off-limits for agents. That was a reasonable v1 stance, but it does not survive contact with real use:
- Write features have landed (item/collection/tag writes, full-replace, and now managed-file upload). Each one silently widens what an agent could do if it is allowed to run zotgo at all.
- The useful workflow the maintainer actually wants is: "let this agent work for the next 30 minutes." A blanket allow makes the blast radius the entire library; a blanket deny makes the agent useless for the task.
The goal is a middle path with a contained blast radius: a human grants a narrow, expiring capability; the agent operates inside it; and afterwards the human can see exactly what was — or could have been — touched, instead of asking "do I need to restore my whole Zotero from backup?"
What this is (and what it is not)
Be precise about the threat model, because it determines what the lease can honestly promise.
The lease is a guardrail that contains a rule-following agent — one that acts
only by invoking zot subcommands and honors the tool's refusals. For that
agent, the lease bounds scope, expires automatically, and records what happened.
That is the real, useful property: it contains accidental blast radius and
gives the human an audit trail and a knowable maximum reach.
The lease is not a forgery-resistant authorization boundary against a capable or malicious same-user process. On a single-user host the agent is the user: it can write the lease file directly, read any credential zotgo stores, or POST to Zotero's local API without going through zotgo at all. This is inherent to the threat model, not a defect to be fixed with cryptography — a signing key would live in the same directory the attacker already controls (see Q1). The doc therefore never claims the lease "cannot be forged" or that "an agent cannot mint its own lease"; it claims the lease contains an agent that plays by the rules.
Goals
- A human, not an agent, mints a lease through an interactive step an agent cannot perform non-interactively.
- Each grant is scoped — which library, which operations. (Sub-library collection scope is deferred; see Q2.)
- Each grant is time-boxed and expires automatically; there is no unexpiring lease.
- Every write — and every refusal — is audited, and the potential blast radius is knowable before granting.
- Writes fail closed: absent, expired, unreadable, or out-of-scope authority means no write, with an actionable, dimension-specific message (building on the fail-fast work in #42).
Non-goals
- Multi-user / server authz. zotgo is a single-user local tool.
- Protecting against a fully compromised host or a malicious same-user process. If an attacker already runs code as the user, no in-process boundary saves them. This raises the bar against accidents; it is not a sandbox.
- Replacing Zotero's own permissions where those are real (see
--webbelow). - Tamper-resistant auditing. The audit log is a same-user-writable file: a convenience record for a well-behaved agent and forensics after an accident, not a trail that survives a hostile process.
Research: what Zotero can and cannot enforce
The decisive question is where the boundary can live. Zotero's own authorization was investigated first, because a boundary the server enforces is more robust than one the client promises.
| Capability the model needs | Zotero Web API | Zotero Local API (used by file upload) |
|---|---|---|
| Time-box / TTL on access | No — keys are valid indefinitely unless manually revoked | No — local key is single-use or persistent ("Always Allow") |
| Scope below a library (per collection/project) | No — per-library/group only | No — grants full local write |
| Mint a key programmatically | Only via a registered OAuth app + user handshake | Via the desktop authorize modal (a human approves) |
| Revoke a key programmatically | Yes — DELETE /keys/<key> | n/a (no local revoke API) |
Sources: Zotero Web API v3 docs — basics
(key permissions, DELETE revocation, "valid indefinitely, unless revoked"),
OAuth key exchange
(programmatic key creation with requested permissions), and the local write
contract notes in docs/zotero-api.md.
Conclusion: Zotero cannot natively provide the two properties the model most needs — an expiring grant and sub-library scope — and the Local API that file upload depends on has neither scoping nor expiry at all. Zotero-side authorization therefore cannot be the primary mechanism.
Decision: enforce in the zotgo layer, harden with Zotero, harness as a belt
The write-authority boundary lives in zotgo as a write lease the tool checks before every non-interactive write. This is chosen over Zotero-side enforcement because, per the research above, Zotero cannot express TTL or fine scope; and over the harness layer as primary because a boundary tied to one agent runtime does not protect the tool when driven another way.
The other layers still contribute, in their proper role:
- Zotero-side (defense in depth,
--webonly): for--webwrites, require a library-scoped write key and verify its grants are a subset of the lease's library scope (see Q4), so even a lease bug cannot write outside the granted library. This is real, server-enforced hardening; it applies only to--web, does not enforce TTL or sub-library scope, and does not apply to the local endpoint at all. - Harness (optional belt): an agent-config deny-rule on
zot grant, so an agent literally cannot mint its own lease. Useful, but not relied upon — the tool must fail closed even if the harness is misconfigured.
human ──approves modal──▶ zot grant ──mints──▶ write lease
(scope + ops + expiry + audit
+ bound write key)
│
agent ──runs `zot … --yes`──▶ zotgo ──authorizer at writeRequest──▶ Zotero write
│ ▲
└─ no/expired/out-of-scope ⇒ refuse (fail closed)
(--web only) lease library scope ⊇ Zotero key grants ──────────────┘ (server-enforced)
The write lease
A lease is a small local record — a 0600 file under the existing config dir
(~/.config/zotgo/, itself 0700; overridable with ZOTGO_CONFIG_DIR), written
with the same discipline as cmd/zot/keystore.go. There is one active lease at
a time. Shape:
{
"id": "lease_...",
"created": "2026-08-20T15:00:00Z",
"expires": "2026-08-20T15:30:00Z",
"scope": {
"libraries": ["user:0"],
"operations": ["item.create", "item.patch", "attachment.import"]
},
"writeKey": "<local write key, bound to this lease>",
"note": "PBR project cleanup"
}
scope.librariesuses the canonical library token (kind:id) derived the same way at mint and enforcement; locally the user library isuser:0, groups use their real id (see Q6).scope.operationsis a closed, per-command vocabulary (see Q3).writeKeybinds the credential into the lease (see Bound key), so expiry and revocation actually remove write ability.scope.collectionsis intentionally absent in phase 1 (deferred, Q2); the field is reserved so adding real collection enforcement later is non-breaking.- The audit path is derived from
id(~/.config/zotgo/audit/<id>.jsonl), not stored separately.
Minting — zot grant (human-only)
zot grant is the only command that creates a lease, and it is deliberately the
inverse of every other write command:
- It requires a TTY and refuses
--yes/ non-interactive invocation. An agent cannot mint a lease non-interactively; the harness deny-rule is an additional belt. (The refusal message says why — minting needs a human to approve Zotero's authorize modal in an interactive terminal — and states explicitly that--yescannot substitute here, so the reflex from other commands does not hit a bare wall.) - The root of trust at mint time is Zotero's own authorize modal (local) — a
desktop GUI a human must click;
zot grantties the lease to a successful authorize and stores the resulting key in the lease. - It takes
--ttl(e.g.--ttl 30m) with a bounded default (30m) and a documented maximum (30 days). There is no unexpiring lease. A TTL above 24h is long-lived and takes a second confirmation naming the concrete end date;grant statusflags it for as long as it runs (see Q7). - It requires an explicit library —
--library, orZOTGO_LIBRARYfor the session. This is the one command whose target is never inferred: every other command's My Library default is self-correcting (a read reaches the wrong library and the user sees it), while a lease's is not, silently authorizing writes over the largest library on the account. Declining to guess costs one flag and removes the failure mode. - Before confirming, it prints the concrete authorization — resolved library,
operations, and the count of items currently in scope — as the pre-grant
blast-radius picture. (
--dry-runremains the after-the-fact per-write preview; blast radius "before granting" cannot rely on it because it is a property of the write commands, not ofgrant.) - Minting while a live lease exists warns and replaces only on explicit confirmation, so an agent's authority is never silently clobbered.
Surface: zot grant, zot grant status, zot grant revoke (noun-verb
subcommands matching item create / collection rename; no --revoke/--status
flag-verbs).
Checking — a deny-by-default authorizer at the write chokepoint
Enforcement lives in internal/zotero at the single writeRequest
chokepoint, not sprinkled across the twelve cmd/ write actions. The Client
holds a WriteAuthorizer interface; a nil authorizer denies. cmd/zot
injects an implementation that reads the lease file (keeping file/CLI logic out
of the dependency-light client). Every write funnels through writeRequest, so a
new or forgotten write path (the reason #52 was held before it merged) is
structurally unable to skip the check.
The write methods thread a (library, operation) scope descriptor to
writeRequest. Ordering composes with the existing fail-fast layering:
RequireWriteCapability (capability) first, then the lease (authority).
Interactive human writes do not require a lease. A human at a TTY who answers
the existing confirm() prompt is their own authority. The lease is required only
for non-interactive / --yes / machine-mode writes — the case where a human
is not present. This maps the lease precisely to its purpose and avoids a
friction regression (and lockout footgun) on the maintainer's own manual edits.
Bound write key — writes never self-authorize
When leases are in force, zotgo does not persist a standalone "Always Allow"
key. The local write key lives inside the lease record, and write commands
never trigger Client.Authorize — only zot grant does. Consequences:
- A forged or hand-written lease carries no valid key, so the write
401s / refuses even though the file exists. - Expiry and
zot grant revokeactually remove write ability, instead of leaving a broader, longer-lived Zotero credential behind that a re-forged lease could replay.
This is the single change that gives the authority layer real teeth against an
accidental same-user agent, and it closes the "Always Allow" replay hole
(Q5). Residual honesty: a process can still POST to Zotero's
local API directly, and Zotero has no local API to forget an "Always Allow" key,
so zot grant revoke removes zotgo-side state only — documented as a limitation
in writing.md.
Fail-closed, with dimension-specific messages
Any lease that is missing, unreadable, malformed, expired, or out-of-scope
denies the write (a parse error never defaults to allow). Each refusal
dimension gets a distinct sentinel and message, wired through writeFriendly
alongside the existing ErrWrite* sentinels:
| Condition | Message shape |
|---|---|
| No lease | no active write lease; run 'zot grant' to authorize writes |
| Expired | write lease expired at <ts>; run 'zot grant' to renew |
| Wrong operation | lease does not permit item.delete; re-grant with that operation |
| Wrong library | names the target library vs the granted one |
Dimension-specific errors are what let an agent report which boundary it hit so a human can re-scope.
Audit — every decision, allowed and refused
Every authorization decision — allowed and refused/out-of-scope/expired —
appends a record (timestamp, operation, library, decision, and the refusal reason)
to the lease's audit log. Recording refusals matters because a burst of them is
exactly the signal a worried maintainer wants. zot grant status surfaces it: the
active lease, its expiry, the audit path, and a decision summary (N allowed, M
refused). The JSONL file is directly inspectable.
Two honest limitations of the phase-1 audit, both planned enrichments rather than gaps to hide:
- Records name the operation and library, not the individual target keys — the authorizer sees the operation and library, not the request body. Adding per-object target keys means threading them to the authorizer and is deferred.
- A record is written when a write is authorized, before the HTTP write; an
allowed write can still fail Zotero's own preconditions afterwards, so the summary
counts authorization decisions, not confirmed writes. A formatted
zot grant logand per-write outcomes are a later refinement.
The audit file is same-user-writable, so it is a convenience/forensics record, not a tamper-resistant trail — stated plainly so it is not oversold.
How the write commands conform
item create/patch/replace/delete,collection create/rename/move/delete,tag add/remove/delete— each gets the centralized authorizer check (nothing per-command to add beyond threading the operation id). Their operation identifiers are the closed vocabulary in Q3.attachment import(#52) — the managed-file upload. It conforms the same way, adding theattachment.importoperation identifier to the vocabulary rather than a bespoke check; its credential/redirect boundaries and staging TOCTOU defenses were verified in review before it merged.
Resolved questions
Q1 — Lease integrity: no signing
Do not sign/HMAC the lease. A 0600 file under the 0700 config dir is the
whole mechanism; trust is anchored in Zotero's authorize modal at mint time. A
same-user agent can read any signing key that lives beside the lease, so a
signature adds zero forgery-resistance under the stated threat model. Permissions
are set on write (as keystore.go already does); on read, the tool
parses against the schema, requires a valid unexpired expires, verifies scope,
and fails closed on anything malformed. It does not add strict read-side gates
(owner check, exactly-0600, parent-dir mode) — those harden a declared non-goal,
are inconsistent with the unguarded key sitting in the same dir, and risk false
lockouts on benign umask/backup/network-FS states.
Q2 — Collection scope: deferred to library-level
Phase 1 enforces library-level scope only. Zotero enforces nothing below a
library server-side, so collection scope would be client-only (TOCTOU-adjacent,
no backstop) with genuinely ambiguous create-into-collection and subtree
semantics, and tag.delete cannot be bounded below a library at all. For a
single user — typically one user library — library scope + a 30-minute TTL +
audit already delivers a contained blast radius; collection scope is the largest
bug surface for the least marginal safety. scope.collections is reserved in the
JSON as an accepted-but-unenforced field so real enforcement is a non-breaking
add later.
If collection scope is added later, the membership rule must be the
two-sided freeze invariant pre\scope == post\scope for any membership-changing
write — not post ⊆ pre ∪ scope, which still permits silently stripping an
item out of an out-of-scope collection (the write.go clearing path — data loss).
Parented creates and attachment.import must resolve the parent item's
collections (a read) to determine destination scope, because child items inherit
membership and carry an empty data.collections. Recorded here so it is not
re-litigated.
Q3 — Operation vocabulary: per-command, closed, fail-closed on unmapped
scope.operations is a closed, per-command vocabulary that exactly matches the
write command surface and reuses the Operation labels the code already emits:
item.create item.patch item.replace item.delete
collection.create collection.rename collection.move collection.delete
tag.add tag.remove tag.delete
attachment.import
Any write whose operation id is absent from scope.operations refuses
(default-deny, including future unwired commands). Per-command is vindicated by
item.replace (destructive full overwrite, must be separable from patch) and
tag.delete (library-wide, must be separable from per-item tag.remove) —
distinctions a per-class item.write would collapse. The common "clean up
metadata" grant is therefore verbose; wildcards/presets are deferred as purely
additive sugar.
Q4 — --web without OAuth: verify-only
For --web, require the user to supply an already-library-scoped Web API key and
merely verify its grants; do not build OAuth mint/revoke (OAuth 1.0a is
hundreds of fiddly, dependency-tempting lines for a defense-in-depth layer on a
path that is not the driving use case — YAGNI). Verify-only cannot manufacture
authority and matches Zotero's real per-library capability; TTL still comes from
the lease. Enforce key_write_libraries ⊆ lease.scope.libraries (subset, not
equality); walk Access.User and Access.Groups (including the special all
entry) rather than reusing the boolean grantsWrite(); normalize user:0 vs
user:<realid> and fail closed on any unmappable library identity. zotgo
never DELETEs a user-supplied key it did not mint.
Note: the CLI has no --web write path today (writes are local-only). Phase 3
is therefore "build --web writes, then verify," and RequireWriteCapability
currently returns nil for the web endpoint — real, modest work, scoped as its own
phase.
Q5 — "Always Allow": modal is the mint gate, lease is the runtime gate
Zotero's authorize modal is the mint-time gate (enforced by zot grant's
TTY-required / --yes-refused confirmation); the lease is the runtime gate
(expiry + scope). Binding the key into the lease and never re-authorizing on the
write path (above) means a persisted "Always Allow" grant cannot be replayed by a
forged lease. The premise that "Always Allow" suppresses the modal on later
authorizes is provisional — docs/zotero-api.md documents single-use only for
plain "Allow" — and is marked pending live verification; the conclusion holds
either way, since a silent re-authorize would equally defeat a per-write modal.
Q7 — TTL ceiling: 30 days, never unexpiring
The original ceiling was 24h. That is below the period of the very caller this
document names first — a cron sync — so a recurring job failed closed at the same
time every day, and recovery needed a human at a terminal to approve Zotero's
modal. zot grant requires a TTY by design, so there was no way to close that
gap from the automation side. Raised in #93,
which asked for an "until revoked" lease.
The ceiling is raised to 30 days; an unexpiring lease is still refused. The distinction is the point. What Goal 3 defends is not a short window — it is that authority ends on a date the human set, whether or not anyone acts. A 30-day lease keeps that property; "until revoked" makes the end of authority depend on someone remembering, which is the failure the lease exists to survive. It would also restore the all-or-nothing switch the lease replaced, and leave the bound write key on disk indefinitely with no rotation boundary — reopening the persisted-"Always Allow" replay hole Q5 closed. The contained blast radius here is scope × time; an unexpiring grant removes one axis outright.
The 24h figure is kept as the long-lived threshold rather than discarded: a
lease above it takes its own confirmation naming the end date, and is flagged by
grant status for its whole life. So every lease that was mintable before is
still mintable with exactly the friction it had, and the new range is opt-in and
visible.
Two alternatives were considered and rejected. A grant renew that extends an
existing lease without re-authorizing is a cheaper ritual but still needs a human
every day, so it does not address the cron case; it remains available as additive
sugar. Non-interactive renewal inside a longer outer window is a refresh
token: it reconstructs unbounded authority with more moving parts to get wrong.
Q6 — Canonical library identity
Pin one canonical library token derived the same way at mint and at
enforcement, so the two always agree. In phase 1 that token is
LibraryRef.Kind:LibraryRef.ID — and locally the user library's id is the 0
sentinel everywhere (routing, selfUser, and the lease alike), so both sides read
user:0 and match; groups use their real id. The token is never resolved from a
Zotero response, which is where the mismatch risk lives: the Local API accepts 0
on input but reports the real id in envelopes, so keying a lease off a response id
while routing on 0 would be a silent fail-open/lockout — the #1 correctness risk
in docs/zotero-api.md. Real-numeric-id canonicalization only becomes relevant for
--web (phase 3), where the user id is real on both sides. A table test covers the
user:0 and group forms.
Q8 — Ingestion: authorize the resolved target, and stay out of the default grant
Connector ingestion (zot ingest bib|pdf|url, #21) conforms to the lease model
like every other write, with one difference that has to be handled rather than
waved through: its destination is chosen by the GUI, not by the command line.
Authorize against the resolved target. The Connector saves to whatever the
Zotero UI has selected, so the library an ingest writes to is not knowable from
the argv the user typed. The authorizer must therefore be consulted against the
resolved destination, not an assumed one. This is achievable because
getSelectedCollection answers before any write happens — resolve first, then
authorize, then write. A command that cannot name its destination must not be
able to authorize one.
Excluded from the default grant. defaultGrantOperations() grants every
operation that is not in destructiveOperations, where destructive means can
lose data. Import only adds items, so that rule would admit it automatically.
It should not, and the rule needs a second exclusion rather than a special case:
- destructive — the write can lose data (
item.replace,item.delete,collection.delete,tag.delete; see Q9) - nondeterministic target — the write's destination is chosen outside the
command (the
ingest.*operations)
Both are withheld from an omitted --operations for the same underlying reason:
a default grant should only authorize writes whose blast radius the person
minting it could actually predict. A 30-day default lease plus --yes in a
script would otherwise deposit items into whichever collection happened to be
selected in the sidebar, which is not something the mint-time prompt can
meaningfully warn about. Naming ingest.bib explicitly is a small cost paid
once, by someone who has just been told where it will write.
Duplicates are a related but separate matter and deliberately not a reason for
exclusion: Zotero's import silently creates duplicates with no dedup and nothing
in the response marking a match (see docs/zotero-api.md). That is noise, and
recoverable by deleting — it is not data loss, and it does not carry the
prediction problem above.
The ingest.bib token enters the vocabulary when #99 lands, following the
precedent attachment.import set in #52; the Q3 list above describes the
vocabulary as it currently ships.
Q9 — item.replace is destructive, and always was
item.replace is withheld from the default grant. This is a correction to how the
existing rule was applied, not a new category.
The rule is withhold what can lose data. A full replace deletes no item, but it
resets every field the payload omits — a caller that meant to change a title
and sent an object without the abstract has destroyed the abstract, with no
recovery short of sync history. That is data loss by the same definition
item.delete is withheld under.
Q3 had already said so, in the course of arguing for a per-command vocabulary:
item.replace is "a destructive full overwrite, must be separable from patch".
It was then placed in the default anyway, because destructiveOperations was
populated from the commands whose names say delete. So the vocabulary and the
default disagreed, and the default was the one that was wrong.
The practical shape this took: a lease minted for a single item.create also
carried the ability to overwrite any item in the library wholesale, from a command
typed with no arguments.
What the default still grants — item.create, item.patch, collection.create,
collection.rename, collection.move, tag.add, tag.remove,
attachment.import — is unchanged, and remains a usable middle ground. A default
nobody can work with is not a safe default; it is one everybody overrides.
Phased rollout
- Lease core. (Shipped.) The
0600single-lease file (id, created, expires, library scope, per-command operations, bound write key, note);zot grant/grant status/grant revoke, TTY-gated and tied to a successful Zotero authorize, with--ttl(30m default, documented max) and a printed pre-grant blast radius; a deny-by-defaultWriteAuthorizerinjected into theClientand enforced atwriteRequest, applied only to non-interactive/--yes/ machine writes; per-command operation scope with fail-closed on any unmapped op; a canonical library token (user:0locally — see Q6); dimension-specific refusal sentinels; and an append-only JSONL audit of every decision (allowed and refused), surfaced bygrant status. - #52 conforms. (Shipped.) Add
attachment.importto the vocabulary; unblock and merge. --webhardening. (Roadmap — see the issue tracker.) Build the--webwrite path, then verify a user-supplied library-scoped key (subset check, fail-closed on unmappable identity). OAuth mint/revoke is not in scope — deferred indefinitely until a concrete demand justifies the code.- #99 conforms. (Roadmap.) Add
ingest.bibto the vocabulary as a nondeterministic-target operation — withheld from the default grant, and authorized against the destinationgetSelectedCollectionresolves rather than an assumed one (see Q8). - Docs + harness belt. (User docs shipped in
writing.md.) Document the model (including the honest limitations and the local-key residual); add the caller deny-rule guidance.
Each phase is independently shippable and CI-gated, per the project's small-PR convention. The lease logic is unit-tested against fakes, and per the project's iron rule the shipped phases were confirmed by a live run against a Zotero build with the write API (zotero/zotero#5015, released in Zotero 10.0): the authorize/key/precondition interplay the lease wraps is verified live.