Deanonymize Workflow
Protect PII before it reaches your LLM, then restore original values in the LLM's response — without any server round-trip.
If you haven't set up meshulash-guard yet, start with the Quickstart guide.
Overview
When users send messages containing sensitive information — Social Security numbers, email addresses, phone numbers, names — that data flows to your LLM and potentially into logs, embeddings, or third-party services. You want to strip PII before sending the message, but you need the LLM's response to make sense with the real values restored.
The deanonymize workflow solves this in four steps:
- Scan the user's input — PII is replaced with opaque placeholders like
[SSN-X1Y2]. - Send the redacted text to your LLM — the LLM never sees real PII.
- Scan the LLM's output — catch any PII the LLM might have echoed or inferred.
- Restore originals —
guard.deanonymize()swaps placeholders back to real values in the final response.
The deanonymize step runs entirely client-side — no server call, no latency added, no PII ever sent back to the network.
The Workflow
Below is a complete, runnable example. The input contains a Social Security number and an email address. The LLM receives redacted text, responds with placeholders, and the final output is restored to the original values.
from meshulash_guard import Guard, Action
from meshulash_guard.scanners import PIIScanner, PIILabel
# Step 1: Initialize the Guard
guard = Guard(api_key="your-api-key", tenant_id="your-tenant-id")
# Step 2: Configure the scanner and scan input
pii = PIIScanner(labels=[PIILabel.ALL], action=Action.REPLACE)
result = guard.scan_input(
text="My SSN is 123-45-6789 and my email is sarah@company.com",
scanners=[pii],
)
print("Status:", result.status)
print("Redacted:", result.processed_text)
print("Placeholders:", result.placeholders)
Expected output after Step 2:
Status: secured
Redacted: My SSN is [NATIONAL_ID-X1Y2] and my email is [EMAIL_ADDRESS-A1B2]
Placeholders: {'[NATIONAL_ID-X1Y2]': '123-45-6789', '[EMAIL_ADDRESS-A1B2]': 'sarah@company.com'}
# Step 3: Send the REDACTED text to your LLM (not the original!)
# The LLM sees: "My SSN is [NATIONAL_ID-X1Y2] and my email is [EMAIL_ADDRESS-A1B2]"
# Never pass result.text or the original user message here.
def call_your_llm(prompt: str) -> str:
# Replace this with your actual LLM call (OpenAI, Gemini, etc.)
return f"I can see you provided [NATIONAL_ID-X1Y2] and [EMAIL_ADDRESS-A1B2]. How can I help?"
llm_response = call_your_llm(result.processed_text)
print("LLM responded:", llm_response)
Expected output after Step 3:
# Step 4: Scan LLM output before returning to the user
# This catches any PII the LLM may have echoed or inferred
output_result = guard.scan_output(text=llm_response, scanners=[pii])
# Step 5: Restore original values (client-side, no server call)
final = guard.deanonymize(output_result.processed_text)
print("Final response:", final)
Expected output after Steps 4–5:
How the Vault Works
When Action.REPLACE is used, meshulash-guard maintains a placeholder vault — a mapping from generated tokens back to original values.
Placeholder format: [LABEL_NAME-HASH]
Examples: [EMAIL_ADDRESS-A1B2], [NATIONAL_ID-X1Y2], [PHONE_NUMBER-C3D4]
The hash component is a short random identifier that makes each placeholder unique within a session, even if the same value appears multiple times.
Vault accumulation: The vault grows with every scan_input() and scan_output() call in the same session. Placeholders from turn 1 are still restorable in turn 5 — you do not need to reset between turns.
Replacement order: deanonymize() applies replacements longest-first. This prevents a shorter placeholder from being partially matched inside a longer one that shares the same prefix.
Safe to call always: If the vault is empty or the text contains no recognized placeholders, deanonymize() returns the text unchanged. It is safe to call unconditionally.
Multi-Turn Conversations
In a multi-turn chat, the vault accumulates across all turns. You do not reset it between turns — doing so would lose the ability to restore placeholders from earlier messages.
from meshulash_guard import Guard, Action
from meshulash_guard.scanners import PIIScanner, PIILabel
guard = Guard(api_key="your-api-key", tenant_id="your-tenant-id")
pii = PIIScanner(labels=[PIILabel.ALL], action=Action.REPLACE)
# Turn 1: user shares their email
turn1 = guard.scan_input(
text="You can reach me at alice@example.com",
scanners=[pii],
)
print("Turn 1 redacted:", turn1.processed_text)
# "You can reach me at [EMAIL_ADDRESS-A1B2]"
# Turn 2: user shares their phone number
turn2 = guard.scan_input(
text="Or call me at 555-0198",
scanners=[pii],
)
print("Turn 2 redacted:", turn2.processed_text)
# "Or call me at [PHONE_NUMBER-C3D4]"
# Simulate the LLM echoing placeholders from both turns
llm_says = "Got it — [EMAIL_ADDRESS-A1B2] or [PHONE_NUMBER-C3D4]. I'll follow up soon."
# Restore originals — vault contains both turns' placeholders
restored = guard.deanonymize(llm_says)
print("Restored:", restored)
Expected output:
Turn 1 redacted: You can reach me at [EMAIL_ADDRESS-A1B2]
Turn 2 redacted: Or call me at [PHONE_NUMBER-C3D4]
Restored: Got it — alice@example.com or 555-0198. I'll follow up soon.
The vault correctly resolves both placeholders because it accumulated entries from both turns before deanonymize() was called.
Resetting the Session
Call guard.clear_cache() to wipe the vault and start fresh.
from meshulash_guard import Guard, Action
from meshulash_guard.scanners import PIIScanner, PIILabel
guard = Guard(api_key="your-api-key", tenant_id="your-tenant-id")
# ... conversation happened, vault has entries ...
# User starts a new conversation or logs out
guard.clear_cache()
# Vault is now empty — previous placeholders cannot be restored
When to call clear_cache():
| Situation | Call clear_cache()? |
|---|---|
| Starting a new conversation or session | Yes — clear any leftover placeholders from the previous session |
| User logs out | Yes — remove any PII from the in-memory vault |
Explicit /reset command in your chat UI |
Yes — user expects a clean slate |
| Between turns in the same conversation | No — you would lose the ability to restore earlier placeholders |
After calling deanonymize() |
No — the vault is still valid for future turns |
Common Patterns
Check status before sending to LLM
Always inspect result.status before deciding what to send to your LLM:
from meshulash_guard import Guard, Action
from meshulash_guard.scanners import PIIScanner, PIILabel, ToxicityScanner, ToxicityLabel
guard = Guard(api_key="your-api-key", tenant_id="your-tenant-id")
pii = PIIScanner(labels=[PIILabel.ALL], action=Action.REPLACE)
toxicity = ToxicityScanner(labels=[ToxicityLabel.TOXICITY], action=Action.BLOCK)
user_message = "My email is carol@example.com — help me write a complaint letter"
result = guard.scan_input(text=user_message, scanners=[pii, toxicity])
if result.status == "blocked":
# Toxic content — reject the request, do not send to LLM
response = "Your message was flagged as inappropriate. Please rephrase."
elif result.status == "secured":
# PII was replaced — send the redacted version to LLM
llm_output = call_your_llm(result.processed_text)
output_result = guard.scan_output(text=llm_output, scanners=[pii])
response = guard.deanonymize(output_result.processed_text)
else:
# status == "clean" — nothing detected, send original text
response = call_your_llm(user_message)
print(response)
Expected output:
The key rule: never send the original text when status is "secured" — always use result.processed_text. And never send anything to your LLM when status is "blocked".