Agents are chatty at exactly the boundaries that matter: user prompts, tool arguments and results, traces, logs, memory. Every one of those surfaces can carry PII, and every one is a place a redaction layer either covers — or silently doesn’t.

The surprising part is that the hard problem is not detection. It is integration. Detection is a deployment concern — which model, which recognizers, how it’s tuned — and a good deployment team owns it. Integration is a package concern: three Mastra hooks, one redactText call, zero custom wiring. mastra-pii is an adapter, not a detector — the package owns the integration; the deployment owns the intelligence.

One interface, deployment-configured intelligence

The adapter talks to a deployed Presidio analyzer — stock container, spaCy NER out of the box. Adding a model layer or custom recognizers is a deployment-side decision: the package neither gates nor configures those layers, it talks to whatever the deployment provides. Swap deployments and application code does not change.

What ships inside the package is the deterministic layer: Indian regex recognizers (INDIAN_DEFAULTS), client-side checksums (Verhoeff for Aadhaar, Luhn for cards), a curated entity allowlist, and shape post-filters — sent to the deployment as per-request ad_hoc_recognizers, and runnable standalone with no deployment at all. An agent application whose users write Hinglish and share Indian identifiers gets that layer’s coverage on day one — before any NER is involved.

Drop-in Mastra usage

import { Agent } from '@mastra/core/agent';
import { createLayeredPii } from '@kripamishra/mastra-pii';

const pii = createLayeredPii({
  presidio: { url: process.env.PRESIDIO_URL ?? 'http://localhost:3000' },
  fallback: 'local',             // degrade to the deterministic engine on outage
  cacheSize: 256,                // per-text LRU; 0 disables
  anonymize: { format: 'type' }, // 'type' → [PAN_1], 'uniform' → [REDACTED]
});

await pii.warmup(); // health-checks the remote service (no-op for local)

const agent = new Agent({
  inputProcessors: [pii.processor],   // user input + every prompt
  outputProcessors: [pii.processor],  // assistant output
});

The processor hooks three phases of the loop: processInput (user input), processLLMRequest (the final prompt before every model call, including tool continuations — each tool round-trip re-enters the boundary), and processOutputResult (assistant output). Provider-generated structural identifiers — tool call IDs, tool names, approval IDs — are copied verbatim because they are not user content, and redacting them would break tool calls. Placeholders stay type-tagged ([PAN_1], [NAME_1]) so the message keeps its structure with the values gone.

The deterministic layer, in two curls

The test console exposes the same engine over POST /api/redact. Baseline first — Indian identifiers, local engine, no deployment required:

curl -s -X POST http://localhost:3001/api/redact \
  -H 'content-type: application/json' \
  -d '{"text":"Aadhaar 7316 7253 5875, PAN ABCDE1234F, UPI 9999999999@ybl, call 98765 43210"}'
# → Aadhaar [AADHAAR_1], PAN [PAN_1], UPI [UPI_1], call [PHONE_1]

Then the case the deterministic layer alone cannot cover — a Hinglish name:

curl -s -X POST http://localhost:3001/api/redact \
  -H 'content-type: application/json' \
  -d '{"text":"bhai new joiner ka Aadhar verify kar lo -> 4829 1048 5920. Name is Kripa Shankar Mishra"}'
# → deployment reachable: … Name is Kripa [NAME_1]
#   (spaCy tags "Shankar Mishra"; the shape filter leaves "bhai" alone)
# → local engine:        no name redaction — the number fails Verhoeff;
#   names need the deployment's NER

That contrast is the architecture in one exchange: checksums and boundary guards run client-side so the container is never trusted to validate on its own, and names are a deployment capability, not a package promise. Around it, the config surface is per-call: entities restricts the emitted set; custom patterns (name, regex, entity) extend it. The same options apply to standalone redactText calls.

Adapter KPIs

KPIDefinition
Config surfaceentities, custom patterns, anonymize format, recognizer set — switchable per call, no code changes
Compatibilitydrop-in Mastra processor (3 hooks), redactText contract, Node ≥22, Mastra ≥1.57
Behavioral guaranteesfail-closed contract, time-boxed custom regex workers, LRU cache, structured-message walk (tool calls, media, approvals)
Overheadadapter latency excluding analyzer round-trip; cache hit rate on agent-loop repeats

Guarantees

Fail-closed is the default: an analyzer outage degrades to the local deterministic engine — or to [REDACTION_FAILED] under fallback: 'strict' — and public output contains only redacted text or the marker, never raw PII. Custom regexes run in time-bounded worker sandboxes — catastrophic-backtracking patterns are rejected, not executed. Results are cached per text, so agent-loop repeats are cheap; malformed structured data fails its containing part closed rather than passing it through.

Roadmap

Recognizer pass-through is the documented extension path — tracked in #25: user recognizers (Presidio shape) replace the adapter’s recognizer set, and custom patterns (local shape) run in both modes. No new built-in recognizers ship. Built-in recognizer expansion and obfuscation canonicalization are closed as out of scope: leet speak, spaced characters, and [at] emails defeat every engine, and that limit is stated in the README, not promised away. The model layer is explicitly out of scope: deployment-side, and the subject of a future project that will carry its own evaluation.

Bottom line

An adapter earns its place by disappearing: three hooks, one contract, no integration code — and a hard boundary around what it does not do. The package is public on npm with provenance attestation, the README documents the full contract, and the comparison harnesses live in the repo — internal evaluation, where it belongs.