CLI reference — wordsworth
wordsworth (alias wordsworthctl) is a dependency-free client for the
Wordsworth HTTP API. It lives in src/wordsworth/client.py and uses only the
Python 3 standard library, so it runs on any machine with Python 3 — you do not
need to install the wordsworth package or its dependencies. This makes it the
tool of choice on a machine that only reaches the API over the network (e.g. to
ingest a corpus).
Install
Put it on PATH as wordsworth (and wordsworthctl):
scripts/install-cli.sh [--url <api-base-url>] [--bin-dir <dir>]
--urlbakes a default API base URL into the installed copy, sowordsworth healthworks with zero configuration. TheWORDSWORTH_API_URLenvironment variable still overrides it.--bin-dirdefaults to~/.local/bin(ensure it is on yourPATH).
No package install is required — the script just copies the single stdlib file.
If the wordsworth package is installed (uv sync / pip install), the
wordsworth and wordsworthctl console scripts are provided too.
Configuration
Set the API URL once, persistently, so you don't repeat --url:
wordsworth config --url http://100.100.181.23:8000 # also --batch, --timeout
wordsworth config --show # print current config
This writes ~/.config/wordsworth/config.yaml (override the path with
$WORDSWORTH_CONFIG) — a flat key: value file (url, batch, timeout),
parsed with the standard library (so still no dependencies; it is a small YAML
subset, not full YAML). install-cli.sh --url writes it for you.
The API base URL is resolved in this order:
--url <url>on the command line$WORDSWORTH_API_URLurlin the config file- built-in default
http://localhost:8000
--batch / --timeout resolve as: flag → config file → built-in default.
Commands
Every console script in pyproject.toml is started in its own interpreter by
tests/test_entry_points_import.py. That test exists because an import cycle
kept wordsworth-ingest (the batch-ingest Job) from starting between
2026-09-22 and 2026-09-24, invisible to a test suite that runs in one process.
| Command | Description |
|---|---|
wordsworth health |
Check the API is up (GET /health). |
wordsworth ingest <path> |
Upload a PDF file, or every PDF under a directory, to POST /ingest. |
wordsworth search <query> [--size N] |
Lexical (BM25) search (GET /search). |
wordsworth hybrid <query> [--size N] |
Hybrid BM25 + vector relevance search (GET /hybrid). |
wordsworth ask <query> [--k N] |
RAG answer with citations via the local LLM (GET /ask). |
wordsworth state <document-id> |
Pipeline state of a document (GET /documents/{id}/state). |
wordsworth meta <document-id> |
Full metadata: duration, PII counts, step trail (GET /documents/{id}). |
wordsworth config [--url … --batch … --timeout …] [--show] |
Show or set persistent defaults. |
ingest
wordsworth ingest <file-or-directory> [--all] [--batch N] [--timeout SECONDS]
- Walks a directory recursively; by default only
*.pdf, or--allfor every file. - Uploads in batches of
--batchfiles per request (default 25). On slow (CPU-only) deployments use a smaller batch, e.g.--batch 5or--batch 1, so each request stays short. - Prints a per-file line —
filename: state, with the processing duration and non-zero PII counts on success (e.g.2130276.pdf: indexed (1.8s, bsn=1 person=2)), or the error class on failure — and a finalX/Y indexed, Z failedsummary. A file that fails does not abort the batch. Exit code is non-zero if any file failed. - The pipeline is PDF-only; non-PDF files come back as
error.
wordsworth-ingest
The batch-ingest Job (src/wordsworth/ingest_corpus.py), not the HTTP API.
wordsworth-ingest --corpus-dir ./corpus --dossier "zaak-a"
wordsworth-ingest --corpus-dir ./corpus --dossier "zaak-a" --manifest ./corpus/manifest.jsonl
--manifest is the stricter, provenance-proving path (see
docs/reference/ingest.md): an unlisted or sha256-mismatched file aborts the
whole run before anything is ingested, each document's provenance is read
back from the audit record after it is ingested, check.json next to the
manifest must pass, and WORDSWORTH_BACKEND_ENV=homelab is required. Without
--manifest, behaviour is unchanged.
wordsworth-dossier-uit-herkomst and wordsworth-dossier-hernoem
Correcting a classification, without guessing.
wordsworth-dossier-uit-herkomst herkomst.jsonl --weg-uit "corpus-2026-09" --dry-run
wordsworth-dossier-hernoem "corpus-2026-09" "Gooise Meren Woo-publicatie 2022"
uit-herkomst puts each document into the dossier its recorded provenance
names — scripts/eval/fetch_woo_corpus.py writes one line per document with the
source URL and the decision it belongs to. A document with no provenance entry is
not assigned and is counted. A classification derived from a date or a text
pattern is one nobody can retell afterwards, and that is worse than none.
Both commands update the search index, because the dossiers live there per
document: a membership the index does not know about is a document a scoped
search cannot reach. They update only that field — writing the whole document
back drops the embedding of any caller who forgets vector=, which is exactly
what cost 770 documents their vectors on 2026-09-18.
A document that is not in the index at all is reported, not created. It never got through the straat, and indexing it here would bury that under this.
--weg-uit removes them from a dossier they were wrongly placed in, once they
are somewhere else. If that leaves documents belonging nowhere at all, the count
is reported — such a document is invisible to every scoped search, and the moment
it happens is the moment someone can still act on it.
hernoem moves no document: the identity is the dossier, not the word used for
it.
The provenance file may contain duplicate lines — the fetcher opens it with
append, so running it twice duplicates every entry. Both commands key on the
filename, so that is harmless; it is only a reason not to count lines.
wordsworth-access-preflight
Reports the grants that go inert once callers are identities instead of key labels, and changes nothing.
wordsworth-access-preflight
A grant names who may reveal and the caller must be that recipient. A recipient that is a key label stops matching anybody the moment callers are people. Run this before switching, not after — see access identity.
wordsworth-backfill-dossier
Places documents that predate dossiers into one named dossier, so a scoped search can still reach them.
wordsworth-backfill-dossier "corpus-2026-09" --dry-run
wordsworth-backfill-dossier "corpus-2026-09"
A scoped search answers only from the dossiers in scope, so documents belonging to none are invisible — and every document ingested before dossiers existed belongs to none. A scope that makes the existing corpus unfindable is not a migration but a loss.
Name them for where they came from rather than pretending they were a case. They arrived as one corpus, in one go.
It also updates the index, because the index holds the dossiers per document:
a membership the index does not know about is a document a scoped search still
cannot reach, and then the command would not have done the one thing it exists
for. --no-index skips that and says plainly that a reindex stays due.
--dry-run does not touch the index. The database can be rolled back; the
index cannot. The first dry run against production wrote 770 documents before
this was fixed, which made "dry" a lie.
Run it after deploying the new code, not before: in between, the existing documents are in no dossier while the new code already requires a scope.
wordsworth-dedupe
Retires every document that is a copy of an object another document already is
(one-document-per-object). The oldest registered copy of each object survives;
the others become superseded, leave the search index and their dossiers
(each removal recorded, actor dedupe), and pass on any dossier only they were
in.
wordsworth-dedupe # dry run: what it would do
wordsworth-dedupe --apply --expect-copies 173 --expect-memberships 173 \
--expect-index-entries 159
--apply refuses to start without the numbers you expect, and stops before
changing anything if the database says otherwise: a cleanup that finds more
than it was sent for has found something nobody looked at. Index entries can
only be counted by removing them, so that number is checked afterwards and a
difference sets exit code 1. It commits one object at a time, so it never holds
a long transaction on documents. A rerun finds nothing left to do.
Since release 2 the database refuses a second live document for the same
bytes (uq_documents_live_object_key), so on a current database a dry run
finds nothing. On one that still holds copies, wordsworth-init fails when it
tries to create that index: run this command first.
A retired copy is not deleted: its audit trail requires the row. It still
answers GET /documents/{id}/state with superseded and superseded_by, and
reveal on it is refused with the survivor's id. Actually deleting a document —
removing it, not retiring it — is wordsworth-delete, below.
wordsworth-delete
Removes a document's content: the stored object, its text, its pseudonyms,
its mappings, its search-index entry and its grants. The documents row and
its audit trail stay — the trail is append-only, the same reason a superseded
copy keeps its row. There is no undo.
wordsworth-delete <document-id> [<document-id> ...] --actor <who> [--dry-run]
--actor is required and is checked the same way the API checks a caller:
only role beheerder (held via an active, unexpired grant naming it) or a
label in WORDSWORTH_GRANT_ISSUER_LABELS may delete. Any other actor is
refused and nothing is removed.
--dry-run reports the counts it would remove, using the exact same code
path, and stops before the first removal — no store, index or database
change, and no deleted record. Each named document commits on its own, so a
failure partway through a multi-document run leaves only the untouched ones
for a rerun to finish; removing what a previous run already removed is a
no-op.
--dossier
wordsworth-delete --dossier <dossier-id> --actor <who> [--dry-run]
Deletes a dossier instead of named documents — mutually exclusive with the
positional document ids. Removes the dossier's memberships, its topics and
the dossier row; a member document is deleted only if it belongs to no other
dossier, otherwise only its membership of this one goes. --actor and
--dry-run are checked and behave the same way as above; the dry run reports
which documents would be deleted, which would only be released, and the
number of topics (topics) that would be removed; the output of a real run
carries the same topics count.
See deleting documents for what goes, what
stays, and DELETE /documents/{id} / DELETE /dossiers/{id} over the API.
wordsworth-backfill-passages
Gives every indexed document a vector per passage (200-word windows every 150
words, wordsworth.passages), so hybrid search can rank a document by its best
passage instead of one vector for all of it (change
long-documents-rank-fairly).
wordsworth-backfill-passages # --workers 2 (one per Ollama instance)
New and reprocessed documents get passages when they are indexed; this fills in
the ones indexed before. It only sets the passages field (a partial update),
never the text or the document vector. It asks the index what is still missing,
so a stopped run resumes. A document with no text, or whose embedding fails
after the retry budget, is set aside and counted; exit code 1 if any failed.
Switch search over only when it is done: WORDSWORTH_HYBRID_KNN=passage. A
document without passages is invisible to the passage kNN half.
wordsworth-backfill-filenames
Gives existing documents back the name their file arrived under, by content hash.
wordsworth-backfill-filenames /data/corpus --dry-run
wordsworth-backfill-filenames /data/corpus
Ingestion has always been content-addressed (documents/<sha256>) — the right
identity, and unreadable. Documents ingested before the filename column existed
have no name in the database, but the bytes on disk still do, and the hash is the
bridge.
It does not guess. A document whose bytes are not in the directory keeps no name,
and the console then says naamloos (<first 8 of the hash>) rather than printing
a hash where a name belongs. A name already recorded is kept — that one came from
the caller at ingest time, which beats a file that happens to sit in a directory
today; --overwrite says otherwise.
Reports four numbers: named, kept, documents without a file, files without a document.
wordsworth-measure-combinations
Counts, per declared combination, in how many documents every type occurs. Measure before claiming: without this number, any statement about quasi- identifiers in a corpus is a guess.
wordsworth-measure-combinations profiles/example-wi.json
It accepts a profile (it reads the combinations block) or a bare JSON list of
{types, reason} declarations. Output is one line per combination:
12 BSN + POSTCODE
0 DATE + GENDER + POSTCODE [unobservable: DATE, GENDER]
Two things to read carefully.
The count comes from the pseudonyms the pipeline minted per document
(document_pseudonyms), not from re-running the detectors over the source text.
That makes it a statement about what the pipeline did, on the text it saw.
unobservable names types the corpus carries nowhere, because the pipeline has
no detector for them. "Does not occur" and "cannot be seen" are different
answers, and a bare zero gives the reassuring one.
Exit code 2 if the file declares no combinations.
Examples
# one-off, explicit URL
wordsworth --url http://100.100.181.23:8000 health
# ingest a corpus directory in small batches
wordsworth --url http://100.100.181.23:8000 ingest /data/corpus --batch 5
# search and inspect
wordsworth search "vergunning" --dossier zaak-a --size 5
wordsworth state 66c86e91-830f-4ed7-99cf-ac4407d262fb
# how often does a declared combination actually occur?
wordsworth-measure-combinations profiles/example-wi.json
See also the live, interactive API docs at /docs (Swagger) and /redoc on a
running instance.