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:

    Contact Jane Doe at jane.doe@example.com, SSN 123-45-6789, phone (415) 555-0142.

    becomes

    Contact <PERSON> at <EMAIL_ADDRESS>, SSN <US_SSN>, phone <PHONE_NUMBER>.
  • 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

CategoryWhat it matchesMasked in textMasked in images
<PERSON>Names of people, detected by a named-entity model rather than a fixed list.
<LOCATION>Place names such as cities, states, countries, and addresses.

Contact

CategoryWhat it matchesMasked in textMasked in images
<EMAIL_ADDRESS>Email addresses.
<PHONE_NUMBER>Phone numbers in common national and international formats.

Financial

CategoryWhat it matchesMasked in textMasked in images
<CREDIT_CARD>Credit and debit card numbers from the major networks.
<IBAN_CODE>International Bank Account Numbers (IBAN).
<US_BANK_NUMBER>US bank account numbers.
<CRYPTO>Cryptocurrency wallet addresses.

Government and professional IDs

CategoryWhat it matchesMasked in textMasked in images
<US_SSN>US Social Security numbers in any common format (dashed, spaced, or unseparated).
<US_PASSPORT>US passport numbers.
<US_DRIVER_LICENSE>US driver’s license numbers.
<US_ITIN>US Individual Taxpayer Identification Numbers.
<MEDICAL_LICENSE>Medical license numbers.

Compliance

CategoryWhat it matchesMasked in textMasked in images
<US_NMLS_ID>NMLS unique identifiers with their label, in every common rendering: NMLS #123456, NMLSR ID, the Loan Estimate’s NMLS/__ LICENSE ID, and Closing Disclosure table rows with multiple IDs per row.
<US_SURETY_BOND_NUMBER>Surety bond numbers anchored to their label (Bond No. 106648000), including alpha prefixes, leading zeros, and rider suffixes such as 14862-A.
<SURETY_COMPANY>Admitted surety carrier names (the Treasury Circular 570 set) plus carrier-shaped legal names such as Pacific Coast Surety Company, across line breaks and in all-caps.
<SURETY_BOND_PRINCIPAL>The bond principal’s name where the document labels it: Name of Principal: Ficus Bank LLC or Ficus Bank, as Principal.

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

CategoryWhat it matchesMasked in textMasked in images
<IP_ADDRESS>IPv4 and IPv6 addresses.

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:

curl -X POST "https://api.captain.dev/v2/collections/{collection_name}/index/text" \
-H "Authorization: Bearer $CAPTAIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"content": "Employee E-48213 (Priya Raghunathan) moved to bed 12-B; contact 206-555-0177.",
"filename": "ward-note.txt",
"mask_pii": true,
"pii_fields": [
{
"name": "EMPLOYEE_ID",
"description": "Employee badge numbers: the letter E, a dash and five digits.",
"examples": ["E-48213", "E-10022"]
},
{
"name": "BED_NUMBER",
"description": "Ward bed assignments such as 12-B or 4A. They locate a patient, so treat them as personal data."
}
]
}'

The stored text becomes:

Employee <EMPLOYEE_ID> (<PERSON>) moved to bed <BED_NUMBER>; contact <PHONE_NUMBER>.

Each field has three parts:

PartRequiredWhat it does
nameyesThe tag. Uppercase letters, digits and underscores, 2 to 40 characters, unique within the request and not one of the built-in tags. Masked values are replaced with the name in angle brackets, and the masking report lists them under this name in type.
descriptionyesWhat the category covers, in plain language, as you would explain it to a colleague. This is what decides whether a value is masked, so say what the values look like and where they appear. Up to 300 characters.
examplesnoUp to ten sample values that make the description concrete. They are not a pattern: a value that does not resemble the examples is still masked when the description covers it.

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:

{
"mask_pii": true,
"pii_fields": [
{ "name": "PATIENT_RECORD", "description": "Medical record numbers, written MRN followed by seven digits." }
],
"pii_instructions": "Names of hospital staff (attending, charge nurse, pharmacist) may stay. Patient names, record numbers and bed assignments must be masked."
}

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, pass pii_fields as a JSON string form field, the same way custom_metadata is 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 type and the tag as its replacement.
  • Validation. pii_fields or pii_instructions without mask_pii: true, a name that collides with a built-in tag, a duplicate name, or a description that is empty are rejected with 422 and a message naming the field.
  • kev and jev only. The presidio engine has no way to read a description, so a request that sets pii_engine: "presidio" together with pii_fields or pii_instructions is rejected with 422.
  • Kept on jev fallback. When a jev request falls back to another engine, your custom fields and instructions are applied there too. The earlier captain-jev engine is different: its fallback to captain-presidio masks the built-in categories only, without your custom fields or instructions. Set its pii_fallback to false when those rules are required, or use kev or jev.

Engines

Captain has three masking engines. Pick one per request with pii_engine. The default is kev.

EngineModelCustom fields and instructionsFallback
kev (default)Kev, on Captain’s infrastructureNone
jevJev, hosted by TypeSafeOptional: jev-openrouter, jev-ai-gateway, kev
presidioPresidio pattern recognizers and a named-entity modelNone

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.

  1. 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.
  2. 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_fields you 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 as BADGE_NUMBER comes out as <BADGE_NUMBER>, not as the built-in <EMPLOYEE_ID>. pii_instructions is 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:

{
"mask_pii": true,
"pii_engine": "jev",
"pii_fallback": {
"engines": ["jev-openrouter", "jev-ai-gateway", "kev"],
"retry_budget_seconds": 30
}
}
Engine in enginesWhat it is
jev-openrouterThe same Jev model, served through OpenRouter
jev-ai-gatewayThe same Jev model, served through Vercel AI Gateway
kevKev on Captain’s infrastructure

How it behaves:

  • Order. Captain tries jev first, then each engine in engines in the order you list them. Omitting pii_fallback, or sending an empty engines list, 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_fields and pii_instructions apply 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. With 0, 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 422 with a message naming the problem: fallback engines with kev or presidio as the engine, presidio in engines, a duplicate engine or the primary engine repeated in the list, an unknown engine name, or a retry_budget_seconds outside 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):

curl -X POST "https://api.captain.dev/v2/collections/{collection_name}/index/file" \
-H "Authorization: Bearer $CAPTAIN_API_KEY" \
-F "file=@intake-form.pdf" \
-F "mask_pii=true" \
-F "pii_engine=jev" \
-F 'pii_fallback={"engines": ["kev"], "retry_budget_seconds": 0}'

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:

{
"job_id": "job_01HZP5G8N3",
"state": "retained",
"entity_count": 24,
"engine": "jev",
"fallback": true,
"files": [
{
"file_id": "f05d93c3da314ab4fa24077afc08bd04",
"entity_count": 24,
"truncated": false,
"requested_engine": "jev",
"used_engines": ["kev"],
"fallback_used": true,
"fallback_reason": "rate_limited",
"coverage": "full_requested",
"model": "jaredpalmer/kev-4b",
"image_redaction": "built_in",
"objects": []
}
],
"incomplete": false,
"truncated_objects": false,
"expires_in_seconds": 300
}

objects is left empty here for brevity. In a real response it lists each report object with its signed download URL.

FieldMeaning
requested_engineThe engine the request selected, kev when it was omitted.
used_enginesThe engines whose masking output was kept for this file, in the order they were first used. An engine that was tried and failed is not listed.
fallback_usedtrue when any part of the file was masked by a fallback engine.
fallback_reasonWhy the first fallback happened: rate_limited, timeout, capacity, provider_error, invalid_response or unavailable. null without a fallback.
coverageWhat ran on the file. full_requested: every masking capability you asked for ran, including custom fields and instructions. text_full_images_built_in: custom fields or instructions were requested and applied to text, and image pixels were masked with the built-in categories. partial: requested instructions could not be applied. It describes what ran, not a guarantee that nothing was missed.
modelThe model that answered, such as jaredpalmer/kev-4b. When more than one model masked the file, the value lists them comma-separated. Each report object records its own model, so the file can be traced chunk by chunk.
image_redactionHow image pixels were masked: built_in (the built-in categories), or null when the file had no images. Pixel masking never applies custom fields or instructions.

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.

{
"query": "SSN",
"results": [
{
"chunk_id": "133671d8fe07301cc1c8c1e52419b447:0",
"score": 0.142857,
"rerank_score": 0.72265625,
"text": "Uploaded image:\n\n# EMPLOYEE ID CARD\n\nName: <PERSON>\n\nSSN: <US_SSN>\n\n<EMAIL_ADDRESS>\n\nPhone: <PHONE_NUMBER>",
"modality": "pdf",
"match_sources": ["content_embedding", "keyword", "ocr"],
"document": {
"id": "133671d8fe07301cc1c8c1e52419b447",
"filename": "jane-card.png.pdf"
}
}
],
"total_results": 10
}

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.

curl -X POST "https://api.captain.dev/v2/collections/{collection_name}/index/s3" \
-H "Authorization: Bearer $CAPTAIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"bucket_name": "my-documents",
"aws_access_key_id": "AKIA...",
"aws_secret_access_key": "...",
"processing_type": "advanced",
"mask_pii": true
}'

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):

{
"schema_version": "1.3",
"file_id": "f05d93c3da314ab4fa24077afc08bd04",
"job_id": "job_01HZP5G8N3",
"category": "DOC",
"requested_engine": "kev",
"engine": "kev",
"used_engines": ["kev"],
"fallback_used": false,
"fallback_reason": null,
"coverage": "full_requested",
"image_redaction": null,
"model": "jaredpalmer/kev-4b",
"entity_count": 24,
"truncated": false,
"entities": [
{
"type": "US_SSN",
"value": "123-45-6789",
"replacement": "<US_SSN>",
"score": 1.0,
"page": 1,
"bbox": { "top": 0.1076, "left": 0.0693, "width": 0.7279, "height": 0.0715 },
"block_type": "Text"
}
]
}

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}/pii lists 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}/pii destroys the stored objects. Afterwards GET answers 410 with deleted_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:

{
"pii_report": {
"state": "retained",
"url": "/v2/jobs/job_01HZP5G8N3/pii",
"engine": "kev",
"fallback": false
}
}

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: true are 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 in pii_fallback first.
  • 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.
© 2026 Captain