A model asked nicely to behave will usually behave — usually. Guardrails are the programmatic checks that enforce your policy around a model call regardless of what the model feels like doing. The shift in posture is the whole point: you stop asking the model to be safe and you make the pipeline safe, with code you control and can test on every request.
A guardrail runs in two places. An input guardrail inspects the request before the model is called — is this topic allowed, is the input short enough, does it carry data you refuse to process? An output guardrail inspects the response after the model returns — did it parse into the shape downstream code expects, did it refuse, did it leak something it should not? Both are deterministic, both run on every request, and neither trusts the model to police itself.
flowchart LR R["incoming request"] --> IG["input guardrail<br/>(allowlist + limits)"] IG -->|"allowed"| M["model call"] M --> OG["output guardrail<br/>(schema + refusal)"] OG -->|"valid"| A["act on result"] IG -.->|"rejected"| X["block early"] OG -.->|"invalid"| X style M fill:#3776ab,color:#fff style X fill:#b91c1c,color:#fff
Input: allow what you expect, not block what you fear
The safer default for an input check is an allowlist: explicitly list the topics, commands, or document types your application handles, and reject everything else. A blocklist — listing forbidden things — fails the same way a keyword injection filter does: it knows about the bad it has seen and nothing about the bad it has not. An allowlist inverts that: unknown is rejected by default, and adding a new capability becomes a deliberate, reviewable change.
Input checks also fail fast. If a request is off-topic or oversized, you learn that for the cost of a substring test, not a model call. That is cheaper, faster, and it stops abuse before a single token is generated.
flowchart TD B["blocklist<br/>block known-bad"] --> J["unknown threat?"] J -->|"passes through"| L["leak"] AL["allowlist<br/>permit known-good"] --> K["unknown topic?"] K -->|"rejected by default"| S["safe"] style L fill:#b91c1c,color:#fff style S fill:#1e7f3d,color:#fff
function isAllowedTopic(topic, allowed) {
return allowed.indexOf(topic.toLowerCase()) !== -1;
}
const allowed = ['weather', 'sports', 'news'];
console.log(isAllowedTopic('Weather', allowed));
console.log(isAllowedTopic('finances', allowed));
console.log(isAllowedTopic('news', allowed));
Output: check the shape, not just the words
A model that returns a sentence where your code expects a JSON object will crash the line that reads result.summary. The fix is a schema check: after parsing the response, confirm it has the required keys with the right kinds of value before you trust a single field. This turns a vague 'the model misbehaved' into a precise, testable failure: this response is missing the sentiment field.
Schema validation is where deterministic code does the heavy lifting that prompting cannot reliably do. You can ask the model to return JSON until you are blue in the face; the line that actually guarantees it is the one that rejects anything that does not match the schema.
flowchart LR T["raw model text"] --> P["JSON.parse"] P -->|"parses"| V["schema check<br/>(required keys + types)"] P -.->|"throws"| R["reject not JSON"] V -->|"ok"| O["typed object<br/>safe to use"] V -.->|"fail"| R2["reject wrong shape"] style O fill:#1e7f3d,color:#fff style R fill:#b91c1c,color:#fff style R2 fill:#b91c1c,color:#fff
function validateSchema(obj, schema) {
if (obj === null || typeof obj !== 'object') return false;
for (const key of schema.required) {
if (!(key in obj)) return false;
}
return true;
}
const schema = { required: ['summary', 'sentiment'] };
console.log(validateSchema({ summary: 'good quarter', sentiment: 'positive' }, schema));
console.log(validateSchema({ summary: 'good quarter' }, schema));
console.log(validateSchema(null, schema));
Detecting refusals
Even a well-behaved model refuses sometimes — the request was unsafe, or it hit a policy, or it simply did not know. A pipeline that feeds the model's refusal straight to a downstream tool hands that tool a paragraph of apology instead of the data it needs. Refusal detection scans the output for the stock phrases models use to decline — 'I cannot help with that', 'as an AI, I cannot' — and routes those responses to a fallback or a retry instead of forwarding them.
Like input filtering, this is a substring check run case-insensitively over a list of known phrases. It is not perfect, because a model can refuse in novel words, but it catches the common cases deterministically and stops a refusal from being mistaken for a successful answer.
function refusalDetected(text) {
const t = text.toLowerCase();
const phrases = ['i cannot', 'i am not able to', 'sorry, i', 'as an ai'];
return phrases.some((p) => t.indexOf(p) !== -1);
}
console.log(refusalDetected('As an AI, I cannot help with that.'));
console.log(refusalDetected('The capital of France is Paris.'));
When a check fails: block, retry, or fall back
When a guardrail trips, the pipeline has three honest options. Block the request and return a safe canned response — the right call for policy violations. Retry with a repaired prompt — useful when the model returned malformed JSON and a nudge might fix it. Fall back to a simpler, non-model path — a deterministic lookup or a default that is always correct.
The wrong option is to silently pass the bad output along, because then the guardrail exists only on paper. Decide the failure mode deliberately, log it, and make sure the downstream tool never receives something the guardrail just rejected.
What does this print? The refusal detector lowercases the text and looks for any decline phrase as a substring.
function refusalDetected(text) {
const t = text.toLowerCase();
const phrases = ['i cannot', 'i am not able to', 'sorry, i', 'as an ai'];
return phrases.some((p) => t.indexOf(p) !== -1);
}
const out = refusalDetected('As an AI, I cannot help with that.');
console.log(out);
Lowercase the text first so the phrase matching is case-insensitive.
The phrase 'as an ai' is a prefix of the input once lowercased, so the result is true.
Write refusalDetected(text) that returns true if text contains any refusal phrase, case-insensitively. Treat these as refusal phrases: i cannot, i am not able to, sorry, i, and as an ai. Return false otherwise.
function refusalDetected(text) {
// return true if any refusal phrase is present (case-insensitive)
}
Lowercase the text first so the matching is case-insensitive.
Return true if the text includes any of the known refusal phrases.
function refusalDetected(text) {
const t = text.toLowerCase();
const phrases = ['i cannot', 'i am not able to', 'sorry, i', 'as an ai'];
return phrases.some((p) => t.includes(p));
}
This hasSummary check is meant to confirm a model response carries a real summary. But typeof null === 'object' in JavaScript, so a null response slips through, and so does an empty object that lacks the key. Fix it so it returns true only when obj is a non-null object whose summary is a non-empty string.
function hasSummary(obj) {
return typeof obj === 'object';
}
typeof null is 'object', so checking type alone is not enough.
Also require obj is not null, that obj.summary is a string, and that its length is greater than zero.
function hasSummary(obj) {
return obj !== null && typeof obj === 'object' && typeof obj.summary === 'string' && obj.summary.length > 0;
}
Write validateOutput(obj, requiredKeys) that returns true only when obj is a non-null object and every key in requiredKeys is present with a value that is neither null nor an empty string. Return false otherwise.
function validateOutput(obj, requiredKeys) {
// true only if obj is a non-null object with every required key present and non-empty
}
First reject null and anything that is not an object.
Use every on requiredKeys, checking each value is not null, not undefined, and not the empty string.
function validateOutput(obj, requiredKeys) {
if (obj === null || typeof obj !== 'object') return false;
return requiredKeys.every((k) => obj[k] !== null && obj[k] !== undefined && obj[k] !== '');
}
You want to stop a request from reaching the model at all if the user is asking about a topic your app does not support. Is that an input guardrail or an output guardrail, and when does it run?
Checking the request against an allowed-topics list before calling the model is an input guardrail. It runs first and fails fast, so you never spend a model call on a request you were going to reject anyway.
Recap
- Guardrails are programmatic checks that wrap a model call — input before, output after — enforcing policy with code rather than by asking the model nicely.
- An input guardrail leans on an allowlist (permit known-good, reject everything else by default) and fails fast before spending a model call.
- An output guardrail runs a schema check so downstream code can trust the response's shape, plus refusal detection so a polite decline is not mistaken for a real answer.
- Mind the
typeof null === 'object'trap: validate not null AND is object AND has required keys. - When a check fails, choose deliberately — block, retry, or fall back — and never silently forward what the guardrail rejected.
- All of this is deterministic string and structure handling, testable without a model and runnable on every request.
Next you will turn these per-request checks into a measurable evaluation harness, so you can prove run over run that the pipeline behaves the way you intend.