An AI Agent That Only Sees Pseudonyms
Ask an assistant to "draft an invoice for Talleres García, with their tax ID" and the easy implementation writes itself. The model searches the contacts, reads back B86641234, and passes it to a tool that creates the draft. It works on the first try, and that is the problem. The tax ID is now in the model's context. It is in the provider's request log, in the conversation summary written ten turns later, in any memory the assistant decides to keep, and in the reply that goes out to a WhatsApp number.
Sumalos, my invoicing app for freelancers in Spain, has an assistant called Suma that does exactly this kind of work: it drafts, issues and sends invoices. So the question for this post is narrow and a bit odd. Can a model act on a value it has never seen? Put a tax ID on an invoice, email a customer, filter contacts by a NIF, without the value ever entering its context?
It can, if there is one layer on the server that does all the translating. I call it the clear proxy. On the model's side of it, records and sensitive values only exist as stand-ins. On the database's side, everything is in the clear. The proxy is the only place where one turns into the other, and it does it again on every request, for the person who is asking.
In the code it is literally called the data proxy (createAiData): every read Suma makes goes through it, and a test greps the tools' imports and fails if one of them touches the database directly. That boundary, the restricted Postgres role and the masking rules belong to the post on privacy filters. This one follows the stand-ins.
Two kinds of stand-in
The model needs to point at two different things. Sometimes it means a record: "this invoice", "that contact". Sometimes it means a value: "this tax ID", "that phone number". They get different stand-ins, because they live for different lengths of time and belong to different people.
A handle is a pointer the model was handed. A pseudonym is a name for a value that stays the same everywhere, so the model can compare two of them without knowing what either says.
Handles: a pointer you were given
A database id is a bad thing to show a model. It works in any conversation, for anyone, forever, and a model that saw one can paste it into a tool call next week. A handle works only where it was issued.
Each handle is a three-letter type prefix and six random characters from an alphabet of 31, with the look-alikes removed (no 0/o, no 1/l/i), since a model or a person may have to copy it. The interesting part is the key it's stored under:
export function principalKey(access: AiAccess): string {
return access.principal.kind === "phone" ? `p:${access.principal.phone}` : `u:${access.principal.userId}`;
}
// in the Handles constructor:
this.scopeKey = `${principalKey(access)}:${conversationKey}`;The principal is the person behind the request, a user on the web or a phone number on WhatsApp, decided by the server and never by the model. The conversation is the web chat or the WhatsApp chat. A temporary chat gets the request id instead, so its handles die with it.
The reverse key matters more than it looks. Without it, every tool call would mint a fresh handle for the same invoice, and the memory profile at the top of the prompt would change between turns. With it, a record keeps one handle for the whole conversation, the prefix stays byte for byte the same, and the prompt cache keeps hitting.
Resolving is where it fails closed. A handle has to match the shape, exist under this person's and this conversation's key, be of a type the tool accepts, and belong to one of the organizations of this request:
const raw = await this.backend.get(`${this.scopeKey}:h:${clean}`);
if (!raw) return null;
// …parse it…
const wanted = types ? (Array.isArray(types) ? types : [types]) : null;
if (wanted && !wanted.includes(target.type)) return null;
// An organization record only within this request's organizations (a WhatsApp number narrowed to one).
if (target.organizationId && !this.access.organizationIds.includes(target.organizationId)) return null;
return target;Every failure is the same null, and every tool turns it into the same sentence: not found, look it up first. Never "that belongs to someone else", which would confirm it exists. The test reads like a list of the ways a model, or someone steering one, might try to get around it:
expect(await new Handles(backend, bea, "chat-1").resolve(handle)).toBeNull();
expect(await new Handles(backend, ana, "chat-2").resolve(handle)).toBeNull();
const own = new Handles(backend, ana, "chat-1");
expect(await own.resolve("fac_aaaaaa")).toBeNull();
expect(await own.resolve(INVOICE)).toBeNull();
expect(await own.resolve(`fac_${INVOICE}`)).toBeNull();
expect(await own.resolve("'; drop table invoices; --")).toBeNull();
expect(await own.resolve(handle, "contact")).toBeNull();Another person in the same chat, the same person in another chat, a forged handle, a raw uuid, a uuid wearing a prefix, SQL, the wrong type: all null. The action engine adds one more rule on top: a write goes to exactly one organization, so a handle from the other organization of a WhatsApp number that belongs to two is not found there either.
Pseudonyms: a name that hides its value
Handles cover records. They don't help when the model has to say a value, like the tax ID that goes on an invoice. For that the proxy hands out pseudonyms, and they are built the other way around: deterministic, not random.
digest(organizationId: string, value: string): string {
return createHmac("sha256", this.key).update(`${organizationId}\u0000${normalizeValue(value)}`).digest("hex");
}The token is the label of the field type plus the first ten hex characters of that HMAC: ‹NIF-3f9a12c4d0›. The value is normalized first (identifiers lose their spaces, dots and dashes and go to capitals), so B-86641234 and b86641234 get the same token. The organization is part of the input, so the same NIF in two organizations gets two unrelated tokens. And the key is derived from the server's master keys, so nobody can compute a token from a guessed value without it.
Being deterministic is what makes a pseudonym useful to a model. The same tax ID is the same token in every conversation and on every channel. The model can notice that the contact on this invoice and the contact on that quote share a NIF, or that the email it read in one tool result is the one in another, without knowing either value.
The value itself has to live somewhere, because the proxy has to put it back. It goes in a table, ai_pseudonyms, keyed by organization and digest, sealed with AES-256-GCM under a key derived from the master keys. The seal is bound to its row: the additional data is pseudonym:<organization>:<digest>, so a sealed value copied onto another row doesn't open. The app user writes it; the restricted role the assistant reads with has no grant on the table at all.
The way in: putting the real value back
Now the model wants to act. It read the contact through the proxy, saw the NIF as a pseudonym, and calls actuar (Spanish for "act", the tool that runs actions):
{ "pasos": [{ "accion": "factura.crear_borrador",
"datos": { "cliente": "con_4m8tqz", "nif": "‹NIF-3f9a12c4d0›" } }] }Before the action engine sees that plan, the proxy walks every string in it. A string that is exactly one pseudonym, and nothing else, gets four checks before it becomes a value:
if (tokens.length > 1 || !isPseudonym(value)) throw new PseudonymRefused(`Un seudónimo va solo en su campo, sin más texto: «${value.slice(0, 60)}».`);
const [, label, prefix] = tokens[0]!;
const entry = await vault.open(access.organizationIds, prefix!);
const shown = value.trim();
if (!entry || entry.label !== label) throw new PseudonymRefused(`No encontrado: ${shown} no es un dato de esta organización. No lo inventes: pide el dato a la persona o búscalo antes.`);
if (entry.area !== "general" && !accessCan(access, entry.organizationId, entry.area as Area, "read")) throw new PseudonymRefused(`No encontrado: ${shown} no es un dato que puedas usar aquí.`);
const sink = normalizePath(keyName);
const fits = SINK[label!]?.test(sink) || normalizePath(entry.path.split(".").at(-1) ?? "") === sink;
if (!fits) throw new PseudonymRefused(`${shown} solo se puede usar en un campo de su tipo (no en «${keyName}»).`);The first and last checks are about laundering. Without them there is an obvious trick: write the pseudonym into a contact's notes, let the proxy resolve it into the real NIF, then read the notes back. So a pseudonym must stand alone in its field ("copy ‹NIF-…›" is refused), and the field has to be of its kind: a NIF goes into a NIF field or a field that looks up a contact, a phone into a phone field, an email into a recipient. Notes are none of those.
The middle two are about who is asking. A pseudonym is per organization, but the person asking might not be allowed to see where it came from. The vault remembers the area each value was read in (Contacts, Invoices, Expenses), and someone without Contacts can't turn a contact's NIF back into a value, even if the token somehow reached their conversation. That case, like a made-up token or one from another organization, gets the same "not found".
One detail in vault.open I like: it looks the token's ten-character prefix up with limit 2, and if two values share the prefix it returns nothing. A collision is refused, never guessed.
Queries follow the same rule. If the model wants to find the contact with a given NIF, it can filter by equality with the pseudonym (nif igual ‹NIF-…›), and the proxy swaps the value in before the SQL is built. It can't filter by a pattern, sort or group by a masked field, since a row count would confirm a guess; the reasons for that refusal are in the privacy filters post. Equality with a pseudonym reveals nothing the model didn't already have.
The way out: masking what comes back
The action engine now has the real NIF and does its job: it validates the plan and writes a preview with real figures, the way the web shows it. That preview is exactly the text that would leak the value straight back into the model's context. So before the outcome leaves the tool, it goes through the proxy again:
/**
* An outcome's texts for the model with the masked values out: those resolved from
* pseudonyms, those of the records the plan names, and the text net (data.privacy). The
* cards keep the real values: the person sees them in the app.
*/
async function maskedOutcome(env: ToolEnv, out: ActionOutcome, steps: PlanStepInput[]): Promise<ActionOutcome> {The values resolved on the way in are remembered for the rest of the request, and the records the plan named by handle are read so their masked fields are known too. Then the text goes through the same safety net as any other free text: known values are swapped back for their tokens, and detectors for IBANs, cards, ID numbers, phones and emails catch the rest. The model reads "Done: draft invoice for Talleres Mecánicos Hermanos García S.L. (NIF ‹NIF-3f9a12c4d0›)".
There are two audiences for one outcome, and they get different versions of it:
The card on the web shows the real NIF because the person can already see it in the app; hiding it from them would only make the card useless. WhatsApp has no cards, so there everything the person is told, the model's reply and the result after they answer "yes", goes out masked, except what they typed themselves.
The database test that drives this end to end uses a scripted model and the real tools. It reads a contact, passes the pseudonym back to actuar, then tries to launder it and to invent one:
// The action got the real NIF; the model got the pseudonym back, never the value.
expect(received.length).toBe(1);
expect(received[0]![0]!.input.nif).toBe("B86641234");
expect(first!.text).toMatch(/NIF ‹NIF-[0-9a-f]{10}›/);
noSecrets(first!.text);
// The card the person sees keeps the real value (they may see it in the app).
expect(first!.cards).toContain("B86641234");
expect(rest[0]!.text).toContain("solo se puede usar en un campo de su tipo");
expect(rest[1]!.text).toContain("va solo en su campo");
expect(rest[2]!.text).toContain("No encontrado");received.length is 1: of four actuar calls, only the honest one reached the engine.
The stand-in the model never gets
There is a third opaque value in this flow, and the model doesn't even get a stand-in for it. When a plan is risky (issuing an invoice, emailing a customer, deleting), the engine doesn't run it. It returns a confirmation token: 24 random bytes, kept in Redis for ten minutes, bound to the plan's hash, the person, the organization and the conversation, and usable once.
That token goes in the web card's button and in the WhatsApp agent's own state. The model's text only says that something is waiting for the person to confirm. As a last check, the tool output replaces anything token-shaped (30 or more URL-safe characters that aren't a uuid) before the model reads it, so no tool can confirm on the model's behalf. On WhatsApp, the agent's code reads the person's "yes", not the model. How each action checks the person's permissions is in the post on tool permissions.
What the model is told
None of this works if the model fights it. A model that sees ‹NIF-3f9a12c4d0› and doesn't know what it is will ask the person to read the NIF out, or try to work it out with a few searches. So the organization's policy comes with a paragraph in every turn's context, written from the same rules the proxy applies: these fields come as pseudonyms, each one is always the same for the same value, you can name it and use it as is in actuar (the server puts the real value in), but you don't know the value and can't work it out. And don't ask the person to dictate a hidden value: if they need it, they can look in the app.
That's also the trade. The model can name, compare and use a value, but it can't read it. It can't check that a NIF has the right check letter or say which province a postal code is in, if the organization chose to hide those. When it needs the value itself, the answer is a person and the app, which is the point.
Everything here runs in Sumalos's test suite, against a development Postgres with the restricted role, a scripted model and the real tools. None of it has met a real customer yet, and one gap is written down in the project's own notes: the text net recognises a hidden address or name in free text only after it has met that value in the same request, while identifiers like NIFs, IBANs and phones are caught by shape every time.
What I like about the clear proxy is where it puts the trust. The model doesn't have to be careful with the NIF, because it never had it. The question "may this person use this value here?" isn't asked of a prompt. It's asked of the server, on every request, in one function that a test can cover.
Related articles
Payment terms said on the web on Monday should be known on WhatsApp on Thursday, by the right people only. How Sumalos designs its AI agent's memory.