Guardrails
Four hooks around the agentic loop — onInput, onToolCall, onToolResult, onOutput — each returning pass, block or rewrite, with built-ins on @deuz-sdk/core/guardrails.
Die Dokumentationsseiten selbst sind auf Englisch. Navigation, Suche und UI-Texte folgen der gewählten Sprache.
A guardrail is a plain function that inspects one boundary of a run and returns a verdict. Four hooks, three verdicts, and no framework:
import { generateText } from '@deuz-sdk/core';
import { promptInjectionGuardrail, maxOutputLength } from '@deuz-sdk/core/guardrails';
const { text, providerMetadata } = await generateText({
model,
messages,
tools,
maxSteps: 8,
guardrails: {
onInput: promptInjectionGuardrail(),
onToolCall: (ctx) =>
ctx.toolCall.toolName === 'shell' ? { action: 'block', reason: 'shell is disabled' } : undefined,
onOutput: maxOutputLength(4000),
},
});
providerMetadata?.deuz?.guardrails; // every non-pass verdict, in orderThe contract types live on CommonCallOptions; the built-in values ship from @deuz-sdk/core/guardrails (also re-exported from @deuz-sdk/core and @deuz-sdk/core/edge). Nothing here has a clock, randomness or I/O — the module is edge-safe, and a call that never names a built-in pulls none of it into a bundle.
When to reach for this
Use a guardrail when the rule is about this run: refuse a request from a suspended tenant, keep a tool off-limits for a role, cap what leaves the process. It is the layer where a policy can end a run and where a UI can be told a rule fired.
Do not reach for it when:
- the rule is about the request body — a client-injected
systemturn, a forgedtool_result, a message flood. That belongs to request validation, which runs before the loop and rejects instead of repairing; - the rule must apply to every model call that a descriptor makes, including compaction summaries and sub-agent side calls. That is middleware — see the comparison below;
- the rule is really "a human must approve this" — that is
needsApprovalplusapproveToolCall, which suspends rather than refuses; - you need a sandbox. A guardrail is advisory code running in your own process; it constrains what the loop does with a model's request, not what a compromised tool implementation can do.
Hooks × actions
pass (or undefined) | block | rewrite | |
|---|---|---|---|
onInputonce per run leg, before the first model call | Nothing happens. No part, no metadata entry. | The run ends before any provider request. Empty answer, stoppedBy: 'guardrail:input'. A graceful stop, not a throw. | { messages } replaces the history the run starts from. |
onToolCallper call, before the approval gate | The call proceeds to the gate. | The call becomes an is_error tool_result the model can react to. The run continues. | { args } substitutes the arguments passed to the gate and to execute. |
onToolResult (2.2)per executed tool, after it returns, before the model sees it | The result goes to the model unchanged. | The result becomes an is_error tool_result carrying the reason. The run continues. | { result } replaces what the model sees. |
onOutputat a natural completion, after doneWhen + verifyStep | The model's text is returned as-is. | The text is suppressed; replacement ?? '' is returned instead, with stoppedBy: 'guardrail:output'. | { text } replaces the final answer. |
What each verdict actually leaves behind:
| Hook · action | text | stoppedBy | Stream part | response.messages / checkpoint / chat record | Steps still run |
|---|---|---|---|---|---|
onInput · block | '' | guardrail:input | guardrail (hook: 'input') | Unchanged — nothing was appended, and a durable run is checkpointed completed, not suspended | none |
onInput · rewrite | normal | — | guardrail | The rewritten history is what the run starts from; it is not appended to your delta | all |
onToolCall · block | normal | — | guardrail (hook: 'tool-call', carries toolCallId) + a denied tool-state part | The is_error tool_result is written, like any denial | yes, the run continues |
onToolCall · rewrite | normal | — | guardrail | History keeps the model's original arguments — see below | yes |
onToolResult · block | normal | — | guardrail (hook: 'tool-result', carries toolCallId) + an error tool-state part | The is_error tool_result replaces the real one — the output never enters history | yes, the run continues |
onToolResult · rewrite | normal | — | guardrail, then a tool-result part with the rewritten output | The rewritten result is what history, steps[].toolResults and the checkpoint carry | yes |
onOutput · block | replacement ?? '' | guardrail:output | guardrail (hook: 'output') | Rewritten to match. A block with no replacement on a text-only turn drops the assistant message entirely rather than persisting an empty, unsendable one | run ends |
onOutput · rewrite | the rewritten text | — | guardrail | Rewritten to match, including the steps[] entry | run ends |
Neither stoppedBy marker is an error: both are graceful stops on providerMetadata.deuz.stoppedBy, reported the same way a budget stop is. finishReason does not tell you — an input block reports 'stop' (no step ever ran) and an output verdict leaves the last step's reason in place, so stoppedBy is the only field that distinguishes "the model finished" from "a rule ended it".
Each hook takes one guardrail or an ordered array.
Ordering rules (identical for all four hooks)
- The array runs in order.
- Rewrites chain — the next guardrail sees what the previous one wrote.
- The first
blockshort-circuits the rest of that hook. passis silent —undefinedand{ action: 'pass' }are the same thing, and neither emits a part nor a metadata entry.- A throw propagates. A guardrail is caller code, like
prepareStep/verifyStep/doneWhen. Swallowing a throw would leave you believing a defense is armed while it is inert — the worse failure mode for a safety control.
For onToolCall and onToolResult, rules 1–3 apply per call: one call's block never short-circuits another call's evaluation.
Consequences of rules 2 and 3 worth planning around: order your array cheapest and most decisive first, since a block skips the rest; and if two rewrites both edit the final text, the second one is editing the first one's output, not the model's.
When each hook runs
run leg starts
├─ resume settle · memory recall computed
├─ onInput ─────────────────── once. block ⇒ no model call at all
└─ for each step:
├─ MCP refresh · compaction · prepareStep · model call
├─ execution policy ───────── a forbidden call fails the run, before anyone sees it
├─ onToolCall (per call) ──── block/rewrite, transfers included (2.3)
├─ approval gate (needsApproval / requireApproval / approveToolCall)
├─ handoff decision ───────── among the transfers the steps above cleared
├─ executeTools
├─ onToolResult (per result) ─ block/rewrite, before the model sees it
└─ at a natural completion:
├─ doneWhen
├─ verifyStep ← a rejection re-drives; the hook below never sees that round
└─ onOutput ─────────── the LAST wordEach placement is a decision, not an accident:
onInputruns after the resume settle, so a durable leg that answers pending approvals is not re-gated on a history the user did not just send. It runs once per run leg — not once per step, and not once per sub-agent. It also runs after the memory recall block is computed, but that block is spliced in at the model-call site, so an input guardrail does not see it.onToolCallruns immediately before the approval gate, which is why a rewrite reaches theneedsApprovalpredicate, the approval request a human sees, andexecute. Running it after the gate would mean a human approved arguments the loop then changed. It runs after the execution policy, which fails the run on a forbidden call before any rule or human sees the batch, and before the handoff decision, so a transfer is judged like any other call.onToolResultruns once the whole batch has settled, in call order, before the results are appended to history or streamed. A throwing guardrail therefore propagates out of the run instead of self-healing into a fake tool failure.onOutputruns afterdoneWhenandverifyStephave both accepted. A re-driven round never reaches it, so the text a guardrail sees is the one the run is actually about to return — and it is not re-run per attempt, so an expensive check (a classifier call) is paid for once.
A transfer is a call like any other (2.3)
A transfer_to_* call takes the same path as every call: onToolCall sees it and can block it, and a needsApproval on the transfer tool or the execution policy's requireApproval gates it. The handoff decision comes last, among the transfers that cleared all of that, and is still made by looking at the call, never by executing it. A refused transfer is answered with its denial (a guardrail block reports outcome: 'permission-denied') and the run stays with the current agent; a transfer waiting for a verdict suspends the run, and the resume decides it. Before 2.3 a transfer was decided before the guardrails and never queued for approval, so an allowlist guardrail written then now judges transfers too: let transfer_to_* calls pass if it should not block them. Guardrails themselves survive a transfer untouched — they belong to the run, not to the agent. See Handoffs.
A blocked tool call joins the existing denial machinery
This is the design decision most worth knowing, because it is what makes onToolCall usable in a real loop.
A blocked call takes exactly the path an approval denial takes:
- it becomes an
is_errortool_resultwhose text names the rule —Blocked by guardrail 'noShell': shell is disabled— so the model's next turn can route around a block it can read; - it emits a
deniedtool-state part; - it does not count toward the runaway-error guard. A verdict is not a tool failure, and three policy refusals in a row must not hard-stop a run the way three genuine tool crashes do;
- and the run continues.
It also still rides through the batch, because every tool_use_id must be answered (the Anthropic 400 guard) — executeTools is what turns the denial into that answer. A blocked call never reaches the approval gate: it already has a verdict, and gating it would suspend the run on a call that is not going to run either way.
A blocked client tool is likewise not "pending on the caller": it is answered in the same turn instead of breaking the loop for a round-trip that would never come.
Observation reports the cause as 'server-denied': the ToolDeniedEvent.cause union is a locked 1.6 surface with no 'guardrail' member, a guardrail is a server-side verdict, and the reason string carries the rest.
Guardrails are re-applied after a suspension
When a run resumes and settles the tool calls that were waiting for approval, onToolCall is evaluated again against the calls read back out of the history — before anything runs, and before the approval verdicts are applied. A suspension must not become a way to launder a call a rule blocked or rewrote.
Two things follow. First, a guardrail block on the resume leg outranks an explicit human approval — the denial map is written last, so an approved call a rule refuses reports the rule. Second, guardrails are assumed pure and deterministic: the same call, on the same history, must produce the same verdict. A guardrail whose answer depends on wall-clock time or an external service that changed in between will disagree with itself across a suspension, and the resume leg's answer is the one that wins.
Verdicts produced on that leg carry no stepIndex (the settle happens before step 1), which is how you tell them apart in deuz.guardrails.
A rewrite deliberately does not rewrite history
onToolCall: 'rewrite' is an execution-side substitution. The assistant message already in the history keeps the arguments the model issued; only the gate and execute see the new ones.
That asymmetry is on purpose. Rewriting history would make the transcript lie about what the model asked for (and would break prompt-cache reuse), while a model that is told it called rm -rf / when the loop actually ran rm -rf ./tmp cannot reason about the next step. The tool-call stream part likewise carries the model's arguments; the guardrail part a moment later is how a UI learns they were changed.
onOutput is the opposite, and also on purpose: an output rewrite is authoritative. The appended assistant message in response.messages, the steps[] entry, the durable checkpoint and the chat record are all rewritten to match, so nothing downstream disagrees with what you were handed.
Guarding tool results (2.2)
Tool output is the widest door into a model's context: a scraped page, an email body or an MCP server's reply can carry instructions the model was never meant to follow, or a secret it was never meant to repeat. onToolResult sits between a tool's return and the model:
import { generateText } from '@deuz-sdk/core';
import { createMockModel } from '@deuz-sdk/core/testing';
import type { ToolResultGuardrail } from '@deuz-sdk/core';
const model = createMockModel({
responses: [
{ toolCalls: [{ toolName: 'readFile', args: { path: '.env.example' } }] },
{ text: 'The file sets an API key.' },
],
});
const redactKeys: ToolResultGuardrail = (ctx) =>
typeof ctx.result === 'string' && /sk-[A-Za-z0-9]+/.test(ctx.result)
? { action: 'rewrite', result: ctx.result.replace(/sk-[A-Za-z0-9]+/g, 'sk-[redacted]') }
: undefined;
const res = await generateText({
model,
messages: [{ role: 'user', content: 'What does .env.example configure?' }],
tools: {
readFile: {
description: 'Read a file',
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
execute: async () => 'OPENAI_API_KEY=sk-live123456',
},
},
maxSteps: 3,
guardrails: { onToolResult: redactKeys },
});
res.steps?.[0]?.toolResults[0]?.result; // 'OPENAI_API_KEY=sk-[redacted]'
res.providerMetadata?.deuz?.guardrails; // [{ hook: 'tool-result', action: 'rewrite', name: 'redactKeys', toolCallId: 'call_1', stepIndex: 0 }]What it sees, and what it does not:
- Only output a tool produced. A success, a throw or a timeout (the last two with
isError: trueand the self-heal message asresult). SDK-authored answers never reach it: approval and guardrail denials, unknown tool names, missing executors and argument validation failures carry no tool output to inspect. - The call that ran.
ctx.toolCallcarries the arguments after anyonToolCallrewrite. rewriteis authoritative. Unlike anonToolCallrewrite, this one changes history: the model must reason about the result it was given, so thetool_resultinresponse.messages,steps[].toolResults, thetool-resultstream part and the durable checkpoint all carry the rewritten value. Observation events (tool.completedwith content capture on) still describe what the tool actually returned.blockcounts toward the runaway-error guard. A blocked call never ran; a blocked result did. Three withheld outputs in a row from the same tool is the kind of loop the guard exists to stop. One clean result resets the counter.- Resume legs are guarded too. Calls executed while a durable run settles its approvals go through the same hook, with no
stepIndex.
Native runs: the projection, not the receipt
In a native runAgent run a tool has two results: the raw value execute returned, persisted in the tool receipt, and the model-facing projection (toModelOutput's value, else the validated result). onToolResult acts on the projection. The receipt is written before the hook runs and keeps the raw result and the projection exactly as the tool produced them, so an audit or a replay still sees the truth while the model sees the guarded version.
Contexts
Every hook receives the same base, plus its own field:
interface GuardrailBaseContext {
runtimeContext?: unknown; // the call's opaque per-request context, forwarded untouched
messages: Message[]; // effective model history at this moment (immutable snapshot)
stepIndex?: number; // absent on the pre-run input hook
agentPath?: string[]; // sub-agent path of the evaluating loop; absent at the root
}
type InputGuardrailContext = GuardrailBaseContext;
interface OutputGuardrailContext extends GuardrailBaseContext { text: string; }
interface ToolCallGuardrailContext extends GuardrailBaseContext { toolCall: ToolCall; }
interface ToolResultGuardrailContext extends GuardrailBaseContext {
toolCall: ToolCall; // the call that ran
result: unknown; // the model-facing result (earlier rewrites in the array applied)
isError: boolean; // true for a thrown or timed-out tool
}toolCall.args are already parsed — you inspect a value, not a JSON string — and they are the arguments as accumulated from the wire, before any rewrite by an earlier guardrail in the same array (each guardrail sees the previous one's output, per rule 2).
runtimeContext
runtimeContext (2.0) is an opaque per-call value threaded, untouched, into every hook that can act on it: ToolExecuteContext.runtimeContext, prepareStep, verifyStep, doneWhen, and the four guardrail hooks (onInput, onToolCall, onToolResult, onOutput). Sub-agents inherit it.
It exists so the request-scoped facts a guardrail needs — the tenant, the signed-in user, a DB handle, a feature flag — travel with the call instead of being captured in a closure, which is what otherwise forces you to rebuild the whole ToolSet (and every guardrail) per request.
import type { ToolCallGuardrail } from '@deuz-sdk/core';
interface Ctx { tenantId: string; allowShell: boolean }
const shellPolicy: ToolCallGuardrail = (ctx) => {
const { allowShell } = ctx.runtimeContext as Ctx;
if (ctx.toolCall.toolName !== 'shell' || allowShell) return undefined;
return { action: 'block', reason: 'shell is disabled for this tenant' };
};
await generateText({
model, messages, tools, maxSteps: 8,
runtimeContext: { tenantId: 't_42', allowShell: false } satisfies Ctx,
guardrails: { onToolCall: shellPolicy },
});The SDK never reads, copies or serializes runtimeContext. It does not reach checkpoints, chat records or observation events, so a live connection or a secret is safe to put there. It is typed unknown on purpose — cast it once, at the top of each guardrail.
Four guardrails you will probably write
Every example below compiles against the real contract types. None of them needs anything from the SDK at runtime.
Refuse a request that carries PII
import type { InputGuardrail } from '@deuz-sdk/core';
const SSN = /\b\d{3}-\d{2}-\d{4}\b/;
const CARD = /\b(?:\d[ -]?){13,16}\b/;
const noPii: InputGuardrail = (ctx) => {
const text = ctx.messages
.flatMap((m) =>
typeof m.content === 'string'
? [m.content]
: m.content.map((p) => (p.type === 'text' ? p.text : '')),
)
.join('\n');
if (SSN.test(text) || CARD.test(text)) {
return { action: 'block', reason: 'The request contained an SSN or card number.' };
}
return undefined; // pass
};
// text === '', providerMetadata.deuz.stoppedBy === 'guardrail:input', no provider request made.A block here is the whole point: nothing reaches the provider, so the sensitive string never leaves your process. If you would rather strip than refuse, return { action: 'rewrite', messages } with the redacted history — the run then proceeds on the sanitized version.
Block a dangerous shell command
import type { ToolCallGuardrail } from '@deuz-sdk/core';
const DENY = [/\brm\s+-rf\s+\/(?!\w)/, /\bmkfs\b/, /\bdd\s+if=/, /:\(\)\s*\{.*\};:/];
const safeShell: ToolCallGuardrail = (ctx) => {
if (ctx.toolCall.toolName !== 'shell') return undefined;
const { command } = ctx.toolCall.args as { command: string };
const hit = DENY.find((re) => re.test(command));
return hit ? { action: 'block', reason: `refused: matches ${hit}` } : undefined;
};The model gets Blocked by guardrail 'safeShell': refused: matches /…/ back as a tool result and can try something else. The run does not end, and three refusals in a row do not trip the runaway guard.
A denylist is a backstop, not a security boundary — pair it with needsApproval on the tool if the consequences are real.
Rewrite arguments instead of refusing
import type { ToolCallGuardrail } from '@deuz-sdk/core';
const clampLimit: ToolCallGuardrail = (ctx) => {
if (ctx.toolCall.toolName !== 'searchDocs') return undefined;
const args = ctx.toolCall.args as { query: string; limit?: number };
if ((args.limit ?? 0) <= 50) return undefined;
return { action: 'rewrite', args: { ...args, limit: 50 } };
};execute and the approval gate see limit: 50; the transcript still shows what the model asked for. That is what lets the model notice it got fewer results than it requested.
Cap or refuse the final answer
import { maxOutputLength } from '@deuz-sdk/core/guardrails';
import type { OutputGuardrail } from '@deuz-sdk/core';
const noSecrets: OutputGuardrail = (ctx) =>
/sk-[A-Za-z0-9]{16,}/.test(ctx.text)
? { action: 'block', reason: 'answer contained an API key', replacement: 'Redacted.' }
: undefined;
guardrails: { onOutput: [noSecrets, maxOutputLength(4000)] }noSecrets runs first and short-circuits; otherwise maxOutputLength truncates whatever survived. Because an output verdict is authoritative, the caller, the checkpoint and the chat record all see 'Redacted.' — there is no copy of the leaked answer left behind.
Reporting: the guardrail part and deuz.guardrails
One part per non-pass verdict — a UI should be able to show that a rule blocked or rewrote something instead of silently receiving different content than the model produced.
interface GuardrailPart {
type: 'guardrail';
hook: 'input' | 'output' | 'tool-call' | 'tool-result';
action: 'block' | 'rewrite'; // passes are not emitted
name?: string;
reason?: string;
toolCallId?: string; // set on hook: 'tool-call' and 'tool-result'
stepIndex?: number; // absent on the pre-run input hook
}streamChat emits these live on fullStream; both loops also collect them in bulk on providerMetadata.deuz.guardrails (the same entries minus the type discriminant). Both readouts are produced from one part, so they cannot describe the same verdict differently.
Note that a rewrite carries no reason — only blocks do. A rewrite says that the content changed, not why; if you need the why in a UI, name the guardrail.
Names come from the function
A guardrail's name is read off the function, so the common shapes label themselves for free through JavaScript's own name inference:
const noSecrets: InputGuardrail = (ctx) => { … }; // reports 'noSecrets'
guardrails: { onOutput: (ctx) => { … } } // reports 'onOutput'
guardrails: { onInput: [(ctx) => { … }] } // an ARRAY element gets no inferred name → reports nothingSet one explicitly with Object.defineProperty(fn, 'name', { value: 'myRule', configurable: true }). A plain fn.name = '…' throws in strict mode — a function's name is configurable but not writable, and every ES module is strict. (This is exactly what the built-ins do.)
The name is not decoration: it is what the model reads in Blocked by guardrail 'X': …, so an anonymous array element also gives the model a vaguer message.
Built-ins
promptInjectionGuardrail()
Spotlighting: prepends a system turn telling the model to treat user content and tool output as data, never as instructions.
function promptInjectionGuardrail(opts?: { policy?: string }): InputGuardrail;The default policy text is PROMPT_INJECTION_POLICY (also exported from @deuz-sdk/core/guardrails, so you can extend it rather than replace it), verbatim the text the promptInjectionGuard() middleware has prepended since 1.2 — so moving a call from the middleware to this guardrail changes where the instruction is applied, never what the model is told.
It adds a separate leading system turn rather than merging into an existing one, so your own system prompt stays byte-identical, which is what keeps prompt caching on the rest of the history intact.
It adds that turn once per run (2.3). A history whose leading system messages already carry the policy text passes unchanged: every leg of a durable run after the first (a native run's too) finds the policy in its checkpoint, and a history that already went through the guard is not given a second copy. So the run's first leg emits the one guardrail part (a rewrite) and a resumed leg emits none. Before 2.3 every resumed leg prepended another copy, which on Claude 5.x changed the top-level system that every earlier thinking block is bound to (Preserved thinking).
And it is what it says on the tin — a prompt, not a filter. It raises the cost of an injection; it does not make one impossible.
maxOutputLength(n, opts?)
Caps the final answer at n characters.
function maxOutputLength(n: number, opts?: { mode?: 'truncate' | 'block' }): OutputGuardrail;mode | Behavior |
|---|---|
'truncate' (default) | Rewrites the text to its first n characters. Authoritative: the appended assistant message, the checkpoint and the chat record all carry the truncated text. |
'block' | Refuses instead — empty answer plus stoppedBy: 'guardrail:output'. |
Characters, not tokens: this is a cheap output-size backstop (a UI field limit, a webhook payload cap), not a billing control. maxOutputTokens and budget are the token-side knobs. A negative or NaN cap is clamped to 0 rather than silently disabling the guard, and truncation is a plain slice — it will cut a word, a code fence or a surrogate pair in half if that is where the limit lands.
maxToolResultLength(n, opts?) (2.2)
Caps each tool result the model sees at n characters, so one page scrape or log dump cannot fill the context window.
function maxToolResultLength(n: number, opts?: { mode?: 'truncate' | 'block' }): ToolResultGuardrail;mode | Behavior |
|---|---|
'truncate' (default) | Rewrites the result to its first n characters plus a [truncated: n of total characters shown] line, so the model knows there is more and can ask for a narrower slice. A non-string result is measured and cut as its JSON text. |
'block' | Withholds the result: the model gets an is_error result naming the limit. |
It ships from @deuz-sdk/core/guardrails. In a native run it measures the projection; the receipt keeps the full raw result.
import { generateText } from '@deuz-sdk/core';
import { createMockModel } from '@deuz-sdk/core/testing';
import { maxToolResultLength } from '@deuz-sdk/core/guardrails';
const model = createMockModel({
responses: [{ toolCalls: [{ toolName: 'scrape', args: {} }] }, { text: 'Summarized.' }],
});
await generateText({
model,
messages: [{ role: 'user', content: 'Summarize the page.' }],
tools: {
scrape: { parameters: { type: 'object', properties: {} }, execute: async () => 'x'.repeat(50_000) },
},
maxSteps: 3,
guardrails: { onToolResult: maxToolResultLength(8_000) },
});Guardrail vs. middleware
Both can defend a call, and they wrap different things. Neither replaces the other.
guardrails | wrapModel(model, […]) | |
|---|---|---|
| Wraps | the run | the model |
| Sees | run boundaries: start, each tool call and result, the final answer | every model call made with that wrapped descriptor |
| Applies to compaction summaries, sub-agent side calls | no — those are model calls, not run boundaries | yes — they go through the same wrapped model |
| Runs per | run leg (input/output) or tool call | model call — every step, every retry-free re-drive |
| Can refuse a tool call | yes | no (it never sees one) |
| Can end a run | yes (stoppedBy) | no |
| Reported as | guardrail parts + deuz.guardrails | nothing (it is transparent) |
| Attached at | the call site (guardrails: {…}) | the model descriptor (so it travels with the model) |
Concretely: promptInjectionGuard() (middleware) re-applies its system turn on every step and on side calls such as compaction summaries, because it wraps the model. promptInjectionGuardrail() applies it once per run: the first leg prepends it and reports itself as a guardrail part, and a leg resumed from the checkpoint finds it there and passes.
Which to pick:
- Guardrail for run-level policy you want visible and able to stop things — and when the cost of re-applying on every step is not worth paying.
- Middleware when the instruction must be on literally every request that descriptor makes, including the ones the loop makes on its own behalf. It is also the only one of the two that a handoff target's own model would not carry — a wrapped model is a different descriptor, so a target defined with an unwrapped model loses the middleware while keeping the run's guardrails.
They compose: use the middleware for the always-on instruction and guardrails for the decisions.
Routing and refusals
- A guarded call always routes through the agentic loop, even with no
tools. The input and output hooks hang off loop boundaries a single-turn call does not have, and an accepted-but-inert safety control is the worst possible outcome for this option. The visible consequence:step-start/step-finishparts appear on the stream for a call that previously had none. generateObject/streamObjectrejectguardrailsfor the same reason — they are single-shot by design and have no such boundary. Passing it is a caller error, not a silent no-op. (mcpis refused there too.)- A handoff keeps them. A transfer changes the active agent, not the run, and
guardrailsis a run-level option — so the same rules keep applying after the swap. That is deliberate: a handoff must never silently re-point a defense.
Sub-agents inherit the tool guardrails (2.3)
agentTool forwards runtimeContext, the approval gate, the abort signal and, since 2.3, the run's onToolCall and onToolResult into the sub-agent loop (through ToolExecuteContext.guardrails). A sub-agent's own calls and results are judged by the same rules as the parent's, so a sub-agent narrows what the run may do, never widens it. In the sub-agent's loop the contexts carry its agentPath, which is how a rule tells the two apart.
onInput and onOutput are not inherited: they guard the run's own input and final answer, and a sub-agent's answer reaches the parent as a tool result, which the parent's onToolResult sees. AgentToolDef has no guardrails field of its own. Before 2.3 a sub-agent ran with no guardrails at all.
See also
- Tools —
needsApproval,prepareStep, and the budget stop conditions guardrails sit alongside. - Tool loop — the denial machinery a blocked call joins.
- Handoffs —
onToolCallsees transfer calls too (2.3). - Middleware — the model-level wrapper, and
promptInjectionGuard. - Request validation — the layer before this one: reject a hostile POST body before it reaches the loop at all.
Preserved Thinking on Claude 5.x
How the agentic loops keep the system prompt, the tools array and every earlier message byte-identical for Claude Fable 5.1, Opus 5.5, Sonnet 5.5 and Haiku 5.5, what may still change between requests, and where a conversation can break the prefix.
Durable Runtime
Checkpoint/resume for agentic runs on any storage backend — plus HMAC-signed approvals. No vendor runtime, no new loop concept.