Skip to content

Guard

Safety and cost control middleware. Protect agents from runaway costs, bad inputs, unauthorized tool use, and infinite loops.

Enforces a per-session USD cost limit by tracking accumulated cost across all model calls.

function budgetGuard(config: BudgetConfig): Middleware
import { guard } from "agent-express"
// Throws [BudgetExceededError](/guides/errors/) when limit is reached
agent.use(guard.budget({ limit: 0.50 }))
// Graceful stop: turn ends with empty text
agent.use(guard.budget({ limit: 0.50, onLimit: "stop" }))
// Custom handler
agent.use(guard.budget({
limit: 1.00,
onLimit: (ctx, cost) => "Sorry, I've reached my budget limit.",
}))

Config options:

OptionTypeDefaultDescription
limitnumberrequiredMaximum USD cost per session
onLimit"error" | "stop" | (ctx, cost) => string | void"error"Behavior when limit reached
pricingRecord<string, ModelPricing>built-in tablePer-model pricing override (USD per 1M tokens). Merged with defaults.
fallbackPricingModelPricingundefinedFallback pricing for models not in the default or custom table

Hooks: model — checks budget before next(), records cost after.

State keys:

  • guard:budget:totalCost — accumulated cost in USD (reducer: sum)
  • guard:budget:calls — array of CostRecord objects (reducer: append)

Each CostRecord contains: { model: string; inputTokens: number; outputTokens: number; cost: number }.


Validates input before each LLM call. Runs in the model hook before next().

function inputGuard(validator: InputValidator): Middleware
agent.use(guard.input(async (ctx) => {
const hasInjection = ctx.messages.some(
m => typeof m.content === "string" && m.content.includes("ignore previous")
)
if (hasInjection) {
return { ok: false, reason: "Potential prompt injection" }
}
return { ok: true }
}))

The validator receives a ModelContext and returns an InputValidationResult:

PropertyTypeDescription
okbooleanWhether the input passed validation.
reasonstring?Reason for rejection (when !ok).
messagesMessage[]?Modified messages to use instead of originals (when ok + messages provided).

If ok is false, throws InputGuardrailError.


Validates each model response after the LLM call but before tool execution. Accepts a validator function (shorthand) or a config object (full control).

function outputGuard(validatorOrConfig: OutputValidator | OutputGuardConfig): Middleware
// Shorthand: validator function
agent.use(guard.output(async (response, ctx) => {
if (response.toolCalls?.some(tc => tc.toolName === "delete_all")) {
return { ok: false, reason: "Dangerous tool call blocked" }
}
return { ok: true }
}))
// Full config: throw on block
agent.use(guard.output({
validate: myValidator,
onBlock: "error", // throws OutputGuardrailError
}))

Config options (object form):

OptionTypeDefaultDescription
validateOutputValidatorrequiredValidation function
onBlock"replace" | "error""replace""replace": strip tool calls, return reason as text. "error": throw OutputGuardrailError.

The validator returns an OutputValidationResult:

  • { ok: true } — pass through
  • { ok: false, reason: "..." } — block the response
  • { ok: true, output: "modified text" } — pass through with modified output text

Hooks: model — validates after next().


Limits the number of model calls per turn. Prevents runaway agent loops.

function guardMaxIterations(max?: number): Middleware
agent.use(guard.maxIterations()) // default: 25
agent.use(guard.maxIterations(10)) // custom limit

When the limit is reached, tool calls are stripped from the response and the turn ends gracefully. No error is thrown.

Hooks: turn (resets counter), model (increments and checks). Uses an internal closure-based counter keyed by turnId.


Enforces time limits on turns and individual model calls.

function guardTimeout(config?: TimeoutConfig): Middleware
agent.use(guard.timeout()) // defaults: turn 2min, model 1min
agent.use(guard.timeout({ turn: 30_000, model: 10_000 })) // custom

Config options:

OptionTypeDefaultDescription
turnnumber120000 (2 min)Maximum ms for a single turn
modelnumber60000 (1 min)Maximum ms for a single model call

Hooks: turn, model (both always active with defaults).

Throws TurnTimeoutError when a limit is exceeded. Both options are optional; omit either to skip that check.


Human-in-the-loop tool approval. Intercepts tool calls for tools with requireApproval set.

function guardApprove(config: ApproveConfig): Middleware

The ApprovalFunction receives the tool name, args, and ToolContext:

type ApprovalFunction = (
toolName: string,
args: Record<string, unknown>,
ctx: ToolContext,
) => ApprovalDecision | Promise<ApprovalDecision>
import { guard, approve, deny, modify } from "agent-express"
agent.use(guard.approve({
approve: async (toolName, args, ctx) => {
if (toolName === "delete_user") return deny("Blocked by policy")
if (toolName === "send_email") {
const confirmed = await askUser(`Send email to ${args.to}?`)
return confirmed ? approve() : deny("User declined")
}
return approve()
},
}))

Three decision helpers are exported from agent-express:

HelperResultDescription
approve({ remember? }){ action: "approve" }Allow the tool call. remember: true skips future approvals for this tool in this session.
deny(reason){ action: "deny", reason }Soft-block — error message returned to the LLM so it can adapt.
modify(args){ action: "modify", args }Replace the tool call arguments.

Hooks: tool — intercepts before execution.

State keys:

  • guard:approve:remembered — array of tool names that have been remembered

Tools opt in to approval via requireApproval in tools.function():

agent.use(tools.function({
name: "delete_file",
description: "Delete a file",
schema: z.object({ path: z.string() }),
execute: async ({ path }) => { /* ... */ },
requireApproval: true, // always require
// or: requireApproval: (args) => args.path.startsWith("/important")
}))

Detects and masks PII (personally identifiable information) in user messages before the LLM sees them. Maintains a per-session mapping so tools can access original values (e.g., email lookup).

agent.use(guard.piiRedact())
// "My email is [email protected]" → "My email is [EMAIL_1]"
OptionTypeDefaultDescription
typesPiiType[]allWhich types to detect: "email", "phone", "creditCard", "ssn", "ip"
customArray<{ pattern, placeholder }>Custom regex patterns

Detection order: creditCard → SSN → email → phone → IP (longer patterns first to prevent partial matches).

Restore mechanism: Tools receive original PII values via the tool hook — the model sees [EMAIL_1] but lookup_user({ email }) gets the real email.


Per-session or per-IP rate limiting with configurable behavior when limit is exceeded.

agent.use(guard.rateLimit({
maxPerMinute: 60,
onExceeded: "message", // "message" | "throw" | "skip"
message: "Please wait a moment...",
}))
OptionTypeDefaultDescription
maxPerMinutenumber60Max requests per minute
by"sessionId" | "ip""sessionId"Rate limit key
onExceeded"message" | "throw" | "skip""message"Behavior when limit hit
messagestring"Please wait..."Custom message (for "message" strategy)

Built-in InputValidator for prompt injection detection. Use with guard.input():

import { guard, injectionDetector } from "agent-express"
// Regex only (fast, default)
agent.use(guard.input(injectionDetector()))
// Regex + LLM classifier (production-recommended)
agent.use(guard.input(injectionDetector({ llmClassifier: true })))

Catches patterns like “ignore previous instructions”, “system prompt:”, “you are now a”, “reveal your system prompt”, etc.