Skip to content

Documentation navigation

Private Data and PII

Redact sensitive data, understand what leaves your machine, and manage private-value restoration.

Privacy control, not certification

mAIvn holds no SOC 2, HIPAA, ISO 27001, or FedRAMP certification. Use these controls at your own risk under the Platform Disclaimer.

#Private Data and PII Protection

The SDK provides controls designed to keep secrets and personal data out of model-visible context. This guide shows how to configure those controls and understand their limits.

Controls, not certifications. These are technical privacy controls, not certifications. mAIvn holds no SOC 2, HIPAA, ISO 27001, or FedRAMP certification. phi_mode limits HIPAA Safe Harbor whitelist categories; it does not make a deployment HIPAA-compliant, provide a BAA, guarantee detection, or replace required audits and organizational controls. Evaluate and use these controls at your own risk under the mAIvn Platform Disclaimer.

#When these controls apply

Declare sensitive values with private_data or send RedactedMessage to request automatic detection. Plain strings, HumanMessage and unmarked public decision text receive no automatic redaction, placeholders or privacy refusals solely because the text resembles personal data.

DecisionRequest.private_data marks known values for platform shielding. Private/unknown requests keep their fallback. Each decision checks its own request; it does not export a surrounding private context automatically. See Decisions.

#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 provides these default controls to reduce that exposure:

  1. The model plans against a schema. It sees field names, types, and labels, never the raw values.
  2. Real values are injected only at tool-execution time, inside the runtime, just before your code runs.
  3. Tool results are scanned and redacted to reduce the chance of private values reaching the model in an output.
  4. 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.

Python
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:

Python
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

The PrivateData model carries a value and optional metadata. Use it in RedactedMessage.known_pii_values when the runtime needs the name, label, entity type, or format hint. Passing descriptors to a scope's private_data list keeps only their names and values; that path does not preserve the other metadata.

Python
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:

Python
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 Scope lists normalize to a caller map keyed by each descriptor's name. Use that caller key in depends_on_private_data. In a redacted message, declared names are allocated user_ keys, such as user_patient_name. Keep these two input paths distinct when wiring tool dependencies.

For entries in RedactedMessage.known_pii_values:

FeatureRaw stringPrivateData
Key nameAuto-generatedCustom (user_<name>)
Entity typeInferredExplicitly declared
Schema labelNoneCustom
Schema descriptionNoneCustom
Format hintInferredExplicitly 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).

Python
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:

Python
@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):

Plain text
Available private data fields:
- api_key (string): API key for external service
- db_password (string): Database password

It 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:

Python
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 for registered private values and replaces matches before forwarding the result to the model. Detection has limits; transformed values, logs, and external side effects need their own controls.

Python
@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.

#Keep Private Values Masked in Output

By default, private values are restored in the final message and result before they reach you, so your application sees the real values. Some callers don't want that: they need to check where a value came from, or they keep private values in their own store, and would rather receive masked output.

Pass restore_private_values=False when constructing the client, and give that client to your agent:

Python
from maivn import Agent, Client

client = Client(api_key='...', restore_private_values=False)
agent = Agent(name='api_agent', client=client)

What this changes: the final output — the final message, the structured result, and any buffered final text — keeps placeholders instead of the real values. The client also stops restoring values in the events it streams to you.

What it doesn't change: your local tools still receive real values. Tool-call arguments, including those injected through @depends_on_private_data, are still resolved before your function runs, so private values still travel to your process when a tool needs them. The setting masks what you get back, not what your tools receive.

#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.

Python
from maivn import Agent
from 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:

  1. You send a RedactedMessage.
  2. The runtime detects PII in the content.
  3. Detected values are replaced with stable, opaque placeholders before the text is sent onward.
  4. The original values are registered into the session's private_data — held within the runtime, not surfaced to the model.
  5. 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:

Python
@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:

Python
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).

Python
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:

FieldMatch behaviorUse when
entity_typeSuppresses every detected span of this categoryYou trust an entire category in a non-PHI context
valueCase-insensitive exact match against the detected spanOne known-safe specific value
patternAnchored regex match against the detected spanA bounded family of values (for example one domain)

The justification field is required (minimum 8 characters) and records why the suppression was configured; it is not compliance evidence by itself.

#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 the application rejects that broad whitelist rule before sending a request:

Python
from maivn import PIIWhitelist, PIIWhitelistEntry

# Raises ValueError — `ssn` is a Safe Harbor identifier.
PIIWhitelist(
    entries=(PIIWhitelistEntry(entity_type='ssn', justification='Broad category is not allowed.'),),
    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.

Python
# 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.

TypeExamplesValidation approach
emailuser@example.comstructural
phone+1-555-123-4567, (555) 123-4567NANP / E.164 boundaries
ssn123-45-6789rejects reserved area codes
credit_card4111-1111-1111-1111Luhn (mod-10) checksum
ibanDE89370400440532013000per-country length + ISO 13616 mod-97
swiftDEUTDEFF, DEUTDEFF500ISO 3166 country code + length 8 or 11
medical_record_numberMRN: AB-12345label-anchored
vehicle_id1HGCM82633A004352 (VIN)ISO 3779 alphabet + 17 chars
health_plan_idMember ID: HP-994221label-anchored
personNamesconfidence-scored detection
locationAddresses, citiesconfidence-scored detection
date / datetime2025-04-29, April 29, 2025confidence-scored detection
ip_address192.168.1.1confidence-scored detection
urlhttps://...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:

Python
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']

#Values That Outlive One Call

A conversation on a thread_id has two placeholder maps. Knowing which is which tells you exactly what gets re-sent.

MapHoldsLives
Your PrivateDataStoreThe values you declaredYour process or your disk, under your key
Server custodyThe complete map for the thread, including the keys the shield allocated during a runEncrypted, on the server, per conversation

Both are per-thread. Neither replaces the other: your store means you don't re-supply serial_number on every turn, and server custody means turn two can resolve a value the shield discovered on turn one, a key your process never saw allocated and so could never resolve itself.

Nothing the server allocates is ever written into your store. Your client is never handed the server's map.

When both hold the same key:

  • a name you chose (serial_number): what you pass on this call wins. Re-declaring your own field is you saying what it means from this turn on.
  • a name the server allocated: custody wins. That key already stands for one particular value in the stored transcript, and redefining it from a later request would change who the earlier turns were about.

#Automatic local storage

The SDK installs private-data-vault and uses encrypted local storage by default. On a supported desktop, unlock the OS credential store before using private values. The vault opens when needed; installing the SDK does not create one.

The store retains string-to-string pairs from the caller's private_data map. Other mapping values remain specific to the invocation. Saved pairs are merged into later calls on the same thread, even when the call supplies no private map. New caller values replace the saved values for matching caller keys.

Reuse the thread ID and the same authenticated user, organization, project, and same base address after a restart. Rotating the API key within that identity does not select a new vault. Keep the vault files and their matching key.

Python
import os
from maivn import Client, RunOptions

client = Client(api_key=os.environ['MAIVN_API_KEY'])
options = RunOptions(thread_id='serial-support')
client.invoke('Use my serial number to check the warranty.', options=options,
              private_data={'serial_number': 'SN-8831'})
client.invoke('Check the same serial again.', options=options)

# Delete saved caller values for this thread on this machine.
client.forget_private_data('serial-support')

Async applications use await client.aforget_private_data(thread_id). Local deletion does not remove server-held data, message history, or uploaded documents. It does not create a vault when there is nothing to delete.

#Servers and containers

Configure both settings in your hosting provider:

  • MAIVN_VAULT_DIRECTORY: an absolute parent directory on durable storage.
  • MAIVN_VAULT_KEY: a base64-encoded, randomly generated 32-byte key injected by your secret manager.

Create the key once and retain it across deployments. Do not use your mAIvn API key or generate a replacement at each startup. Ordinary Client() construction uses these settings. The SDK does not provision storage or a secret manager.

If your application retrieves secrets itself, supply a callable:

Python
from maivn import Client, LocalVaultConfig

# load_vault_key is your secret-manager callback; it returns exactly 32 bytes.
client = Client(local_vault=LocalVaultConfig(
    directory='/srv/my-app/private-values',
    key_provider=load_vault_key,
))

The SDK calls the provider when it opens the vault. Explicit local_vault configuration replaces the environment-based vault settings.

Use a persistent filesystem with working file locks and atomic replacement. Serverless temporary storage cannot retain values between instances. An object-store URL is not a vault directory. Qualify a shared filesystem before using multiple writers.

#Recovery and opt-out

Back up encrypted files and retain their matching key. A new key cannot recover existing records. Desktop credential entries depend on the vault's location; copying files to another path or machine alone does not restore access. Server recovery requires both durable files and the secret-manager key.

If secure storage cannot open, the SDK raises VaultSetupError when private storage is needed. It does not fall back to plaintext or temporary storage. Check the OS credential store, server configuration, and authenticated API connection.

Use Client(private_data_store=None) to disable local persistence explicitly. You can instead pass a custom PrivateDataStore, including InMemoryPrivateDataStore for values that should last only as long as that store instance. A custom store and local_vault configuration cannot be combined.

Automatic local storage holds caller-declared placeholder values. Private file uploads and document retrieval retain their separate API, authorization, and retention requirements. See generated message attachments.

#Redaction preview models

RedactionPreviewRequest and RedactionPreviewResponse are exported data models, but the current public Agent, Swarm, and Client classes do not expose a preview_redaction() transport method. Treat preview execution as an open SDK topic; do not infer an endpoint from the models.

#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_search applies controls intended to keep registered private values out of outbound queries and scans returned content.
  • repl can run with injected private values and applies redaction controls to output before it returns to the model.
  • think is designed to receive the private-data schema rather than raw values and applies redaction controls to its output.

These are technical controls, not a guarantee that every private value will be detected or contained. See the System Tools guide for the current surface and apply your own testing and governance.

Python
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.

The same rule applies to observability storage. The public maivn.telemetry contract is metadata-only and has no content-export option: message bodies, tool arguments/results, final results, resolved private values, and placeholders are absent from listener events and OpenTelemetry spans.

Python
# 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

AspectPrivate dataEnvironment variables
ScopePer-agent / per-swarmProcess-wide
Model visibilitySchema onlyNot visible
Runtime injectionYesNo (static)
Automatic redactionYesNo

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

#Reviewing over-redaction guidance

The prepared release adds InvokeResponse.privacy_guidance, a list of advisory objects (empty by default). It helps a developer review likely over-redaction without changing shielded context or automatically creating a whitelist.

The delivered advisory may contain the actual value that was redacted. Present it only in a trusted developer interface. Never log or persist it, send it to a model, or forward it wholesale to end users. repr=False hides it from the response representation; it does not remove it from model_dump() or JSON. Select safe fields when serializing a response.

Review any proposed PIIWhitelistEntry and its justification before applying it to a later RedactedMessage. Absence of guidance is not proof every detection was correct. Decision results have no privacy_guidance field.

Guidance delivered in the raw terminal final event can populate InvokeResponse. Generic normalized final-event builders do not promise to forward it. Keep a separate trusted developer channel instead of bypassing frontend_safe protections.