Home Playground Docs hub

Guide G-51 · Media

Scrubbing PII from text

Verified against local · 2026-09-25 · privacy-scrubbing-service 0.1.0 (workspace HEAD)
Journey. As a developer, I want to send user text through an LLM or an
analytics pipeline without leaking anybody's contact details — and get the
original text back afterwards, so my app can show the user their data
while the outside world only ever saw placeholders.

The Privacy Scrubbing service (:40052, prefix /v3) is a deliberately small, self-contained filter: it detects five structural PII types — EMAIL, PHONE, CREDIT_CARD, IP_ADDRESS, URL — and replaces each match with a stable placeholder ([EMAIL_1], [PHONE_2], …). A companion deanonymize call restores the originals later.

Two design facts shape everything else:

the no-auth surface — the service never touches tenant data, and the text you send never leaves the process (no database, no downstream calls). (The MCP stream is the exception: it is key-pair + JWT gated like every other agent surface.)

vault. Scrubbing stores each minted placeholder in process memory; deanonymize looks it up. That makes the round trip trivial — you send only the scrubbed text back, no mapping table to manage — but it means the mapping is per service instance and lost on restart*, and a placeholder minted twice maps to the latest* value. For durable round trips, keep the findings array from the scrub response (it carries value + placeholder client-side) and restore yourself, or accept the vault's lifetime.

Prerequisites

regex engine; no Postgres, no Evergreen AI, no models).

Concepts

vocabulary — an email is @-and-dots, a phone is a structured digit run, a card is 13–19 digits that pass a Luhn checksum (so order IDs and tracking numbers don't false-positive). No names, street addresses, or ID numbers are detected — "Amina" in the examples below passes through.

(an email inside a URL, a phone inside a credit card), the match is redacted once under the higher-priority kind: CREDIT_CARD > URL > EMAIL > IP_ADDRESS > PHONE.

the first phone is always [PHONE_1] regardless of how many emails preceded it.

text in any script — Arabic, Cyrillic, CJK — is untouched while the PII inside it is still caught. The optional locale field is currently accepted and ignored; there are no locale-specific patterns yet.

{"error":"text must not be empty"}.

Step 1 — Scrub a message

bash
SCRUB_JSON=$(curl -s -X POST "${SCRUB_URL}/v3/scrub" \
  -H "Content-Type: application/json" \
  -d '{"text": "Contact Amina at amina@example.com or +968 9000 1234; card 4242 4242 4242 4242, ip 192.168.10.1, see https://user.example.com/path", "locale": "en"}')
echo "${SCRUB_JSON}" | python3 -c '
import json, sys
r = json.load(sys.stdin)
print("scrubbed:", r["scrubbed_text"])
for f in r["findings"]:
    print("  {p:>18}  <- {k}  {v!r}  [{s}:{e}]".format(
        p=f["placeholder"], k=f["kind"], v=f["value"], s=f["start"], e=f["end"]))
'
SCRUBBED_TEXT=$(echo "${SCRUB_JSON}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["scrubbed_text"])')
echo "${SCRUBBED_TEXT}" | grep -q "\[EMAIL_1\]" && echo "email redacted"

Expected response — 200 with scrubbed_text (the five kinds each redacted once) and a findings array: {kind, value, placeholder, start, end} with byte offsets into the original text. The locale field is optional and currently ignored.

Step 2 — Deanonymize to restore the original

Send the scrubbed text back; the server-side vault restores the originals:

bash
curl -s -X POST "${SCRUB_URL}/v3/deanonymize" \
  -H "Content-Type: application/json" \
  -d "{\"text\": \"${SCRUBBED_TEXT}\"}" \
| python3 -c '
import json, sys
r = json.load(sys.stdin)
original = "Contact Amina at amina@example.com or +968 9000 1234; card 4242 4242 4242 4242, ip 192.168.10.1, see https://user.example.com/path"
assert r["deanonymized_text"] == original, r["deanonymized_text"]
assert r["restored_count"] == 5, r["restored_count"]
print("round trip restored", r["restored_count"], "entities verbatim")
'

Expected — 200 with the original text byte-for-byte and restored_count: 5. Placeholders the vault doesn't know pass through untouched (and don't count) — try "call [PHONE_9] now" → restored_count: 0.

Step 3 — Language independence

Detection keys on structure, so it works identically whatever language the human text around the PII is written in — the locale field changes nothing today:

bash
curl -s -X POST "${SCRUB_URL}/v3/scrub" \
  -H "Content-Type: application/json" \
  -d '{"text": "تواصل مع آمنة عبر amina@example.com أو الرقم +968 9000 1234"}' \
| python3 -c '
import json, sys
r = json.load(sys.stdin)
assert r["scrubbed_text"] == "تواصل مع آمنة عبر [EMAIL_1] أو الرقم [PHONE_1]", r["scrubbed_text"]
kinds = [f["kind"] for f in r["findings"]]
assert kinds == ["EMAIL", "PHONE"], kinds
print("arabic text preserved, PII caught:", r["scrubbed_text"])
'

Expected — the Arabic prose survives untouched while the email and phone inside it are redacted — the same result the English text produced. Two honest caveats: proper names are not detected (structural types only), and this is script-agnostic detection, not multilingual entity recognition — locale-specific refinements are a planned future layer.

Step 4 — Precision rules worth knowing

bash
# Luhn guard: 13-19 digit runs that are NOT valid cards pass through
curl -s -X POST "${SCRUB_URL}/v3/scrub" -H "Content-Type: application/json" \
  -d '{"text": "order 4242 4242 4242 4243 shipped"}' \
| python3 -c '
import json, sys
r = json.load(sys.stdin)
assert r["findings"] == [], r["findings"]
print("luhn guard ok: invalid card number left alone")
'

# Overlap: an email inside a URL is redacted ONCE, as the URL
curl -s -X POST "${SCRUB_URL}/v3/scrub" -H "Content-Type: application/json" \
  -d '{"text": "see https://user@example.com/path now"}' \
| python3 -c '
import json, sys
r = json.load(sys.stdin)
assert r["scrubbed_text"] == "see [URL_1] now", r["scrubbed_text"]
assert [f["kind"] for f in r["findings"]] == ["URL"]
print("overlap rule ok: redacted once as", r["findings"][0]["placeholder"])
'

Expected — invalid card numbers survive (Luhn checksum), and nested matches collapse to a single redaction under the higher-priority kind.

Step 5 — Error contract

bash
# 422 — empty / whitespace-only text
CODE=$(curl -s -o /tmp/g51-err.txt -w '%{http_code}' -X POST "${SCRUB_URL}/v3/scrub" \
  -H "Content-Type: application/json" \
  -d '{"text": "   "}')
[ "${CODE}" = "422" ] && grep -q "text must not be empty" /tmp/g51-err.txt \
  && echo "422 validation error ok: $(cat /tmp/g51-err.txt)"

Expected — 422 with {"error":"text must not be empty"} (a missing text field deserializes to the same validation failure).

Putting it together

A two-phase flow: scrub before sending text to a third party, deanonymize when displaying results back to the user:

bash
# Phase 1 — before the text leaves your perimeter
RESP=$(curl -sf -X POST "$SCRUB_URL/v3/scrub" -H "Content-Type: application/json" \
  -d '{"text": "Reply to john.doe@company.com from 203.0.113.7 please"}')
SAFE=$(echo "$RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin)["scrubbed_text"])')
# … send $SAFE to the LLM / analytics / support tool …

# Phase 2 — restore for the user’s own view
curl -sf -X POST "$SCRUB_URL/v3/deanonymize" -H "Content-Type: application/json" \
  -d "{\"text\": \"$SAFE\"}"

(SCRUB_URL is http://127.0.0.1:40052 on the local stack. No auth headers are needed — or accepted — on these two endpoints.)

Try it live

Open the Playground → Media — the Privacy Scrubbing console exercises the same scrub → deanonymize round trip against the live service.

Copy agent prompt

Agent prompt — copy and hand to your agent
You are working with the Inaya Labs Privacy Scrubbing service at SCRUB_URL
(:40052, prefix /v3). The REST endpoints are ANONYMOUS by design — no
x-api-key, no JWT. Steps: (1) POST /v3/scrub {"text": "Contact Amina at
amina@example.com or +968 9000 1234; card 4242 4242 4242 4242, ip
192.168.10.1, see https://user.example.com/path", "locale": "en"} — assert
200, capture scrubbed_text, and verify findings[] entries carry kind
(EMAIL/PHONE/CREDIT_CARD/IP_ADDRESS/URL), value, placeholder ([EMAIL_1]
style), and byte-offset start/end. (2) POST /v3/deanonymize {"text":
<scrubbed_text>} — assert the response equals the ORIGINAL text verbatim
and restored_count is 5. Note the mapping lives in the service's in-memory
vault: per-instance, lost on restart, latest-wins on placeholder reuse —
for durable round trips keep the findings array and restore client-side.
(3) Language independence: scrub "تواصل مع آمنة عبر amina@example.com أو
الرقم +968 9000 1234" — assert the Arabic prose is untouched while the
email/phone are redacted; note locale is accepted but IGNORED and proper
names are NOT detected. (4) Precision: "order 4242 4242 4242 4243 shipped"
→ 0 findings (Luhn guard); "see https://user@example.com/path now" → one
[URL_1] redaction (overlap resolution, CREDIT_CARD > URL > EMAIL >
IP_ADDRESS > PHONE). (5) Errors: {"text": " "} → 422 {"error":"text must
not be empty"}. Report findings counts and restored_count per step. The
MCP tool scrub_sensitive_data exists but is key+JWT gated, unlike the REST
surface.

API reference

Next journeys