Since 0.5.0. The code below runs in this repository's CI against the real Vercel AI SDK (ai@7) and a mock model, so it is the code that is tested, not a sketch.
Three patterns we see most: a support bot that must never forward an SSN, a KYB agent whose model drafts the note while the arithmetic stays on your machine, and questions over a folder of health records where only the blocks that answer leave, redacted. Both use your own model and key; Sırdaş opens no connection of its own.
npm i ai @ai-sdk/openai @sirdaspdf/gateway @sirdaspdf/mcp
A support bot that never sends an SSN
Names, emails and account numbers become placeholders before the request leaves and come back in the answer. block: ["SSN"] refuses the call before the model sees anything, because support has no reason to handle one, even redacted. The log receives counts by type, never values.
// Receta: un bot de soporte que contesta con un modelo en la nube y nunca le
// manda un SSN, ni siquiera tapado. Nombres, correos y cuentas se tapan antes
// de salir y vuelven en la respuesta; un SSN para la llamada en seco, porque
// soporte no tiene por qué tratarlo.
import { generateText, wrapLanguageModel } from "ai";
import { sirdasMiddleware, SirdasRedactionError } from "@sirdaspdf/gateway/ai-sdk";
export function supportModel(model, { log = () => {} } = {}) {
return wrapLanguageModel({
model,
middleware: sirdasMiddleware({ block: ["SSN"], onRedact: (counts) => log({ redacted: counts }) }),
});
}
export async function answerTicket(model, ticket) {
try {
const { text } = await generateText({
model: supportModel(model),
system: "You are a support agent. Answer in two sentences.",
prompt: ticket,
});
return { ok: true, text };
} catch (e) {
// Un SSN en el ticket no es un error del sistema: es la regla funcionando.
if (e instanceof SirdasRedactionError && e.reason === "blocked") {
return { ok: false, text: "This ticket contains a Social Security number. Please remove it and send it again." };
}
throw e;
}
}
Use it with any AI SDK model:
import { openai } from "@ai-sdk/openai";
const reply = await answerTicket(openai("gpt-5"), ticketText);
A KYB agent that keeps the EIN on your machine
checkPackageBytes compares the real values across documents locally (legal name, EIN, statement balances row by row) and returns the result already redacted. The model only sees [ORG_1] and [TAX_ID_1] and writes the note for the person who decides; it does not approve anyone. The middleware checks the prompt a second time before it leaves.
// Receta: un agente de KYB que revisa el paquete en tu máquina y solo manda al
// modelo el resultado anonimizado. Las cuentas cuadran con los valores reales,
// en local; el modelo ve [ORG_1] y [TAX_ID_1], nunca el EIN, y redacta la nota
// para la persona que decide. El modelo no aprueba a nadie.
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { generateText, wrapLanguageModel } from "ai";
import { checkPackageBytes } from "@sirdaspdf/mcp";
import { sirdasMiddleware } from "@sirdaspdf/gateway/ai-sdk";
export async function reviewFolder(folder, profile = "lending") {
const names = (await readdir(folder)).filter((n) => n.endsWith(".pdf")).sort();
const files = await Promise.all(names.map(async (name) => ({ name, bytes: new Uint8Array(await readFile(path.join(folder, name))) })));
return checkPackageBytes(files, { profile });
}
export async function underwriterNote(model, folder) {
const review = await reviewFolder(folder);
const findings = review.checks
.filter((c) => c.status !== "pass")
.map((c) => `- ${c.status.toUpperCase()} ${c.id}: ${c.message}${c.evidence[0] ? ` (${c.evidence[0].source}, page ${c.evidence[0].page ?? "?"})` : ""}`)
.join("\n");
// Segunda capa: aunque el resultado ya sale anonimizado, el middleware vuelve
// a mirar el prompt antes de que salga.
const { text } = await generateText({
model: wrapLanguageModel({ model, middleware: sirdasMiddleware() }),
system: "You write a short note for a human underwriter. Do not approve or decline.",
prompt: `Package review findings:\n${findings || "- nothing flagged"}\n\nWrite the note.`,
});
return { review, note: text };
}
Ask a folder of health records
The context engine picks, on your machine, the few blocks that answer the question (BM25 over sections, no embeddings and no model), redacts them in place and sends only that, each block with its source and page. The answer comes back with the real names on your machine: the clinician sees them, the provider does not.
// Receta: preguntar a una carpeta de historias clínicas. El motor de contexto
// elige en tu máquina los pocos bloques que responden, los anonimiza en su
// sitio y solo eso va al modelo, con la fuente y la página de cada bloque. La
// respuesta vuelve con los nombres reales también en tu máquina: quien atiende
// los ve, el proveedor no.
import { generateText, wrapLanguageModel } from "ai";
import { anonymizeContext, buildContext, buildDocument, createIndex, CONTEXT_PREAMBLE } from "@sirdaspdf/core/context";
import { deanonymize } from "@sirdaspdf/core/anonymize";
import { sirdasMiddleware } from "@sirdaspdf/gateway/ai-sdk";
/** notes: [{ name, markdown }], el texto de cada historia (sirdas markdown la saca de un PDF). */
export function indexRecords(notes) {
return createIndex(notes.map((n) => buildDocument(n.markdown, { source: { name: n.name } })));
}
export async function askRecords(model, index, question) {
const context = anonymizeContext(buildContext(index, question, { maxTokens: 1500 }));
const { text } = await generateText({
// Segunda red: si algo se escapó del contexto, el middleware lo tapa antes de salir.
model: wrapLanguageModel({ model, middleware: sirdasMiddleware() }),
system: `${CONTEXT_PREAMBLE} Answer only from the documents and name the source of each fact.`,
prompt: `${context.text}\n\nQuestion: ${question}`,
});
return {
answer: deanonymize(text, context.findings).text,
sources: context.items.map((i) => ({ source: i.source, pages: i.pages })),
tokens: { corpus: context.stats.sourceTokens, sent: context.stats.contextTokens },
};
}
LangChain JS agents
LangChain 1.x ships its own piiMiddleware with strategies (redact, mask, hash, block) but detects only emails, cards, IPs, MAC addresses and URLs. It accepts a custom detector, and sirdasDetector is one: it adds SSNs, EINs, labelled names, MRNs, account and ID numbers. LangChain keeps the strategy; Sırdaş decides what is personal data.
import { createAgent, piiMiddleware } from "langchain";
import { sirdasDetector } from "@sirdaspdf/core/detect";
const agent = createAgent({
model: "openai:gpt-5",
middleware: [
piiMiddleware("ssn", { detector: sirdasDetector({ types: ["SSN"] }), strategy: "block" }),
piiMiddleware("sirdas", { detector: sirdasDetector(), strategy: "redact" }),
],
});
Checked by hand against langchain@1.6.2: the model receives Patient Name: [REDACTED_SIRDAS], and an SSN raises PIIDetectionError before the call. LangChain's strategies do not put the real values back in the answer; if you need that round trip, use the Gateway or the AI SDK middleware.
In Python (pip install sirdas), the same detector plugs into LangChain's PIIMiddleware; positions index the Python str directly:
from langchain.agents.middleware import PIIMiddleware
from sirdas.integrations import langchain_pii_detector
PIIMiddleware("sirdas", detector=langchain_pii_detector(), strategy="redact")
Checked by hand against langchain==1.4.9 (Python): the model receives Patient Name: [REDACTED_PERSON].
What these recipes do not do
- They do not redact attached files: the middleware rejects them. Convert PDFs to text first (
sirdas markdown) or review them withcheckPackageBytes. - Rule-based redaction misses names in free prose with no label; that is why
blockexists. The public benchmark (/benchmarks/pii-v1.jsonl, scored indocs/benchmarks/pii.md) lists what it catches and what it misses.