Skip to content

Hide personal data from the model

Enable the pseudonymisation capability to replace personal data before the model provider receives a turn's text. Core looks for names, email addresses, phone numbers, IBANs, credit card numbers and Dutch citizen service numbers (BSN) in everything that goes to the model, replaces each finding with a placeholder such as <PERSON_1>, and puts the original values back in everything you receive.

graph LR
    A[your application] -->|your text| C[Artific Core]
    C -->|text with placeholders| M[model]
    M -->|answer with placeholders| C
    C -->|answer with your text| A

Only the model provider sees a placeholder. The response, the streamed fragments and the stored messages of the conversation all carry your own text.

pseudonymisation needs no other capability. A call that stores nothing is pseudonymised just the same; threads only makes the placeholder table outlive the turn, so that a later turn hides the same person the same way.

Settings

The capability must be permitted by your organisation and enabled on your key. Your administrator sets three things per key:

Field What it does
language the language every text on a turn is analysed as: nl (the default), en, de, fr or es
entities which kinds of data are hidden
score_threshold the minimum detection confidence, 0 to 1, defaulting to 0.5

entities contains PERSON, EMAIL_ADDRESS, PHONE_NUMBER, IBAN_CODE, CREDIT_CARD and NL_BSN by default -- NL_BSN matches in any of the five languages, since it is a checksum over digits and not a fact about the surrounding language. Five types are off by default and a key asks for them individually: LOCATION (replacing place names and addresses costs a model much of its usefulness), IP_ADDRESS, and the national identifiers that only match their own language's text -- DE_TAX_ID (the German tax id) and ES_NIF/ES_NIE (the Spanish identity numbers). French text has no national identifier of its own: Core detects only the language-neutral types there.

entities names at least one type. A key saved with an empty list is refused with 422 on capabilities.pseudonymisation.entities; to stop hiding personal data, turn the capability off on the key instead.

A lower score_threshold hides more text and produces more false positives; a higher one lets borderline findings through to the model. GET /key returns the settings currently in force:

{
  "capabilities": {
    "threads": {},
    "pseudonymisation": {
      "language": "nl",
      "entities": ["PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER", "IBAN_CODE", "CREDIT_CARD", "NL_BSN"],
      "score_threshold": 0.5
    }
  }
}

If pseudonymisation is missing from that answer, your text reaches the provider as written.

Per tool and per kind of media

The model always gets placeholders. What a tool that sends something to another party gets, you set per tool with edges; what happens to images, recordings and speech with media:

"pseudonymisation": {
  "edges": [
    {"tools": "builtin:web_search", "mode": "clear"},
    {"tools": "mcp:crm", "mode": "detect", "arguments": ["note"]}
  ],
  "media": {"images": "allow", "audio": "self_hosted", "speech": "refuse"}
}
mode What the tool gets
clear the real values
hide every value the turn hid from the model, as its placeholder
detect what hide sends, after the detection has read the arguments too, so a name the model wrote itself is hidden as well; that costs one detection per call

tools names builtin:web_search, builtin:web_fetch, builtin:generate_image, builtin:transcribe_audio (its prompt), builtin:computer, api:<connector>, sql:<connector>, mail:<connector>, mcp:<server>, script:<name>, tool:<tool name> (an alias too) or *; the most specific rule applies. arguments narrows the rule to those arguments. Without a rule, what always held still holds: the web, image and transcription tools get placeholders, every other tool the real values. Core's own tools (knowledge, memory, datastores, files) cannot be set: they send nothing to another party. A decision model (builtin__decide__<model>, POST /decisions) always gets placeholders, as the chat model does, and a speak tool (builtin__speak__<model>) follows media.speech.

media says per kind allow (send it as it is), self_hosted (only to a model the deployment's operator marked as run by the deployment itself) or refuse. By default images and recordings go along and speech is refused. A withheld image or recording is a 403 on the request, or Denied by policy. for the tool that would send it. An image already earlier in the conversation is then no longer handed to the model: it reads [image unavailable] in its place.

Your organisation can set a floor in its policy document, settings.pseudonymisation_floor, in the same shape; it holds for every key below it that hides personal data, and a key can only be stricter. The audit event pseudonymisation.applied says per tool what it got: edges, one line of counts per tool, never a value.

What the detection finds

The detection runs inside Core and reads each text as it is written, in the key's language. With the default score_threshold of 0.5 you may expect this:

  • Phone numbers written the way their country writes them are hidden with or without a word such as "phone" near them: 0612345678, 06-12345678, 06 1234 5678, 020-1234567, +31 6 12345678, and the national forms of Belgium, Germany, France, the United Kingdom and the United States. A number from any other country is found when it is written internationally, starting with + or 00. Other digits, such as a date, a postcode with a house number or an order number written as one run without a leading 0, are left alone unless a word such as "phone", "call" or "mobile" stands near them. A reference that is written like a phone number is hidden as one: ten digits starting with 0, or ten digits in groups such as 2025-123-456.
  • Names are found by a name recogniser for the key's language. It reads a full name in a sentence well and misses some names, and it can find only part of a name that has a lower-case word such as "van" inside it, so that the rest stays readable. A name after a form of address is also found by how it is written, so it is hidden where the recogniser missed it: "mevrouw De Jong", "dhr. P. van der Meer", "de heer El Idrissi", "prof. dr. Hoekstra" on a Dutch key; "Mr.", "Mrs.", "Ms.", "Dr." on an English key; "Herr", "Frau" on a German key; "M.", "Mme", "monsieur" on a French key; "Sr.", "Sra.", "señor" on a Spanish key. The placeholder stands for the name only; the form of address stays in the text. Only the forms of address of the key's own language are read, so "Mrs. Smith" on a Dutch key depends on the recogniser alone. Titles written out in full are read as well: "dokter", "professor", "meester", "dominee", "zuster" and "broeder" on a Dutch key, "Mister", "Doctor" and "Professor" on an English key. On a Dutch key "mr", "dr", "ir" and "ing" written without their dot count only before a name of two parts or more, such as "dr P. Bakker"; "dr Bakker" depends on the recogniser alone.
  • What is hidden without being a name: a capitalised word after a form of address that is not a name, such as a brand ("Dr. Pepper"). Common words that open a sentence and offices are left alone ("meneer Dank", "Mr. President", "Herr Direktor"). Now and then the recogniser takes a capitalised word for a name, such as "Hello" at the start of a sentence. Stop hiding one value corrects this per conversation.
  • What it does not find: a name written without capitals, often a first name on its own, and a name that is spelled out, encoded or split by other characters. Text in another language than the key's language is read as if it were in that language, so expect names in it to be found less reliably. Treat the detection as a filter that removes most personal data, never as proof that none is left.

What the model is told

A placeholder is <TYPE_n>: the type that was found and a number that counts up per type, such as <PERSON_1>, <PERSON_2> and <EMAIL_ADDRESS_1>. Core replaces findings in everything the model reads: your instructions, the messages of the conversation and the results of tools.

Core adds one fixed notice to the context telling the model that placeholders are opaque, that each one stands for exactly one thing, and that they must be repeated verbatim. That text comes from Core and contains nothing of yours.

Models sometimes drop the angle brackets where they do not fit, for example in a file name such as offer_PERSON_1.md. Core puts the value back there too, but only for a placeholder the conversation actually issued, and only where no letter or digit runs into it from the front: offer_PERSON_1.md becomes offer_Jan Bakker.md. If the model answers with a placeholder the table does not know — it invented one such as PERSON_11, or mangled an existing one — you get it back literally. Core does not guess at what it might have meant. A word in capitals that is no type Core detects, such as MAX_RETRIES_3, is never touched.

Text of your own that already contains something shaped like a placeholder, such as <PERSON_1> or PERSON_1 typed in a message or standing in your instructions, is not one of the conversation's placeholders. Core marks it before the model call so the model does not read it as one, puts no value in its place, and returns it to you as it was typed.

Streaming changes none of this. Core holds back a fragment ending in what could still become a placeholder until the next fragment settles it, so response.output_text.delta never carries half a replaced name.

What a conversation remembers

Within one conversation the same text always gets the same placeholder, including in a turn sent days later. The match is on the literal text: Jan Jansen and Jan are two values and therefore two placeholders. A value the detection later reads as a different type keeps the placeholder it already has. The one exception is two turns running on the conversation at the same time: see renumbered.

A value the conversation holds is hidden everywhere it stands in what the model is sent, including where the detection does not find it again: an earlier message, the model's own earlier answer, a tool result. Within one turn a name found in the third message is hidden in the first. This match is on the exact text and on whole words: with Jan held, Jan is hidden wherever it stands as a word, and Jans, Januari, jan and JAN stay as written unless the detection finds them itself. Where two held values start at the same place the longest is taken, so Jan Jansen is replaced as one value.

A turn that stores nothing (store: false, or a key without threads) has a placeholder table that exists only for that turn. The protection is the same; there is simply nothing to read back afterwards, and the next call starts again at <PERSON_1>.

Read the table back

GET /threads/{thread_id}/pseudonyms returns what this conversation hid, oldest first, with cursor pagination:

curl -s "$CORE_URL/threads/thr_4Np8.../pseudonyms?limit=50" \
    -H "Authorization: Bearer $CORE_KEY"
{
  "items": [
    {
      "id": "psn_2Ktq...",
      "object": "pseudonym",
      "entity": "EMAIL_ADDRESS",
      "placeholder": "<EMAIL_ADDRESS_1>",
      "value": "jan.jansen@example.nl",
      "suppressed": false,
      "created_at": "2026-09-14T09:12:03Z"
    }
  ],
  "next_cursor": null
}

The route belongs to a key of the organisation that owns the conversation. If your key is missing threads or pseudonymisation, Core answers 404, indistinguishable from a path that does not exist.

The table holds what you and your tools brought into the conversation. When a turn runs a sealed copy from another organisation, such as an agent whose instructions you cannot read, the names and numbers in that sealed text are hidden from the model provider like any other, but they are the publisher's: they get no row, they are not in an export, and the pseudonymisation.applied event does not count them. A value the sealed text holds that you also send yourself is listed like any other from that turn on.

The rows go with the conversation: DELETE /threads/{thread_id} deletes them too, and they have no retention period of their own.

Correct a false positive

If the detection hides something that is not personal data — a product name read as a person, for example — stop it per row:

curl -s -X DELETE "$CORE_URL/threads/thr_4Np8.../pseudonyms/psn_2Ktq..." \
    -H "Authorization: Bearer $CORE_KEY"

The answer is 204. The row stays and gets suppressed: true, which means two things:

  • from the next turn on, that exact text reaches the model as written;
  • the placeholder is never handed to another value, so every earlier message the model read under it still means the same thing.

There is no route to change a value or a placeholder. The rows are what later turns of the conversation hide values by; rewriting them afterwards would make the conversation's history untrue.

A value that is hidden is hidden in every message where it stands as a word, so a false positive costs more than the sentence it was found in: a word such as Hello read as a person is replaced throughout the conversation until you stop it. A value you stopped hiding is left whole: with Jan Jansen stopped and Jan still held, Jan Jansen reaches the model as written.

When pseudonymisation cannot run

If Core cannot analyse the text, the turn does not proceed. Core does not send the text to the provider as written instead. The answer comes back with:

{
  "status": "failed",
  "error": {
    "code": "pseudonymisation_failed",
    "message": "Personal data could not be pseudonymised."
  }
}

A streamed turn reports the same answer as response.failed, and a background run ends the same way. The HTTP status is the ordinary status of a turn that ran, so always read status in the answer. The call that did not go out costs no model tokens: no model was called.

The turn can be sent again: the call that failed never reached the provider. If it keeps happening, report it to your administrator — the cause is in the installation, not in your request.

Web and image tools

builtin__web_search, builtin__web_fetch and the builtin__generate_image__<model> tools send what the model wrote — a search query, a page address, an image prompt — to a service outside your organisation (Web fetch and search, Image generation). Core puts the real values back into a tool call's arguments before a tool runs, so for these tools without a rule it takes them out again before the call goes to the service: every value the turn's placeholder table holds is replaced by its placeholder in the query, the address or the prompt.

So if the model searches for <PERSON_1> address, the search service receives exactly that, and not the name. The call is never refused for this and the turn goes on, but the result changes: a search for a placeholder finds little, a page whose address contains a hidden value will usually not be found (Web page could not be fetched.), and an image prompt reaches the image model with the placeholder in it. What you read back — the tool call in the response and the stream — still carries your own text, and the citation of a page whose address was rewritten names the address the turn asked for.

What this does and does not cover:

  • Nothing is detected at this point, unless the tool is set to detect. Only values already in the table are replaced. Anything detection missed in your text, or of a kind your key does not hide (entities), was never in the table and goes out as written. So does a name the model came up with itself; that is deliberate, so the model can still look up a public figure.
  • Only exact matches. A value is replaced where it appears exactly as the table holds it, and in an address also where it is percent-encoded (Jan%20Jansen, Jan+Jansen). Different capitalisation, another spelling, part of a name or a slug such as jan-jansen is not recognised. The model only ever saw the placeholder, so this is rare, but it is not checked.
  • A short value is also replaced inside a longer word. If Jan is in the table, Januari goes out as <PERSON_1>uari. Unlike in what the model is sent, no whole word is asked for here.
  • A value you stopped hiding is sent as written, as it is to the model.
  • Calling the tool yourself is not covered, unless the tool is set to detect. POST /tools/{name}/call is not a turn: nothing was hidden from a model, so there is no table, and the query, address or prompt you write reaches the service as written; under detect the detection reads what you write. A tool the model runs through core__call_tool inside a turn is covered like any other call of that turn.
  • Reference images are untouched, unless media.images withholds them. Only the prompt's text is rewritten.
  • An image drawn in the background is covered the same way. When the model starts an image tool as a task, Core keeps the entries of the table that the prompt holds, encrypted on the task, and replaces them when the task runs; they are deleted when the task ends. They are fixed when the task starts, so a value you stop hiding after that is still replaced in that task's prompt.

Audio

A recording is sent to the audio model's vendor as it is, also on a key with this capability. Detection reads text, and a recording has no text until the vendor has transcribed it. So the voices and every name spoken in a recording reach the vendor, whether you send it to POST /audio/transcriptions, name it to the transcription tool, or put it in an input_audio part (Transcribe audio). Which vendor that is, and in which region it runs, is decided by your key's policy before anything is sent; choose an audio model whose region you accept, or withhold recordings with media.audio (Per tool and per kind of media).

What happens to the text afterwards:

  • In a conversation, the transcript is hidden like any other text. The transcript of an input_audio part is your user's own message, and the result of the transcription tool is a tool result; the chat model reads both with personal data replaced by placeholders.
  • The transcription tool's prompt is rewritten as an image prompt is (Web and image tools): a value the turn hid is replaced by its placeholder before the prompt goes to the vendor.
  • POST /audio/transcriptions is not a turn. It returns the transcript to you as the vendor heard it; nothing is hidden from you.

What stays in clear

This capability narrows one thing: what a model provider is shown of a turn. The rest is left as written, by design:

  • Tools receive the real values, unless their rule says otherwise. A tool call is a call Core makes on your behalf against your own endpoint, MCP server or index, so Core puts the placeholders in the arguments back before the tool runs. A function_call you have to execute yourself also comes back with real values. The web and image tools are the exception, because they send what the model wrote to a service outside your organisation: see Web and image tools above.
  • The stored history is stored in clear. Messages, summaries, memory records and the response object carry your own text, and so do the arguments of a task the model started. The placeholder table is one more place that text lives; all of it goes when you delete the conversation.
  • Search and indexing are not covered. Knowledge and memory send their texts and search queries to the embedding provider as written: a placeholder would change the vector, so an index built one way and searched the other would stop matching. Indexing a knowledge source runs outside any turn, so the model steps in such a pipeline read the source's own text as it is. The same holds for POST /knowledge/retrieval-tools/{id}/query and POST /knowledge/pipelines/{id}/test: they also run outside a turn and receive your query and the matched text as written. The same steps a turn reaches through a search tool do go through the replacement.
  • Images are not covered. The detection reads text; an image reaches the provider exactly as it arrived, or not at all when media.images withholds it.
  • Audio is not covered. A recording reaches the audio model's vendor exactly as it arrived, or not at all when media.audio withholds it; see Audio.

Every turn the capability was in force for records a pseudonymisation.applied audit event carrying counts only: how many placeholders of each type were sent, how many occurrences came back, how many did not resolve, and renumbered. Suppressing a row records pseudonym.suppressed with that row's id. Neither event contains any text.

renumbered is above zero only when two turns ran on the same conversation at the same time. Each numbers its new placeholders from the table as it stood when the turn started, so both can hand out <PERSON_2>, each for a different value. The turn that is stored first keeps its numbers. The values of the other turn are stored under the next free number, or under the placeholder the first turn already gave them, and renumbered counts the placeholders of that turn this happened to. The answer you received and the stored messages are not affected: they carry your own text. That turn's trace is: it shows the numbers its model read, and those cannot be looked up in the table. Send the turns of one conversation one after the other when every trace has to be resolvable.