Skip to main content
loomcycle
§ architecture note

Even with no-training contracts, the LLM should never see your name

I was reading agent logs in fake-user mode when I noticed it. The first turn of a CV-tailoring run included the user's full name, email, phone number, and a postal address — verbatim, on the wire, part of the prompt the model was asked to think about. Test data; none of it real. But the architecture was sending those fields exactly the same way it would send a real user's. The fakeness didn't change anything about the shape of the exposure.

JobEmber runs on Anthropic's no-training tier. The contract is clear: prompts and completions are not retained, not used to train or fine-tune models, processed only to return the requested completion. I trust that contract. I also know it is a contract about retention and downstream use, not a reduction of what gets sent in the first place. The bytes still cross someone else's network, sit in someone else's inference cache for the duration of the call, and are processed by someone else's code paths. A no-training tier is a promise. Defense in depth is a property of the data path itself.

This week we rebuilt JobEmber's data path so identifying PII never reaches the LLM at all — names, emails, phones, postal addresses, and even the user's location preferences. We also took the chance to shrink the agents' tool surface, because data minimization without tool minimization is half a story: an agent that can Read arbitrary files or hit arbitrary URLs can fetch back what you took out of the prompt.

1. Placeholders, server-side substitution

The redactor sits between the database and the prompt builder. Five PII fields per user — name, email, phone, postal address, and the list of preferred onsite cities — each map to a deterministic placeholder before any text is handed to a model:

{{pii.name}}
{{pii.email}}
{{pii.phone}}
{{pii.address}}

The real values are stored once, in the user's structured account row, and never copied into anything the agent reads. The CV templates, cover-letter drafts, application versions, and Q&A answers in the database all hold placeholders, not raw values. The historical record of past applications therefore contains no raw PII — a property that matters for audit, for breach reduction, and for the data-export endpoint we have to ship under GDPR Article 20.

Re-inflation happens only at the last possible moment: the user clicks Download PDF or Download DOCX, the server-side export reads the placeholders out of the document, looks the real values up from the account row, and writes them into the file before streaming it to the browser. The model has already returned its completion at that point. The substitution runs entirely inside JobEmber, on our server, in code we control.

This works because the renderer is server-side. If we were generating PDFs in the browser from raw HTML, the re-inflation step would either have to ship the real PII to the client (defeating the point) or use a separate authenticated round-trip (more complex; more attack surface). The placeholders-plus-late-binding pattern is clean only when you control the rendering boundary.

2. Comparison without exposure

Placeholders solve the easy case — PII as a label the model doesn't need to reason about. The harder case is PII as an operand: prompts that ask the model to decide something using the user's private data.

Concrete example. The job-searcher agent has to score whether a job posting's location matches the user's preferred onsite cities. The old prompt looked roughly like this:

USER_PREFERRED_CITIES: Berlin, Amsterdam, Remote-EU
JOB_LOCATION: "Munich, Germany (hybrid)"

Does this job match the user's preferences? Reply yes/no with one
sentence of reasoning.

That sends the user's preferences to the model directly. Even under a no-training contract, it's a fingerprint of where the user wants to live.

The new pattern keeps the user's preferences server-side. The agent gets a narrow MCP tool — mcp__jobs__matchUserLocations — that takes a job's location string and returns a match verdict. The agent never sees the user's list; only the answer. Schematically:

Agent says:  matchUserLocations({ jobLocation: "Munich, Germany (hybrid)" })
JobEmber returns: { match: false, reason: "outside preferred cities,
                                            but remote-friendly tag present" }

The comparison happens in a JobEmber endpoint that has the user's profile, runs the city-match logic in TypeScript, and returns the smallest answer the agent needs. The decision is auditable: it's ordinary application code, not a model judgment, and the matching rules are versioned in our repo. The user's preferred cities never enter an LLM prompt.

The shape generalizes. Anywhere an agent would naturally write "give the model a private value plus a public value and ask it to compare them," the better move is "give the agent a tool that compares them server-side and returns the verdict." The model gets the verdict; the operand stays private.

3. Shrinking the tool surface

An agent that can hit arbitrary HTTP endpoints or read arbitrary files can fetch back the data you took out of the prompt. Data minimization is bounded by tool minimization. So we did a sweep across .claude/agents/*.md and looked at every tool every agent had, then asked the same question of each: does this agent actually need this tool, or did it inherit it from a template?

The cuts:

The end state: across the entire agent fleet, there are zero direct HTTP-tool consumers. Every outbound HTTP call flows through either MCP (narrow, server-mediated) or WebFetch (Pre-hook-governed). Both layers are auditable on the server.

4. The bearer that never reaches the prompt

There's a category of "secret" we already kept out of LLM prompts: the per-run bearer that authorizes JobEmber's agents to call our own /api/mcp endpoint. The MCP auth war story from last week landed a per-run bearer mechanism (v0.8.14): a fresh short-lived token is minted at agent-context creation and substituted into the MCP Authorization header on the wire by loomcycle, never placed in the prompt.

This week's cleanup tightened that further: agents whose policy has no mcp__jobs__* tools don't get a bearer minted at all. The check lives in agentNeedsBearer(agentType) in src/lib/agent-context.ts. If there's nothing for the bearer to authenticate against, there's nothing to mint, nothing to substitute, and nothing to forget about leaking. The simplest credential is the one you never created.

A side effect of the same audit: a handful of routes had been including an auth-preamble string ("Your session token is …") in prompts as a courtesy hint to the model. Stripped. The model doesn't need to know the token exists; loomcycle handles it on the wire.

5. The runtime layer and the application layer have to shrink together

Loomcycle gives the application layer some sharp primitives for this: per-agent tool allowlists, per-run bearer substitution, Pre-hooks for host widening, MCP for narrow trusted tools. None of those primitives, by themselves, prevent the application from stuffing the user's address into the first prompt.

Conversely, the application layer can be careful with what it puts in prompts and still leak everything, if the agent has a Read tool over a directory of CVs or an HTTP tool with no Pre-hook gating. The first leak is a data-path bug; the second is a tool-surface bug; they both end in the same place — bytes of user identity on someone else's network.

The rule, after this week's work, is the boring one. Ask of each thing the agent is about to see: does the model need this to do its job? If no, don't send it. If the answer is "the model needs to decide something about this private value," route the comparison through a tool that does the deciding server-side and returns the verdict. If the agent needs a tool, give it the narrowest tool that does the job. If the agent's input is attacker-controllable, give it no tools at all.

The work landed across a few PRs this week: PII placeholder redaction across the data path (#26), mcp__jobs__checkUrl replacing the HTTP tool on the job-searcher (#27), the Read-tool cleanup across the agent fleet, and a privacy policy update (v0.2, § 5.5) that documents the guarantee so users and auditors can read it. The strictest case of the tool-surface sweep — the job-posting-parser lockdown for attacker-controllable HTML inputs — gets its own writeup: What tools should an agent reading attacker HTML get? None. What the model doesn't see can't leak.