PII Masking
Captain can strip personally identifiable information (PII) from content at
index time. Set mask_pii: true on any indexing request and Captain detects
and masks PII in the parsed text, the layout regions, and the pixels of
images, before anything is embedded or written to the search index.
It is opt-in and per-job: the flag defaults to false, so existing indexing is
unchanged, and only the files you index with mask_pii: true are masked.
What gets masked
Masking covers three layers, so PII cannot leak through any of them:
-
Text: the parsed content of documents and the text Captain reads out of images. Detected PII is replaced with an entity tag, for example:
becomes
-
Images: for standalone image files and images embedded in PDFs, PII text that is visible in the pixels is covered with solid boxes (shown above). This applies to both the stored, retrievable image and the visual embedding.
-
Regions: when you query with
include.regions, the per-region text is masked as well, so the layout view carries no raw PII either.
Audio and video files are not masked. When a request sets mask_pii: true,
Captain skips them: they are not indexed, and the job lists them as skipped.
What Captain detects
Captain masks a curated set of high-value identifiers. The same detection rules run on text and on the text Captain reads out of image pixels. Each category is listed by the tag that replaces it in masked output.
Identity
Contact
Financial
Government and professional IDs
Compliance
The compliance entities are label-anchored: a bare number is never masked on its own, so loan numbers, NAIC codes, MIC numbers, and dollar amounts on the same form stay readable. Company names in ordinary prose (a header or a letterhead) are only masked when a bond label identifies them.
Network
In masked output, each detected value in chunk text (and in region text) is
replaced with its tag, for example <US_SSN> where the number appeared. In
images, the pixels of each detected value are covered with a solid black box.
If a PII category matters to your use case and is missing here, define it on the request with custom PII fields. If it is something every customer would want, tell us at support@runcaptain.com as well.
What is not masked
By default, Captain does not mask dates or generic numbers as a category. Masking those would corrupt ordinary content (a revenue figure, a row count, a timestamp) without meaningfully protecting anyone. A date that the surrounding text identifies as a person’s date of birth is masked, because the classifier reads the context; a delivery date or a meeting date is not.
That default is yours to change. If dates, amounts, order numbers or any other
value class are sensitive in your documents, describe them as a
custom PII field or say so in pii_instructions, and
they are masked for that job.
Image masking covers OCR-readable text in images. Detecting and blurring faces, handwriting, and signatures is on the roadmap but not available yet.
Custom PII fields
The built-in categories cover identifiers that mean the same thing everywhere. Many organisations also have identifiers of their own: employee badge numbers, patient record numbers, policy numbers, bed assignments, internal customer ids. Describe them on the request and Captain masks them in the same pass, with a tag of your choosing.
Add pii_fields to any indexing request that has mask_pii: true:
The stored text becomes:
Each field has three parts:
A request may carry up to 20 fields. The built-in categories always stay on; custom fields add to them.
Steering with instructions
Sometimes the question is not which categories to mask but whose values
count. A hospital may want patient names masked and clinician names kept; an HR
export may need salaries hidden but names left readable. pii_instructions
takes that guidance in plain language and applies it to the built-in categories
and to your custom fields:
Instructions are limited to 1,000 characters. Write them as rules about the content, not as commands to the system: what to mask, what to keep, and how to tell the two apart.
How custom fields behave
- Text and regions. Custom fields and instructions apply to parsed text,
the text Captain reads out of images, and the per-region text returned with
include.regions. Pixel masking of images uses the built-in categories only. - Every indexing endpoint. The fields ride on the same request as
mask_pii, on the bucket, directory and single-file variants of every connector, on URL and YouTube indexing, and on text and file upload. On the multipart file upload endpoint, passpii_fieldsas a JSON string form field, the same waycustom_metadatais passed there. - Per job. They apply to the files indexed by that request. Re-index a file to change its masking.
- Reported like everything else. The masking report lists each custom
value with your field name as its
typeand the tag as itsreplacement. - Validation.
pii_fieldsorpii_instructionswithoutmask_pii: true, a name that collides with a built-in tag, a duplicate name, or a description that is empty are rejected with422and a message naming the field. kevandjevonly. Thepresidioengine has no way to read a description, so a request that setspii_engine: "presidio"together withpii_fieldsorpii_instructionsis rejected with422.- Kept on
jevfallback. When ajevrequest falls back to another engine, your custom fields and instructions are applied there too. The earliercaptain-jevengine is different: its fallback tocaptain-presidiomasks the built-in categories only, without your custom fields or instructions. Set itspii_fallbacktofalsewhen those rules are required, or usekevorjev.
Engines
Captain has three masking engines. Pick one per request with pii_engine. The
default is kev.
A request with mask_pii: true and no pii_engine is masked by kev, with no
fallback. This is a change: before, such a request was masked by captain-jev
and fell back to Presidio when the model could not be reached. To keep that
behaviour, send "pii_engine": "captain-jev" (see
Earlier engine names).
How kev and jev read text
kev and jev are classifier models. Captain gives the model a piece of JSON
state and typed questions, and it returns calibrated probabilities. The yes/no
question type is a noul, from TypeSafe’s System One
question format, which both models implement. The model does the finding and
the deciding, in two passes over each chunk of text.
- Locate. Captain splits the text into tokens: words, numbers, and the runs that identifiers form (an email address, a phone number, a badge number, a URL path). Every token goes to the model with its surrounding text, and the model answers one noul per token: is this token part of a value that is personal data? Adjacent positive tokens become one value.
- Decide the kind. Each located value goes back to the model with one
choice question: which kind is it? The choices are the built-in categories
above, any
pii_fieldsyou defined, and “none of these”. Captain masks the value with the kind the model chose. A customer-defined kind wins when it fits, so a badge number defined asBADGE_NUMBERcomes out as<BADGE_NUMBER>, not as the built-in<EMPLOYEE_ID>.pii_instructionsis part of what the model reads in both passes.
Reading in context is what lets Captain mask a date of birth but not a due date, an employee id but not a ticket number, and a library called Jackson without treating it as a person. It also catches identifiers with no fixed pattern (dates of birth, member and employee ids, usernames, plates) that pattern recognizers miss, and it leaves more look-alikes alone.
kev (default)
kev runs Kev, an open-weights decision
model (Apache-2.0), on Captain’s infrastructure.
kev has no fallback. When kev is busy, Captain waits for capacity and
retries within the job. If kev still cannot mask a file, that file fails to
index and the rest of the job continues. A request that sets pii_fallback
engines with kev as the engine is rejected with 422.
jev
jev runs Jev, hosted by TypeSafe. It is slightly more
accurate than kev, and its capacity is best effort: under load it can rate
limit or time out. Pair it with a fallback list so those files are still
masked.
presidio
The engine Captain used before the classifier models: Microsoft Presidio
pattern recognizers with Captain’s additions (a high-confidence US SSN
recognizer, the compliance recognizers validated against the CFPB TRID model
forms and state licensing bond forms) and the spaCy en_core_web_trf
named-entity model for names and places. It masks the built-in categories
only. It does not accept pii_fields or pii_instructions; a request that
sets either one answers 422. It is never used as a fallback.
Earlier engine names
The engine names from earlier versions of this guide keep working exactly as
before. captain-jev masks with the classifier and takes a boolean
pii_fallback: true (the default) masks a file with captain-presidio when
the model cannot be reached, with the built-in categories only (custom fields
and instructions are not applied there), and false fails the file instead.
captain-presidio is the pattern engine. The two contracts do not mix: a
request that sends captain-jev or captain-presidio with the fallback
object, or kev, jev or presidio with true or false, is rejected with
422.
Fallback
Only jev takes a fallback. Set pii_fallback to an object with an ordered
list of engines to try when Jev cannot answer:
How it behaves:
- Order. Captain tries
jevfirst, then each engine inenginesin the order you list them. Omittingpii_fallback, or sending an emptyengineslist, means no fallback. - When it falls back. Only on an operational failure: a rate limit, a timeout, a capacity limit, a provider error, or an answer the model returned malformed. Finding no personal data is a successful result, not a reason to fall back.
- Custom masking is kept.
pii_fieldsandpii_instructionsapply on every engine in the list. retry_budget_seconds(a whole number from 0 to 300, default 60) caps the extra time Captain spends retrying or waiting on one engine for each piece of a file, before it moves to the next engine. Time spent on successful answers does not count. With0, Captain makes one attempt per engine and moves on at once. Normal request timeouts still apply.- Exhaustion. If every engine in the list fails, that file fails to index and the rest of the job continues. A file is never published with some of its content unmasked.
- Validation. These answer
422with a message naming the problem: fallback engines withkevorpresidioas the engine,presidioinengines, a duplicate engine or the primary engine repeated in the list, an unknown engine name, or aretry_budget_secondsoutside 0 to 300.
On the multipart file upload endpoint, send pii_fallback as a JSON string
form field (with the earlier name captain-jev, send true or false):
What the job reports
Each file in GET /v2/jobs/{job_id}/pii says which
engine did the work. A trimmed response for a jev job whose file fell back to
kev:
objects is left empty here for brevity. In a real response it lists each
report object with its signed download URL.
When any file in the job failed to index, the response carries
incomplete: true.
At the job level, engine is the engine the request selected (kev, jev or
presidio), and fallback is true when any file in the job used a fallback
engine. The job status response carries the same two values in pii_report
(see Masking report).
For jobs that used the earlier engine names, job status reports the engine the
request asked for, and GET /v2/jobs/{job_id}/pii reports captain-presidio
only when every file was masked by Presidio, and captain-jev otherwise. The
per-file engine fields are null for those jobs.
Masked responses
The examples below are real (trimmed) responses from a collection indexed with
mask_pii: true.
Search response
Get document
The figure region’s image_url serves the redacted crop: the pixels behind
each detected value are covered with solid black boxes, so the retrievable
image carries no raw PII either.
Enable it
Add "mask_pii": true to any indexing request. It works on every indexing
endpoint (S3, GCS, Azure, R2, Supabase, Backblaze, Dropbox, Google Drive,
SharePoint, OneDrive, URL, YouTube, and text/file upload), across the bucket,
directory, and single-file variants.
Index a file
Index raw text
Query the collection afterwards and the returned results[].text (and
regions, if requested) contains the entity tags, never the original values.
To mask identifiers of your own alongside the built-in categories, add
pii_fields to the same request. Without pii_engine, the
file is masked by kev; to choose another engine or give jev a fallback
list, see Engines and Fallback.
Masking report
Every job indexed with mask_pii: true keeps a record of what was masked, so
the original values can be handed to a system that needs them without ever
entering the index. Captain writes it while the job runs, as one JSON object per
file (doc.json) or per text chunk (chunk-{n}.json):
value is the original text; replacement is the tag that took its place in
the stored content. requested_engine, used_engines, fallback_used,
fallback_reason, coverage and image_redaction describe this file or
chunk, with the same meanings as in What the job reports.
engine is the engine that answered for this object, such as kev or jev, and
model names the model behind it: jaredpalmer/kev-4b for kev, and the Jev
model name for jev and its gateway routes. When more than one model masked
the object, model lists them comma-separated. bbox uses the
same 0 to 1 page-normalized coordinates as document regions. page and bbox are null for text-lane objects and for
documents so large that the parser returned no block layout.
Two endpoints manage the report, available to any key with the index
permission:
GET /v2/jobs/{job_id}/piilists the objects with signed download URLs (valid for at most 300 seconds) and entity counts. The response itself never contains a value.DELETE /v2/jobs/{job_id}/piidestroys the stored objects. AfterwardsGETanswers410withdeleted_at, forever; the report is never regenerated even though the source files still exist.
The job status response points at the report while it exists:
Reports are kept for 90 days, then expire (GET answers 410 expired). Jobs
indexed before 2026-08-28 have no report (409 predates_feature);
re-index them to obtain one. Every GET and DELETE is audited.
Notes
- Opt-in, per job. Files indexed without
mask_pii: trueare stored as-is; the flag does not retroactively mask previously indexed data. - Source files are untouched. Captain only masks the copies it generates and stores (parsed text, region data, figure crops). Your original file in your own bucket is never modified.
- Fail-safe. If masking cannot run for a file, that file fails to index
rather than being stored unmasked, and no part of it is published. With
jev, Captain tries the engines inpii_fallbackfirst. - Audio and video are skipped. With
mask_pii: true, audio and video files are not indexed and the job lists them as skipped. - Earlier engine names. Requests that use the engine names from earlier versions of this guide keep working as they did.