Skip to content

PII filtering

pi agents redact personal data before every model-provider call and restore it in the reply. Only the model provider ever sees a placeholder — transcript, tool calls, memory, cron output and the chat window keep real values. Off by default, and scoped to the pi runtime.

The three detector modes

set <url> set --local set --local-js
where the model runs your own KServe v2 service a host service, once per pi host inside each pi agent's own process
host footprint none 4 dedicated cores, 2 GB RAM, ~1.7 GB disk ~15 MB disk
credential AGENTCTL_PII_API_KEY none none
egress at apply none ~1.5 GB from huggingface.co none — the weights ship in the package
[model] argument yes yes no
bare dates redacted your model decides yes no

The three are mutually exclusive — setting one clears whichever was set before. With no detector set the pi extension registers no event handlers: zero behaviour change, zero added latency.

command effect
pii-filter set <url> [model] point the fleet at an external KServe v2 detector
pii-filter set --local [model] self-host the Python/ONNX detector as a host service
pii-filter set --local-js detect in-process, inside each pi agent
pii-filter status the mode, and its service/weights/key state
pii-filter clear back to the no-op path on the next apply; a local detector is stopped and disabled
pii-filter log what the provider saw

Redaction policy is fleet-wide, not per-agent.

Turning it on — external detector

# 1. Point the fleet at a KServe v2 detector.
sudo agentctl pii-filter set https://privacy-filter.example.internal

# 2. Store its API key. --value, never a positional: a positional stores EMPTY,
#    and argv is world-readable on a shared multi-agent host.
sudo agentctl secret set AGENTCTL_PII_API_KEY --value <token>

# 3. Render it into every pi agent.
sudo agentctl apply

The base URL may be a service root — the /v2/models/<model>/infer suffix is appended — or a full endpoint already ending in /infer, used verbatim. The optional [model] argument is the KServe path segment.

item location
base URL + model name agents.yaml, in the clear — it is committed to the ledger repo
credential AGENTCTL_PII_API_KEY in /etc/agentctl/env/mcp-shared.env
consumer pi-agent@.service, which sources that file

Scope is shared by default: one detector per deployment. For a per-agent override, the unit sources mcp-shared.env and then agent-<name>.env, and the later file wins:

sudo agentctl secret set AGENTCTL_PII_API_KEY --agent <name> --value <token>

This key is readable from the agent's own env file. The pi bridge calls the detector as the agent's own uid.

Turning it on — the host service (--local)

# No URL, no API key. The fleet runs its own.
sudo agentctl pii-filter set --local

# Downloads the weights (first time only) and starts the service on this host.
sudo agentctl apply

--local runs openai/privacy-filter (Apache-2.0) on ONNX Runtime as a host service behind the same KServe v2 route. It is a token classifier over eight span classes — private_person, private_address, private_email, private_phone, private_date, private_url, account_number and secret. Read Coverage and limits before you turn it on.

The agent side cannot tell the difference: apply renders AGENTCTL_PII_BASE_URL to the fixed loopback endpoint

http://127.0.0.1:8737/v2/models/<model>/infer

No AGENTCTL_PII_API_KEY is needed or used. The endpoint is loopback-only, enforced by the bind address and by IPAddressDeny=any on the unit. Which checkpoint the service loads is pinned to a repository revision in the release and is not settable from the ledger.

This service requires 4 dedicated CPU cores and 2 GB of memory

The unit ships CPUQuota=400% and MemoryMax=2G. Both are flat fleet-wide requirements you size the host against — nothing detects the host's core count and nothing varies per host. A host that cannot spare four cores should not run with pii_filter: local: true.

Which hosts get the service

apply installs the unit on a host only when that host runs at least one agent with runtime: pi. One pi agent is enough: a mixed claude/pi host installs it. A host that stops qualifying — its last pi agent moves away, or pii-filter clear — has the unit stopped, disabled and removed on its next apply. The weight cache under /var/lib/agentctl/pii-filter-local is left in place, so re-qualifying does not re-download 1.5 GB.

What it costs

Measured over HTTP against the running service:

disk, weights ~1.5 GB — /var/lib/agentctl/pii-filter-local/hf-cache/model
disk, venv ~164 MB — onnxruntime + tokenizers, no torch
memory, steady ~1.6 GiB resident, most of it the memory-mapped weight file
CPU CPUQuota=400% — a requirement, not a ceiling it grows into
start-up ~4–5 s from process start to first answer; the port does not open until it finishes
latency ~0.3 s per distinct string on an idle host, ~0.5 s when the box is busy

That last row is per new string, not per turn: verdicts are memoized on exact text, so a continuing conversation re-sends mostly-cached content and pays only for the message that just arrived.

A string longer than ~75,000 tokens is refused outright with 413 {"error":"text too large to vet"} — answered immediately rather than after a full deadline. See the size sentinel.

Tuning

Each agent dispatches at most 4 detector calls at once. The service accepts PII_LOCAL_MAX_QUEUE in flight plus queued and answers 503 {"error":"queue full"} beyond it; the agent retries a queue-full refusal once, after 300 ms, then withholds that one string.

variable default effect
PII_LOCAL_SESSIONS 2 independent ONNX Runtime sessions, i.e. forward passes in flight
PII_LOCAL_THREADS 2 intra-op threads per session
PII_LOCAL_MAX_QUEUE 4 × the pi agents on this host admission cap; rendered per host by apply
PII_LOCAL_MAX_BATCH 8 rows coalesced into one forward pass; 1 disables batching
PII_LOCAL_CALL_PASSES PII_LOCAL_SESSIONS passes of one call that may run at once; clamped to the session count
PII_LOCAL_DEADLINE_S 59 server-side deadline, one second under the agent's own 60 s ceiling
MALLOC_ARENA_MAX 2 glibc arena cap

PII_LOCAL_SESSIONS × PII_LOCAL_THREADS × 100% must equal the unit's CPUQuota. A quota short of the thread count does not fail — it makes every request slower with nothing reporting it. Raising either means editing CPUQuota in the same change.

Only PII_LOCAL_SESSIONS, PII_LOCAL_THREADS, PII_LOCAL_MAX_QUEUE and MALLOC_ARENA_MAX appear in /etc/agentctl/env/pii-filter-local.env, which apply rewrites. Set the rest in a unit drop-in, which survives:

# /etc/systemd/system/agentctl-pii-filter.service.d/override.conf
[Service]
Environment=PII_LOCAL_MAX_BATCH=1

More slots is not faster. Two intra-op threads per session is the measured floor of the per-pass thread curve; splitting the same cores into thinner sessions loses against it, and past two concurrent passes the limit is memory bandwidth rather than cores. Whatever closes the last fraction of a second is a deadline decision, not a pool one.

Where it lives

item location
wrapper source /usr/share/agentctl/runtime/pii-filter-local
staged venv + cache /var/lib/agentctl/pii-filter-local
unit agentctl-pii-filter.service, in host-services.slice
config /etc/agentctl/env/pii-filter-local.env, rendered by apply
logs journalctl -u agentctl-pii-filter

clear stops and removes the service but leaves the weights and venv on disk. Removing the cache is a deliberate rm -rf on the state directory.

The weights and the python wheels are fetched at apply, once, through this host's host_proxy:. Only the int8 ONNX export is pulled — ~1.5 GB rather than the ~7 GB the repository publishes. The service itself only opens local files and runs behind IPAddressDeny=any, so it cannot dial out. See Egress proxies and air-gapped hosts.

If the unit sits near MemoryMax with active (running) and every call timing out, systemctl restart agentctl-pii-filter is the unblock. Read systemctl show agentctl-pii-filter -p MemoryCurrent,MemoryAvailable beside a curl against the /infer route to confirm it.

Turning it on — in-process (--local-js)

# No URL, no API key, no [model]. Detection runs inside the pi-bridge itself.
sudo agentctl pii-filter set --local-js

# Unpacks the weights from the package (first time only, ~15 MB) and renders the
# mode flag into every pi agent. No download, no unit to start.
sudo agentctl apply

--local-js runs a 14.7 MB ONNX MiniLM token classifier plus a deterministic regex/checksum layer as a plain function call inside the pi-agent@.service process that is already running — no socket, no queue, no second process, no CPU quota to size. It covers 7 Latin-script languages: en, es, fr, de, it, pt, nl.

Four layers feed one merge:

  1. Emails, URLs, IP addresses, SSNs and credit cards — heuristic and validator-backed (Luhn for cards), plus a 17-entity NER taxonomy covering names, phones, tax and government IDs, passports, licences and street addresses. CITY, STATE and ZIP_CODE are classified but never redacted: broad geography is treated as non-identifying.
  2. Credentials — UUIDs and provider-shaped API keys and tokens (AWS, GitHub, Slack, JWT, Stripe, sk--prefixed keys, and a generic mixed-case-plus-digit 32+ character run). A bare 40-hex rule is deliberately excluded: it matches git commit SHAs at a measured 91.7% false-positive rate.
  3. Phone numbers — libphonenumber-js, on top of the NER model's own PHONE class.
  4. Swiss AHV numbers — checksum-validated over 756.CCCC.CCCC.CX. This layer's span wins over any overlapping partial NER match on the same digits.

A 40-character hex run is suppressed from NER results regardless of label, so a git commit SHA is never redacted. Abbreviated 7–12 character SHAs are not covered.

No message-size ceiling. The classifier's context is 512 tokens, so longer text is scanned as overlapping ~500-token windows and the spans are merged; every window is word-aligned, so no entity is split across a seam. Measured over 16 KB of mixed prose, CJK, accented Latin and digit-dense input, the longest sequence reaching the model was 502 tokens. This mode therefore never returns the too large for the privacy filter to check refusal that the HTTP modes raise on a 413.

This mode needs no egress at all. The checkpoint ships inside the signed .deb/.rpm and apply unpacks it locally into /var/lib/agentctl/pii-filter-local-js (root-owned 0755/0644, shared by every pi agent on the host). Every apply also repairs those owner/mode bits on an existing cache, so a legacy cache created under one agent's private umask cannot strand the other agents. The agent process never downloads anything, so a cold cache is a loud, immediate WITHHELD rather than a silent hang. There is no fallback to a live fetch. The huggingface.co row in Egress proxies and air-gapped hosts applies to --local only.

ONNX Runtime loads with an explicit 2 intra-op / 2 inter-op thread cap. Without it, each agent process sizes its own pool off the host's total logical CPU count, with no coordination between agents.

pii-filter status reports the mode, whether the weights are cached, and the custom-entity list's state, and prints nothing about a key.

Your own entity list (optional)

--local-js can also redact a list of literal strings you supply — client names, investor names, portfolio company names, project codenames — on top of what the model finds. Off unless you configure it. No file, no effect.

Write the list into the repo overlay at /opt/agentctl/pii-filter/custom-entities.yaml:

version: 1
entities:
  # An entry with aliases: every name and alias becomes its own literal pattern.
  - name: Alpine Capital Holding AG
    aliases: [Alpine Capital, ACH Holding]
  # The shorthand, for an entity with no aliases.
  - Projekt Nordlicht
  # A block list works too.
  - name: Zürcher Beteiligungen
    aliases:
      - ZB Partners
      - Beteiligungen Zürich

Then sudo agentctl apply. It compiles the list into an Aho-Corasick automaton at /var/lib/agentctl/pii-filter-custom-entities/automaton.bin and restarts the pi agents on the host so they pick it up — an agent loads the automaton once at process start, so a rebuilt list does not reach a running agent without the bounce.

Matches are reported under one label, CUSTOM_ENTITY, and flow through the same merge, side-log and cockpit reveal as every other layer. A custom-entity span scores 1, so it wins over a lower-confidence model guess covering the same characters: your list is authoritative about those names.

Scale. Tested to 100,000 entities (~250,000 patterns with aliases). At that size the automaton takes ~1.4 s to compile once on the host, ~108 ms to load per agent process, and ~118 MB resident per agent. Scanning costs 0.006 ms per message — 0.004% of the NER pass it runs beside, so matching is free relative to what is already happening.

Patterns must be at least 4 characters. apply refuses a shorter one and names the line. The matcher has no notion of a word boundary — deliberately, because that is the only way to catch a name inside a URL, a filename or a CSV cell — so a 2-character pattern also matches inside ordinary words: AG would redact the middle of MANAGE. If an entity's real name is shorter than that, list it with context (Projekt X rather than X).

Matching is exact and case-sensitive. Alpine Capital does not match alpine capital or Alpine Capital. List the spellings you need as aliases.

To turn it off, delete the file and apply. That removes the automaton and bounces the agents. An empty entities: list is refused rather than treated as off, because a list that parses to nothing is more often a mistake than an intention.

pii-filter status tells you which of four states a host is in: not configured, compiled and current, configured but not yet compiled, or — if you deleted the file and have not applied yet — STALE, meaning the agents are still redacting a list that is no longer in the config.

The weight revision is pinned per release, not per content hash

The npm package version is pinned, and the weights are resolved once per release rather than once per host — so every host on a given agentctl version loads a byte-identical checkpoint. Two agentctl versions built either side of an upstream re-push can carry different weights under the same package version.

Coverage and limits

Read this before enabling either local mode. These are measured characteristics of the shipped checkpoints, and none of them is worked around.

--local (the host service)

  • A redaction can delete words that are not the PII. Span boundaries sometimes overreach, and the offsets are authoritative for placement, so the overreach is removed from the text the provider sees. Observed: an email span that swallowed a trailing " and"; an address span that swallowed a following clause. Expect occasional odd replies rather than obvious breakage.
  • Addresses fragment. Bahnhofstrasse 12, 8001 Zürich redacts the street name and the postal code but leaves the house number and the city in clear text.
  • German is worse than English. A password in German prose passes through unredacted where the English phrasing is caught. Card CVVs are missed in both languages; card numbers are missed in German.
  • Labels can be wrong — a city read as private_person, a customer number as private_phone. A wrong label still redacts, so this costs accuracy in the identity map, not confidentiality.
  • There is no threshold to tune. The model judges whether a mention is private in this sentence rather than extracting entities by type, and the decode is an argmax over a constrained lattice. "My birthday is X" redacts; "she was born on X" does not. Tuning recall means changing the checkpoint.

--local-js (in-process)

Measured against a synthetic battery and ~2,000 real (text, verdict) pairs:

category real-traffic recall
URLs, well-formed emails, US SSN, Luhn-valid credit cards, IBAN-shaped account numbers, addresses (US/CH/DE) ~99–100%
phone numbers 93.3%, with the libphonenumber-js layer carrying most of it
secrets and credentials 43.2% from the NER model alone, higher with the credential regexes
person names 53.6% — the weakest category, with no deterministic fix
bare dates not modelled at all

Fleet-internal usernames get misflagged as person names

~43% of production traffic overall, and ~92% on short isolated single-line messages such as a bare "OK restarted minime (always-on tier left up)." status line. The failure direction is redacting too much, never too little — it garbles short status output rather than leaking anything.

Neither mode meets "no PII reaches the provider, guaranteed" unconditionally, and the shapes each one misses differ. A fleet that needs bare dates redacted must use --local or an external detector.

How it works

Redaction happens at one seam: the model call itself, both directions, every call. Real values are swapped for placeholders in a copy of the request built fresh for that one call, and never sent any other way. The reply is restored to real values before it is added to the agent's own state, so what lands in agent state and in the persisted transcript is always the un-redacted text — a placeholder never becomes durable anywhere.

Identity is ours, not the detector's

The extension ignores the detector's placeholder field and mints its own token from a per-label counter, keyed on the normalized real text. Normalization folds whitespace only — it is case-sensitive:

[PII_PRIVATE_PERSON_1]   [PII_ACCOUNT_NUMBER_1]   [PII_PRIVATE_ADDRESS_2]

So Daniel Dutli and Daniel Dutli are one identity with one token, while Christoph and christoph are two identities with two tokens. Case used to fold as well. It no longer does, because a name recurs both as prose and as a lowercase surface form — a slug, a username, an email local-part — that is not a typo: folding them froze un-redaction on whichever form was seen first and used it everywhere, including an outbound email where only the properly-cased form is correct. Two tokens means each surface form keeps its own spelling. The cost is that a genuinely incidental case slip now also mints a second token (normalizeEntity, runtime/pi/extensions/pii-filter.ts).

The same entity gets the same token for the life of the session. The map persists to ~/.cache/agentctl/pii-map-<session>.jsonl (0600), so a resumed session keeps its tokens.

Whenever the session's map is non-empty, the extension prepends a short user message explaining the placeholder convention. A session that has never seen PII sends no guidance and pays nothing.

Failure is fail-closed

Any string the detector cannot vet is replaced by a sentinel. Withholding is per string, not per call: the message array keeps its shape, every tool call keeps its tool result, and the model loses one message instead of the turn.

There are two sentinels and they say opposite things. They are told apart on the wire, not guessed: a 503 with body queue full is saturation, a 413 with body text too large to vet is size. Anything else in the path — a proxy, a service mid-restart — falls back to the overload text.

Overload, from a 503 or a timeout:

[pii-filter: content withheld — the privacy filter was overloaded and could not
check this text for sensitive data. Transient, not a block. This exact copy will
stay withheld, so do not wait for it or ask about it again: RETRY WHATEVER
PRODUCED IT — re-run the tool call, re-read the file, or ask the sender to
resend — and the fresh copy is checked normally.]

Size, from a 413:

[pii-filter: content withheld — this text is too large for the privacy filter to
check for sensitive data. NOT transient and NOT an overload: the same text will
be refused every time, however long you wait, so retrying or resending it
unchanged cannot work. MAKE IT SMALLER INSTEAD — re-run the tool call over a
narrower range, read the file in parts, request fewer results, or summarise
before sending — and each smaller piece is checked normally.]

A size refusal is arithmetic on the string's own token count against a fixed window and deadline budget. Detector load is not one of its inputs — the same string gets the same refusal on an idle host, on a saturated one, and after a restart. Retrying it cannot work.

If an agent suddenly claims not to know things it should, check agentctl pii-filter status and the detector's reachability.

A withheld string is not retried

Once a string comes back withheld it stays withheld for the rest of that session, and is not sent to the detector again. The record persists to ~/.cache/agentctl/pii-withheld-<session>.jsonl (0600), so a restart does not retry every string that failed just before it. A session rotation starts clean: the session key is in the filename, and a missing file loads empty.

A pi session rotates when the selected provider or model differs from the one the recent session recorded. A compaction does not rotate a pi session — it is an entry appended to the session already open.

Recovery is the agent's job and it already has what it needs: it receives the sentinel in place of the content and reacts as it would to any unreadable tool result, usually by re-running the call. That produces new text, which goes through the detector normally. The cost, stated plainly: a string that failed for a transient reason stays withheld until the agent produces new text.

Each line of the file is {"sha256": "<hex>", "sentinel": "<the text substituted>"} — hashes and sentinels, never plaintext. To find out whether a specific string is the one that is stuck, hash it and grep:

grep "$(printf %s "$TEXT" | sha256sum | cut -d' ' -f1)" \
  ~/.cache/agentctl/pii-withheld-*.jsonl

Inspecting what the provider saw

agentctl pii-filter log                       # human-readable
agentctl pii-filter log --session <id> --json # machine-readable

Self-scoped and read-only — it takes no agent name. The cockpit reaches it through the op-relay, which runs it as the agent's own uid. Only the log sub is admitted to that relay, never set or clear.

Every record carries ms, the wall-clock of the detector phase of the pass that wrote it. The human-readable output prints it in brackets when it is non-zero:

  · [user] (412ms) Call [PII_PRIVATE_PERSON_1] now

Read it as a per-pass figure. One pass fans out to at most 4 calls at a time and every record it produces carries the same ms, because the turn is blocked for the whole phase.

An outbound (user/toolResult) string gets one record per session, written on the pass that first redacted it. pi re-sends the whole history on every call; a string the log already holds writes nothing further. Inbound (assistant) records are written on every turn.

ms what happened
a few hundred, or seconds a real detector pass — this is the filter's share of the turn
0 on a user/toolResult record the string's first record this session was served from the verdict cache; nothing reached the detector
0 on an assistant record the inbound direction, which un-redacts in-process and calls no detector

So a turn that was slow because of this filter is visible directly:

agentctl pii-filter log --json | jq '[.[] | select(.ms > 1000)] | length'

A long conversation's steady state is mostly assistant zeros, one per reply. A wall of zeros beside a slow turn means the latency is somewhere else.

In the cockpit

The reveal. A redacted message carries an eye control in its header, beside the reply button: Show what the model provider saw. Toggling it swaps the bubble to the string that went over the wire, then sweeps an opaque strip across each substituted span with the placeholder painted on it in the accent colour. Toggling again restores the real text. The reveal animation respects reduced-motion settings: the strip is still shown or hidden, only the travel and flash are skipped.

The progress bar. The detector pass blocks the turn — pi does not call the model until it returns. For a short message that is invisible; for a large tool result or file read it is seconds (~6 s for a 17 KB payload). The cockpit shows a thin indeterminate bar and the word redacting… under the message the turn is blocked on, from the moment the first uncached string is handed to the detector until the last verdict lands.

  • One bar per burst, not per string. Four concurrent strings open one bar. A turn that runs a tool gets a second bar before the follow-up provider call.
  • A memoized turn paints nothing. The steady state of a long conversation is "nothing new to vet", which is instant and shows no bar.
  • A withheld outcome is visible. The bar stops moving, fills flat in amber and reads content withheld — detector unavailable for a few seconds first.
  • The background turn-review pass paints nothing. Its own provider call is still filtered and still lands in the side-log; only the indicator is suppressed.

Two things retire a bar besides its own closing edge: the turn-end signal, and a 5-minute client-side backstop for an agent process that died mid-burst. With no detector set no marker is written, so the surface does not exist.

Where the reveal's data comes from

GET /api/pii-log?agent=<name>[&session=<id>] op-relays agentctl pii-filter log --json as the agent's own uid. The side-log is one file per agent, shared by every session, cron and subsession, and the response is capped — so session scopes the read to one conversation, and the cockpit always passes the live lane's id. It returns a JSON array, never null, with one record per redacted string:

{
  "ts": 1756240000123,
  "ms": 412,
  "session": "abc",
  "index": 0,
  "role": "user",
  "redacted": "Call [PII_PRIVATE_PERSON_1] now",
  "spans": [
    { "label": "private_person",
      "real": "Daniel Dutli",
      "placeholder": "[PII_PRIVATE_PERSON_1]",
      "start": 5, "end": 27 }
  ]
}

The endpoint is gated by allowAgent exactly as /api/apps is, and is read-only, no-store and capped. Invariants the renderer relies on:

  • redacted.slice(start, end) === placeholder — the offsets index the redacted string.
  • Spans within a record are ordered by start and never overlap.
  • Records with role: "orphan" are filtered out by the reader. They are a diagnostic — a token the model invented — and carry no spans.
  • The original text is reconstructable by replacing each placeholder with its real. That is the matching key; a message with no match gets no toggle.

Reconstruction walks by offset, never by string replace — a placeholder is a legal substring of ordinary prose. Malformed and overlapping spans are refused rather than spliced at the wrong index, and the side-log is escaped as untrusted input.

A channel reply carries placeholders

The cockpit renders chat from the transcript, which is restored, so it shows real values. A Telegram, Teams or Slack reply is streamed to the channel as the provider produces it, before the restore — so an interactive pi reply on one of those channels shows [PII_PRIVATE_PERSON_1] where the cockpit shows the name. Scheduled jobs are not affected: a cron's deliverable is read after the restore.

Put work that must read correctly on a channel behind the cockpit, or leave the filter off for that agent's fleet.

Egress: the detector call and the fleet proxy

environment detector call
no proxy configured direct
HTTP_PROXY set, host not in NO_PROXY routed through the proxy
HTTP_PROXY set, host in NO_PROXY direct

bun honours HTTP_PROXY/http_proxy and NO_PROXY itself. A fleet behind agentctl host proxy set, including a SOCKS5→privoxy bridge, needs no change for the detector to route. An explicit per-call proxy option is a no-op.

If the detector sits on the internal network, put its host in NO_PROXY:

sudo agentctl host proxy no-proxy add detector.internal

In --local mode this needs no thought: the endpoint is 127.0.0.1, and NO_PROXY always exempts loopback. --local-js makes no call at all.

Why claude is not covered

The claude runtime has a clean inbound seam only in TXT mode. In MCP mode the Claude Code CLI owns the path to the provider, so agentctl has no interception point, and --resume makes the CLI re-read its own transcript directly.

If the invariant you need is "no model provider in this fleet ever sees raw PII", put the affected work on pi agents.