- Book: AI That Ships
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Guardrails usually start as a paragraph in the system prompt. "Never
reveal internal pricing. Never give legal advice. Always redact card
numbers."
That is worth having and it is not a control. It is a request to a
probabilistic system, with no enforcement, no logging, and no way to
tell whether it held on any particular request.
The version that is a control is ordinary middleware — code that runs
before and after the model, that you can test, and that produces a
typed outcome your caller has to handle.
Nothing here makes a system safe. Each stage reduces a specific class
of risk, and they compose.
The pipeline type
export type Refusal = {
kind: "refusal";
stage: string;
code: RefusalCode;
userMessage: string;
internal: string;
};
export type Guarded<T> =
| { ok: true; value: T }
| { ok: false; refusal: Refusal };
export type InputStage = (
req: AgentRequest,
) => Promise<Guarded<AgentRequest>>;
export type OutputStage = (
out: AgentOutput,
req: AgentRequest,
) => Promise<Guarded<AgentOutput>>;
A stage takes a value and returns either the value — possibly
modified — or a refusal. Because stages transform, an input stage can
redact rather than reject, which is what you want for PII.
userMessage and internal are separate on purpose. One is safe to
show; the other has the detail you need in a log and must never reach
a user.
Composing stages
export function pipeline<T, C>(
stages: ((v: T, ctx: C) => Promise<Guarded<T>>)[],
) {
return async (value: T, ctx: C): Promise<Guarded<T>> => {
let current = value;
for (const stage of stages) {
const res = await stage(current, ctx);
if (!res.ok) return res;
current = res.value;
}
return { ok: true, value: current };
};
}
Sequential, short-circuiting, order-dependent — and order matters.
Redaction must run before anything that logs. Length checks should
run before expensive classification. Putting the order in one array
makes it reviewable:
const guardInput = pipeline<AgentRequest, Ctx>([
lengthLimit,
languageCheck,
redactPii,
topicClassifier,
]);
Input stages
const lengthLimit: InputStage = async (req) => {
const tokens = estimateTokens(req.message);
if (tokens > 8_000) {
return {
ok: false,
refusal: {
kind: "refusal", stage: "lengthLimit", code: "TOO_LONG",
userMessage: "That message is too long. Send under ~6,000 words.",
internal: `estimated ${tokens} tokens`,
},
};
}
return { ok: true, value: req };
};
Cheap and deterministic first. A length check costs nothing and
prevents a large bill from a paste of an entire document.
Redaction transforms rather than refuses:
const redactPii: InputStage = async (req) => {
const { text, found } = redact(req.message, [
{ name: "card", re: /\b(?:\d[ -]*?){13,19}\b/g },
{ name: "iban", re: /\b[A-Z]{2}\d{2}[A-Z0-9]{10,30}\b/g },
]);
if (found.length) {
metrics.increment("guardrail.pii_redacted", found.length);
}
return { ok: true, value: { ...req, message: text, redacted: found } };
};
Regex PII detection catches obvious formats and misses plenty. It is
a reduction in exposure, not a guarantee — treat it as one layer, and
do not let its presence justify logging raw input elsewhere.
Note the metric. A guardrail that fires silently tells you nothing;
the rate at which each stage triggers is the only way to know whether
they are doing anything.
Output stages
Output is where the interesting checks live, because they can inspect
what the model actually produced against what it was given.
const schemaCheck: OutputStage = async (out) => {
const parsed = AnswerSchema.safeParse(out.structured);
if (!parsed.success) {
return refuse("schemaCheck", "MALFORMED",
"I could not produce a valid answer. Try rephrasing.",
parsed.error.message);
}
return { ok: true, value: { ...out, structured: parsed.data } };
};
const noInternalLeaks: OutputStage = async (out) => {
const hits = FORBIDDEN.filter((p) => p.test(out.text));
if (hits.length) {
return refuse("noInternalLeaks", "LEAK",
"I cannot share that information.",
`matched ${hits.length} forbidden patterns`);
}
return { ok: true, value: out };
};
const citationsRequired: OutputStage = async (out, req) => {
if (!req.requiresCitations) return { ok: true, value: out };
const uncited = out.claims.filter((c) => c.sources.length === 0);
if (uncited.length) {
return refuse("citationsRequired", "UNCITED",
"I could not find sources for part of that answer.",
`${uncited.length} uncited claims`);
}
return { ok: true, value: out };
};
citationsRequired is the one worth copying. It enforces a property
of the answer against a requirement of the request, which no prompt
instruction can guarantee — and it catches the specific failure where
a model fills a gap with plausible invention.
Refusal is a value, not an exception
export async function ask(req: AgentRequest, ctx: Ctx) {
const checked = await guardInput(req, ctx);
if (!checked.ok) return checked;
const raw = await runAgent(checked.value, ctx);
return guardOutput(raw, checked.value, ctx);
}
The caller receives Guarded<AgentOutput> and cannot ignore it —
ok must be narrowed before value is reachable. That is the
difference between a guardrail and a hope: the compiler requires the
refusal path to be handled.
const res = await ask(req, ctx);
if (!res.ok) {
return <RefusalNotice message={res.refusal.userMessage} />;
}
return <Answer data={res.value} />;
Throwing would work too, until someone adds a catch that logs and
continues.
Log refusals as data
function refuse(stage: string, code: RefusalCode,
userMessage: string, internal: string): Guarded<never> {
logger.info("guardrail refusal", { stage, code, internal });
metrics.increment(`guardrail.refusal.${stage}`);
return { ok: false, refusal: { kind: "refusal", stage, code,
userMessage, internal } };
}
The per-stage counter is what tells you whether your guardrails are
calibrated. A stage that never fires is either unnecessary or broken,
and you cannot distinguish those without the number. A stage that
fires constantly is refusing legitimate use, and users experience
that as the product being unhelpful.
Both failure modes are invisible without instrumentation, and both
are common.
Testing them
describe("noInternalLeaks", () => {
it("refuses output containing internal pricing", async () => {
const res = await noInternalLeaks(withText("cost basis is 12%"), req);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.refusal.code).toBe("LEAK");
});
it("passes ordinary answers through unchanged", async () => {
const out = withText("Your plan renews on the 12th.");
const res = await noInternalLeaks(out, req);
expect(res.ok).toBe(true);
if (res.ok) expect(res.value).toEqual(out);
});
});
Both directions. The false-positive test is the one people skip and
the one that catches an over-broad pattern before it starts refusing
real questions.
What this does not do
It reduces specific risks. It does not make the system safe.
Regex PII detection misses unusual formats. A forbidden-phrase filter
misses paraphrase. A schema check confirms shape, never truth. And
none of it addresses an agent being manipulated into taking a harmful
action — that requires constraining what tools can do, which is a
different layer.
The value of writing them as middleware is not that the coverage is
complete. It is that each check is explicit, ordered, tested, and
measured — so you know what you have rather than hoping a paragraph
held.
If this was useful
AI That Ships covers
guardrails as engineering — composable stages, typed refusals, PII
handling, output verification, and the metrics that tell you whether
any of it is calibrated.
The full series is at
xgabriel.com/ai-in-typescript.



Top comments (0)