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-schema

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

outcomeMeaning
completeEvery guardrail ran
partialAt least one did not run; the result is not a clean bill of health
failedThe 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.

PlacementWhat it inspects
pre-promptThe prompt and system text about to reach a provider
post-modelA completed model response, before anything is done with it
tool-argsThe arguments a model proposed, before the tool step runs
tool-resultA tool's output, before it re-enters the transcript
egressA URL, host or payload about to leave the runner

Actions

What a verdict asks for.

ActionEffect at tool-args
allowThe tool runs with the model's own arguments
observeDetected but deliberately not enforced - the run is unchanged, the detection is recorded
maskThe tool runs with payload instead; the original never reaches the handler or the step record
rewriteAs mask, for a repaired rather than a redacted value
denyThe tool does not run; the reason goes back to the model as that tool's result
approveThe call is parked on a human, even if the tool itself was not marked as needing approval
haltThe 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.

On this page