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.

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.

Zoteroreads, export, connector ingestionwrites
7.0 – 9.xyesno
10.0+yesyes

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

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-run previews without writing (and without authorizing).
  • --yes skips 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.

EndpointSelected byAuthNotes
Local (default)nothing / --urlnonea running desktop Zotero on this machine
Web--webZOTGO_API_KEYthe 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: write is derived from the Zotero-Server-ID header (present only on builds with the write API); connector-ingest and local-file-access need the desktop app.
  • Web: read/write come from the API key's own grants; connector-ingest and local-file-access have 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:

DTOZotero write field
typeitemType
creators[].typecreators[].creatorType
tags[].name + automatictags[].tag + type
parentKeyparentItem

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.

  • Itemszot item create, patch, replace, deleteitem-mutations. Statuses: planned, created, unchanged, patched, replaced, deleted, notFound, failed. Context fields key, type, title, and sorted patch fields appear when known; a failed record carries a structured failure (see Failures).
  • Collectionszot collection create, rename, move, deletecollection-mutations. Statuses: planned, created, renamed, moved, deleted, notFound, unchanged, failed. Records carry key, name, and parentKey when known.
  • Tagszot tag add, remove, purgetag-mutations. Each requested tag is one record with its tag name; add/remove also carry the target item, while the library-wide purge omits it. Statuses: planned, added, removed, deleted, unchanged. A tag already in the desired state is reported unchanged and triggers no write. (purge reports operation delete: 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:

codemeaningtypical httpStatus
invalidmalformed or rejected data400, 422
not-foundthe target did not exist404
conflicta conflicting object exists409
precondition-failedthe object changed concurrently412
too-largethe request exceeded a size limit413
rate-limitedZotero throttled the request429, 503
server-errorZotero failed internally5xx
unknownno 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:

SurfacePath prefixPurposeDefault state
Local API (read)/api/*read library data, server-side exportOFF by default (httpServer.localAPI.enabled)
Connector API (write)/connector/*create items/attachments, snapshotson 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/ returns 403 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": [...] }
}
  • data is polymorphic by itemType. Decode as a typed envelope with Data json.RawMessage; unmarshal fields on demand.
  • meta gives server-derived fields pyzot rebuilt by hand from SQL: creatorSummary, parsedDate, numChildren. Free.
  • links.attachment inlines 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 with start/limit.
  • Counts are free: GET …?limit=1 and read Total-Results (this is how zot stats should count without pulling rows).
  • Last-Modified-Version enables 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 /items Zotero also returns the matched items' children; on /items/top it returns exactly the requested keys. An empty itemKey= 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 one format= passthrough, not one method per format. Note the Zotero-native csv differs from zotgo's hand-rolled summary csv. include=bib,citation and style= 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's encoding/csv does 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 = 0 is 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> or groups/<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-After if 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 (DELETE multi-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-Version guards updates and deletes against the library's clientVersion (mismatch -> 412 with expected/found). New batch creates have no prior object to guard. File uploads instead use If-None-Match: *. ?since= and If-Modified-Since-Version gate reads; writes return Last-Modified-Version where 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:

  1. Authenticated form POST to /api/.../items/:key/file with MD5, a bare filename, byte length, millisecond mtime, media type, and If-None-Match: *. {"exists":1} means Zotero already has those bytes.
  2. When upload is needed, stream the staged bytes without API credentials to the returned exact same-origin /api/local/uploads/:uploadKey URL. The receiver verifies MD5 and returns 201. Redirects are not followed.
  3. Authenticated form POST of upload=:uploadKey to 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 filePOST /connector/saveStandaloneAttachment with raw bytes, Content-Type, and an X-Metadata header 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/getRecognizedItem with the sessionID — this is the correct API replacement for pyzot's SQL polling hack (wait_for_recognized_parent reaching into the DB). Current response shape is recognized parent display data (title, itemType), not a Zotero item key.
  • Tags / collectionPOST /connector/updateSession with the sessionID after the save.
  • Collection/library targetPOST /connector/updateSession expects a Zotero tree target (L<libraryID> or C<collectionID>). Resolve explicit --collection names through POST /connector/getSelectedCollection, whose targets array exposes those IDs for editable libraries/collections. Local API collection keys are not sufficient for updateSession.
  • RIS / BibTeX / CSL filePOST /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 sessionID and 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

OutcomeStatusBody
Items imported201JSON array of the created top-level items
Parsed, nothing found201[]
Input unparseable400empty
session reused or missing409{"error":"SESSION_EXISTS"}
Content-Type absent500empty

Three traps follow from that table:

  • 201 does 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 400 carries 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 .bib returned six items and created seven — a BibTeX note = {…} field becomes a child note item, 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

BibTeXZoteroCarried through
@articlejournalArticleDOI, publicationTitle, volume, issue, pages
@inproceedingsconferencePaperproceedingsTitle, place, pages
@bookbookISBN, edition, publisher
@patentpatentpatentNumber, country; creators become inventor
@techreportreportinstitution, reportType
@miscdocumenthowpublishedextra: "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 /api prefix; 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's 0 sentinel.
  • 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/current returns {"userID", "username", "access":{"user":{…}, "groups":{…}}}. The access grants (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, so zot export csljson output 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=-scoped format=csljson query returns all matches in one page (limit is ignored; Total-Results equals the match count, no Link next), where the Local API paginates the same query one item per page. The unwrap-and-splice merge handles both.
  • Rate limiting (Web-only): 429/503 carry Retry-After: <seconds> (honored with bounded retries); a Backoff: <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) → no pybtex reimplementation.
  • 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

  1. A human, not an agent, mints a lease through an interactive step an agent cannot perform non-interactively.
  2. Each grant is scoped — which library, which operations. (Sub-library collection scope is deferred; see Q2.)
  3. Each grant is time-boxed and expires automatically; there is no unexpiring lease.
  4. Every write — and every refusal — is audited, and the potential blast radius is knowable before granting.
  5. 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 --web below).
  • 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 needsZotero Web APIZotero Local API (used by file upload)
Time-box / TTL on accessNo — keys are valid indefinitely unless manually revokedNo — local key is single-use or persistent ("Always Allow")
Scope below a library (per collection/project)No — per-library/group onlyNo — grants full local write
Mint a key programmaticallyOnly via a registered OAuth app + user handshakeVia the desktop authorize modal (a human approves)
Revoke a key programmaticallyYesDELETE /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, --web only): for --web writes, 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.libraries uses the canonical library token (kind:id) derived the same way at mint and enforcement; locally the user library is user:0, groups use their real id (see Q6).
  • scope.operations is a closed, per-command vocabulary (see Q3).
  • writeKey binds the credential into the lease (see Bound key), so expiry and revocation actually remove write ability.
  • scope.collections is 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 --yes cannot 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 grant ties 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 status flags it for as long as it runs (see Q7).
  • It requires an explicit library--library, or ZOTGO_LIBRARY for 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-run remains 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 of grant.)
  • 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 revoke actually 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:

ConditionMessage shape
No leaseno active write lease; run 'zot grant' to authorize writes
Expiredwrite lease expired at <ts>; run 'zot grant' to renew
Wrong operationlease does not permit item.delete; re-grant with that operation
Wrong librarynames 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 log and 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 the attachment.import operation 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 provisionaldocs/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

  1. Lease core. (Shipped.) The 0600 single-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-default WriteAuthorizer injected into the Client and enforced at writeRequest, applied only to non-interactive/--yes/ machine writes; per-command operation scope with fail-closed on any unmapped op; a canonical library token (user:0 locally — see Q6); dimension-specific refusal sentinels; and an append-only JSONL audit of every decision (allowed and refused), surfaced by grant status.
  2. #52 conforms. (Shipped.) Add attachment.import to the vocabulary; unblock and merge.
  3. --web hardening. (Roadmap — see the issue tracker.) Build the --web write 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.
  4. #99 conforms. (Roadmap.) Add ingest.bib to the vocabulary as a nondeterministic-target operation — withheld from the default grant, and authorized against the destination getSelectedCollection resolves rather than an assumed one (see Q8).
  5. 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.