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:
-
Readremoved from four agents. Onlydoc-converterlegitimately reads files (it parses user-uploaded CVs from disk). The other four had it as leftover scaffolding from earlier prompt iterations — none used it on the hot path. -
HTTPremoved fromjob-searcher. It was there for a single thing: a Tier-3.5 liveness check on candidate apply URLs. Replaced by a narrow MCP toolmcp__jobs__checkUrlthat does the SSRF-guarded fetch server-side, runsdetectLivenessanddetectAggregatorListingon the body, and returns a structured verdict. The agent no longer has a generic HTTP capability; it has a domain-shaped capability the runtime can audit. -
WebFetchstill allowed where needed, but governed by per-call Pre-hooks — the URL-widening mechanism from the previous post. The runtime asks the operator service, on every fetch, whether the host is allowed for this run. Discovery widens the allowlist by one host at a time; no agent gets a blanket wildcard.
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.