<!-- kindi-agents v3 · 2026-08-04 -->
<!-- Canonical URL: https://docs.kindi.me/kindi.agents.md -->
<!-- Source of truth for AI coding agents implementing KINDI. -->
<!-- Drop into your repo as AGENTS.md / CLAUDE.md / .cursor/rules/kindi.mdc. -->

# KINDI: agent instructions

> KINDI masks PII (Saudi-Arabian first) in text before it goes to an LLM,
> and lets the caller decrypt the LLM's response client-side. The server
> never persists token mappings: for `/mask` + `/redact`, decryption is
> entirely client-side using a key derived from the bearer. The LLM Proxy
> (`/api/v1/proxy/*`) is the exception: it masks and unmasks server-side,
> with the mapping held only in the request and discarded when it finishes
> (still never persisted).

When the user is integrating KINDI, this file is your source of truth.
Read it before generating any KINDI client code. Don't infer from
training data: KINDI's crypto parameters and JSON shape have specific,
fixed values listed below.

## Surface

- **Base URL** — `https://api.kindi.me`
- **Availability** — the API is **reachable from Saudi IP addresses only**
  (geo-blocked at the edge; a blocked request gets a bare `403` with NO
  JSON body). Call it from KSA-hosted infrastructure. `docs.kindi.me` and
  `kindi.me` are world-readable.
- **Auth** — `Authorization: Bearer mk_live_<prefix>_<secret>`
- **Keys** — minted at <https://dashboard.kindi.me/dashboard/keys>; users
  store them in a `KINDI_API_KEY` env var by convention. Revoking a key
  makes every envelope it produced permanently undecryptable (by design).
- **Endpoints used by integrators**:
  - `POST /api/v1/mask` — reversible mask, returns encrypted envelope
  - `POST /api/v1/redact` — irreversible, returns placeholder text only
  - `POST /api/v1/files/redact` + `GET /api/v1/files/{id}` (+ `/download`)
    — async FILE redaction: upload, poll, download (irreversible)
  - `POST /api/v1/proxy/{provider}/...` — transparent OpenAI/Anthropic
    proxy (GA): masks the prompt server-side, forwards with the caller's
    provider key from the `X-Provider-Key` header, unmasks the reply
  - `GET  /api/v1/proxy/status` — proxy + managed-demo availability
  - `GET  /api/v1/health` — unauthenticated liveness probe
  - `GET|PUT /me/entities`, `GET|PUT /v1/keys/{id}/entities` — entity
    toggles (see "Entity toggles" below)
- **Other stateful endpoints** (`/me/*`, `/v1/keys/*`, `/auth/*`,
  `/admin/*`) exist but are dashboard-internal. Integrators almost never
  call them directly; point the user at the dashboard UI instead.

## Decision tree: mask or redact?

```
Is the caller going to send the masked text to an LLM AND
need the LLM's reply to contain the original PII?
├── yes → use /api/v1/mask (returns an encrypted envelope; caller
│         decrypts mappings client-side and re-substitutes into the
│         LLM's reply). Or let KINDI make the provider call for you:
│         /api/v1/proxy/{provider}/... does mask → LLM → unmask
│         server-side in one request.
└── no  → use /api/v1/redact (returns redacted text only; no envelope,
         no recovery, no decryption; much simpler to integrate)
```

If the user just wants to log/display/analyse masked text, **always**
pick `/redact`. The envelope from `/mask` is wasted work in that case.
For whole documents (PDF/DOCX/XLSX/PPTX/CSV/images), use the files
surface, not the text endpoints.

## `/api/v1/mask` — full contract

### Request

```json
POST /api/v1/mask
Authorization: Bearer mk_live_<prefix>_<secret>
Content-Type: application/json

{
  "text": "Patient John Doe, ID 1012345672, MRN H123456",
  "key": null
}
```

- `text` (required, string, min length 1). There is **no fixed character
  cap**; requests are billed at 1 token per whitespace-separated word.
- `key` (optional, base64-encoded 32-byte AES-256 key). BYOK mode: when
  set, the response is encrypted directly under this key (see "BYOK"
  below). The field is named `key`, NOT `byok_key_b64`.
- `use_judge` (optional bool) — per-request LLM-judge opt-in, subject to
  a per-key `judge_allowed` gate (silently ignored if not allowed).
- `glossary` (optional string[]) — inline custom terms to also mask (as
  `CUSTOM_TERM`); `glossary_id` (optional UUID) — a persisted glossary.

### Response (envelope mode, `key` omitted)

```json
{
  "masked_text": "Patient <MASKED_PERSON_a1b2c3d4>, ID <MASKED_NATIONAL_ID_e5f6a7b8>, MRN <MASKED_MRN_c9d0e1f2>",
  "ciphertext": "<base64 AES-GCM ciphertext of the JSON mappings>",
  "nonce_payload": "<base64 12-byte nonce>",
  "wrapped_dek": "<base64 AES-GCM ciphertext of the DEK, wrapped under KEK>",
  "nonce_dek": "<base64 12-byte nonce>",
  "pii_count": 3,
  "spans": [
    {"start": 8,  "end": 16, "type": "PERSON"},
    {"start": 21, "end": 31, "type": "NATIONAL_ID"},
    {"start": 37, "end": 44, "type": "MRN"}
  ],
  "request_id": "mask_346fc866",
  "processing_time_ms": 41,
  "judge_used": false,
  "judge_model": null,
  "mappings": null
}
```

- There is **no `pii_types` field** on the response. Entity counting is
  `pii_count`; per-type stats live in the dashboard, not here.
- `mappings` is null for API/Bearer callers (it is populated only for
  dashboard browser sessions, which have no key to decrypt with). Never
  rely on it in generated client code; decrypt the envelope.
- **One span per entity, canonical names only.** `pii_count` equals
  `len(spans)`, but `pii_count` is the contract for entity counting.
  (Until August 2026 seven Saudi types also emitted a retired
  legacy-named sibling span at identical offsets:
  `SAUDI_NATIONAL_ID` for `NATIONAL_ID`, `IQAMA` for `RESIDENCE_PERMIT`,
  `SAUDI_PASSPORT` for `PASSPORT`, `IBAN_SA` for `IBAN`, `CR_NUMBER`
  for `BUSINESS_ID`, `ZAKAT_NUMBER` for `TAX_ID`, `MRN_MEDICAL` for
  `MRN`. Stored responses from that era carry them; de-dupe by
  `(start, end)` when replaying.)
- `spans` offsets index the ORIGINAL request text (`text[start:end]`),
  right-half-open.

### Response (BYOK mode, `key` set)

`wrapped_dek` and `nonce_dek` are `null`. The `ciphertext` is encrypted
**directly** under the supplied key (no DEK wrapping step).

## `/api/v1/redact` — full contract

### Request

```json
POST /api/v1/redact
Authorization: Bearer mk_live_<prefix>_<secret>
Content-Type: application/json

{ "text": "Call John Doe at +966 50 123 4567" }
```

### Response

```json
{
  "redacted_text": "Call PERSON_NAME_01 at PHONE_01",
  "pii_count": 2,
  "entity_counts": {"PERSON_NAME": 1, "PHONE": 1},
  "request_id": "redact_9f21ac0e",
  "processing_time_ms": 38,
  "judge_used": false,
  "judge_model": null
}
```

There is **no decryption step** for `/redact`. The original spans are
unrecoverable by design. Placeholders are canonical-name only.

## Token formats: they are NOT the same between endpoints

| Endpoint | Shape | Example | Regex |
|---|---|---|---|
| `/mask` | `<MASKED_{TYPE}_{8-char hex}>` | `<MASKED_PERSON_a1b2c3d4>` | `<MASKED_([A-Z_]+)_([a-f0-9]{8})>` |
| `/redact` | `{PREFIX}_{NN}` (enumerated, no angle brackets) | `PERSON_NAME_01` | `([A-Z_]+)_(\d{2,})` |

**Common bug**: applying the redact-style regex to mask output (or vice
versa). They look similar; they are not interchangeable. If you wrote
the LLM round-trip below, use the `<MASKED_...>` regex.

`/mask` mints ONE token per distinct `(entity type, surface text)` pair
and reuses it for every repeat, so the same name always carries the same
token and the mappings object has one entry for it.

## The decryption flow: exact parameters

These values are **fixed** in the KINDI server. Do not paraphrase or
invent alternatives. If a value is wrong, decryption fails with an
AEAD auth-tag mismatch.

| Parameter | Value |
|---|---|
| KDF | HKDF-SHA256 |
| HKDF salt | `b"masker-kek-salt-v1"` (ASCII bytes) |
| HKDF info | `b"masker-kek-v1"` (ASCII bytes) |
| KEK length | 32 bytes (AES-256) |
| AEAD | AES-GCM |
| Nonce size | 12 bytes |
| Associated data | none (empty) |
| Base64 variant | standard (not URL-safe) |

### Two-stage decryption (envelope mode)

1. Derive **KEK** = `HKDF-SHA256(api_key_bytes, salt, info, 32)`.
2. Unwrap **DEK** = `AES-GCM-decrypt(KEK, nonce_dek, wrapped_dek)`.
3. Decrypt **mappings** = `AES-GCM-decrypt(DEK, nonce_payload, ciphertext)`.
4. `JSON.parse(mappings)` → `{ "<MASKED_PERSON_a1b2c3d4>": "John Doe", ... }`.

### One-stage decryption (BYOK mode)

1. Skip steps 1–2.
2. Decrypt **mappings** = `AES-GCM-decrypt(byok_key, nonce_payload, ciphertext)`.

### Python reference (envelope mode)

```python
import base64
import json
import requests
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes

API_KEY = "mk_live_..."  # KINDI_API_KEY env var
text = "Patient John Doe, ID 1012345672"

resp = requests.post(
    "https://api.kindi.me/api/v1/mask",
    headers={"Authorization": f"Bearer {API_KEY}"},
    json={"text": text},
).json()

kek = HKDF(
    algorithm=hashes.SHA256(),
    length=32,
    salt=b"masker-kek-salt-v1",
    info=b"masker-kek-v1",
).derive(API_KEY.encode("utf-8"))

dek = AESGCM(kek).decrypt(
    base64.b64decode(resp["nonce_dek"]),
    base64.b64decode(resp["wrapped_dek"]),
    None,
)
mappings = json.loads(
    AESGCM(dek).decrypt(
        base64.b64decode(resp["nonce_payload"]),
        base64.b64decode(resp["ciphertext"]),
        None,
    ).decode("utf-8")
)
print(resp["masked_text"])
print(mappings)
```

### TypeScript reference (envelope mode, Web Crypto)

```ts
const API_KEY = process.env.KINDI_API_KEY!;
const enc = new TextEncoder();

const resp = await fetch("https://api.kindi.me/api/v1/mask", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ text: "Patient John Doe, ID 1012345672" }),
}).then((r) => r.json());

const km = await crypto.subtle.importKey(
  "raw", enc.encode(API_KEY), "HKDF", false, ["deriveBits"],
);
const kekBits = await crypto.subtle.deriveBits(
  {
    name: "HKDF",
    hash: "SHA-256",
    salt: enc.encode("masker-kek-salt-v1"),
    info: enc.encode("masker-kek-v1"),
  },
  km,
  256,
);
const kek = await crypto.subtle.importKey(
  "raw", kekBits, "AES-GCM", false, ["decrypt"],
);

const b64 = (s: string) =>
  Uint8Array.from(atob(s), (c) => c.charCodeAt(0));

const dekRaw = await crypto.subtle.decrypt(
  { name: "AES-GCM", iv: b64(resp.nonce_dek) },
  kek,
  b64(resp.wrapped_dek),
);
const dek = await crypto.subtle.importKey(
  "raw", dekRaw, "AES-GCM", false, ["decrypt"],
);
const plain = await crypto.subtle.decrypt(
  { name: "AES-GCM", iv: b64(resp.nonce_payload) },
  dek,
  b64(resp.ciphertext),
);
const mappings = JSON.parse(new TextDecoder().decode(plain));
console.log(resp.masked_text, mappings);
```

## The full LLM round-trip pattern

This is the canonical flow. Match this shape when the user asks "wrap
my OpenAI / Anthropic call with KINDI."

```
note  →  POST /api/v1/mask  →  masked_text  →  LLM call (masked)
                                                       │
                                                       ▼
                                                 LLM reply (still masked)
                                                       │
                                                       ▼
                                            re-substitute via mappings
                                                       │
                                                       ▼
                                                  final reply
```

Re-substitution: regex-find every `<MASKED_TYPE_xxxxxxxx>` in the LLM
reply and look it up in `mappings`. If the LLM paraphrased the
placeholder ("the patient" instead of `<MASKED_PERSON_a1b2c3d4>`),
re-substitution fails silently; warn the user about this risk for
long-context summarisation.

**Or skip the client-side round-trip entirely:** the LLM Proxy does the
same flow server-side. `POST /api/v1/proxy/openai/v1/chat/completions`
(or `/api/v1/proxy/anthropic/v1/messages`) with the normal provider body,
the KINDI bearer in `Authorization`, and the caller's OWN provider key in
`X-Provider-Key` (never stored). The response is provider-shaped, already
unmasked. Streaming (`stream: true`) is supported; SSE frames are
unmasked per-frame. Optional `X-Kindi-Preserve-Tags: true` injects a
system-prompt instruction telling the model not to alter `<MASKED_…>`
tokens. Proxy errors use a TOP-LEVEL `{"error": "<code>"}` body, unlike
the text endpoints' `{"detail": ...}`. `GET /api/v1/proxy/status` reports
availability.

## Detection behavior an agent must know

### Checksums: fabricated identifiers are ignored on purpose

KINDI validates check digits and DROPS spans that fail (default-on):
Saudi NID/iqama use a position-doubling Luhn variant over all 10 digits;
IBANs use ISO 13616 mod-97; credit cards use standard Luhn. A number
that merely looks like an ID, e.g. `1012345678`, <!-- doc-check: ignore -->
is deliberately NOT masked. **When generating test fixtures, use
checksum-valid values**: NID `1012345672` (or `1052338942`), iqama
`2012345670`, IBAN `SA03 8000 0000 6080 1016 7519`. File redaction is
the exception: it is checksum-LENIENT because OCR mangles digits, so
identifier-shaped numbers inside documents are redacted anyway.

### Unicode anti-evasion

Before detection, KINDI deletes invisible Unicode format characters
(zero-width, bidi marks, variation selectors) and folds fullwidth
digits. `spans` offsets always refer to the ORIGINAL text you sent.
Don't pre-clean input; don't re-derive offsets from a cleaned copy.

### Entity toggles

Detection is filtered by a per-account (and optional per-key) enabled
set. **New accounts start with only the core 10 types on**: PERSON,
NATIONAL_ID, RESIDENCE_PERMIT, PHONE_NUMBER, EMAIL_ADDRESS, DATE_TIME,
IBAN, CREDIT_CARD, PASSPORT, ADDRESS. The 13 extended types
(ORGANIZATION, LOCATION, MRN, BUSINESS_ID, TAX_ID, VEHICLE_PLATE,
STUDENT_ID, INSURANCE_POLICY, MEDICAL_LICENSE, API_KEY, MONETARY_AMOUNT,
SSN, IP_ADDRESS) are OFF until enabled via `PUT /me/entities` (whole-set
replace) or the dashboard. Per-key override: `PUT /v1/keys/{id}/entities`
(`"override": null` inherits the account set). Accounts predating the
feature are grandfathered to the FULL set. `CUSTOM_TERM` (glossary) is
never filtered. Toggles apply to text `/mask` + `/redact` ONLY; file
redaction and the proxy always run the full set. **"KINDI missed my
ORGANIZATION" is almost always a toggled-off extended type, not a bug.**

## `/api/v1/files/*` — async file redaction

Upload → poll → download; output is irreversible (no envelope).
Formats: PDF, DOCX, XLSX, PPTX, CSV, PNG/JPEG/TIFF (images + scanned
PDFs are OCR'd). 100 MB cap, 24 h output TTL, 100 uploads/day per user.

```
POST /api/v1/files/redact          multipart: file (+ glossary, glossary_id)
  → 201 {file_id, state: "queued", poll_url, expires_at}
GET  /api/v1/files/{id}            → {state: queued|processing|ready|failed|expired,
                                      error_code?, download_url?}
GET  /api/v1/files/{id}/download   → the redacted file (state=ready only)
```

Failures fail CLOSED (`redaction_incomplete` / `verify_failed`): a file
that can't be proven clean is never returned. `expired` downloads get
`410`. Full reference: <https://docs.kindi.me/reference/files.md>.

## PII types KINDI detects (Saudi-first)

These are the **detection entity types**: exactly what `/mask` reports
in `spans[].type` and the `<MASKED_{TYPE}_…>` token. `/redact` renames a
few to a public placeholder prefix (`PERSON` → `PERSON_NAME`,
`PHONE_NUMBER` → `PHONE`, `EMAIL_ADDRESS` → `EMAIL`, `DATE_TIME` →
`DATE`, `ORGANIZATION` → `ORG`, `CREDIT_CARD` → `CARD`, `IP_ADDRESS` →
`IP`, `MONETARY_AMOUNT` → `AMOUNT`); the rest are identical.

| Type | Example | Notes |
|---|---|---|
| `NATIONAL_ID` | `1012345672` | 10 digits, leading `1`, checksum-validated |
| `RESIDENCE_PERMIT` | `2012345670` | 10 digits, leading `2`, checksum-validated |
| `PASSPORT` | `A12345678` | ICAO format |
| `PHONE_NUMBER` | `+966 50 123 4567` | KSA mobile + international |
| `IBAN` | `SA03 8000 0000 6080 1016 7519` | 24-char Saudi IBAN, mod-97 validated |
| `CREDIT_CARD` | `4111 1111 1111 1111` | Luhn-validated, mada BINs |
| `PERSON` | `Mohammed bin Salman` / `محمد بن سلمان` | Latin + Arabic |
| `ADDRESS` | REDF/short-code addresses | Saudi-specific |
| `MRN` | `H123456` | Hospital medical record number (extended, off by default) |
| `VEHICLE_PLATE` | `أ ب ج 1234` | Extended, off by default |
| `BUSINESS_ID` | `CR 1010xxxxxx` | Commercial registration (extended) |
| `TAX_ID` | `3xxxxxxxxxxxxxx` | Leading 3 per ZATCA, shape-only (extended) |
| `INSURANCE_POLICY`, `STUDENT_ID`, `MEDICAL_LICENSE`, `API_KEY`, `MONETARY_AMOUNT`, `SSN`, `IP_ADDRESS` | varies | Extended, off by default |
| `EMAIL_ADDRESS`, `DATE_TIME` | standard | Core, on by default |
| `LOCATION`, `ORGANIZATION` | `Riyadh`, `Saudi Aramco` | Extended, off by default |
| `CUSTOM_TERM` | user glossary terms | Always on, never filtered |

Full catalogue: <https://docs.kindi.me/reference/pii-types>.

## Error catalogue

Text-endpoint errors are `{ "detail": "<message>" }`; proxy errors are
`{ "error": "<code>" }`. Status code carries meaning.

| Status | When | Remediation |
|---|---|---|
| `400` | Validation failure, e.g. `"key must be valid base64"`, `"key must decode to 32 bytes (256 bits), got {n}"` | Fix the request body |
| `401` | Missing, invalid, or revoked bearer (revocation propagates within ~60 s) | Re-mint at `/dashboard/keys` |
| `402` | `insufficient_tokens` — free quota + token balance exhausted. Body has `needed_tokens`, `available_tokens`, `topup_url` (a dashboard-relative path). | Top up at `dashboard.kindi.me/dashboard/billing` or wait for the Asia/Riyadh midnight free-quota refill |
| `403` | **Bare 403, NO body: the geo-block.** The request came from outside Saudi Arabia. Not an auth failure. | Call from a Saudi IP; retries never help |
| `404` | Endpoint typo, or `"Glossary not found"` (unowned `glossary_id`; not billed) | Fix the path / id |
| `413` | `file_too_large` (file upload > 100 MB) or proxy `{"error": "request_too_large"}` | Shrink the payload |
| `422` | Pydantic schema validation (wrong type, empty `text`) | Fix the request body |
| `429` | Rate limit (sliding 60 s window; `Retry-After` header, the only rate-limit header sent) or `too_many_files_today` (daily file cap, no Retry-After) | Sleep `Retry-After` seconds, retry once |
| `500` | `"Masking operation failed"` (generic on purpose; never echoes your text) | Retry with exponential backoff |
| `503` | `masker_unavailable` — detection service down or shedding load; the request is **refunded**, `Retry-After` present on overload | Retry with backoff; a retry costs the same as the first attempt |

When generating retry logic: retry only on `429`, `500`, `503`. Never
retry other `4xx`; those are caller bugs (or billing, or geography).

## Anti-patterns: agents reliably get these wrong

1. **Sending `/mask` output to `/unmask`.** There is no `/unmask`
   endpoint. Decryption is **always** client-side using the bearer
   (except the server-side LLM Proxy; see <https://docs.kindi.me/proxy/overview>).
2. **Storing the encrypted envelope server-side then trying to decrypt
   it later with a different/revoked key.** Revoking the bearer makes
   the envelope permanently unrecoverable, by design.
3. **Using `len(spans)` as the PII count.** `pii_count` is the
   contract. Stored responses from before August 2026 carry retired
   legacy-alias sibling spans that make `spans` up to 2x the entity
   count on Saudi types.
4. **Test fixtures with checksum-invalid IDs.** `1012345678` <!-- doc-check: ignore -->
   fails the check digit and is deliberately not masked; use
   `1012345672` / `2012345670` and a mod-97-valid IBAN.
5. **Sending the BYOK key as `byok_key_b64`.** The field is `key`.
6. **Logging the bearer or the decrypted mappings.** Both are PII-grade
   secrets. Log the key prefix and entity counts, never raw values.
7. **Base64URL instead of standard base64.** All KINDI base64 fields
   are standard (with `+`, `/`, `=` padding). `b64decode`/`atob` work;
   `urlsafe_b64decode` does not.
8. **Decrypting on the server "for convenience."** The whole point of
   KINDI is the LLM call happens against masked text. Decrypt in the
   process that finally renders the result to the end user, and
   nowhere else.
9. **Confusing mask and redact token formats.** `<MASKED_TYPE_xxxxxxxx>`
   for `/mask`, `TYPE_NN` for `/redact`. Don't apply one regex to the
   other endpoint's output.
10. **Re-substituting before validating placeholders survived the LLM
    call.** Long contexts can drop placeholders silently. For
    high-stakes pipelines, assert every mapping key appears in the LLM
    reply before doing the regex replace.
11. **Hard-coding `mk_live_...` in source.** Always read from env. KINDI
    keys grant billing-affecting access AND decrypt every envelope they
    produced.
12. **Running integration tests from outside Saudi Arabia.** The API is
    geo-blocked; CI outside KSA gets bodyless `403`s. Mock the API or
    use a KSA-hosted runner.

## Verify the skill is loaded

Ask your agent:

> "Without fetching anything, tell me the exact HKDF salt and info
> strings KINDI uses to derive the KEK."

Expected answer: salt `masker-kek-salt-v1`, info `masker-kek-v1`. If
the agent says anything else (or fetches), the skill isn't in context.

## When in doubt

- Full docs (human + machine): https://docs.kindi.me
- Machine-readable index of every page: https://docs.kindi.me/llms.txt
- Bulk download (every page as a `.md` file, zipped for local grep):
  https://docs.kindi.me/docs.zip
- Raw markdown for any single page: append `.md` to the URL
  (e.g. https://docs.kindi.me/reference/mask.md)
- Playground: https://dashboard.kindi.me/dashboard/playground
- Support: support@kindi.me (include the `request_id`, never raw PII)
