> For a complete page index of the Captain API documentation, fetch https://docs.captain.dev/llms.txt?excludeSpec=true

# PII Masking

> Mask personally identifiable information at index time. Set mask_pii on any indexing request and Captain masks PII in parsed text, document regions, and image pixels before anything is embedded or stored.

## Quick reference: PII masking

* Add `"mask_pii": true` to any `/v2/collections/{name}/index/*` request. Optional boolean, **defaults to `false`** (no behavior change when omitted).
* A **masking report** (each masked entity with its original value, page, and bounding box) is kept per job for 90 days. `GET /v2/jobs/{job_id}/pii` fetches it, `DELETE` destroys it. See [Masking report](#masking-report).
* Masking runs **after parsing, before embedding and storage**. Nothing unmasked reaches the search index, the chunk text, the returned regions, or the stored figure crops.
* What is masked:
  * **Text** (documents and image OCR): detected PII is replaced with entity tags: `<PERSON>`, `<EMAIL_ADDRESS>`, `<US_SSN>`, `<PHONE_NUMBER>`, `<CREDIT_CARD>`, `<IP_ADDRESS>`, `<LOCATION>`, and other high-value identifiers.
  * **Images** (standalone image files, and images embedded in PDFs): PII **text visible in the pixels** is covered with solid boxes. Masks OCR-readable text only; faces, handwriting, and signatures are on the roadmap but not available yet.
  * **Regions** (`include.regions` on the v3 query): the per-region text is masked too.
* Entity types masked by default (18): `<PERSON>`, `<LOCATION>`, `<EMAIL_ADDRESS>`, `<PHONE_NUMBER>`, `<CREDIT_CARD>`, `<IBAN_CODE>`, `<US_BANK_NUMBER>`, `<CRYPTO>`, `<US_SSN>`, `<US_PASSPORT>`, `<US_DRIVER_LICENSE>`, `<US_ITIN>`, `<MEDICAL_LICENSE>`, `<IP_ADDRESS>`, plus compliance entities `<US_NMLS_ID>`, `<US_SURETY_BOND_NUMBER>`, `<SURETY_COMPANY>`, `<SURETY_BOND_PRINCIPAL>` (label-anchored: bare numbers are never masked on their own, so loan numbers, NAIC codes, and amounts stay readable). Dates and generic numbers are intentionally NOT masked (avoids destroying ordinary content).
* **Custom PII fields**: add `pii_fields` (up to 20 `{name, description, examples}` objects) to mask categories of your own, such as employee ids or patient record numbers; each masked value becomes `<NAME>` and appears in the masking report under that name. Add `pii_instructions` (plain language, up to 1,000 characters) to steer what counts as personal data for the job. Both require `mask_pii: true`. See [Custom PII fields](#custom-pii-fields).
* **Three engines**, chosen per request with `pii_engine`:
  * `kev` (default): the Kev classifier model on Captain's infrastructure. Reads every token in context, so it catches identifiers with no fixed pattern (dates of birth, member ids, usernames, plates) and leaves look-alikes alone. Supports custom fields and instructions. **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.
  * `jev`: TypeSafe's hosted Jev model, the same classifier approach with slightly higher accuracy, best-effort capacity. Supports custom fields and instructions. May list fallbacks.
  * `presidio`: the legacy engine (pattern recognizers and a named-entity model). Built-in categories only: a request with `pii_fields` or `pii_instructions` (either one) is rejected with `422`. Never used as a fallback.
* **Default change**: `{"mask_pii": true}` with no `pii_engine` masks with `kev` and no fallback. Before, it masked with `captain-jev` and fell back to Presidio. To keep the old behaviour, send `"pii_engine": "captain-jev"`.
* **Fallback** (`jev` only): `"pii_fallback": {"engines": [...], "retry_budget_seconds": 60}`. `engines` is an ordered list from `jev-openrouter` (Jev served through OpenRouter), `jev-ai-gateway` (Jev served through Vercel AI Gateway) and `kev`. Omitted or empty means no fallback. Fallbacks run only on operational failures (rate limit, timeout, capacity, provider error, invalid model answer), never because nothing was found. Custom fields and instructions stay applied on every fallback. `retry_budget_seconds` (integer, 0 to 300, default 60) caps the extra retry and wait time per engine for each piece of a file; time spent on successful answers does not count, `0` means one attempt per engine, and normal request timeouts still apply. On the multipart `/index/file` endpoint, send the object as a JSON string form field.
* **Rejected with `422`**: fallback engines with `kev` or `presidio`; `presidio` in `engines`; a duplicate engine or the primary engine repeated; an unknown engine; `retry_budget_seconds` outside 0 to 300; `presidio` with `pii_fields` or `pii_instructions`; mixing contracts (`captain-jev` or `captain-presidio` with the fallback object, or `kev`, `jev` or `presidio` with `true` or `false`).
* **If every selected engine fails**, that file fails to index and the rest of the job continues. A failed file is never published partially masked, and the job's masking report says `incomplete: true`.
* **Report fields**: each file in `GET /v2/jobs/{job_id}/pii` carries `requested_engine`, `used_engines` (engines whose output was kept), `fallback_used`, `fallback_reason` (`rate_limited`, `timeout`, `capacity`, `provider_error`, `invalid_response`, `unavailable` or `null`), `coverage` (`full_requested`, `text_full_images_built_in` or `partial`), `model` (comma-separated when more than one model masked the file), and `image_redaction`. The job-level `engine` in that response and in job status `pii_report.engine` is the engine the request selected; `fallback` is `true` when any file used a fallback engine.
* **Earlier engine names** `captain-jev` and `captain-presidio` keep working as before, including the boolean `pii_fallback` (default `true`: `captain-jev` falls back to `captain-presidio`).
* **Audio and video files are skipped** when `mask_pii` is `true`: they are not indexed, and the job lists them as skipped.
* Dates and generic numbers are not masked by default; a custom field or `pii_instructions` can ask for them.
* It is **per-job**: only files indexed with `mask_pii: true` are masked. It does not retroactively affect data indexed earlier or without the flag.
* The customer's original source file (in your own bucket) is never modified: only the copies Captain generates and stores.

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

| Category     | What it matches                                                             | Masked in text | Masked 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

| Category          | What it matches                                             | Masked in text | Masked in images |
| ----------------- | ----------------------------------------------------------- | :------------: | :--------------: |
| `<EMAIL_ADDRESS>` | Email addresses.                                            |                |                  |
| `<PHONE_NUMBER>`  | Phone numbers in common national and international formats. |                |                  |

### Financial

| Category           | What it matches                                        | Masked in text | Masked 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

| Category              | What it matches                                                                   | Masked in text | Masked 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

| Category                  | What it matches                                                                                                                                                                                         | Masked in text | Masked 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

| Category       | What it matches          | Masked in text | Masked 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](#custom-pii-fields). If it is something
every customer would want, tell us at [support@runcaptain.com](mailto: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](#custom-pii-fields) 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`:

```bash
curl -X POST "https://api.filesearch.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:

| Part          | Required | What it does                                                                                                                                                                                                                                                 |
| ------------- | :------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name`        |    yes   | The 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`. |
| `description` |    yes   | What 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.                                           |
| `examples`    |    no    | Up 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:

```json
{
  "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`.

| Engine          | Model                                                 | Custom fields and instructions | Fallback                                            |
| --------------- | ----------------------------------------------------- | :----------------------------: | --------------------------------------------------- |
| `kev` (default) | Kev, on Captain's infrastructure                      |                                | None                                                |
| `jev`           | Jev, hosted by TypeSafe                               |                                | Optional: `jev-openrouter`, `jev-ai-gateway`, `kev` |
| `presidio`      | Presidio pattern recognizers and a named-entity model |                                | None                                                |

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](#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](https://typesafe.ai) 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](https://github.com/jaredpalmer/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](https://typesafe.ai). 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](https://microsoft.github.io/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:

```json
{
  "mask_pii": true,
  "pii_engine": "jev",
  "pii_fallback": {
    "engines": ["jev-openrouter", "jev-ai-gateway", "kev"],
    "retry_budget_seconds": 30
  }
}
```

| Engine in `engines` | What it is                                           |
| ------------------- | ---------------------------------------------------- |
| `jev-openrouter`    | The same Jev model, served through OpenRouter        |
| `jev-ai-gateway`    | The same Jev model, served through Vercel AI Gateway |
| `kev`               | Kev 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`):

```bash
curl -X POST "https://api.filesearch.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`](/reference/jobs/pii/get) says which
engine did the work. A trimmed response for a `jev` job whose file fell back to
`kev`:

```json
{
  "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.

| Field              | Meaning                                                                                                                                                                                                                                                                                                                                                                                                         |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `requested_engine` | The engine the request selected, `kev` when it was omitted.                                                                                                                                                                                                                                                                                                                                                     |
| `used_engines`     | The 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_used`    | `true` when any part of the file was masked by a fallback engine.                                                                                                                                                                                                                                                                                                                                               |
| `fallback_reason`  | Why the first fallback happened: `rate_limited`, `timeout`, `capacity`, `provider_error`, `invalid_response` or `unavailable`. `null` without a fallback.                                                                                                                                                                                                                                                       |
| `coverage`         | What 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. |
| `model`            | The 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_redaction`  | How 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](#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

```json
{
  "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
}
```

#### Get document

```json
{
  "document_id": "b8afc1138b3f41ed3f97eb0bc25bfebb",
  "collection_name": "captain_pii_masking_demo_collection",
  "chunk_count": 1,
  "chunks": [
    {
      "chunk_id": "b8afc1138b3f41ed3f97eb0bc25bfebb:0",
      "text": "# EMPLOYEE ONBOARDING PACKET\n\nThis packet contains the new hire record for <PERSON>.\nSSN <US_SSN>, email <EMAIL_ADDRESS>, phone <PHONE_NUMBER>.\nA scan of the employee ID card is attached below.",
      "regions": [
        {
          "type": "text",
          "text": "This packet contains the new hire record for <PERSON>.\nSSN <US_SSN>, email <EMAIL_ADDRESS>, phone <PHONE_NUMBER>.\nA scan of the employee ID card is attached below.",
          "page": 1,
          "bbox": { "top": 0.1076, "left": 0.0693, "width": 0.7279, "height": 0.0715 },
          "confidence": "high",
          "image_url": null
        },
        {
          "type": "figure",
          "text": "<LOCATION> DRIVER LICENSE\nDL <US_DRIVER_LICENSE>\nLN SMITH\n<PERSON>DOB 04/12/1988\nSSN <US_SSN>\n<EMAIL_ADDRESS>",
          "page": 1,
          "bbox": { "top": 0.23, "left": 0.0702, "width": 0.6365, "height": 0.2676 },
          "confidence": "high",
          "image_url": "https://...presigned..."
        }
      ]
    }
  ]
}
```

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

```bash
curl -X POST "https://api.filesearch.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
  }'
```

#### Index raw text

```bash
curl -X POST "https://api.filesearch.dev/v2/collections/{collection_name}/index/text" \
  -H "Authorization: Bearer $CAPTAIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Contact Jane Doe at jane.doe@example.com, SSN 123-45-6789.",
    "filename": "note.txt",
    "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`](#custom-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](#engines) and [Fallback](#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`):

```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](#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`](/reference/jobs/pii/get) 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`](/reference/jobs/pii/delete) 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:

```json
{
  "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.