Guardrails
Check a model's tool arguments before the tool runs. The verdict is a durable step, so a replay reads what was decided instead of deciding again.
A model authors the arguments its tools are called with. Left unchecked, those arguments reach your handler - and whatever it talks to - exactly as the model wrote them. MCP puts this on the server side without qualification: "Servers MUST: Validate all tool inputs" (Tools, Security Considerations).
A guardrail is that check, as a port. Duraton ships one adapter today - schema, which
validates a tool call against the tool's own inputSchema - and the port is what lets a later one
(content policy, allow/deny lists, a classifier) drop in without touching the loop or the adapters
already there.
Guardrails are a TypeScript-SDK feature today. The Python and Go SDKs do not expose them yet - see SDK parity.
Turning it on
npm install @cfworker/json-schemaThe SDK bundles no JSON Schema engine, so the schema adapter loads one at the moment it is first
used. @cfworker/json-schema is the one to install: it does no code generation, which is what lets
it run under a strict CSP and on edge runtimes where new Function is unavailable.
import { createSchemaGuardrail } from "@duraton/sdk";
const result = await ctx.step.ai.loop("agent", {
prompt: `Resolve this ticket: ${ticket}`,
maxIterations: 6,
guardrails: [createSchemaGuardrail()],
tools: {
"issue-credit": {
inputSchema: {
type: "object",
properties: { amount: { type: "number" } },
required: ["amount"],
additionalProperties: false,
},
handler: (input) => credit(input),
},
},
turn,
});With @duraton/agent-kit it is the same option on agent():
const result = await agent(ctx, "agent", {
model: "claude-opus-4-8",
prompt: `Resolve this ticket: ${ticket}`,
tools: [issueCredit],
maxIterations: 6,
guardrails: [createSchemaGuardrail()],
});A tool that declares no inputSchema is allowed through - there is nothing to check it against.
What a block looks like to the model
A refused call does not throw. It comes back as that tool's result, so the model reads why it was refused and can correct itself on the next turn:
{
"valid": false,
"tool": "issue-credit",
"error": "arguments for tool \"issue-credit\" do not match its inputSchema - #: Instance does not have required property \"amount\".; #: Property \"reason\" does not match additional properties schema.; #/reason: False boolean schema."
}That is the model calling issue-credit with { "reason": "duplicate" } against the schema above.
Every failing keyword is listed, located by JSON Pointer, so the model can fix them all in one
turn rather than one per turn - and each extra turn would be another model call and another
durable step.
This is the same shape a human denial produces from an approval gate, and for the same reason: MCP classes invalid input data as a tool execution error reported in the result, not a protocol error. A thrown error would end the run and teach the model nothing.
The refusal carries no decider. Only a person's decision on an approval names a person; a policy refusing a call is not a person saying no, and the two never share a shape.
The verdict is a durable step
Each guardrail pass writes its own step, <base>:iter:N:guard:<callId> - a sibling of the tool step
and the approval step, never a suffix of either. Two consequences follow from that and from nothing
else:
- A replay reads the recorded verdict. The detector does not run again, so a re-run of the same run cannot disagree with the original, and a verdict is an auditable fact rather than something re-derived on every pass.
- A refused call writes no tool step at all. The verdict step exists, the tool step does not, which is what makes "the handler never ran" checkable from the run record.
A loop with no guardrails writes no guardrail steps, so turning this on costs nothing until you do.
When a detector is down
A guardrail that throws does not read as "clean". It produces a verdict whose action is deny
and whose outcome is partial, meaning the check could not complete. Nothing in the loop treats
partial as an allow.
outcome | Meaning |
|---|---|
complete | Every guardrail ran |
partial | At least one did not run; the result is not a clean bill of health |
failed | The check itself failed outright |
Writing a guardrail
A guardrail declares which placements it understands and returns a verdict. It never mutates the caller's state - it says what should happen and the loop applies it.
import type { Guardrail } from "@duraton/sdk";
const noExternalRecipients: Guardrail = {
name: "recipient-policy",
placements: ["tool-args"],
check: async ({ value, tool }) => {
const to = (value as { to?: string }).to ?? "";
if (to.endsWith("@example.com")) {
return { action: "allow", outcome: "complete", detections: [] };
}
return {
action: "deny",
outcome: "complete",
detections: [{ rule: "recipient.external", detected: true }],
reason: `${tool} may only send to internal recipients`,
};
},
};Pass it alongside the others. They run in the order you list them and the first verdict that is not
allow wins, so a later guardrail can never overturn an earlier refusal.
Placements
Where a guardrail runs. tool-args is the placement the loop evaluates today; the rest are part of
the contract so an adapter written now stays valid as they are wired.
| Placement | What it inspects |
|---|---|
pre-prompt | The prompt and system text about to reach a provider |
post-model | A completed model response, before anything is done with it |
tool-args | The arguments a model proposed, before the tool step runs |
tool-result | A tool's output, before it re-enters the transcript |
egress | A URL, host or payload about to leave the runner |
Actions
What a verdict asks for.
| Action | Effect at tool-args |
|---|---|
allow | The tool runs with the model's own arguments |
observe | Detected but deliberately not enforced - the run is unchanged, the detection is recorded |
mask | The tool runs with payload instead; the original never reaches the handler or the step record |
rewrite | As mask, for a repaired rather than a redacted value |
deny | The tool does not run; the reason goes back to the model as that tool's result |
approve | The call is parked on a human, even if the tool itself was not marked as needing approval |
halt | The run ends, naming the rule |
observe is how a new rule earns its place: turn it on, let it record what it would have refused,
and only then promote it to deny.
Which dialect a schema is read in
JSON Schema leaves the dialect of a schema with no $schema up to the implementation
(2020-12 core §8.1.1).
Duraton reads the dialect the schema declares when it declares one, and otherwise uses 2020-12.
Override the fallback if your tool schemas are written against an older draft:
createSchemaGuardrail({ draft: "7" });Supported drafts: 4, 7, 2019-09, 2020-12.
Edits a person makes are checked too
When a tool is approval-gated and the reviewer edits the arguments before approving, those edits are
hand-typed JSON that the model never proposed - so they are re-checked, under their own
<base>:iter:N:guard-edit:<callId> step. A refusal there fails the run rather than returning a
refusal to the model: telling the model its own arguments were wrong would be false when a person
is the one who broke them.