Guardrails
Capabilities decide what the agent can do. Guardrails decide what's acceptable — on the way in and on the way out. They're configured per project and, unlike the per-request switches, a client cannot turn them off.
Off by default. A new project runs none of this, and the agent still refuses obvious injection on its own judgement — guardrails are the checks you want enforced rather than left to it.
await oberik.guardrails.set({
enabled: true,
injection: true, // deterministic, costs nothing
blockedTopics: ["politics", "legal advice"],
groundedness: true, // one model call on the turns it runs
onViolation: "flag", // start here — see below
});
await oberik.guardrails.get();
Or in the dashboard: Project → Capabilities → Guardrails. Over SSH:
guardrails set --enabled true --groundedness true.
flag, especially with groundednessonViolation decides what a verdict costs. On flag a violation is annotated in
guard_flags and the answer still goes out; on block the answer is replaced with a
refusal the user cannot get past.
The semantic checks — groundedness, blockedTopics, moderation — are model
judgements, so they are wrong sometimes, and block turns each wrong judgement into a
refused answer on a question the corpus could answer. Run on flag against your real
corpus first, watch guard_flags on turns you know are good, and move to block once the
false-positive rate is one you can live with. injection and pii are deterministic and
carry no such risk.
Blocked topics are tidied on the way in. Each entry is trimmed, blank ones are dropped,
and duplicates are removed case-insensitively — so ["staffing", " staffing ", "Staffing"]
stores as ["staffing"]. These topics are handed to the judge as prompt material, so a
repeated or blank-looking entry is repetition and a blank line in the prompt it is built
from, paid for on every guarded turn. The entry that survives is the first spelling you
sent. get() shows exactly what was stored, so you can always see what the policy holds.
get() and set() are different shapesget() returns the policy nested — {enabled, input: {injection, blockedTopics, pii}, output: {...}}. set() takes a flat patch, which is what the example above
shows. Note that input.pii reads back and inputPii writes.
Feeding set() the object get() returned is now a 400 naming the flat fields to
send instead. It used to return 200, echo the unchanged policy, and apply nothing — a
project ran a whole round of testing believing a blocked topic was in force when nothing
was enforcing it. Anything you omit is left as it was; anything the endpoint does not
recognise is refused rather than dropped.
Two stages
Input, before the agent runs:
- Injection heuristics — catch prompt-injection and jailbreak attempts in the message.
- Blocked topics — refuse subjects your product shouldn't discuss.
- PII —
off(default),detect, orredact.
Output, before the answer is returned:
- Groundedness ("cite-or-refuse") — if the answer isn't supported by the retrieved sources, say so instead of asserting it.
- Moderation — screen the generated answer.
- PII — same three modes.
Deterministic checks (PII patterns, injection heuristics) always run and need no model. The semantic checks (groundedness, moderation, topics) use your project's model, and only run when you enable them.
What your app sees
res.guard_flags; // e.g. ["injection"], ["blocked_topic:legal advice"], ["pii:EMAIL"]
A blocked-topic flag names which of your topics matched, so you can tell which string
is catching real questions and tune it. guard_flags is present on every turn, streamed
or not, including the blocked ones — that is what lets you measure your own false-positive
rate rather than guessing at it.
When something fires, on_violation decides what happens:
block— the answer is replaced with a refusal (or a "not enough information in the available data" message for ungrounded answers).flag— the answer is returned as-is and annotated, so you can log, review or badge it without breaking the experience.
While streaming, tokens may already have reached the user before the output check completes.
That's why the guardrail event carries the corrected content:
ai.chat.stream({ message }, {
onToken: append,
onGuardrail: (stage, flags, content) => {
if (content) replaceAnswer(content); // reconcile what was streamed
log(stage, flags);
},
});
Handle it, or a blocked answer will still be visible in your UI.
stage says which of the two checks fired, and it is the only thing that does —
guard_flags on the turn is a flat union of both. One guardrail event per stage that
did something:
| when it fires | carries | |
|---|---|---|
stage: "input" | before the first token — the message was redacted or refused | flags, and reason on a refusal. No content: what was redacted is the user's own message, not ours to hand back |
stage: "output" | after the answer, before done | flags and the corrected content |
So an input-only PII policy produces exactly one event, labelled input, before anything
is generated — which is the point at which telling the user "we removed a phone number
from your message" is still useful to them. Injection and blocked topics are input checks
and have no output side; groundedness and moderation are output checks and have no input
side. PII is the only one with both.
Tune your blocked topics
A blocked topic is matched by a model, so it generalises — and it will take on-domain
questions with it if the string is broad. "personal tax advice" on a bookkeeping
assistant caught "What is the mileage rate for 2026?", which is a company
reimbursement rate and the most on-domain question in that corpus.
Two things to do about it:
- Start on
flag. You get the flags without refusing anybody, so you can read a week of real traffic before deciding. - Name the topic narrowly.
"personal income tax filing"refuses less of your own product than"personal tax advice".
By default a refused user sees only "I can't help with that request.", which is a dead
end — they cannot tell anything was blocked, let alone rephrase. Turn explainRefusals
on to append the reason:
await oberik.guardrails.set({ explainRefusals: true });
// "I can't help with that request. (asks for personal tax filing advice)"
Off by default on purpose: the sentence that helps a confused user also helps somebody probing the filter iterate against it. It applies to every refusal a guardrail produces, including a groundedness block — that one starts from its own sentence ("I don't have enough information in the available data to answer that reliably", because "I can't help with that request" would be untrue: the agent can help, it just cannot support this answer) and gains the same parenthetical.
Which check fired is on the record, not only on the response. guard_flags comes back on
the turn, and the same list is stored on the assistant row (extra.guard_flags, with
extra.guardrail: "blocked" when the answer was replaced) and written to the audit trail. So
a caller who did not keep guard_flags at the time can still find out afterwards — which
used to be impossible for an output guard: finish_reason: "guardrail" said that one
fired and nothing said which.
PII is off by default
This is deliberate. On a "talk to your own data" product the end-user is sharing their own information on purpose — redacting it would break the feature. The three modes:
| Mode | Behaviour |
|---|---|
off | the model receives the text unchanged (default) |
detect | flagged in guard_flags for audit, but the model still gets the raw text |
redact | replaced before the model or provider sees it |
Choose redact only if you must keep PII away from your model provider, and accept that the
model can no longer use it to answer.
Exactly what is detected
Pattern matching, not a model — so it is fast, deterministic and free, and it finds identifiers, not people. The full list, which is the whole of it:
| Flag | Matches |
|---|---|
pii:EMAIL | email addresses |
pii:CARD | 13–16 digit payment card numbers, spaced or hyphenated |
pii:PHONE | international (+44 7700 900123), national trunk (07700 900123, 0117 496 0123) and US (555-123-4567) forms |
pii:SSN | US Social Security numbers (123-45-6789) |
pii:NINO | UK National Insurance numbers (QQ 12 34 56 C) |
pii:IP | IPv4 addresses |
There is no pattern for a person's name that does not also match ordinary prose, so
redact will not remove one — Priya Raghunathan reaches the provider unchanged, and
guard_flags will be empty because nothing matched. The same goes for street addresses,
dates of birth and free-text notes about a person.
So redact reduces what reaches your provider; it does not guarantee that nothing
identifying does. If your obligation is "no personal data leaves our infrastructure", the
control that meets it is a self-hosted or region-pinned model — configured per project
under Models — with this as a second layer, not the first one.
Anything above that we do not match is a bug worth reporting. Anything not above, we do not claim to find.
Auditing
Every turn is recorded, with the guardrail flags that fired:
await ai.audit.list({ action: "chat", subject: "acme:fin:ana", limit: 100 });
So is every change to the corpus, and every search of it. A row names the document it
concerns in resource_id, so the two questions an audit exists to answer are answerable
from the rows themselves:
| Question | The rows that answer it |
|---|---|
| When did this document arrive, and who put it there? | document_upload |
| Who removed it, and what was it? | document_delete — recorded before the row goes, so it still describes what went |
| Who changed who may see it? | document_update, with the previous and new value of each field that changed |
| Who has seen this document? | retrieve and chat, both of which carry metadata.document_ids |
A document_update row is written only when a field actually changed; a request that sets
a document to what it already was is not an event.
document finds both — the rows that name it and the rows that merely saw it:
// everything that has ever happened to one document
const rows = await ai.audit.list({ document: doc.id, limit: 100 });
And for erasure requests, one call removes a subject's documents, sessions and vectors:
await ai.audit.forget("acme:fin:ana");
// { documents_deleted, sessions_deleted, vectors_purged, objects_deleted }
Both need an admin role on the token.