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.