| name | paperless-ngx |
| description | Use for Paperless-NGX API work: search, upload, download, metadata, tags, status, logs, bulk ops. Triggers: paperless. |
Skill: Paperless-NGX API
Interact with a Paperless-NGX instance via its REST API using ocli
(openapi-to-cli) wrapped in a
small Python CLI.
All paths below are relative to this skill directory. When invoking the
scripts from elsewhere, use their absolute path.
Configuration
Connection details come entirely from the environment — nothing is hardcoded:
| Variable | Required | Purpose |
|---|
PAPERLESS_URL | yes | Base URL of your instance, e.g. https://paperless.example.com |
PAPERLESS_API_TOKEN | yes* | API token, sent as Authorization: Token <value> |
PAPERLESS_KEYRING_SERVICE | no | Keyring service name to read the token from (default: paperless) |
* The token may instead be stored in the system keyring (see Prerequisites);
the env var takes precedence when both are present.
Create the token in Paperless: Settings → My Account → API Token.
Prerequisites
See .env.example for a copy-ready template of these variables;
load it into your shell (set -a && source <your-env-file> && set +a) or export
the variables directly.
The pcli.py script pins ocli's working directory to this repo root, so the
.ocli/ profile cache (which contains your token) always lands inside this
repo where .gitignore covers it — no matter which directory you launch the
script from.
Setup (first time only)
python3 scripts/pcli.py setup
This configures the paperless ocli profile with the pn_ command prefix,
pointing at $PAPERLESS_URL.
Workflow
1. Discover — find the right command
python3 scripts/pcli.py search "list documents by tag"
python3 scripts/pcli.py search-regex "documents.*list"
python3 scripts/pcli.py list
2. Inspect — check parameters
python3 scripts/pcli.py inspect pn_api_documents
3. Execute — run the command
python3 scripts/pcli.py run pn_api_documents --page 1 --page_size 25
python3 scripts/pcli.py run pn_api_documents_id_get --id 42
python3 scripts/pcli.py run pn_api_search --query "invoice 2024"
Pipe through jq for filtering:
python3 scripts/pcli.py run pn_api_documents --page_size 5 | jq '.results[].title'
Common tasks
| Task | Command |
|---|
| List recent documents | run pn_api_documents --ordering -created --page_size 10 |
| Search documents | run pn_api_search --query "search terms" |
| Get document details | run pn_api_documents_id_get --id <ID> |
| Download document | run pn_api_documents_id_download --id <ID> |
| Upload document | Use pcli_upload.py (see below) — NOT ocli |
| Update document | Use pcli_update.py (see below) — NOT ocli |
| Delete one document | run pn_api_documents_id_delete --id <ID> (see "Deleting documents") |
| List tags | run pn_api_tags_get |
| List correspondents | run pn_api_correspondents_get |
| List document types | run pn_api_document_types_get |
| Statistics | run pn_api_statistics |
| System status | run pn_api_status (requires admin token) |
Updating documents (PATCH)
Do NOT use pcli.py run pn_api_documents_id_patch — ocli cannot serialize array fields (tags, permissions). It passes them as strings instead of JSON integer arrays, so the API may return 200 OK but the data silently does not change.
Use the standalone scripts/pcli_update.py script:
python3 scripts/pcli_update.py --id 49 --tags 54,55
python3 scripts/pcli_update.py \
--id 49 \
--title "New title" \
--correspondent 40 \
--tags 54,55
pcli_update.py sends a proper JSON PATCH via urllib, replaces the full tag list
when --tags is provided, and verifies the result with a follow-up GET.
Supported fields: --title, --tags, --correspondent, --document_type,
--storage_path, --created.
Creating metadata (tag / correspondent / document_type)
pcli.py run pn_api_tags_post --name X works but creates an owner-only object — the new tag/correspondent/document_type is visible only to the API token's user (default owner=<token-user-id>). Other users (or household/group members) then see "object does not exist" when they try to attach it. Symptom: a subsequent pn_api_documents_id_patch with the new id fails 400 Invalid pk "N" - object does not exist.
To create shared metadata visible to a group, POST with owner: null plus a
set_permissions block granting your target group. Send the JSON via urllib
(ocli cannot serialize the nested permissions object):
body = {
"name": "investment",
"owner": None,
"set_permissions": {
"view": {"users": [], "groups": [<GROUP_ID>]},
"change": {"users": [], "groups": [<GROUP_ID>]},
},
}
Recovery for an existing owner-only object: PATCH it with the same
owner: null + set_permissions payload. The id stays the same; only
visibility widens.
Uploading documents (POST multipart)
Do NOT use pcli.py run pn_api_documents_post_document — ocli cannot serialize multipart bodies.
Use scripts/pcli_upload.py (urllib-based, supports single + batch + metadata at upload time):
python3 scripts/pcli_upload.py --file invoice.pdf \
--title "Invoice 2026-05" --correspondent 40 --tag 54 --tag 55
python3 scripts/pcli_upload.py --file a.pdf --file b.pdf \
--correspondent 40 --tag 54
python3 scripts/pcli_upload.py --file a.pdf --no-wait
Supported flags: --file (repeatable, required), --title, --correspondent,
--document_type, --storage_path, --tag (repeatable), --asn, --created
(YYYY-MM-DD), --no-wait.
Default behaviour: after each upload, polls /api/tasks/?task_id=<uuid> every 2s
(timeout 5min) until status leaves PENDING/STARTED, then prints document_id.
Bulk uploads and async consume
The server consume queue is serial — one document is OCR'd/consumed at a
time. For a large batch (hundreds of files), do NOT call pcli_upload.py once
per file in the foreground waiting for each consume; a foreground wait blocks
for the whole queue. Decouple the POST from the consume instead:
- POST every file with
--no-wait. This returns the task UUID immediately
instead of polling, so all POSTs finish quickly while the server consumes
asynchronously. Set per-file metadata at POST time (--correspondent,
--document_type, --created, repeated --tag). Batch mode forbids
--title (would duplicate titles), so run one POST per file when each needs
its own title. Collect the returned UUIDs.
- Verify the queue accepted them, not just that POST returned 0. Read
pn_api_tasks and confirm your tasks are PENDING/STARTED/SUCCESS with
FAILURE count = 0. A duplicate (same SHA256) shows up here as a FAILURE
with a Not consuming …: It is a duplicate result — that is the only place
you see it; the POST itself still returned 200.
- Wait for the queue to drain before verifying metadata. Poll
pn_api_tasks until PENDING+STARTED reaches 0. Use a single background
waiter that exits once when drained, not a chatty short-interval poll.
- Reconcile counts before/after. Record the total-doc count (and the
target correspondent's count) before the batch; after drain, confirm the
deltas equal the number uploaded.
Note on consume-time automations
Some instances run automations (e.g. classification rules, OCR/LLM enrichment
add-ons) that fire on every consumed document and may add tags, change the
document_type, or rewrite the title shortly after upload. If yours does:
- Don't fight it — upload with the metadata the automation can't infer
(e.g. owner,
created date, correspondent) as the foundation, and let the
automation enrich type/topic tags on top.
- Re-reading a document immediately after upload shows pre-enrichment state;
wait for the automation to finish before asserting the final type/tags.
Finding semantic duplicates (SHA256 misses them)
Paperless auto-dedup is only byte-identical (SHA256 of file content). It
does NOT catch the same logical document arriving in a different form — e.g. a
phone-scanned image of an order and a downloaded PDF of the same order have
different bytes, so both consume successfully and you end up with two documents
for one thing.
Before a bulk upload whose items might already exist as scans (or vice versa),
search for each item's stable identifier in existing content FIRST:
python3 scripts/pcli.py run pn_api_search --query "<identifier>" --page_size 10 \
| jq -r '.results[] | "#\(.id) \(.title) | orig=\(.original_file_name)"'
If a pre-existing match turns up, treat the pair as a possible duplicate and
decide deliberately — never auto-delete. After an approved delete, re-search the
identifier to confirm exactly one document remains.
Deleting documents
Single document: pn_api_documents_id_delete works directly (no array body,
so unlike PATCH/bulk_edit it does NOT hit the ocli array-serialization bug):
python3 scripts/pcli.py run pn_api_documents_id_delete --id 21
A successful delete returns an empty body with exit 0 (HTTP 204 No Content) —
empty output is success here, not a failure. Always verify with a follow-up GET
that returns 404:
python3 scripts/pcli.py run pn_api_documents_id_get --id 21
Deletion is destructive and irreversible — get explicit confirmation before
deleting. It removes the document AND its files; it is a different operation
from acknowledging a failed consume task (see "File Tasks" below — that only
clears a task row, documents are untouched). For deleting many documents at
once, pn_api_documents_bulk_edit --method delete exists but is a destructive
bulk method — confirm the target id list first and verify each removal
afterward.
API reference
For the full list of endpoints, filters, and schemas, read
references/api_overview.md.
Tips
- All
pcli.py paths are relative to this skill directory. Use the full path when invoking from elsewhere.
- All responses are JSON. Pipe through
jq for extraction.
- List endpoints support
--page, --page_size, --ordering.
- Filter parameters follow Django ORM conventions:
--name__icontains, --tags__id__all, etc.
- When unsure about exact command name, use
search or search-regex first.
- The
pn_ prefix distinguishes Paperless commands from other ocli profiles.
ocli profile switching
ocli supports multiple API profiles. Only one profile is active at a time. If commands fail with "Command pn_api_... is not available for profile <other>", the wrong profile is active.
ocli profiles list
ocli use paperless
python3 scripts/pcli.py setup
pcli.py setup always activates the paperless profile at the end, so re-running setup is the safest fix.
File Tasks (consume failures) and acknowledging them
The Settings → File Tasks UI list is /api/tasks/. Failed consume jobs stay
in the list until acknowledged; they are NOT data loss.
Duplicate detection is by SHA256 of file CONTENT, not filename. A
"Not consuming X: It is a duplicate of <title> (#<id>)" failure means the
exact bytes already exist as document #<id> (see the task's
related_document field). Identical filenames are irrelevant — Paperless would
accept a same-named file with different content. Do not treat these as false
positives; the original document is already in the system.
List failed tasks:
python3 scripts/pcli.py run pn_api_tasks --status FAILURE \
| jq -r '(if type=="array" then . else .results end)[] | "id=\(.id) related=\(.related_document) \(.result)"'
Acknowledging (clearing) tasks via API requires the change_paperlesstask
permission. A normal user token typically has only view_paperlesstask, so
POST /api/tasks/acknowledge/ returns
403 {"detail":"You do not have permission to perform this action."}. Use a
token belonging to a user who has that permission (e.g. an admin/superuser). The
example below reads it from PAPERLESS_API_TOKEN_ADMIN — set that yourself to an
admin token; it is only needed for acknowledging tasks and no script reads it:
curl -s -X POST "$PAPERLESS_URL/api/tasks/acknowledge/" \
-H "Authorization: Token $PAPERLESS_API_TOKEN_ADMIN" -H "Content-Type: application/json" \
-d '{"tasks":[3817,3824,3825]}'
ocli's pn_api_tasks_acknowledge cannot serialize the tasks array (same
array-body limitation as PATCH) — use curl/urllib directly.
Documents are untouched — only the failed-task rows are marked acknowledged.
Known limitations
- ocli cannot serialize arrays in PATCH/PUT bodies. Commands like
pn_api_documents_id_patch and pn_api_documents_bulk_edit silently fail when the request includes array fields (tags). The API may return 200 OK but data does not change. Always use pcli_update.py for document mutations.
- ocli cannot do multipart file uploads. Use
scripts/pcli_upload.py (urllib-based) for document uploads.