Workflows & triggers¶
A workflow chains several zotio steps into one reviewed run with one preview, one approval, and a shared run ID for journaled writes (vault steps use their own reports instead of journal entries), with data flowing between steps, conditionals, and resume. It is the automation counterpart to safe-by-default writes: the same preview-first, one---yes contract, stretched from a single command to a whole plan. Steps run in order with no rollback, so a later failure leaves earlier successful writes applied.
What you'd use it for¶
Fill missing DOIs in one reviewed pass. Check the library, then enrich the missing-DOI queue in one scope — as a single operation you approve once:
{ "steps": [
{ "name": "diagnose", "args": ["library", "health", "--for", "all", "--json"] },
{ "name": "fix", "args": ["items", "enrich", "--missing-doi", "--scope", "collection:ABCD1234"] }
]}
zotio workflow run fix-dois.json # see exactly what it would change
zotio workflow run fix-dois.json --yes # apply it all on one approval
The diagnose step reports the gaps; the fix step selects its own missing-DOI queue inside the scope rather than consuming the health output. items enrich requires at least one category flag; select the matching flags for other gaps (--missing-abstract, --missing-pdf, and the rest). The preview shows the whole plan; one --yes runs it.
Prove a bibliography is submission-ready. Chain the checks that gate a paper so one command says pass or fail. --fail-on-unknown makes the bibcheck step exit 11 when a cited key is unknown or ambiguous; without it bibcheck only reports findings. Record the baseline first with zotio export snapshot --output corpus.jsonl, which writes the corpus.jsonl.manifest.json sidecar the verify step reads:
{ "steps": [
{ "args": ["items", "bibcheck", "thesis.tex", "--fail-on-unknown"] },
{ "args": ["export", "snapshot", "verify", "corpus.jsonl.manifest.json", "--fail-on-drift"] }
]}
Keep the library tidy automatically. Attach a hygiene workflow to the sync loop — it runs when the library changes and stays quiet when it doesn't:
Just one command?
Run a single command directly. Use a workflow when steps feed each other or must apply together.
Select cohorts, enrich MeSH terms, watch preprints¶
Combine cohorts with AND. Join two or more --scope arms with AND (capital letters, a space on each side) to select only the items that match every arm. It works wherever --scope works, including library health, items audit, items enrich, items summarize, items bibliography, and vault sync:
zotio library health --scope 'collection:ABCD1234 AND tag:include' --fail-on high
zotio items enrich --missing-doi --scope 'collection:ABCD1234 AND tag:include'
The text splits only where AND is followed by another arm (collection:, tag:, item:, query:, saved-search:). So query:trust AND safety stays one query, and lowercase and never splits. library cannot be an arm (exit 2). A saved-search arm that cannot be evaluated refuses with exit 9; it never counts as empty.
Saved searches without the desktop. Zotero desktop evaluates saved-search:KEY when it answers. When the desktop local API is unreachable or refuses access, zotio evaluates the synced search definition against the local mirror and prints a notice: line. searches run KEY does the same, and --data-source local forces the local evaluation; --data-source live turns the fallback off. The local evaluator supports text fields (title, publication title, publisher, DOI), creator, tag, collection, item type, and date conditions, with any/all join and the child-item flags. A saved search with any other condition (full text, notes, nested searches) exits 9 and names the condition; zotio never returns a partial result.
Tag biomedical items with MeSH terms. items enrich --missing-mesh reads an item's PMID (the PMID field or a PMID: line in Extra), or looks up its DOI in PubMed, and proposes the record's MeSH descriptors as automatic mesh/<descriptor> tags, for example mesh/Neoplasms. A DOI match is accepted only when the PubMed record lists the same DOI. Items that already carry a mesh/ tag are skipped, and so are records PubMed has not indexed yet. It previews by default and writes only with --yes. --email (or UNPAYWALL_EMAIL) is optional here; it gives NCBI a contact address. If PubMed is down or rate-limits, the command exits with an error. The items whose lookup failed get no MeSH terms; with --yes, the items whose lookup succeeded are still tagged.
zotio items enrich --missing-mesh --scope collection:ABCD1234 --email you@example.org # preview
zotio items enrich --missing-mesh --scope collection:ABCD1234 --email you@example.org --yes # apply
Watch for preprints that get published. zotio watch --health --health-check-preprints also watches the arXiv preprints in the library. Each cycle it looks up in arXiv and CrossRef the preprints whose last answer is older than --health-preprint-recheck (default 7d; accepts 168h, 7d, or 2w; at least 1h). When a preprint gains a published record, the watch output and the --health-webhook payload carry one new preprint_published finding, once. The finding points at zotio items preprint-check fix, which previews the update before it writes. --health-check-preprints requires --health, and --health-preprint-recheck requires --health-check-preprints (exit 2 otherwise). --health-scope limits the preprints that are checked. Answers are cached in the state directory (watch-preprints.json). A provider failure is a logged skip that keeps the last known answer. Each cycle makes at most 50 lookups, so a large library is covered over several cycles.
Preview, then apply¶
zotio workflow run workflow.json # preview: write steps run --dry-run, non-previewable steps are skipped, read-only steps run
zotio workflow run workflow.json --yes # apply the whole workflow on a single approval
Without --yes, the runner injects --dry-run into every step that writes to Zotero, writes a local file, or declares its own --dry-run, while read-only steps run normally, so the plan reflects real data and a preview never rewrites an export, report, or baseline. A step that cannot preview, such as sync, is skipped with the reason not previewable; runs only on apply. --yes approves the workflow; destructive steps also require invocation-level consent. Each mutation gets its own journal run_id, and all of them share one workflow_run_id. There is no rollback: if a later step fails, earlier successful writes stay applied.
A step may declare --allow-destructive, but that declaration does not approve the workflow. Apply also requires zotio workflow run workflow.json --yes --allow-destructive. Without that invocation flag, the runner exits 9 before any step runs and names the steps that require consent. The human preview marks destructive steps and shows their plans, even when a step suppresses its normal output. MCP workflow_submit uses the same rule: set its top-level allow-destructive to true, along with yes, to approve destructive steps. A step's flag alone cannot approve them.
Invocation-level consent does not add --allow-destructive to every step. Declare it only on steps that need it, so read-only steps retain their normal flags. For triggered workflows, pass --yes --allow-destructive to watch --workflow or tail --workflow. tail rejects --allow-destructive without --workflow.
The runner syncs each checkpoint file and its containing directory before executing the next step. Windows skips the directory sync because the platform does not support it.
zotio journal list --workflow <id> # the entries that run created
zotio journal undo <run-id> # preview the reversal of one entry's reversible parts
zotio journal undo <run-id> --yes # apply it, as with any write
The workflow ID is not an undo handle: journal undo and journal show take the run_id of one entry, so undo each listed entry in turn. journal undo reverses only the reversible operations and reports the rest as refused, so review the run's journal entries before assuming everything came back.
Rules the runner enforces
--dry-runalways wins — even alongside--yes, the run previews.- The workflow owns approval: steps that embed their own
--yes/--dry-runare rejected. - A step's
--allow-destructivedeclaration requires--allow-destructiveonworkflow runitself before apply. - A
${...}placeholder is data — it fills a flag's value but can't build a flag name or redirect a step to another command.
How it works¶
A workflow is a JSON file: an optional vars map, an optional continue_on_error, and an ordered list of steps. Each step is a named zotio argument vector, optionally with stdin_from (plus stdin_select), stdin_trigger, and when.
Variables¶
Declare vars and reference them as ${vars.NAME} in any step argument; override per run with repeatable --var:
An undeclared --var name is rejected (a typo guard).
Data flow between steps¶
A named step's output is addressable downstream:
${steps.NAME.output}— the trimmed stdout of an earlier step, substituted into a later step's arguments.${steps.NAME.json:PATH}— one string, number, or boolean selected from an earlier step's JSON output, substituted into a later step's arguments."stdin_from": "NAME"— pipe an earlier step's raw stdout into this step's stdin."stdin_from": "NAME", "stdin_select": "PATH"— pipe the values selected from that step's JSON output instead, one per line. This is the form--keys-from -reads.
That is how one step's findings become the next step's input. Only stdout flows into data; stderr never does. In preview mode the substituted values are the preview outputs.
Selecting JSON fields¶
A selector PATH walks the step's JSON output. Separate fields with dots. After a field, brackets work on an array:
| Selector part | Selects |
|---|---|
result.key |
the key field of the result object |
findings[0] |
element 0 of the findings array |
findings[] |
every element of the array |
findings[kind=missing_doi] |
the elements whose kind field equals missing_doi |
[0].key |
the key of element 0 when the output itself is an array |
Field names use letters, digits, _, and -. A selector has at most 256 characters and 32 parts. This workflow enriches only the items that library health reports as missing a DOI:
{ "steps": [
{ "name": "diagnose", "args": ["library", "health", "--for", "all", "--json"] },
{ "name": "fix", "args": ["items", "enrich", "--missing-doi", "--keys-from", "-"],
"stdin_from": "diagnose", "stdin_select": "findings[kind=missing_doi].item_key" }
]}
The runner checks every selection and fails closed:
- The step fails if the output is not exactly one JSON value, a field is missing, an index is out of range, or a value has the wrong type.
- An argument takes exactly one string, number, or boolean. A selector that can return a list (
[]or[field=value]) is rejected when the spec loads. stdin_selectwrites each selected string, number, or boolean on its own line, and a selected array contributes its elements. Objects,null, and values with line breaks fail the step.- A selection with no values skips the step (reason
stdin_select selected no values). The step never gets an empty key list. - A selected value fills an argument value only. The runner checks the command and the flag names after substitution, so a value such as
--yesor a subcommand name is refused.
The checkpoint stores each step's raw output, so --resume selects the same values again.
Conditionals¶
Run a step only on an earlier step's outcome with when:
is is one of ok, failed, or skipped. By default a failed step stops the run; set "continue_on_error": true so the workflow proceeds and when branches (a cleanup or notify step) can react.
Interrupted runs resume¶
An applied run records a checkpoint sidecar (<spec>.checkpoint.json) as it goes. If it's interrupted, continue where it stopped:
Resume is spec-hash- and variable-verified: it refuses if the spec or the resolved --var set changed since the checkpoint, and a step whose completion is uncertain is not silently replayed. A run started by a trigger also keeps its change batch in the checkpoint, and resume uses that batch. Re-running --yes while a checkpoint exists is refused — resume it, or delete the sidecar to start over. A successful run removes the sidecar.
Triggers¶
Run a workflow automatically when the library changes by attaching it to the sync loop:
zotio watch --workflow workflow.json # after every successful sync cycle
zotio watch --workflow workflow.json --workflow-on-change # only after a cycle that changed the mirror
zotio tail --workflow workflow.json # once after a poll cycle that emitted change events
watch --workflow fires after each successful sync; tail --workflow fires only on cycles that saw events (quiet when nothing changed). Triggered runs follow the same contract: they preview unless the watch/tail invocation itself carries --yes. With watch, a trigger failure is logged and the loop continues. With tail, a triggered workflow failure stops the command with an error and holds the cursor, so restarting tail can redeliver the same changes. A failed applied trigger leaves its checkpoint (in either command) — later applied triggers refuse until you resume or delete it (zotio workflow run workflow.json --yes --resume).
Add --workflow-on-change (it requires --workflow) to run the workflow only after a cycle that changed the local mirror. A change is a row that sync stored, rewrote with different content, or reaped because the object is gone upstream (an erased collection or a purged trash row). Tags and schema rows that come back unchanged are not changes. An unchanged cycle logs workflow skipped: library unchanged. A cycle whose sync failed, or in which any resource failed, never runs the workflow; its changes wait for the next complete cycle.
Changed keys from tail¶
A workflow that tail starts can read the change batch of that poll cycle. The batch is this JSON document:
{ "source": "tail", "resource": "items",
"events": [ { "event": "upsert", "resource": "items", "key": "AAAA1111" },
{ "event": "delete", "resource": "items", "key": "DDDD4444" } ],
"upsert_keys": ["AAAA1111"],
"delete_keys": ["DDDD4444"] }
Read it with the same selectors: ${trigger.json:PATH} in an argument, or "stdin_trigger": "PATH" for stdin. "stdin_trigger": "upsert_keys" feeds --keys-from - with only the changed records:
{ "steps": [
{ "name": "tag-new", "args": ["items", "tags", "add", "--tag", "to-read", "--keys-from", "-"],
"stdin_trigger": "upsert_keys" }
]}
zotio tail items --workflow tag-new.json # preview each change batch
zotio tail items --workflow tag-new.json --yes # apply each change batch
upsert_keyslists each upserted key once. Deleted keys are only indelete_keys, also when the same key was upserted earlier in the batch.- A batch without upserts skips the
upsert_keysstep (reasonstdin_trigger selected no values). The step never runs with an empty key list, so it cannot select the whole library. - The batch is data. It never approves a write: the run previews unless
tailitself has--yes. - The first poll (no stored cursor) lists the current records, not changes.
tailskips a workflow that reads trigger values on that poll and logsworkflow skipped. - A cycle with more than 500 events also skips that workflow and logs the count. A partial key list would leave changed records out, so the runner does not cut the batch. The cursor still advances; run the workflow over an explicit scope to cover those records.
stdin_triggercannot be combined withstdin_from.- A workflow that reads no trigger values runs exactly as before, on every cycle with events.
- A spec that reads trigger values cannot run without a batch:
zotio workflow runrefuses it with exit code 9.watch --workflowpasses no batch, so such a spec fails there too. Only--yes --resumeof an interrupted triggered run can continue it, with the stored batch.
From an agent — workflow_submit¶
Over MCP, an agent submits a workflow inline through the dedicated workflow_submit tool rather than the local-file workflow run runner (which stays CLI-only). Each submitted step names a mirrorable command and is validated against the same per-command safe-flag allowlist as command_run, then executed through the same preview-first runner — previewing unless the submission sets yes. Steps accept stdin_from with stdin_select, and the runner validates the selector exactly as for a local spec. See the MCP server guide and the MCP tools reference.
See also¶
- Safe-by-default writes — the mutation contract workflows extend.
- Command reference ›
zotio workflow— every flag, generated from the binary.