Skip to main content

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.

Start on flag, especially with groundedness

onViolation 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 shapes

get() 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.
  • PIIoff (default), detect, or redact.

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 firescarries
stage: "input"before the first token — the message was redacted or refusedflags, 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 doneflags 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:

ModeBehaviour
offthe model receives the text unchanged (default)
detectflagged in guard_flags for audit, but the model still gets the raw text
redactreplaced 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:

FlagMatches
pii:EMAILemail addresses
pii:CARD13–16 digit payment card numbers, spaced or hyphenated
pii:PHONEinternational (+44 7700 900123), national trunk (07700 900123, 0117 496 0123) and US (555-123-4567) forms
pii:SSNUS Social Security numbers (123-45-6789)
pii:NINOUK National Insurance numbers (QQ 12 34 56 C)
pii:IPIPv4 addresses
Names and addresses are not detected

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:

QuestionThe 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.