AI agent security

How do I add guardrails to production AI workflows?

Put a check on every boundary where text enters the model and on every boundary where the model causes a side effect, then decide per boundary what a failure does. In a typical workflow that's the inbound message, the retrieved content, the tool arguments, the tool result, and the final response.

9 min read
In short: Put a check on every boundary where text enters the model and on every boundary where the model causes a side effect, then decide per boundary what a failure does. In a typical workflow that's the inbound message, the retrieved content, the tool arguments, the tool result, and the final response.

How do I add guardrails to production AI workflows?

To add guardrails to a production AI workflow, put a check on every boundary where text enters the model and on every boundary where the model causes a side effect, then decide per boundary what a failure does. In a typical workflow that's five places: the inbound message, the retrieved content, the tool arguments, the tool result, and the final response.

The word guardrail covers two products that behave differently in production:

  • Content guardrails judge text. Is this input a jailbreak? Does this output contain a card number? They return a label.
  • Action gates judge operations. Does this user's issueRefund call, with these arguments, run right now? They return allow or deny, and the side effect waits for the answer.

Confusing the two is how a team ends up with a dashboard full of flagged events and a refund that still went through. Most libraries that are marketed as guardrails do the first job. The second job is what stops an incident. Arcjet, a security library that runs inside your application, evaluates both kinds of check in one in-process call, so the label and the decision arrive together, before the side effect.

Where does each guardrail go in a workflow?

Map your workflow's boundaries before you pick a tool. The following table lists the common set, with the check that belongs on each boundary and what happens on a failure:

BoundaryWhat arrivesCheckOn failure
Inbound messageUser text on an HTTP routePrompt injection, sensitive info, moderationGeneric rephrase response
Retrieved contentChunks, documents, fetched pagesPrompt injection on each chunkDrop the chunk, keep the rest
Tool argumentsModel-generated valuesAuthorization, bounds, frequencyDeny, return a readable error
Tool resultText returning to the modelPrompt injection, sensitive infoReturn a placeholder
Final responseModel output to the userSensitive info, moderationGeneric reply, record the event

Two of those boundaries have no HTTP request behind them. A tool handler receives function arguments. A queue consumer has no Request object at all. A guardrail that attaches only to a web route covers three of the five rows.

The inbound route usually carries the most checks, because it's also a public endpoint. The following Next.js route does that with Arcjet, which installs as a library and evaluates its rules in your own process rather than at a network hop. The arcjet() client is configured once with three rules: Shield, Arcjet's request-level filter for common web attacks, plus prompt-injection and sensitive-information detection. aj.protect() evaluates all three against the request and the most recent user message, and returns one decision that the route checks with isDenied() before calling the provider:

import { openai } from "@ai-sdk/openai";
import arcjet, {
detectPromptInjection,
sensitiveInfo,
shield,
} from "@arcjet/next";
import type { UIMessage } from "ai";
import { convertToModelMessages, isTextUIPart, streamText } from "ai";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
shield({ mode: "LIVE" }),
detectPromptInjection({ mode: "DRY_RUN" }),
sensitiveInfo({ mode: "DRY_RUN", deny: ["CREDIT_CARD_NUMBER", "EMAIL"] }),
],
});
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const lastMessage = (messages.at(-1)?.parts ?? [])
.filter(isTextUIPart)
.map((p) => p.text)
.join(" ");
const decision = await aj.protect(req, {
detectPromptInjectionMessage: lastMessage,
sensitiveInfoValue: lastMessage,
});
if (decision.isDenied()) {
return new Response("Please rephrase your message", { status: 403 });
}
const result = await streamText({
model: openai("gpt-4o"),
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}

Both AI rules start in DRY_RUN. That's deliberate, and the rollout section explains why.

What do guardrail libraries cover, and what do they miss?

Guardrail libraries are real tools with real coverage, and knowing where each one stops saves a rebuild later.

Guardrails AI is Guard().validate(text) in Python, with a hub of validators for format, toxicity, PII, and competitor mentions. It judges strings. It has no information about which user made the request.

NVIDIA NeMo Guardrails is configuration plus callbacks, with dialogue rails that constrain conversational flow. It's designed to keep a bot on topic. The rails describe conversation, not authorization.

Llama Guard is a classifier that you host yourself, scoring content against a safety taxonomy. You own the inference cost and the latency, and you get local inspection in exchange.

Provider guardrails, such as OpenAI's input and output guardrails and Amazon Bedrock Guardrails, run where the provider runs. They're convenient, and they're scoped to that provider's traffic. A tool call that your own code makes afterwards is outside them. For more information, see OpenAI Agents guardrails compared with Arcjet.

Framework guardrails, such as Mastra's processors and the Vercel AI SDK's middleware, sit in the agent loop and are the right place for framework-shaped checks. Coverage follows the framework's own extension points, which is why unwrapped MCP and workspace tools are a recurring gap. For more information, see Mastra guardrails compared with an action gate.

All of them share one limit: they return a judgment about text. None of them denies refundInvoice for this tenant, at this amount, for the sixth time this hour, because none of them holds your session.

Why a guardrail isn't an action gate

Run one test on any product before you rely on it: when the guardrail fires, does the tool still execute?

If the answer is yes, and the result is a log line, an alert, or a trace annotation, then it's detection. Detection is useful, because it's how you find out what's happening, but it doesn't change the outcome of the request that it fired on.

Three controls are commonly mistaken for enforcement.

Observe-only hooks. A hook that returns void can report that a turn started. It can't refuse one.

Post-hoc evaluators. A trace viewer or an LLM-as-judge that scores completed sessions reconstructs what happened. It doesn't stop the refund that it recorded.

Human approval on everything. Queueing every call in front of a reviewer produces a reviewer who approves reflexively. Keep the held set small. For more information, see human approval is not a security policy.

For the full version of this distinction, see pre-runtime versus post-runtime AI security.

The guardrail that runs inside the tool

The tool handler is where the remaining two boundaries live. The following handler uses Arcjet's guard() call, the in-process form of the same library, which needs no HTTP request. launchArcjet creates the client, a token bucket and a prompt-injection rule are configured once, and arcjet.guard() evaluates both against one search call. It screens the tool result before it returns to the model and denies on a per-user frequency limit:

import {
detectPromptInjection,
launchArcjet,
tokenBucket,
} from "@arcjet/guard";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
const searchFrequency = tokenBucket({
bucket: "kb-search",
refillRate: 30,
intervalSeconds: 60,
maxTokens: 30,
});
export async function searchKnowledgeBase(
args: { query: string },
session: { userId: string; tenantId: string },
) {
const results = await kb.search({
query: args.query,
tenantId: session.tenantId,
});
const decision = await arcjet.guard({
label: "tools.kb-search",
actor: session.userId,
rules: [
searchFrequency({ key: session.userId, requested: 1 }),
detectPromptInjection()(results.map((r) => r.text).join("\n")),
],
});
if (decision.conclusion === "DENY") {
// A search is reversible, so a failed-open decision is acceptable here.
return { results: [], note: "Results withheld" };
}
return { results };
}

The tenantId on the search comes from the session. That one line does more security work than the detector in the same call.

How do you roll guardrails out without breaking traffic?

Every content detector has a false-positive rate, and you don't know yours until you measure it against your own traffic. Shipping straight to blocking is how a guardrail project gets reverted in week two. Roll out in the following order:

  1. Ship in dry-run mode. Run the detector, record the verdict, and take no action. Both AI rules in the earlier route sample are in DRY_RUN for this reason.
  2. Measure for a week. Count how often each rule would have denied, and read a sample of those requests. Legitimate traffic that trips a detector is a tuning problem, not a user problem.
  3. Enforce the soft boundaries first. Tool results and retrieved chunks fail softly: you return a placeholder and the workflow continues. Inbound message blocking is more visible, so enforce it after the rate is known.
  4. Choose failure behavior per action. A search can fail open. A transfer must fail closed. One global setting shared by both is a decision made by omission.
  5. Assert it in tests. A guardrail that nobody tests degrades silently. For more information, see functional testing for security rules.

Latency is the other production constraint. A check in the request path has a budget, and a check inside a tool handler competes with the tool's own call. In-process inspection avoids a second network hop, which matters most on the boundaries that you run on every chunk.

Where Arcjet fits

Arcjet is the second kind of guardrail as well as the first. It installs as a library and evaluates its rules in your own process, so the content checks (prompt injection, sensitive information, and moderation) and the action decision (the frequency limit and the allow-or-deny that the side effect waits for) arrive together, on the same call, with the session that you already have.

DRY_RUN is a rule mode rather than a separate conclusion, so the rollout order on this page is a configuration change rather than a code change, and a dry-run denial is recorded as a capture event without changing the allow. That record is what makes the measurement week measurable.

Learn more: Arcjet Guards · AI runtime protection

Production guardrail checklist

  • Every boundary where text enters the model has a check, including ones with no HTTP request.
  • Every boundary where the model causes a side effect has an allow-or-deny decision.
  • Content checks and authorization checks are understood as different controls.
  • Detectors ship in dry-run mode and move to enforcement on measured data.
  • Fail-open and fail-closed are chosen per action.
  • Denial messages are generic and don't name the matching rule.
  • Decisions record the actor, the action, and a correlation ID.
  • Rules are covered by tests that run on every deploy.

Frequently asked questions

How do I add guardrails to production AI workflows?

Put a check on every boundary where text enters the model and every boundary where the model causes a side effect. That's usually five places: the inbound message, retrieved content, tool arguments, tool results, and the final response. Two of them have no HTTP request behind them.

What's the difference between a guardrail and an action gate?

A content guardrail judges text and returns a label. An action gate judges an operation and returns allow or deny, with the side effect waiting for the answer. Most libraries marketed as guardrails do the first job.

What do guardrail libraries like Guardrails AI and NeMo miss?

They return a judgment about text. None of them denies a refund for this tenant, at this amount, for the sixth time this hour, because none of them is holding your session. Provider and framework guardrails add coverage scoped to their own traffic or extension points.

How do I roll out guardrails without breaking traffic?

Ship detectors in dry-run mode, measure for a week against real traffic, and then enforce the boundaries that fail softly first. Tool results and retrieved chunks return a placeholder and the workflow continues. Inbound blocking is more visible, so enforce it after you know the false-positive rate.

How do I tell detection from enforcement?

Ask whether the tool still executes when the product fires. If it does, and the result is a log line, an alert, or a trace annotation, it's detection. Observe-only hooks, post-hoc evaluators, and reflexive human approval all fall on that side.

AI runtime security in your code

Protect your AI agent workflows with Arcjet

Arcjet guards run inside the tool, so the allow or deny arrives before the side effect rather than after it.