SDK Guides
Private Data and PII
Keep secrets and PII out of the model: schema-only planning, execution-time injection, and RedactedMessage for automatic detection and redaction.
#Private Data and PII Protection
Your secrets and your users' personal data never reach the AI model in the clear. This guide shows how the mAIvn SDK keeps API keys, passwords, and PII out of the model — and how to control exactly what is protected.
#Why It Matters
When you build with an LLM, two kinds of sensitive data tend to flow toward the model:
- Your secrets — API keys, database passwords, signing tokens that your tools need to do their job.
- Your users' personal data — emails, phone numbers, SSNs, medical record numbers, and other PII that shows up in a message.
You usually want the model to reason about this data ("call the payments API", "email this customer") without ever seeing the actual values. Leaking a live API key or a patient's SSN into a model prompt — or into a tool result, a log line, or an external search query — is exactly the kind of mistake that is hard to undo.
The mAIvn SDK is built so this leak does not happen by default:
- The model plans against a schema — it sees field names, types, and labels, never the raw values.
- Real values are injected only at tool-execution time, inside the runtime, just before your code runs.
- Tool results are scanned and redacted so a private value can't ride back to the model in an output.
- User message text can be auto-scrubbed for PII before it ever reaches the model, via
RedactedMessage.
The rest of this guide shows the SDK surface that makes each of those true.
#Declaring Private Data
Private data lives on an Agent or Swarm via the private_data field. The simplest form is a plain dictionary of key/value pairs.
from maivn import Agent agent = Agent(name='api_agent', api_key='...') agent.private_data = { 'api_key': 'sk-xxx-secret', 'db_password': 'super-secret-password', 'user_ssn': '123-45-6789',}You can also set it at construction time:
agent = Agent( name='api_agent', api_key='...', private_data={'api_key': 'sk-xxx-secret'},)Swarm accepts the same private_data field, shared across its member agents.
#The `PrivateData` Model
For more control, declare values with the PrivateData model instead of a bare string. This enriches the schema the model plans against with a custom key name, a human-readable label, an entity type, and a format hint — so the model can reason more precisely about a redacted field without seeing its value.
from maivn import PrivateData PrivateData( value='Maria Santos', # The actual value (required, never shown to the model) name='patient_name', # Custom key name (instead of an auto-generated one) pii_type='person', # Entity type for categorization label='Patient Name', # Human-readable label in the schema description='Full name of the patient from the intake form', format='name', # Semantic format hint)Pass a list of PrivateData objects to private_data on an Agent or Swarm:
agent = Agent( name='intake', api_key='...', private_data=[ PrivateData(value='claims@hospital.com', name='claims_hotline', label='Claims Hotline', format='email'), PrivateData(value='reviews@hospital.com', name='medical_review_email', label='Medical Review Contact', format='email'), ],)Note
User-suppliednamevalues are stored with auser_prefix (for exampleuser_claims_hotline) so a caller can't collide with an auto-generated slot. Tools that consume the value should reference the prefixed key.
| Feature | Raw string | PrivateData |
|---|---|---|
| Key name | Auto-generated | Custom (user_<name>) |
| Entity type | Inferred | Explicitly declared |
| Schema label | None | Custom |
| Schema description | None | Custom |
| Format hint | Inferred | Explicitly declared |
#Injecting Values into Tools
A tool receives a real private value through the @depends_on_private_data decorator. You map a data_key (the name in private_data) to an arg_name (the parameter on your function).
from maivn import depends_on_private_data @agent.toolify(description='Call external API')@depends_on_private_data(data_key='api_key', arg_name='key')def call_api(endpoint: str, key: str) -> dict: # 'key' holds the real 'sk-xxx-secret' value at runtime return {'status': 'success'}Stack the decorator to inject several values into the same tool:
@agent.toolify(description='Connect to database')@depends_on_private_data(data_key='db_host', arg_name='host')@depends_on_private_data(data_key='db_password', arg_name='password')def connect_db(query: str, host: str, password: str) -> dict: return {'connected': True, 'result': [...]}The model decides when to call the tool, but it never supplies these arguments — they are filled in for you. See the Dependencies guide for combining private-data injection with tool, agent, and interrupt dependencies.
#What the Model Sees vs What Runs
This is the core of the security model: the planning stage and the execution stage see different things.
At planning time, the model receives a schema describing the available private data — names, types, and labels only:
Available private data fields:- api_key (string): API key for external service- db_password (string): Database passwordIt plans which tools to call and in what order, reasoning purely against that schema. The actual values are never part of the prompt.
At execution time, inside the runtime, the real values are looked up and injected into the matching tool arguments just before your function runs. Your tool executes with the real secret; the model is none the wiser.
You can also reference a private value inside a system prompt by its key. The runtime resolves the reference at execution time, so the prompt the model sees carries the placeholder rather than the real value:
agent = Agent( name='notifier', system_prompt=( 'You are a notification assistant. ' "The user's phone is available as the 'phone_number' private value. " 'Never reveal the actual phone number in your responses.' ), api_key='...',) agent.private_data = {'phone_number': '+1-555-123-4567'}For most cases, prefer injecting the value straight into a tool argument with @depends_on_private_data(arg_name=...) (shown above) — it needs no special prompt syntax and keeps the value out of the prompt entirely.
#Automatic Redaction in Tool Results
A tool might accidentally echo a private value back in its output — for example by interpolating a secret into a message string. The runtime scans tool results and redacts any known private value before the result returns to the model, so the leak is contained even when the tool author makes a mistake.
@agent.toolify()@depends_on_private_data(data_key='secret', arg_name='secret')def leaky_tool(secret: str) -> dict: # Bad practice: putting the secret in the output. return {'message': f'Used secret: {secret}'} # The runtime redacts the 'secret' value from the result# before the model ever sees it.This is a safety net, not a license to be careless. Treat private values as write-only inside a tool: use them, never return or log them. See Best Practices below.
#Automatic PII Detection with `RedactedMessage`
Secrets are values you know about. PII is often values you don't — whatever a user happens to type into a message. RedactedMessage handles that case: it asks the runtime to detect PII in the message text and substitute placeholders before the content reaches the model.
from maivn import Agentfrom maivn.messages import RedactedMessage agent = Agent(name='support', api_key='...') response = agent.invoke([ RedactedMessage(content='My email is john@example.com and SSN is 123-45-6789')])What happens:
- You send a
RedactedMessage. - The runtime detects PII in the content.
- Detected values are replaced with stable, opaque placeholders before the text is sent onward.
- The original values are registered into the session's
private_data— held within the runtime, not surfaced to the model. - The model sees only the placeholders.
Because the detected values land in private_data, you can inject them into tools the same way as any other private value. To depend on a specific value, give it a predictable key by declaring it in known_pii_values with a name (see the next section) and reference that key:
@agent.toolify(description='Send email to user')@depends_on_private_data(data_key='user_email', arg_name='email')def send_email(message: str, email: str) -> dict: # 'email' holds the original 'john@example.com' at runtime. return {'sent': True, 'to': email}#Declaring Known PII Alongside the Message
You can mix PrivateData descriptors and raw strings in known_pii_values to declare values you already know are in the message, with custom keys and labels:
from maivn.messages import RedactedMessage, PrivateData response = agent.invoke([ RedactedMessage( content='Process claim CLM-2026-4401 for Maria Santos, DOB 1985-07-14.', known_pii_values=[ PrivateData(value='Maria Santos', name='patient_name', pii_type='person', label='Patient Name'), PrivateData(value='1985-07-14', name='patient_dob', pii_type='date', label='Date of Birth', format='date'), PrivateData(value='MEM-882441', name='member_id', label='Member ID'), '212-555-0101', # Raw strings still work. ], )])The model then sees opaque placeholders in place of those values — each detected value is swapped for a stable token keyed to its name (for example, the span for patient_name becomes a placeholder bound to user_patient_name), and the schema gains metadata (names, labels, types) with no values attached.
#Allow-Listing Safe Values
Not every detected span is actually private. A public marketing URL, your organization's own published support email, or an identifier a downstream tool legitimately needs in cleartext should pass through untouched. PIIWhitelist lets you approve those spans without weakening protection for everything else.
The whitelist is evaluated after detection (so the fact that PII was present is still recorded) but before the value is registered into private_data (so no placeholder is substituted for the approved span).
from maivn import PIIWhitelist, PIIWhitelistEntry, RedactedMessage whitelist = PIIWhitelist( entries=[ # Allow every URL in a non-PHI context. PIIWhitelistEntry( entity_type='url', justification='Public marketing URLs are needed for citations.', ), # Allow one specific known-safe email. PIIWhitelistEntry( value='support@maivn.io', justification='Public support address listed on the docs site.', ), # Allow a bounded family of values via an anchored pattern (use sparingly). PIIWhitelistEntry( pattern=r'^https://docs\.maivn\.io/.*', justification='Public docs URLs only.', ), ], phi_mode=False, # set True for any deployment handling PHI) response = agent.invoke([ RedactedMessage( content='See https://docs.maivn.io/x and email support@maivn.io', pii_whitelist=whitelist, ),])#Entry Shapes
Each PIIWhitelistEntry sets exactly one matcher, plus a required justification:
| Field | Match behavior | Use when |
|---|---|---|
entity_type |
Suppresses every detected span of this category | You trust an entire category in a non-PHI context |
value |
Case-insensitive exact match against the detected span | One known-safe specific value |
pattern |
Anchored regex match against the detected span | A bounded family of values (for example one domain) |
The justification field is required (minimum 8 characters) and recorded with each suppression as compliance evidence.
#PHI Mode
When phi_mode=True, the whitelist refuses to construct any entity_type entry that names a HIPAA Safe Harbor identifier category. This is enforced at construction time, so your application fails loudly rather than shipping a non-compliant policy:
from maivn import PIIWhitelist, PIIWhitelistEntry # Raises ValueError — `ssn` is a Safe Harbor identifier.PIIWhitelist( entries=[PIIWhitelistEntry(entity_type='ssn', justification='nope')], phi_mode=True,) # Allowed — value/pattern entries target a single approved instance,# not a whole category, so they remain valid in PHI mode.PIIWhitelist( entries=[ PIIWhitelistEntry( value='hospital-public@example.org', justification='Hospital published support address; legal-approved.', ), ], phi_mode=True,)PIIWhitelist and PIIWhitelistEntry are frozen models — immutable after construction. To change a policy, build a new one.
#Where to Set the Whitelist
A whitelist travels on the pii_whitelist field of an individual RedactedMessage, so you scope it to exactly the messages where the approved spans appear. Reuse the same PIIWhitelist object across messages to apply a consistent policy.
# Attach the whitelist to each RedactedMessage that needs it:RedactedMessage(content='...', pii_whitelist=whitelist)#Supported PII Types and Safe Harbor Categories
Detection targets HIPAA Safe Harbor identifiers plus common PCI, banking, and governmental categories. Each pattern is paired with a structural validator so structurally similar non-PII (order numbers, internal product codes) is far less likely to be flagged.
| Type | Examples | Validation approach |
|---|---|---|
email |
user@example.com |
structural |
phone |
+1-555-123-4567, (555) 123-4567 |
NANP / E.164 boundaries |
ssn |
123-45-6789 |
rejects reserved area codes |
credit_card |
4111-1111-1111-1111 |
Luhn (mod-10) checksum |
iban |
DE89370400440532013000 |
per-country length + ISO 13616 mod-97 |
swift |
DEUTDEFF, DEUTDEFF500 |
ISO 3166 country code + length 8 or 11 |
medical_record_number |
MRN: AB-12345 |
label-anchored |
vehicle_id |
1HGCM82633A004352 (VIN) |
ISO 3779 alphabet + 17 chars |
health_plan_id |
Member ID: HP-994221 |
label-anchored |
person |
Names | confidence-scored detection |
location |
Addresses, cities | confidence-scored detection |
date / datetime |
2025-04-29, April 29, 2025 |
confidence-scored detection |
ip_address |
192.168.1.1 |
confidence-scored detection |
url |
https://... |
confidence-scored detection |
The full set of HIPAA Safe Harbor categories is exported as a public constant so you can validate your own policy before constructing a whitelist:
from maivn import HIPAA_SAFE_HARBOR_CATEGORIES print(sorted(HIPAA_SAFE_HARBOR_CATEGORIES))# ['account_id', 'biometric_id', 'certificate_id', 'credit_card', 'date',# 'datetime', 'device_id', 'email', 'fax', 'health_plan_id', 'iban',# 'ip_address', 'license_id', 'medical_record_number', 'person', 'phone',# 'ssn', 'swift', 'url', 'vehicle_id']#Previewing Redaction
Before you run a session, you can preview exactly what the runtime will do to a RedactedMessage — which placeholders get inserted, which values get added to private_data, and which of your declared known values actually matched. Use preview_redaction() on any Agent or Swarm.
from maivn import Agent, RedactedMessage agent = Agent(name='support', api_key='...') preview = agent.preview_redaction( RedactedMessage(content='Contact me at alice@example.com'), known_pii_values=['alice@example.com', 'bob@example.com'],) assert len(preview.inserted_keys) == 1 # one email detected and redactedassert preview.matched_known_pii_values == ['alice@example.com']preview_redaction() accepts a RedactedMessage plus optional known_pii_values and private_data, and returns a RedactionPreviewResponse with:
| Field | Description |
|---|---|
message |
The redacted message with placeholders applied |
inserted_keys |
Newly inserted placeholder keys |
added_private_data |
Only the values added by this preview |
merged_private_data |
Existing plus newly added private data |
redacted_message_count |
Number of messages redacted |
redacted_value_count |
Number of values redacted |
matched_known_pii_values |
Declared values that were found |
unmatched_known_pii_values |
Declared values that were not found |
If you prefer to build the request explicitly, RedactionPreviewRequest is exported from maivn for use with Client.preview_redaction(...).
#System Tools Respect the Same Boundary
The built-in system tools (web_search, repl, think) honor the same private-data rules as your own tools:
web_searchnever includes private values in outbound queries, and results are scanned for PII before returning.replcan run with injected private values, and its output is redacted before it returns to the model.thinkreceives only the private-data schema, never the raw values, and its reasoning output is redacted.
In other words, enabling system tools does not open a side channel around the protection model. See the System Tools guide for the full surface.
agent.private_data = {'user_email': 'john@example.com'} # The agent can search for context without leaking the email# into the external search query.response = agent.invoke([ HumanMessage(content="Search for information about this user's preferences")])#Best Practices
Treat private values as write-only inside a tool. Use them to do work; never return them in a result or write them to a log.
# Avoid:def my_tool(secret: str) -> dict: print(f'Using secret: {secret}') # leaks to logs return {'secret_used': secret} # leaks back toward the model # Prefer:def my_tool(secret: str) -> dict: result = call_api(secret) return {'status': 'success'}Use descriptive key names (stripe_api_key, not key1) so the schema the model plans against is self-explanatory.
Include only what's needed. Every value in private_data enlarges the schema the model sees. Don't register secrets a session won't use.
Turn on PHI mode for any healthcare deployment. phi_mode=True prevents whole-category whitelist entries for Safe Harbor identifiers, so an over-broad policy fails at construction instead of in production.
#How Private Data Compares to Environment Variables
| Aspect | Private data | Environment variables |
|---|---|---|
| Scope | Per-agent / per-swarm | Process-wide |
| Model visibility | Schema only | Not visible |
| Runtime injection | Yes | No (static) |
| Automatic redaction | Yes | No |
Use private data when the model needs to know a value exists, tools need it injected, or you want automatic redaction. Use environment variables for static configuration consumed at SDK initialization where no model awareness is needed.
#Next Steps
- Dependencies — combine
@depends_on_private_datawith tool, agent, and interrupt dependencies. - System Tools — how
web_search,repl, andthinkhonor the privacy boundary. - Decorators Reference — the full
@depends_on_private_datasignature. - Messages Reference —
RedactedMessage,PrivateData, andpreview_redaction(). - Private Data Examples — runnable placeholder and
RedactedMessagewalkthroughs.