Chapter 5: Mask PII with an LLM
Back in Chapter 0, your agent may have hesitated before escalating that ticket. It saw the card number and the SSN, warned you, and maybe offered to redact them. But it couldn’t, because the escalate_ticket tool takes only a ticket ID (or it may have offered to circumvent the MCP server entirely). The MCP server then builds the issue content from the data.
The data crosses the compliance boundary on the server’s terms, so now you’ll build the server-side solution. Before an escalation posts to Linear, an LLM intervenes to draft an engineering-facing title for the issue, redacts all PII and pasted credentials, and labels the issue. Nothing customer-typed reaches Linear unsanitized.
On the Keycard side, you already added the Anthropic API key to the vault in Chapter 1. The MCP server exchanges the caller’s token for it on every escalation, the same way it gets the Supabase key. By the end of this chapter, one tool call produces two attributed credential events in your audit log.
Server-side LLM
Section titled “Server-side LLM”src/pii.ts(new) builds the rewrite, including PII types, label taxonomy, and amaskTicket()function that makes one structured-output call to a small, fast model and returns the title, the masked content, and the labels. Raw PII exists only inside that function.src/tools/escalate-ticket.tsnow rewrites before it posts. The issue title becomes the LLM’s case summary (prefixed[case]), the content becomes the masked text plus non-PII metadata (e.g., plan tier, severity) and the ticket UUID, and the tool’s output now reports what it masked.src/linear.tsadds one query: Linear attaches labels by ID, not name, so last but not least, the LLM’s label names get resolved to IDs.
Note that Linear itself still uses the shared API key in .env. Chapter 6 will address this.
LLM vs. deterministic code
Section titled “LLM vs. deterministic code”escalate_ticket already computes Linear priority. Severity is a structured enum (critical, high, medium, low), so priority is a four-row lookup table. It’s deterministic, instant, free, and can’t return an invalid value. Putting an LLM there would make it slower, more expensive, and less reliable.
Masking, titling, and labeling are the opposite. “Find the PII a stressed customer pasted into a refund form,” “summarize this for the engineer picking it up,” and “decide whether this is a payments issue or an api issue” are judgment calls over free text. Use a table when the answer is deterministic, and use the LLM only where judgment is the task.
The title is the cleanest example of both. The [case] prefix is deterministic (every escalation gets it equally). The title text is judgment: a support engineer handing a case to engineering doesn’t copy the customer’s subject line, they describe the problem. The LLM writes the case summary fresh from the technical content instead of redacting customer prose, redacting PII in the process.
Install AI packages
Section titled “Install AI packages”Run this command from your working server directory:
npm install ai@6.0.208 @ai-sdk/anthropic@3.0.85ai is the AI SDK core (the generateText + structured output call) and @ai-sdk/anthropic is its Anthropic provider.
Add the Anthropic identifier to .env
Section titled “Add the Anthropic identifier to .env”The Anthropic API key is already in the Keycard vault from Chapter 1, the resource identifier is the API URL, and the MCP server’s exchange mechanism was implemented in Chapter 4. The codebase just needs the name of the resource to request:
# The LLM provider. Also the vault resource identifier# you registered in Chapter 1; must match exactly.ANTHROPIC_API_URL=https://api.anthropic.comImplement server-side LLM
Section titled “Implement server-side LLM”Add LLM-based PII masking, issue titling, and classification to the escalation path.
Create src/pii.ts. Export EntityTypeSchema, a zod enum of “NAME”, “EMAIL”, “PHONE”, “SSN”, “ADDRESS”, “CREDIT_CARD”, “CREDENTIAL”. Define ISSUE_LABELS (module-private), a const array of the workspace’s eight Linear label names: payments, webhooks, data-export, auth, frontend, api, infrastructure, notifications. Read ANTHROPIC_API_URL from the environment into a module constant via a requireEnv helper (the same pattern tickets.ts uses for SUPABASE_URL); it is both the vault resource identifier and the base of the provider URL. Define MaskResultSchema, a zod object with: title (string; describe it as a concise engineering-facing title summarizing the technical problem that must never include names, contact details, or anything else identifying the customer), maskedText (string), detectedEntities (an array of objects with type EntityTypeSchema and placeholder, where placeholder is a string constrained by the zod regex /^[[A-Z_]+(_\d+)?]$/ so a raw value can never pass validation), and labels (an array of a zod enum over ISSUE_LABELS, with min 1 and max 3). Export the inferred MaskResult type.
Export an async function maskTicket(subject, body, auth), where auth is AuthInfo from “@modelcontextprotocol/sdk/server/auth/types.js”, that does the following on every call, caching nothing: exchange the caller’s bearer token (auth.token) for the Anthropic API key via exchangeForCredential from ./keycard.js with ANTHROPIC_API_URL as the resource; build a provider with createAnthropic from “@ai-sdk/anthropic”, passing the exchanged apiKey AND an explicit baseURL of ANTHROPIC_API_URL plus “/v1” (without the explicit baseURL the provider falls back to an ambient ANTHROPIC_BASE_URL env var, and the shell a coding agent launches the server from often has one); then make ONE structured-output call with generateText from “ai”, model anthropic(“claude-haiku-4-5”), the output option set to Output.object with MaskResultSchema, and a prompt containing the subject line and the body. The system prompt tells the model: it receives a ticket’s subject and body; write a concise engineering-facing title that describes the malfunction, never the customer, carrying no customer-identifying information; rewrite the body replacing every piece of PII (names, emails, phone numbers, SSNs, postal addresses, payment card numbers, and credentials of any kind such as passwords, API keys, tokens, and signing secrets) with bracketed placeholders numbered per type like [EMAIL_1]; mask PII wherever it appears, including inside pasted artifacts like forms, configs, CSV rows, and request logs; keep all technical detail intact; report each masked value in detectedEntities as type and placeholder only (the type being exactly one of the seven EntityTypeSchema values — payment card numbers are CREDIT_CARD and every secret is CREDENTIAL, there are no separate types for those), never repeating an original value anywhere in the output; and classify the issue with one to three labels drawn exactly from the eight ISSUE_LABELS names, where “api” means inbound requests the customer makes to us, “webhooks” means outbound machine-to-machine events we send, and “notifications” means outbound human-facing messages. Wrap the generateText call in a try/catch: ai-sdk errors carry the raw prompt and raw model output on the error object, so never rethrow or log them whole; instead throw a new plain Error with a short PII-free message prefixed “PII masking failed:” in both branches (followed by “the model’s answer was incomplete or didn’t match the schema, retry the escalation” for the SDK’s NoOutputGeneratedError / NoObjectGeneratedError, and by the original error’s message string otherwise). Return the validated object.
In src/linear.ts, add an exported getLabelIds(names) that uses the existing linearRequest helper to run an issueLabels query filtered by name (Linear’s IssueLabelFilter supports a name “in” comparator against a non-null list of strings) selecting only the node ids, and returns them. Give createIssue a fourth required parameter labelIds (a string array) and include it in the issueCreate input. Don’t change how either function obtains the API key.
In src/tools/escalate-ticket.ts: after loading the ticket, call maskTicket(ticket.subject, ticket.body, auth). Build the issue title as the string “[case] ” followed by the returned title (the prefix is deterministic code, like the priority table; the title text is the model’s judgment, like the labels). Build the issue description from the masked text, then the existing ”---” divider, then the plan tier and severity line, then the support ticket UUID; remove the customer name/email/phone line entirely. Resolve the returned label names to IDs with getLabelIds and pass them to createIssue. Keep the severity-to-priority lookup table exactly as it is. Update the tool’s description to say the escalation is sanitized server-side: an engineering-facing title, a body with PII replaced by placeholders, and labels, all generated automatically. Add maskedText and detectedEntities to the tool’s outputSchema and to the returned result; in the outputSchema declare detectedEntities as an array of objects whose type and placeholder are plain z.string() fields, not by reusing MaskResultSchema.shape.detectedEntities (the schema’s regex and enum constraints describe how the model must generate, not how the tool reports its output).
Don’t change src/keycard.ts, src/tickets.ts, src/tools/get-tickets.ts, or src/tools/delete-issue.ts.
What the agent builds
Section titled “What the agent builds”src/pii.ts is one function, with error handling abbreviated below to conserve space:
export async function maskTicket(subject: string, body: string, auth: AuthInfo): Promise<MaskResult> { const apiKey = await exchangeForCredential(auth.token, ANTHROPIC_API_URL); const anthropic = createAnthropic({ apiKey, baseURL: `${ANTHROPIC_API_URL}/v1` });
const { output } = await generateText({ model: anthropic("claude-haiku-4-5"), system: SYSTEM_PROMPT, prompt: `Subject: ${subject}\n\n${body}`, output: Output.object({ schema: MaskResultSchema }), }); return output;}The exchangeForCredential call is the same as the one tickets.ts makes, but with a different target resource. It returns a static API key instead of an OAuth token.
The schema provides structure. Output.object() takes the zod schema for MaskResult, so the LLM’s response comes back parsed and validated: labels are enums of the Linear workspace labels (the LLM cannot hallucinate a label that isn’t in Linear) and every placeholder must match a regex.
In escalate-ticket.ts, the PII masking, title, description, and labels all come together from the LLM’s output:
// Rewrite the ticket before it crosses the boundary. Past this line,// nothing downstream ever sees the raw ticket.const masked = await maskTicket(ticket.subject, ticket.body, auth);
// The "[case]" prefix is deterministic code, like the priority table;// the title text is the model's case summary — judgment, like labels.const title = `[case] ${masked.title}`;
const description = [ masked.maskedText, "---", `**Plan:** ${ticket.plan_tier} · **Severity:** ${ticket.severity}`, `**Support ticket:** ${ticket.id}`,].join("\n\n");In Chapter 0’s version, the customer’s subject line was copied into the title. Their name, email, and phone number were appended under the free text content. Now engineering sees a useful title that describes the problem, the symptoms, the plan tier, the severity, and a UUID. Anyone who legitimately needs the customer follows the UUID back into the support system, where PII belongs.
Test the server-side LLM
Section titled “Test the server-side LLM”Restart the MCP server (npm run dev), then re-run the prompt that triggered the oh-no moment in Chapter 0:
Using the support-escalation tools, list the open support tickets. Then escalate the critical payment ticket (the one about double charges) to engineering with escalate_ticket.
Open the Linear issue your agent presents.
- The title is freshly generated for engineering. It might be something like
[case] Payment portal processes duplicate charge on timeout retry. The title is now the server-side LLM’s summary of the problem, not the customer’s subject line. - The refund form is still in the body, but it now reads
Card: [CREDIT_CARD_1],SSN (identity check): [SSN_1]. - The invoice number, the timeline, the customer’s actual problem are untouched.
- The issue arrived with priority Urgent (deterministic mapping) and the label payments (the LLM), and no customer contact information is present.
The server does not rely on your agent’s judgment. The server-side rewrite runs on every ticket escalation, for every caller, whether or not the agent driving the tool noticed anything sensitive or tried to do anything about it.
One tool, two audit log exchanges
Section titled “One tool, two audit log exchanges”Chapter 4 showed one credentials:issue event for every ticket read. See what a single escalation looks like now.
-
Escalate a ticket (the one you just did counts).
-
In console.keycard.ai, open your Audit Log.
-
The one tool call produced two
credentials:issueevents: one for Supabase Database (reading the ticket) and one for Anthropic API (processing the ticket). Open the Anthropic event. Actor Details shows the same two-identity chain you saw in Chapter 4: your application plus you. Request information defineshttps://api.anthropic.comas the credential provider with thetoken-exchangegrant type.
Every credential is now brokered, attributed, and logged per call. When someone asks “which humans’ requests hit the Anthropic API last Tuesday,” the audit log provides the answer.
The one credential still missing from this picture is the Linear key the issue was posted with, and that’s deliberate: it’s the last API key standing, and it’s next.