Deuz SDK 2.0 est sorti — stores, guardrails, handoffs et MCP zéro-config. Nouveautés de la 2.0
Deuz SDK
Agents

Guardrails

Three hooks around the agentic loop — onInput, onToolCall, onOutput — each returning pass, block or rewrite, with built-ins on @deuz-sdk/core/guardrails.

Le contenu des pages de documentation est en anglais. La navigation, la recherche et l’interface suivent la langue choisie.

A guardrail is a plain function that inspects one boundary of a run and returns a verdict. Three hooks, three verdicts, and no framework:

guarded.ts
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 order

The 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 system turn, a forged tool_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 needsApproval plus approveToolCall, 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)blockrewrite
onInput
once 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.
onToolCall
per 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.
onOutput
at 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 · actiontextstoppedByStream partresponse.messages / checkpoint / chat recordSteps still run
onInput · block''guardrail:inputguardrail (hook: 'input')Unchanged — nothing was appended, and a durable run is checkpointed completed, not suspendednone
onInput · rewritenormalguardrailThe rewritten history is what the run starts from; it is not appended to your deltaall
onToolCall · blocknormalguardrail (hook: 'tool-call', carries toolCallId) + a denied tool-state partThe is_error tool_result is written, like any denialyes, the run continues
onToolCall · rewritenormalguardrailHistory keeps the model's original arguments — see belowyes
onOutput · blockreplacement ?? ''guardrail:outputguardrail (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 onerun ends
onOutput · rewritethe rewritten textguardrailRewritten to match, including the steps[] entryrun 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 three hooks)

  1. The array runs in order.
  2. Rewrites chain — the next guardrail sees what the previous one wrote.
  3. The first block short-circuits the rest of that hook.
  4. pass is silentundefined and { action: 'pass' } are the same thing, and neither emits a part nor a metadata entry.
  5. 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, 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:
       ├─ compaction · prepareStep · model call
       ├─ handoff interception       ← transfers are decided BEFORE guardrails
       ├─ onToolCall (per call) ──── block/rewrite
       ├─ approval gate (needsApproval / approveToolCall)
       ├─ executeTools
       └─ at a natural completion:
            ├─ doneWhen
            ├─ verifyStep           ← a rejection re-drives; the hook below never sees that round
            └─ onOutput ─────────── the LAST word

Each placement is a decision, not an accident:

  • onInput runs 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.
  • onToolCall runs immediately before the approval gate, which is why a rewrite reaches the needsApproval predicate, the approval request a human sees, and execute. Running it after the gate would mean a human approved arguments the loop then changed; running it before the handoff interception would mean a routing decision could be blocked as if it were a tool.
  • onOutput runs after doneWhen and verifyStep have 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.

Handoffs are intercepted before guardrails

A transfer_to_* call is resolved by the loop deterministically, before onToolCall. So an onToolCall guardrail never sees a transfer, and a transfer is never queued for approval. 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_error tool_result whose 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 denied tool-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.

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; }

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 all three guardrail hooks. 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.

tenant-aware.ts
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

pii-input.ts
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

dangerous-command.ts
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

clamp-args.ts
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

output-policy.ts
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';
  action: 'block' | 'rewrite';   // passes are not emitted
  name?: string;
  reason?: string;
  toolCallId?: string;           // set on hook: 'tool-call'
  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 nothing

Set 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 always returns a rewrite, so it always emits one guardrail part per run. 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;
modeBehavior
'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.

Guardrail vs. middleware

Both can defend a call, and they wrap different things. Neither replaces the other.

guardrailswrapModel(model, […])
Wrapsthe runthe model
Seesrun boundaries: start, each tool call, the final answerevery model call made with that wrapped descriptor
Applies to compaction summaries, sub-agent side callsno — those are model calls, not run boundariesyes — they go through the same wrapped model
Runs perrun leg (input/output) or tool callmodel call — every step, every retry-free re-drive
Can refuse a tool callyesno (it never sees one)
Can end a runyes (stoppedBy)no
Reported asguardrail parts + deuz.guardrailsnothing (it is transparent)
Attached atthe 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, at the start of the run, and reports itself as a guardrail part.

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-finish parts appear on the stream for a call that previously had none.
  • generateObject / streamObject reject guardrails for 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. (mcp is refused there too.)
  • A handoff keeps them. A transfer changes the active agent, not the run, and guardrails is 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 do NOT inherit guardrails

agentTool forwards runtimeContext, the approval gate and the abort signal into the sub-agent loop — but not guardrails, and AgentToolDef has no guardrails field of its own. A sub-agent therefore runs unguarded, even though the guardrail contexts carry an agentPath field for it.

If a sub-agent must be constrained today, put the constraint inside it: needsApproval on its tools, stopWhen, or an execute that checks ctx.runtimeContext (which is inherited). agentPath is declared on the contexts for the same reason the other loop hooks carry it — it is where the value would appear — but in 2.0 it is effectively always absent.

See also

  • ToolsneedsApproval, prepareStep, and the budget stop conditions guardrails sit alongside.
  • Tool loop — the denial machinery a blocked call joins.
  • Handoffs — intercepted before onToolCall, deliberately.
  • 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.

Sur cette page