Guardrails
A guardrail is a rule that inspects a message before your agent sees it, or a reply before the user does, and blocks it, masks it, or holds it for a person. You do not write guardrails in code. An operator configures them in Studio and the runtime enforces them at the border, so the same rule covers a deep agent and a workflow without either one importing anything.
Where guardrails run
The runtime checks two borders, and only these two:
- A chat turn: the runtime judges the user's message before it reaches your agent, and the agent's final reply before it reaches the user.
- A workflow run: a run started by a trigger, a schedule, or by hand carries no user message, so the runtime judges its input payload. It inspects every string inside the payload, however deeply nested.
Guardrails do not fire on internal hops. A workflow that calls a deep agent does
not re-run that agent's guardrails, because the content already crossed the
border the user came in through. Tool calls are not a guardrail boundary either:
your tool's own Zod schema still validates what the model passes it.
Configure them in Studio
Open the Guardrails screen in Studio. A guardrail is six fields:
| Field | What it means |
|---|---|
name |
What the operator calls it. A block reason names this, so make it readable. |
checkType |
Which catalog check runs. See the table below. |
target |
input (the incoming message) or output (the agent's reply). |
action |
block, redact, or require_approval. |
params |
The check's own settings. The form is generated from the check's schema. |
failOpen |
What to do when the check itself cannot run. Off by default, which fails closed. |
Studio renders the form from the same catalog the runtime enforces, so every
rule you save runs. It rejects three combinations at save time rather than
saving a rule that does nothing: an output rule on a check that can only judge
an incoming message, a redact action on a check that can only say yes or no,
and a require_approval action on the agent's reply. The runtime cannot hold a
reply that already exists for review.
The catalog
Six checks ship today. The first three are deterministic and cost nothing per turn. The last three ask a model, so they add latency.
| Check | Guards | What it needs |
|---|---|---|
denylist |
input, output | A list of words, or regular expressions in pattern mode. |
length_cap |
input | A maximum number of characters. |
pii |
input, output | One row per kind of personal data, and how to handle each. |
prompt_injection |
input | Nothing. Optional guidance about your own agent. |
denied_topics |
input, output | Subjects written in plain language. |
content_moderation |
input, output | The categories of harmful content you want gated. |
A few details that decide whether a rule is useful:
denylistmatches whole words:passworddoes not fire onpasswordless. Switch to pattern mode when you want a regular expression.length_capcounts characters the way a reader does: an emoji costs one.piifindscredit_card,iban,national_id,credential,email,phone,ip_addressandurl. Add only the kinds you need. Every extra kind is another chance of masking something legitimate.urlis never on by default, because masking web addresses breaks any agent that browses or cites sources.denied_topicstakes prose, not words to match: put "competitor pricing" off limits and a question about your own pricing still goes through.content_moderationgates only what you pick. The categories arehate,harassment,violence,sexual,self_harm,illegalandprofanity.prompt_injection,denied_topicsandcontent_moderationtake an optionalthreshold, a number from 0 to 1 (0.7 when you leave it empty). It sets how sure the decision model must be before it stops a message or a reply, on aninputrule and anoutputrule alike. A lower value stops more. Studio refuses a value outside 0 to 1.
denied_topics and content_moderation judge the whole list in one model
call. Six categories cost what one costs, so switch on every category you want
gated.
What an action does
| Action | On a chat turn | On a workflow run |
|---|---|---|
block |
The turn ends. The message never reaches the agent. | The run never starts, and does not retry. |
redact |
The matched values are masked and the conversation carries on. | Each masked value goes back where it came from. |
require_approval |
The turn is held. A person decides it in the approval inbox. | Restated as a refusal (see below). |
redact works only on a check that can transform content, which today
means pii. Each kind you list gets a strategy: placeholder replaces the value
entirely, partial keeps the last few characters readable, and hash turns it
into a stable unreadable token.
require_approval reuses the same inbox as
requestApproval(). Approving the turn runs it. Rejecting
it ends the turn, naming the rule. A workflow run has nobody waiting and no
thread to replay, so the runtime restates that action as a plain refusal rather
than promising a review it never queued.
Redaction preserves the shape of a payload: same keys, same array lengths, and non-string values untouched, so a workflow's own input schema still accepts what comes out.
Where a rule applies
A guardrail applies nowhere until you attach it. A rule you wrote but never attached does nothing, whatever the screen says about it being enabled.
Three scopes:
workspaceis the default set. It covers every agent and every workflow, including ones you add later.agentattaches to one named deep agent.workflowattaches to one named workflow.
A rule reached through both the default set and an explicit attachment still runs once. Attaching to an agent or a workflow requires its name, so an attachment can never cover nothing.
Runs the platform starts on your behalf are exempt: indexing an uploaded document, mapping a trigger payload, and scoring an eval case. The exemption follows the platform's start call, never the workflow's name, so the runtime still judges a run you start yourself on the same workflow.
The checking model
prompt_injection, denied_topics and content_moderation need a model. The
checking model judges them on a box that does not use the
decision model, and takes over when the decision model
fails. Pick it once in
Studio on the Guardrail classifier row under Settings › Model defaults,
which is the guardrailModel key your code can read through
stackbone.settings. Leave it unset and the
runtime uses openai/gpt-4o-mini when your model provider serves it, and the
workspace Default model otherwise.
The call goes through the model provider the operator already configured, so there is no second credential to manage. Budget for one call per checked content: a turn with model-backed rules on both the incoming message and the reply makes two.
The decision model
When the box (the container that runs your workspace) has OpenRouter as its
first model provider, with an API key, a rule on
one of the three model-backed checks asks the decision model Jev
(jev-1.13) instead of the checking model, on input and on output alike.
Jev is not a chat model. It reads the text and scores each of the check's
questions from 0 to 1, all in one request. A score at or above the rule's
threshold stops the message or the reply.
Jev answers in about 210 ms, where the checking model takes close to a second, so a model-backed rule adds much less delay to a turn. You hear the difference most on a voice call: before the agent answers, and between the sentences of its reply. Workflow runs get the same model.
| Rule | First provider is OpenRouter, with a key | Any other box |
|---|---|---|
input |
The decision model, then the checking model if it fails | The checking model |
output |
The decision model, then the checking model if it fails | The checking model |
On an output rule, Jev decides content_moderation and denied_topics.
prompt_injection guards input only. The guidance on the Guardrail
classifier row frames the checking model only and never reaches Jev, on
either border. To give Jev more context, write it into the rule's params, for
example in the wording of a denied topic.
If Jev returns an error, takes longer than 1.5 seconds, or leaves a question without a score, the checking model decides the same check and the turn carries on. The box log says why, in one line:
[guardrails] the decision model jev-1.13 was skipped (timeout after 1500 ms); the checking model "openai/gpt-4o-mini" decides this checkA content_moderation rule that Jev stops says the text falls under gated
content, without the name of the category. A denied_topics rule still names
the subject.
When a check cannot run
A model call can time out or rate-limit. By default that counts as a violation
and the runtime stops the turn, because a guard that opens in silence is worse
than a noisy one. Set failOpen on a specific rule to let the turn through instead.
When the decision model judges a message or a reply, the check fails only if
the checking model behind it fails too.
Either way, the decision says which of the two happened. A notice carries
cause: 'policy' when your content broke a rule, and cause: 'infrastructure'
when the check itself died.
An unreadable answer from the checking model counts as a failure.
What you see when one fires
Every decision shows up in two places:
- On the chat stream, as an inline notice carrying the rule's name, the check type, the side of the turn, the action, and the reason. A redaction notice names the kinds of data the rule masked and never the values, because echoing the value would copy the data back out of the message the rule removed it from.
- On the run timeline, as a
guardrailstep, next to the model calls and tool calls in the same trace. See observability.
Streaming caveat
In a text chat, judging a reply needs the whole reply, so a turn carrying an
output guardrail buffers the message before sending it. That turn loses
token-by-token streaming. A turn with input rules only never buffers, so keep
output rules to the agents that need them.
On a voice call, an output guardrail judges the reply one sentence at a time,
so the caller hears the first sentence while the agent is still writing the
rest. A sentence that breaks a rule is never spoken: the caller hears the
sentences before it, then the agent's refusal phrase, and the reply stops there.
There is no stackbone.guardrails in the SDK
You configure guardrails; your code never calls them. There is no
stackbone.guardrails.check() on the ambient client, and your agent code does
not import anything to get the protection described here.
For the checks that belong inside your own code, use what already ships:
- For tool input, the Zod
schemayou declare on every tool validates it, so malformed arguments never reach your function. - For a human gate on one step,
requestApproval()from@stackbone/sdk/workflowpauses the run durably and races the decision against a timeout.
import { requestApproval } from '@stackbone/sdk/workflow';
const decision = await requestApproval({
// No `token`: the SDK assigns a resume key that is unique to this run.
topic: 'high-risk-action',
payload: { summary: input.summary },
title: 'Approve this action',
timeout: '24h',
fallback: 'reject',
});
if (decision.status !== 'approved') {
return { performed: false, reason: decision.status };
}What's next
- Human-in-the-loop: the inbox a
require_approvalguardrail routes into. stackbone.config: reading the settings an operator picks in the dashboard, from your agent code.- Observability: the run timeline where every guardrail decision lands.
stackbone.ai: the model client, and the provider the checking model borrows.