Skip to content

Configuration reference

papio loads TOML from ~/.config/papio/config.toml (on Windows, %APPDATA%\papio\config.toml) unless the global --config <path> option selects another file. Configuration is layered over the built-in defaults; unknown TOML fields are rejected. papio init writes a validated user-only config file and papio doctor reports readiness.

The tables below list every decoded key in internal/config. Paths beginning with ~/ are expanded when papio loads them.

Integration credentials

papio config credentials manages TypeSafe, OpenAlex, Semantic Scholar, CORE, Crossref TDM, OpenAIRE, ILLiad and notification-webhook credentials through one service. The default store is macOS Keychain, Windows Credential Manager or Linux Secret Service. Linux needs an unlocked Secret Service; Papio never falls back to a plaintext file when a store is unavailable.

papio config credentials set sources.openalex
papio config credentials status
papio config credentials migrate

set TARGET reads a key at a hidden prompt and creates a fresh record. A secret manager can pipe input with --key-stdin. Never put a real secret in a command argument. Use the global --config <path> to select a profile.

The targets are sources.openalex, sources.semanticscholar, sources.core, sources.crossref_tdm, sources.openaire, agent.typesafe, notify.webhook, browser.document_delivery, and browser.resolvers.<name>.document_delivery. Delivery targets exist when that institution has an ILLiad route configured.

OpenAIRE and webhooks take a typed JSON record through stdin. These examples show the input shape; replace the placeholders through your secret manager, without putting credentials in shell history:

{"version":1,"kind":"openaire_client","client_id":"CLIENT_ID","client_secret":"CLIENT_SECRET"}
{"version":1,"kind":"openaire_token","api_key":"PERSONAL_ACCESS_TOKEN"}
{"version":1,"kind":"webhook","url":"https://hooks.example.org/PRIVATE_ENDPOINT","bearer":"OPTIONAL_BEARER"}

The complete webhook URL is secret storage material: paths and query strings can contain tokens. Omit bearer when the endpoint needs none. Records are limited to 2,048 encoded UTF-8 bytes to fit every supported OS store.

Configuration stores an explicit credential_ref, such as keyring:0123456789abcdef0123456789abcdef, at the consuming integration. Notifications use notify.webhook_credential_ref. Moving the config or data directory preserves this binding. Copying config to another machine does not copy its secrets. To share a record deliberately, use papio config credentials bind TARGET REFERENCE. The record's kind must match the target. Binding TypeSafe also enables cloud decisions for that profile; a key stored for another profile does not enable them.

For headless use, bind env:YOUR_SECRET_VARIABLE. Single-key integrations read the variable as a key; OpenAIRE and webhooks read the JSON shapes above. The variable must be available when binding and when starting the daemon. Papio snapshots selected variables and removes them before launching vault helpers, PDF workers or hooks. References are authoritative: a missing or invalid value never falls back to another credential.

status [TARGET] reports the selected source and whether it resolves locally; it does not test provider authentication. detach TARGET removes the binding without deleting a shared record. delete REFERENCE explicitly deletes an OS record and can affect other configs that refer to it. Restart the daemon after changes with papio daemon stop; the next ordinary command starts it again.

migrate converts this profile's legacy TOML secrets and old TypeSafe record. It validates the inventory, creates and verifies fresh records, rejects observed concurrent config edits, then publishes one atomic config update. A failed save leaves the original config in place and reports any staged references for cleanup. It leaves the old TypeSafe record intact and saves the previous config to config.toml.bak (mode 0600), which can still hold the literal secrets; delete it after you check the result. An active legacy TypeSafe environment override must be removed before migration. Install a compatible daemon and native host first: older strict config parsers cannot read the new reference fields.

Resolved secrets stay in runtime memory, separate from serializable config. Missing keys disable required-key integrations; services with a supported keyless mode continue at that mode's quota. papio doctor explains the selected credential's state. Unrelated acquisition remains available.

Agent acquisition

Run papio config agent set to enable TypeSafe/Jev for delegated browser acquisition when packaged and generic routes fail. It reads your key at a hidden prompt and saves it in macOS Keychain, Windows Credential Manager or Linux Secret Service. A secret manager can pipe the key into papio config agent set --key-stdin; avoid literal keys in shell commands. These commands are compatibility entry points to the shared credential service above. New setup writes an explicit reference, so moving the profile preserves its key binding. Existing path-derived records remain readable until migration. On Windows, run setup and start the daemon from your signed-in desktop session. SSH network logons cannot read Windows Credential Manager's user credential set; a daemon started there cannot use the saved key. This is a Windows logon-session constraint, not a missing key. Use a local terminal or let the browser's native host start the daemon.

Supplying the key opts into sending the article DOI, bounded title and sanitized control descriptions to TypeSafe; see Privacy. The credential never enters TOML, the extension or job history. Setup stores backend = "typesafe" and a non-secret credential_ref in the [agent] section. Empty or absent backend disables stored-key lookup; other backend values are currently rejected. This new section requires a compatible daemon and native host before it is added to an older installation's config.

Run papio daemon stop after setup; the next ordinary command starts the daemon with the new setting. papio config agent status reports saved configuration, credential availability and the current command's environment override, without printing a key or claiming the running daemon reloaded. papio config agent remove detaches the key and disables enrollment without deleting the stored record; restart afterward. Use papio config credentials delete REFERENCE --yes for explicit record deletion; without --yes it only describes the deletion, and it refuses a record this configuration still binds. If the OS store is unavailable, agent inference is disabled with a daemon diagnostic, while ordinary acquisition remains available.

PAPIO_TYPESAFE_API_KEY in the daemon's environment remains a legacy override when no agent.credential_ref is configured, and does not require [agent]. An explicitly empty legacy value disables Jev even when an old saved key exists. A new explicit reference takes precedence over this legacy variable. Removing the variable restores the profile's configured behavior after restart. To disable Jev completely, remove both enrollment and any launch environment override. Changing your shell's environment does not change an already running daemon.

Both daemon and extension must support agent_fallback_v1. Effective site access is still required, including for publishers without an adapter. The initial implementation handles controls on a DOI-identified article in a managed tab. Chrome uses its filename-steering download API. Firefox requires both peers to support native_click_adoption_v1. It uses a fresh download with an exact article referrer, then asks the daemon to copy and validate that file. The file must be directly in the parent of the configured adoption root, normally Downloads. Custom browser download directories must match that configuration. Missing referrers, ambiguous downloads, UNC paths and save dialogs remain assisted. With agent_navigation_v1, both browsers can follow a selected same-origin link in the same tab, verify the destination DOI and continue under the original attempt. Firefox transfers its unused reservation without resetting its expiry or download-directory baseline. Unexpected redirects, new tabs and native PDF viewer saving still require assistance. An HTML PDF wrapper can be followed when it exposes one matching file. This is distinct from saving a document already loaded in the native PDF viewer; signed viewer URLs are not replayed to simulate that capability. The daemon and decision contract are shared across operating systems. It allows up to 60 decisions in ten minutes per drive permit and 30 seconds per model call. Failures consume their call reservation; retries do not reset the budget. These limits are implementation defaults, not TOML settings.

The daemon removes the key from its process environment after reading it, before starting PDF workers, hooks or other integrations. The backend interface supports local implementations without a key or network; no local model runtime ships yet. Credential setup is currently a CLI command.

Top-level keys

Key Type Default Effect and constraints
access_mode string empty Required before acquisition. Allowed values are conservative, assisted, and delegated; a fresh guided papio init chooses conservative. Conservative records institutional OpenURL availability without opening a handoff; assisted and delegated can route eligible exhaustion to browser handoff.
email string empty Contact identity for polite API pools. Sent to third parties: as a query parameter to Unpaywall (required) and OpenAlex (required), to Crossref metadata and Retraction Watch when set, in the User-Agent of DOI-registration lookups when set, and in the User-Agent of open_books repository lookups (IRIS by default; OAPEN when open_books.oapen is on). See Privacy for the full recipient table. Doctor fails when an enabled source that needs one (Unpaywall, OpenAlex) has no email; OpenAlex without an API key runs on the smaller anonymous allowance instead, and doctor warns.
data_dir path string ~/.local/share/papio (Windows: %LOCALAPPDATA%\papio) Private writable data directory for the database, artifacts, and socket. Browser-download adoption does not follow this directory: the adoption root defaults to <your download folder>/papio (see [browser] download_adoption_root), while the historical <data_dir>/adoptions path remains drain-only for files already sitting there.

[fetch]

Key Type Default Effect and constraints
max_bytes integer bytes 104857600 (100 MiB) Maximum artifact-download size, and the largest file papio jobs supply-pdf accepts. It must be at least 1048576 (1 MiB).
timeout_seconds integer seconds 120 Fetch deadline. It must be in 5..3600 seconds.
allow_http_loopback boolean false Development and test override that permits HTTP loopback. Doctor warns while it is enabled; production policy is HTTPS-only.

[host_suppression]

Key Type Default Effect and constraints
enabled boolean true Pace the daemon away from publisher hosts that answer with access-control walls instead of files (challenge/403). Suppression skips the daemon fetch, never the candidate: the candidate still reaches the browser or institutional route.
failure_threshold integer 3 Kind-matching daemon failures (challenge + 403 pooled) with zero daemon fetch successes for the host inside the window before suppression starts. It must be between 1 and 100.
window_days integer days 90 Trailing window for the failure and success counts. It must be between 1 and 365.
probe_interval_days integer days 7 At most one real revival probe per suppressed host per interval. It must be between 1 and 90.
rate_limit_backoff_cap_seconds integer seconds 3600 Cap for the per-host 429 backoff honoring Retry-After. 429s back off, never suppress. It must be between 60 and 86400.
min_wall_span_hours integer hours 24 The walls inside the window must span at least this long before a host suppresses, so one burst of refusals from an intermittent host never does. 0 disables the check. It must be between 0 and 720.
excluded_hosts list of hostnames ["doi.org"] Hosts that never suppress (pure redirectors). Each entry must be a bare hostname.

The [host_suppression] section is strict-mode configuration: an older daemon rejects a config containing it. Deploy the binary that supports this section together with the configuration change.

[captures]

Key Type Default Effect and constraints
enabled boolean true Stores sanitized diagnostic page captures received from the extension in the local data directory. Disable it to keep no diagnostic page HTML.
max_per_host integer 10 Maximum retained captures for each host. It must be between 1 and 1000; when the limit is exceeded, the oldest captures for that host are removed.
max_age_days integer days 14 Maximum capture age. It must be between 1 and 365; older captures are removed.

The [captures] section is strict-mode configuration: an older daemon rejects a config containing it. Deploy the binary that supports this section together with the configuration change.

[pdf]

Key Type Default Effect and constraints
ocr_enabled boolean true Enables the OCR fallback. If it is enabled, doctor requires both pdftoppm and tesseract; disabling it makes image-only papers require review.
min_text_chars integer 400 Minimum extracted-text threshold used by PDF validation before OCR fallback is relevant.
max_ocr_pages integer 4 Maximum pages processed by the OCR fallback.
title_match_threshold number 0.6 PDF title-match threshold. It must be greater than 0 and no greater than 1.

[browser]

Key Type Default Effect and constraints
extension_id string empty The Chrome extension ID allowed to use the native host. It must be 32 characters from a through p; an empty value disables the bridge.
extension_ids list of strings empty Additional Chrome-family extension IDs allowed to reach the native host alongside extension_id — e.g. an Edge Add-ons store copy or a second keyed build, which carry different IDs than the Chrome Web Store package. Each is 32 chars a–p. The manifest's allowed_origins lists extension_id plus every entry here.
firefox_extension_id string empty The Firefox (Gecko) add-on ID allowed to use the native host — papio@orgmentem.com for the built extension. Accepts an email-style ID or a braced GUID; an empty value disables the Firefox bridge.
direct_routes_enabled boolean true Enables papio's compiled-in provider-direct HTTPS route candidates when the connected extension advertises support. Set false to disable these routes daemon-wide; institutional OpenURL routing is unchanged.
openurl_base_url string URL empty Legacy/default institutional OpenURL resolver base. It must use https:// and must not contain a # fragment or userinfo (named profiles follow the same rule); an empty value prevents default-profile institutional routing. Existing query parameters are preserved when papio adds citation metadata. Prefer the institution's direct-link-enabled endpoint so a single electronic service bypasses the resolver menu.
shibboleth_entity_id string URL empty Default institution's Shibboleth IdP entityID (https://). When set, a provider login wall is auto-routed to this IdP (skipping the WAYF selector). Empty disables federated login-routing for the default profile.
proquest_account_id string digits empty Default institution's ProQuest account id (digits, max 64). When set, papio appends ?accountid=<id> to unlock the institution's ProQuest link-resolver without a manual sign-in. Empty disables the append. During papio init you may paste a ProQuest URL containing accountid= instead of the bare id.
openathens_domain string domain empty Default institution's OpenAthens organisation domain: the <domain> in go.openathens.net/redirector/<domain>, such as example.edu. It must be a bare lowercase DNS name of at least two labels (letters, digits, hyphens and dots; no scheme, slash, port or query) and is rejected at load otherwise. When set, papio sends the DOI browser route through https://go.openathens.net/redirector/<domain>?url=https://doi.org/<doi>. That redirector signs in to the publisher with your institution's identity provider and then opens the article, so a paper whose publisher has a packaged adapter reaches its page without the library's discovery layer. papio offers it first and falls back to the openurl_base_url route when it parks without a PDF. Empty disables it. Built only from this value and the job's validated DOI — never from a page. Because config is strict-mode, deploy the binary that knows this key before you add it. See Use your library's OpenAthens link.
libkey_mode string empty LibKey routing for the default institution: empty or off disables it; link routes DOI/PMID handoffs through the keyless libkey.io/libraries/<id>/<doi-or-pmid> institution link ahead of the bare OpenURL resolver, falling back to OpenURL for works without a DOI or PMID and whenever LibKey is unavailable. api is reserved and rejected (not implemented). link requires both a positive libkey_library_id and a non-empty openurl_base_url on the default institution.
libkey_library_id integer 0 The institution's numeric Third Iron library id — the number in its BrowZine/LibKey.io URL (…/libraries/<id>/…). Required (positive) when libkey_mode = "link"; setting it without link mode is rejected rather than left silently dead.
default_resolver string empty Named [browser.resolvers.<name>] profile used when a request omits resolver (e.g. papio acquire without --resolver). Empty preserves the historical default institution. Must name a configured profile — the implicit default profile (when openurl_base_url is set) or a [browser.resolvers.<name>] key; an unconfigured name is rejected at load.
download_adoption_root path string empty Root for browser-download adoption; adoption is confined to a job subdirectory beneath it. When empty the effective value is <your download folder>/papio — on macOS ~/Downloads/papio, on Linux/BSD the XDG XDG_DOWNLOAD_DIR folder (falling back to ~/Downloads), on Windows the Downloads known folder. It must be a papio directory inside the folder your browser downloads into: Chrome can only steer a download to a path relative to its own download directory, so any other root adopts nothing at all, silently. papio init creates the effective root, and papio doctor's adoption_root check fails and names the resolved path when it is unreachable.
action_expiry_seconds integer seconds 1800 Browser-handoff expiry and the initial age before an open human action is reminded. Later reminders double their per-action interval through 24 hours. It must be in 0..2592000 seconds (30 days).
native_viewer_helper removed — Removed. It named the macOS helper program that saved a PDF from Firefox's viewer; the extension now saves that PDF itself. A config that still sets it loads, the value is ignored, and papio doctor warns with config_ignored_settings. Delete the line.

papio init can derive openurl_base_url from a pasted library discovery URL (--institution-url) or from the resolver configured in Zotero.

The browser path uses the user's ordinary Chrome session. It is not configured with passwords, MFA, CAPTCHA tokens, or publisher credentials.

papio init collects extension_id and firefox_extension_id during setup (Firefox defaults to the built add-on's papio@orgmentem.com), or set them non-interactively with --extension-id / --firefox-extension-id, so the native messaging host installs on the first run.

[browser.resolvers]

Named resolver profiles are per-institution tables keyed by a lowercase alphanumeric name; default is reserved for the implicit top-level institution and is rejected at load if used as a profile key. A name that matches a packaged provider route hint (a provider family such as sage-doi-pdf, or an exact route revision) is also rejected at load. Each carries its own OpenURL base and, optionally, the same shibboleth_entity_id, proquest_account_id, openathens_domain, libkey_mode, and libkey_library_id fields as the default [browser] institution — so a multi-institution user routes each job's login to the right library instead of inheriting the default's identity:

[browser.resolvers.campus]
openurl_base_url = "https://library.example.edu/discovery/openurl?institution=EXAMPLE"
shibboleth_entity_id = "https://idp.example.edu/idp/shibboleth"  # optional
proquest_account_id = "12345"                                     # optional
openathens_domain = "example.edu"                                 # optional
libkey_mode = "link"                                              # optional
libkey_library_id = 1234                                          # required for link mode

A profile may also be written as a bare string — campus = "https://library.example.edu/discovery/openurl?institution=EXAMPLE" — which is shorthand for a table with only openurl_base_url set. This keeps older single-base configs valid; add the table form when a profile needs its own login identity.

Select one with papio acquire --resolver campus, papio acquire --batch works.json --resolver campus, or the corresponding MCP field. The selected name is snapshotted in the job policy, so re-opened actions cannot silently fall back to another institution.

Set browser.default_resolver to one of these names (or leave it empty) to choose which profile an omitted resolver uses; an explicit --resolver/MCP resolver value always takes precedence, and an unconfigured default_resolver value is rejected at load.

On a tracked Alma/Primo resolver page, the extension may follow the first same-origin resolveService link selected by the institution's Online Services order. This emulates resolver direct linking without accepting provider terms or initiating physical-item, scan, or interlibrary-loan requests. Script access remains constrained by extension/manifest.json host permissions; an unlisted custom resolver origin stays in assisted mode.

[browser.document_delivery]

Configures the default institution's document-delivery / interlibrary-loan route (ADR-0017). Omitting the table disables it — a job that exhausts every acquisition candidate falls back to the profile's plain OpenURL route, the same behavior as before this table existed. The identical table nests under [browser.resolvers.<name>.document_delivery] for a named institution profile, alongside that profile's own openurl_base_url, shibboleth_entity_id, proquest_account_id, openathens_domain, libkey_mode, and libkey_library_id.

Key Type Default Effect and constraints
kind string none (required) Delivery adapter: openurl | libkey | illiad | custom. Required — papio never guesses which ILL system an institution runs from branding or a landing page. oclc and rapido are named as intended future providers but rejected: a kind whose adapter has not shipped must not parse, the same fail-closed rule sources apply to [sources.<name>].
base_url string URL empty Request form or API base. Used as the request-form URL for openurl/custom, and as the ILLiad Web Platform base for illiad. Must use https://.
patron_web_base_url string URL empty Patron-facing ILLiad web client base (the illiad.dll URL patrons use), permitted only for kind = "illiad". Must use https://. Never derived from base_url — shared-server sites and customised directories make derivation unreliable. When set, a fulfilled request's "View PDF" page (form 75) routes through the ordinary browser handoff for retrieval; when empty, fulfillment falls back to a recorded human action and the compiled profile reports fulfillment: none even if submission is auto-capable.
allowed_hosts list of strings empty Hosts a prefilled request form or API base may reach.
submit_policy string never never (default) | prefill_only | auto_if_unconditional. Narrows what the daemon-wide access_mode permits — it never widens it: under conservative the route is discovered and recorded only, never opened or submitted; under assisted the prefilled form opens but submission stays human. auto_if_unconditional is accepted only when kind = "illiad": openurl, libkey, and custom route to a form with no deterministic submission-and-reconciliation contract, so they compile permanently prefill_only.
request_classes list of strings empty Request classes this profile is declared for. v1 recognizes only digital_journal_article; any other value is rejected as not yet modelled.
legal_basis string unknown institution_policy | copyright_act_s49 | unknown. Configured, never inferred from a hostname. copyright_act_s49 (Australian document supply) compiles prefill_only permanently, by statute: the patron's declaration is an affirmative, per-request statutory act — "not previously supplied" — that no standing declaration can truthfully cover, and papio must never tick, script, or represent it. An AU-jurisdiction profile defaults patron_attestation to unknown until the institution confirms otherwise.
patron_attestation string unknown not_required | standing_completed | per_request | unknown. standing_completed counts only when the institution has confirmed a registration-time agreement covers API-created requests of this class — never inferred from an account's existence, a missing checkbox in one render, an API accepting a request, or the institution's country or hostname.
patron_fee_policy string unknown zero_standard | per_request | unknown. Only zero_standard can ever compile auto_capable — v1 auto-submission covers zero-patron-fee digital journal articles only; books, chapters, theses, physical loans, rush service, and any nonzero or provider-quoted fee stay prefill_only.
monthly_request_cap integer 0 (no cap) Bounds auto-submitted requests per calendar month; 0 means no declared cap. Values below zero are tolerated and behave as 0 (no cap).
status_poll_minutes integer minutes 0 (adapter default) Delivery status poll cadence; 0 uses the delivery service's own default. Values below zero are tolerated and behave as 0 (the default). Delivery polling draws on its own budget, never on ordinary resolver/HTTP retry counts, so a slow ILL turnaround cannot exhaust the acquisition waterfall's retry budget.
credential_ref string empty OS-store or environment reference for this institution's ILLiad key. Set it with papio config credentials set browser.document_delivery (or the named resolver target). Cannot coexist with api_key.
api_key string empty Legacy ILLiad application key, supported for compatibility. Migrate it into the credential service. Never sent to the extension or browser wire; rejected for other delivery kinds.
patron_ref string empty Configured, non-secret patron reference used to map requests to the institution's system. Personal identity data, not a secret: 0600 config only, redacted from events, diagnostics, and delivery provenance.

A compiled auto_capable verdict additionally requires one recorded live acceptance — a supervised submit-and-reconcile against the real deployment under the institution's authority — so a compiled adapter plus matching config is necessary but not sufficient. papio init prints the compiled gate class before saving (AUTO-CAPABLE with its evidence, or PREFILL ONLY with the specific blocker) and papio doctor verifies what is verifiable while keeping DECLARED configuration and PASS/OBSERVED facts strictly separate: neither command claims automatic submission for a profile that cannot actually reach it.

[browser.resolvers.campus.document_delivery]
kind = "illiad"
base_url = "https://ill.campus.example.edu/illiadwebplatform"
allowed_hosts = ["ill.campus.example.edu"]
submit_policy = "auto_if_unconditional"      # or "prefill_only" / "never"
request_classes = ["digital_journal_article"]
legal_basis = "institution_policy"           # or "copyright_act_s49"
patron_attestation = "standing_completed"    # or "not_required" / "per_request"
patron_fee_policy = "zero_standard"
monthly_request_cap = 25
status_poll_minutes = 60
credential_ref = "keyring:0123456789abcdef0123456789abcdef"
patron_ref = "configured-non-secret-reference"

[retraction]

Key Type Default Effect and constraints
scope string acquired Selects the DOI corpus for the daily Retraction Watch check. acquired checks works that papio has acquired. library also checks DOI records from every [[library.sources]] export and, when configured, the personal Zotero library through paged zotio --agent items list reads. Allowed values are acquired and library. The check downloads the Crossref Retraction Watch CSV at most once daily, caches its last valid copy in data_dir, and matches locally. Thus its request count does not grow with the library size. The CSV response limit is 256 MiB; the live dataset was 66.7 MB with 72,526 rows on 2026-09-15.

The default preserves the earlier job-scoped behavior. An upgrade does not silently disclose a wider DOI corpus. This strict-mode section requires a daemon version that recognizes it.

An empty or sharply reduced library export can indicate an unfinished write. The sweep keeps earlier notices and acknowledgements while it confirms the smaller size. Consecutive complete reads must report the same smaller DOI count over at least 72 hours before the sweep accepts it. A restored export, a different count, or an enumeration failure resets that window. The cache keeps the confirmation state across daemon restarts.

[zotio]

Key Type Default Effect and constraints
executable path or command string zotio zotio executable papio invokes at the Zotero boundary. Optional: an empty value disables the deep Zotero integration (auto-import, plan/apply, queue). When no generic library.sources authority is configured, ownership lookup then classifies every work as not-owned. Required only when auto_import = true.
timeout_seconds integer seconds 120 zotio command deadline. It must be between 5 and 600 seconds inclusive.
attachment_mode string stored zotio attachment mode. Allowed values are stored and linked-file.
auto_import boolean false Default acquisition policy for automatic zotio plan-and-apply after a job is ready. An acquire --auto-import request can opt in per job.
auto_import_paused boolean false Pauses all automatic Zotero imports, including retries and jobs that already saved auto_import = true. Acquisition and library lookups continue; job policies stay unchanged. This also pauses the follow-up retries after an import: a collection filing or abstract fill the Web API refused with 404 is repeated up to four times, and these pause too. Restart the daemon after changing this setting. Set it back to false to resume eligible pending imports. Explicit manual plan/apply commands are unaffected.
auto_enrich boolean true Fills a missing DOI and abstract on an auto-imported paper. In stored mode, a new paper whose Crossref record has no abstract gets the discovery source's abstract (OpenAlex by default) before Zotero desktop saves it. Any field still missing after the import gets the conservative scoped zotio enrichment of the imported parent.
exception_tags boolean false Enables the reconciled exception-tag ledger: papio maintains papio:needs-action and papio:unavailable as Zotero automatic tags on provenance-confirmed personal-library items, reconciling job state with current attachment state (papio zotio tags reconcile runs one pass on demand). Requires executable and zotio ≥ 0.13.0. Lifecycle states are never tagged; a same-name manual tag is never retyped or removed. After a daemon reload, turning this off makes the next pass remove papio-owned tags.
retraction_tags boolean false Enables the papio:retracted automatic tag on personal-library items whose DOI exactly equals the DOI of a current verified retraction notice (the retraction sentinel's cache; [sources.retraction_watch] must be enabled, and retraction.scope decides which works get notices at all). Corrections and expressions of concern are never tagged. papio zotio tags retractions previews the changes and --apply writes them; with the option on, the daemon also reconciles after the notice set changes and at most once a day. Acknowledging the inbox notice does not remove the tag. A withdrawn notice, or turning the option off, removes only the tag papio owns; a same-name manual tag is never retyped or removed. Requires executable and zotio ≥ 0.13.0.
unavailable_recheck_days integer days 14 How long an unavailable outcome parks an item before Zotero backfill or the source-independent daemon runner re-checks it through the ordinary submission path (open-access availability drifts upward). Must be between 1 and 365 inclusive.

papio invokes zotio but does not read or store Zotero credentials. Item and attachment mutation stays preview-first: papio zotio plan returns immutable plans and papio zotio apply requires the exact confirmation SHA-256. papio zotio tags reconcile is the separate exception: it converges papio's own papio:needs-action and papio:unavailable tags with no preview step. papio zotio tags retractions previews by default and writes only with --apply.

[hooks]

Key Type Default Effect and constraints
on_ready shell command string empty When set, runs once via the system shell (/bin/sh -c; on Windows cmd /d /s /c "<command>", which runs the command exactly as written, quotes included) each time a job reaches ready (validated artifact). Job metadata arrives as PAPIO_* environment variables. Fire-and-forget: a failing hook is recorded as a hook.on_ready job event but never fails or retries the job. Empty disables it. See the hooks guide.
timeout_seconds integer seconds 120 Deadline for one hook run. Validated (5..600) only when on_ready is set.

[filing]

Key Type Default Effect and constraints
folder string empty Opt-in managed folder destination. Requires an absolute path without surrounding whitespace. Empty disables it. The daemon keeps destination-specific receipts and retries safe folder writes. Arbitrary hooks.on_ready scripts remain best-effort and do not gain automatic retries.

See the filing guide for receipt inspection and explicit retry commands.

[[library.sources]]

Libraries papio consults to answer "do I already hold this paper?" for users who do not run Zotero. Repeat the table for each source (maximum 8 — every one is read on every search, batch, and discovery watch pass). Generic library.sources are ignored while zotio.executable is configured; otherwise they are the ownership authority for discovery watches: acquire watches, alert watches, and papio acquire --from-digest.

Key Type Default Effect and constraints
name string — Required, unique, and must have no leading or trailing whitespace. Identifies the source in papio doctor output and in warnings.
kind string — Required. file reads a bibliographic export; command runs a program and reads its standard output as the export. Anything else is rejected rather than ignored.
path string — Required for kind = "file", and must be empty for kind = "command". The bibliographic export to read. ~ is expanded and the resulting path must be absolute.
format string empty bibtex, ris, csl-json, or nbib. Empty detects from the path (file sources) and content.
claim string — Required, no default: pdf_present (entries whose full text you hold, so a match may skip acquisition) or record_present (citations only — annotates papio search but never skips).
argv array of strings — Required for kind = "command", rejected for kind = "file". The program and its arguments, run directly with no shell. The first element must be an absolute path; a leading ~/ expands when the command runs. papio never looks the program up on PATH, so a bare name such as papis or a relative path such as ./export is rejected when the config loads. The daemon's PATH is often not your shell's, so a PATH lookup could fail in the daemon, or run a different program there, after papio doctor accepted it. A program that is missing when the command runs fails the read, and papio doctor reports that the command could not be started. A shell string is not accepted: to use a pipeline, name the shell, for example ["/bin/sh", "-c", "papis export --all --format bibtex"]. The shell then finds papis on the daemon's PATH.
timeout_seconds integer seconds 30 kind = "command" only. Deadline for one run, 1 to 300. At the deadline papio stops the command and all the processes it started, and the read fails.
max_output_bytes integer bytes 33554432 (32 MiB) kind = "command" only. Largest standard output accepted, 1 to 134217728 (128 MiB). More output fails the read; papio never indexes a cut-off export.
refresh_seconds integer seconds 300 kind = "command" only. How long one successful run answers lookups before the command runs again, 10 to 86400.

A command source runs at most once per refresh_seconds, never once per paper. Lookups that arrive while it runs share that one run. The command inherits the daemon's environment. Its standard input is empty, and papio never reads or reports its standard error. When the command exits, papio stops every process it started and left running, so do not start background work from it. The command must print only the records its claim covers. For pdf_present, print only the entries whose PDF you hold; papio does not filter the output by attachment fields. A run fails when the command exits non-zero, passes timeout_seconds, prints more than max_output_bytes, or prints output that does not parse. A failed run never counts as an empty library: the source reports as unreadable. The previous successful run can then still mark papio search results, but it never skips an acquisition. After a failure, papio waits up to 30 seconds (or refresh_seconds, if shorter) before it runs the command again. When the last successful run is older than refresh_seconds plus 15 minutes, its results are no longer shown at all.

Both file and command sources keep their last good entry count in data_dir. A drop below one tenth of a previous count of at least 20 entries makes the source incomplete. This protects against truncated exports even after a restart. An intentional reduction becomes complete after consecutive successful reads report the same smaller count over at least 72 hours. A restored export, a different count, or a failed read resets that confirmation window.

Matching is exact on identifiers represented by the source format. No format supports every identifier, and titles are never matched. ISBN is excluded, because an edited volume shares one ISBN with every chapter in it. papio never infers PDF presence from per-manager attachment fields (BibTeX file, papis files) — the source declares it via claim.

Format DOI arXiv PMID
BibTeX yes yes yes
CSL-JSON yes no yes
NBIB yes no yes
RIS yes no no

A source unreadable to papio is reported as unreadable, not as holding nothing: papio acquire --batch then refuses to create jobs rather than re-downloading the whole batch. Before the fifth consecutive failure, each cadence attempts another run; a successful run resets the failure count. The fifth consecutive failure disables the watch. After fixing the source, force a run with papio watch run <id>; when it succeeds, papio watch resume <id> puts the same watch back on its schedule. --include-owned is available only for papio acquire --batch, meaning "proceed despite ownership uncertainty". Because a bibliographic export cannot say which manifestation it holds, such a source never satisfies an explicit --desired-version published request. papio doctor performs a fresh one-shot probe of each source and reports that read's record count and outcome; it does not report daemon cached age, count-collapse detection, or retained failure state. See the filing guide.

[notify]

The notification router has two independent legs: local desktop interruptions for a human, and webhook delivery for automation. Desktop delivery is best-effort and currently available only through the macOS sender; an unsupported platform is reported by papio doctor and does not consume the desktop rate budget. Webhooks are not subject to quiet hours, focused-surface suppression, or the desktop rate ceiling.

The daemon claims a webhook before it sends the POST. It does not send that intent again after a crash or a failed response. A later event that arrives after the claim gets a new delivery_key and its own POST. Activity attempts to record a failed attempt, but a logging failure can leave no Activity row. The JSON event carries the key so receivers can check each delivery.

Key Type Default Effect and constraints
enabled boolean true Enables best-effort local desktop notifications from the daemon. This affects only the desktop leg; it does not disable webhooks.
preset string milestones Base desktop/webhook routing policy. Accepted values are quiet, milestones, and verbose; per-category overrides below layer on top.
max_per_hour integer 6 Maximum desktop notification reservations in a rolling hour. 0 means unlimited. Must be in 0..10000. This ceiling never applies to webhooks.
quiet_hours string empty (disabled) Optional local civil-time interval in HH:MM-HH:MM form. The start and end must differ; spring-forward and fall-back transitions are handled as one local interval.
quiet_mode string hold Behavior for desktop notices during quiet_hours: hold keeps them pending until the window ends; drop records them as dropped. Accepted values are hold and drop.
digest_every_minutes integer minutes 240 Interval for pending-decision digests. 0 uses the four-hour default. Must be in 0..10080.
completion_quiet_minutes integer minutes 10 Quiet period before a batch completion checkpoint may be surfaced. Must be in 0..1440.
completion_max_hold_minutes integer minutes 120 Maximum hold for a batch completion checkpoint. Must be in 0..10080; when nonzero, it must be at least completion_quiet_minutes.
stall_after_minutes integer minutes 30 Minimum named episode age before a system-degraded/stall notice. Must be in 0..10080. A scheduled retry or delivery poll is not a stall.
categories table of tables empty Per-category overrides. Keys must be one of the seven names listed below; an unknown category is rejected.
webhook_url string URL empty When set, notification intents are delivered as POSTs to this automation endpoint, in the shape set by webhook_format (JSON by default). It is independent of enabled and desktop policy; must be an absolute https URL (plain http is accepted only for a loopback host: localhost, 127.0.0.1, or [::1]). A webhook credential record follows the same rule. papio does not follow redirects from the endpoint; a 3xx answer counts as a failed delivery.
webhook_credential_ref string empty Reference to a webhook record containing the complete endpoint URL and optional bearer. Set with papio config credentials set notify.webhook --key-stdin. Cannot coexist with webhook_url or webhook_secret.
webhook_secret string empty Legacy bearer token for webhook_url. Use papio config credentials migrate to move both into one record.
webhook_format string json Request shape for the webhook, whether it comes from webhook_url or a webhook credential record. Accepted values are json, ntfy, and slack; any other value is rejected. json posts {source, event, message, count, …, sent_at} with Content-Type: application/json. ntfy posts message as a plain-text body with Title: papio[: <event>][ (<count>)], Tags: papio[,<event>], and Priority (high for action.opened and action.reminder, default otherwise); a configured bearer is sent as Authorization: Bearer, which ntfy accepts. slack posts {"text": "*<title>*\n<message>"}, the Slack incoming-webhook shape, with &, <, and > escaped. ntfy and slack also send the stable delivery key in an X-Papio-Delivery-Key header, because their bodies have no field for it. An empty value means json.

The milestones preset is the default: it sends immediate coalesced standalone outcomes and newly opened decisions, a four-hour pending-decision digest, a bounded batch checkpoint/final summary, discovery as catch-up or digest material, capped integrity notices, and one system-degraded notice per episode. Work that is continuing or scheduled does not produce a desktop interruption. The preset treatment is:

Category quiet milestones (default) verbose
request_outcome Immediate failures; digest successes Immediate, coalesced Immediate
decision_opened Digest Immediate, coalesced over 5 minutes Immediate, coalesced over 60 seconds
decision_pending Off Digest, at most every 4 hours Immediate, once per pass
completion_batch Off One checkpoint when useful plus one meaningful final delta One checkpoint when useful plus one meaningful final delta
discovery_new Off Catch-up or desktop digest Immediate, coalesced
integrity_notice Immediate, capped Immediate, capped Immediate, capped
system_degraded Immediate, state-deduped Immediate, state-deduped Immediate, state-deduped

[notify.categories.<name>]

Use one table for each category whose routing should differ from the preset:

[notify.categories.decision_opened]
desktop = "immediate"   # off | digest | immediate
webhook = "immediate"   # off | digest | immediate
window_seconds = 300

The valid category names are request_outcome, decision_opened, decision_pending, completion_batch, discovery_new, integrity_notice, and system_degraded. desktop and webhook each accept off, digest, or immediate; an empty value inherits the preset. window_seconds defaults to the preset's window when 0 (or when omitted) and otherwise must be in 0..86400. Webhook mode is independent automation routing: it remains immediate by default and ignores desktop quiet hours, focus suppression, and rate limits. A category may override its webhook mode without changing its desktop mode.

This section is strict-mode configuration. Deploy the binary that understands these fields together with the configuration change; an older daemon rejects unknown [notify] fields.

[updates]

Key Type Default Effect and constraints
check boolean false Enables the daemon's once-daily background check for a newer papio or zotio release against GitHub's public release API. Sends no identifier, count, or telemetry beyond the anonymous request GitHub receives for any web hit. See Privacy. papio init's guided setup suggests enabling it (non-interactive --check-updates also defaults true); the config default when the key is absent (or on a config written before this key existed) is false. papio doctor reports the check as skipped while it is off. The same switch controls the once-daily fetch of the signed adapter control document (control_url below); with it off, the daemon makes no control request but keeps applying any control it already holds.
control_url string empty (= https://raw.githubusercontent.com/OrgMentem/papio/main/control/adapter-control.json) Where the daemon fetches the signed adapter control document when check is on. The document can only suspend exact packaged adapter revisions and safety domains, so a broken adapter stops automating without a store release; a higher sequence lifts it. Fetched at most once a day, on the same cadence as the release check. A 404 means nothing is published and is normal. The daemon verifies an Ed25519 signature against the papio control key embedded in the binary and refuses unsigned, tampered, replayed, or older documents, so the URL only has to be reachable, not trusted. Must be https, or http to a loopback host for a local test server. A build that embeds no control key fetches nothing. papio doctor reports the adapter control state and every suspension. See Privacy.

[discovery]

Key Type Default Effect and constraints
sources string array empty (= ["openalex"]) Discovery backends for papio search and watches, in merge-preference order. Valid entries: arxiv, openalex, semanticscholar (each at most once). Results merge with DOI-then-title deduplication; earlier backends win ties. arXiv supports text and submitted-year searches but not citation snowballs. Per-backend credentials bind through sources.<name>.credential_ref (optional for Semantic Scholar, which works keyless at public rate limits); arXiv needs no credential.

[open_books]

Configures the open_books source, which finds an open-access copy of a book from its ISBN. [sources.open_books] carries its enabled, rate_per_sec and burst; this table carries where it looks. The source acts only on a work whose sole strong identifier is the ISBN (papio acquire --isbn …): a request that also has a DOI, PMID, arXiv ID or OpenAlex ID is fetched by those, and the ISBN alone is still not a fetchable identifier for any other source. A repository record is a candidate only when its own ISBN field lists the requested ISBN, it carries an open licence statement (or the repository is open access by definition), and its ORIGINAL bundle holds exactly one PDF. See ISBN-only requests.

Key Type Default Effect and constraints
dspace_servers string array ["https://iris.who.int/server"] DSpace 7 REST roots to search, in order, at most 8 (the server's dspace.server.url; papio appends /api). Each must be an https URL with no credentials, query or fragment (plain http only for a loopback host), and none may repeat. An empty list searches no DSpace server. The default is the World Health Organization's IRIS. Listing a repository that also holds restricted items is safe: an item without an open licence statement is never a candidate.
oapen boolean false Also search the OAPEN Library, whose books are open access by definition. Off by default: its REST API refuses some networks (HTTP 403), and this lookup has not been verified against the live service. When on, a refusal is recorded against the source and the job carries on.

[actions]

Key Type Default Effect and constraints
stale_after_seconds integer seconds 604800 (7 days) How long an open human action may wait before listings report it stale. 0 selects the default; a negative value is rejected, and values above 31536000 seconds (365 days) are rejected. papio actions list marks stale rows (stale and age_seconds in --json), and nothing else happens: papio never cancels, expires, or sweeps a handoff on a timer.

This is deliberately not browser.action_expiry_seconds. That one (30 minutes by default) is a reminder cadence — how soon to nudge you again about an open action — so reusing it as a staleness threshold would report a handoff queued over lunch as abandoned. This one answers "has anyone given up on this?", which is measured in days.

[drive]

The paced drive opens parked handoffs for you, one paper at a time, oldest first, through the same path papio actions open uses. The extension's own handoff settings still decide where the tab opens (the work window or papio's tab group). The drive never accepts terms, never submits a delivery request, and never resolves an identity review. It only puts the handoff on screen, as an operator's open would.

Key Type Default Effect and constraints
enabled boolean false Turns the paced drive on. Off unless set: opening human work with nobody asking is an operator's decision for one machine.
max_opens_per_hour integer 10 Maximum paced opens in a rolling hour. 0 selects the default. Must be in 0..60.
job_backoff_hours integer hours 6 How long the drive leaves a paper it opened before it may open that paper again, whatever the result was. 0 selects the default. Must be in 0..168.
sign_in_wait_minutes integer minutes 10 How long a paced paper's institutional sign-in may wait for its return. After that the drive settles that paper (drive.sign_in_stalled), cools its route for job_backoff_hours, and moves on. When the two most recent paced papers, on two different DOI prefixes, both stall this way, the drive pauses and sends one decision_opened notification; it resumes by itself when any sign-in returns. 0 selects the default. Must be in 0..1440.
settle_minutes integer minutes 30 How long one paced open counts as in flight when the browser reports nothing about it. 0 selects the default. Must be in 0..1440.

The drive opens nothing while any of these holds, and papio drive status names each one as a blocker:

  • it is disabled, or paused by papio drive pause or because two paced papers in a row stalled at the sign-in;
  • the time is inside notify.quiet_hours;
  • no browser holds the papio session;
  • the hourly bound is spent;
  • its previous open is still in flight;
  • any browser claim or effect permit is live, including one an operator started;
  • a sign-in is open and still inside sign_in_wait_minutes;
  • no parked job is eligible.

Eligible jobs are those awaiting a person on one open openurl_handoff, and those on one open manual_download that papio jobs redrive would accept; the drive redrives such a job through the same store function first. When that redrive sends the job back to resolving (a manual download left by an open-access route, or a job whose institution has already reported no entitlement), the drive opens nothing on that pass: resolving offers the open-access handoff again when that route remains. It never opens an openurl_available advisory, because that link opens outside papio's window. It skips a job it opened within job_backoff_hours, and every job behind a provider host that is refusing this browser: a host in a browser.provider_cooldown, or a job with a Cloudflare-style challenge in the last 10 minutes. The skip covers the papers that went to that host and every paper under the same DOI prefix. A paced paper whose sign-in stalled cools its own route the same way.

Every paced open records drive.paced_open on the job and, when it opens a handoff, handoff.opened with principal pacer. Pauses and resumes are the system events drive.paused and drive.resumed. papio drive resume always holds: sign-in evidence recorded before it never pauses the drive again.

This section is strict-mode configuration. Deploy the binary that understands it together with the configuration change; an older daemon rejects the whole file when [drive] is present.

[sources.<name>]

[sources] is a map of resolver policies. Its keys are whitelisted separately from papio's general strict-unknown-field rejection: the map itself decodes freely, and validate() rejects an unrecognized key at load with the list below. The valid names are arxiv, europepmc, unpaywall, openalex, core, crossref_tdm, crossref_metadata, retraction_watch, semanticscholar, openaire, and open_books. One further name, openalex_content, is a removed key: an earlier papio release wrote it into Default() and no adapter for it ever shipped, so a config carrying it loads normally and drops the key silently rather than breaking on upgrade — it is not a valid key to add yourself. For arxiv and semanticscholar, enabled governs the acquisition resolver. Selection as a search backend is separate and lives in [discovery]; Semantic Scholar search also uses this section's resolved credential. For openaire, candidates come from the OpenAIRE Graph (metadata licensed CC-BY, acknowledged here and in candidate provenance); the keyless public limit is 60 requests/hour — the default rate_per_sec honors it. A resolved registered-service client pair raises the effective pacing; a short-lived personal token does not. Each named section accepts these keys:

Key Type Default Effect and constraints
enabled boolean source-specific; see below Enables the resolver policy.
credential_ref string empty OS-store or environment reference for OpenAlex, Semantic Scholar, CORE, Crossref TDM or OpenAIRE. Cannot coexist with literal credential fields. Resolved before source pacing and quota identity are chosen.
api_key string empty Legacy credential or token; use papio config credentials migrate to move it into the shared store. CORE and Crossref TDM require a key; OpenAlex supports a smaller keyless allowance and also needs email. For openaire this holds a personal access token, which OpenAIRE expires one hour after issuing it — usable for a manual check, not for unattended operation; use client_id/client_secret instead.
client_id string empty Legacy OpenAIRE registered-service client id. Migrate it together with client_secret into one atomic record. Unlike a personal access token these credentials do not expire, so this is the only OpenAIRE credential that survives unattended operation. Must be set together with client_secret; half a pair is ignored.
client_secret string empty Legacy secret paired with client_id. papio exchanges the pair for a short-lived access token at OpenAIRE's AAI endpoint and re-exchanges it before expiry; the secret is never logged or echoed in an error.
rate_per_sec number source-specific; see below Per-source request-rate budget.
burst integer source-specific; see below Per-source burst budget.
max_cost_usd number 0 Monthly budget for paid sources. 0 means unmetered.
daily_credit_fraction number 0 (OpenAlex default 0.5) Fraction of the provider's observed daily credit limit the daemon may spend before the daily fuse parks the identity. Must be in [0,1]. OpenAlex ships 0.5 in built-in defaults; other sources default to 0 until a fuse adopts them.
daily_credit_limit integer credits 0 Absolute operator override and hard maximum for the daily credit fuse; the fraction may never exceed this cap. 0 means no absolute ceiling (unmetered allowance from the observed limit × fraction). The egress debit still happens when metering is active.
sibling_title_search boolean false OpenAlex fuzzy sibling title search — a 10-credit search= query that looks for an open-access copy of a paywalled work under a different DOI. Off by default, because each search costs 10 credits and few searches find a copy that passes validation. When off, the hop makes no request at all and reports that it made none. Set true under [sources.openalex] to turn it back on.
allow_keep_alives boolean false When false (default), metadata transports open one connection per request — no HTTP keep-alive reuse, the replay-hygiene posture. Set true only with evidence that a high-rate source needs reuse; it is an opt-out of the safe default and requires per-source policy justification. HTTP/1-only negotiation is not configurable.
base_url_for_dev string URL empty Test/development endpoint override. If set, it must be a plain http URL whose host is exactly localhost, a 127.x.x.x address, or [::1] (no userinfo); do not use it for a remote production endpoint.
### Built-in source defaults

| Source name | enabled | rate_per_sec | burst | | arxiv | true | 1 | 1 | | europepmc | true | 2 | 2 | | unpaywall | true | 1 | 1 | | openalex | true | 2 | 2 | | openaire | true | 0.016 | 1 | | core | false | 0.4 | 1 | | crossref_tdm | false | 1 | 1 | | crossref_metadata | true | 1 | 1 | | retraction_watch | true | 1 | 1 | | semanticscholar | true | 1 | 1 | | open_books | true | 0.5 | 1 |

openaire's rate_per_sec looks oddly precise because it is pinned to a documented ceiling: OpenAIRE allows 60 requests per hour to unauthenticated callers, and 0.016 × 3600 = 57.6. Do not raise burst to make bursty demand go through — the hourly budget is burst + rate_per_sec × 3600, so at this rate a burst above about 2 would breach the published ceiling.

To get more OpenAIRE throughput, authenticate: 7,200 requests per hour is 120× the keyless allowance, and it is free. OpenAIRE issues two kinds of credential and only one of them can run a daemon:

  1. Fill in the personal information form — the newer registration flow gates both credential types behind it.
  2. Go to Registered Services, click + New Service, pick the Basic security level, and create it. Copy the Client ID and Client Secret it shows you.
  3. Save them as one openaire_client record with papio config credentials set sources.openaire --key-stdin, using the JSON shape in Integration credentials.

papio exchanges that pair for an access token as needed and refreshes it before it expires. The alternative credential — a personal access token — can be stored as an openaire_token record, but OpenAIRE expires it one hour after issuing it, so it is only useful for a manual check. papio doctor warns when api_key is carrying one.

Note that OpenAIRE reports x-ratelimit-limit: 7199 in its responses even to unauthenticated requests, so that header is not a safe basis for choosing rate_per_sec. Use the documented ceiling for the tier you are actually in.

Resolving a complete client credential pair also raises rate_per_sec to 1.9 and burst to 5 (6,845 requests/hour against the documented 7,200), because a credential that authenticated but changed nothing observable would be two knobs for one intent. An explicit rate_per_sec or burst in your config always wins. A personal access token in api_key does not raise pacing: it authenticates for an hour, and pacing to the authenticated ceiling on a credential that can vanish mid-hour would leave papio running at 120× an unauthenticated allowance.

Watch configuration

There is no [watch] section or watch-specific key in papio's TOML config. Watch query, year filters, OA filter, collection, cadence, and per-run cap are stored with each watch created by papio watch add or the corresponding MCP tool. Use papio watch list to inspect them and papio watch remove <id> to remove one.

Validation and file permissions

papio validates configuration when loading and saving it. It writes the config file with mode 0600 and its config directory with mode 0700; doctor reports a configuration permission failure when group or other read bits are present. Use papio doctor rather than weakening these permissions to diagnose a setup problem.