Skip to content

Genkit agent guard

Genkit JS flows call ai.generate() / chat.send() and authored ai.defineTool actions. Arcjet Guard sits at those boundaries so a remote policy can allow or deny the action before a side effect runs.

What is Arcjet? Arcjet is the runtime security platform that ships with your code. Enforce budgets, stop prompt injection, detect bots, and protect personal information with Arcjet's AI security building blocks.

Use protect() on HTTP routes. Use the helpers on this page for agent tools and other actions that have no HTTP request.

You need an Arcjet account and an ARCJET_KEY. Launch one client at module scope and reuse it.

Framework wrappers take an action string such as email.sent. That slug selects the matching remote policy and names the event in the Arcjet Console. Direct guard() calls use the field name label for the same slug. Don’t pass label to a wrapper such as guardTool().

You can submit SDK rules in code, rely on a published remote policy, or combine both. For more information about the decision model, see Agent guards.

Vercel AI SDK, Python LangChain, Eve, Mastra, LangGraph, OpenAI Agents, and Claude wrappers are on Framework integrations.

This adapter is Genkit JS genkit() plus ai.defineTool plus ai.generate(). It is not Go or Python Genkit. There is no inbound hook. Middleware model is not Guard. Don’t also wrap these tools with @arcjet/guard/vercel-ai/v7.

Install the Guard SDK and Genkit:

Terminal window
npm install @arcjet/guard genkit

Import helpers from the versioned path @arcjet/guard/genkit/v1. There is no unversioned alias. @arcjet/guard/genkit does not resolve. The version segment is Genkit’s major. genkit (>=1.0.0 <2) is an optional peer. guardMiddleware needs the generateMiddleware tool hook (Genkit >= 1.33). Verified against Genkit 1.41.0. The integration requires Node.js 22 or later. Until @arcjet/guard/genkit/v1 is published, the import lives on 4e416787, not in the published @arcjet/guard package on npm.

Launch one client at module scope:

import { launchArcjet } from "@arcjet/guard";
export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });

The integration exposes three surfaces:

  • guardTool() wraps the ToolAction that ai.defineTool returns. It replaces that callable and .run. It does not wrap the inner defineTool handler. outputSchema validation runs inside action(), so wrapping the handler would throw on a schema-mismatched denial and fail generate(). Prefer omitting outputSchema on guarded tools. The helper overwrites the original registry key because generate() converts tools to name and schema defs and looks the live action up. On DENY the original action never runs. The model receives an ArcjetDenialResult as a completed toolResponse.output. It does not throw. It does not call interrupt() or ToolInterruptError. finishReason is not "interrupted".
  • guardMiddleware() is a plain { name, instantiate } object with a tool hook. Pass it on ai.generate({ use }). A raw function becomes a model hook only and cannot deny. The hook denies by returning a completed ToolResponsePart without calling next(). It skips branded (guardTool) tools when it can look them up. Tools that cannot be looked up are still gated.
  • genkitContext() reads a caller-owned ID: correlationId, then sessionId, then conversationId, then flowId / runId, then envelope copies, then init.sessionId. It never mints an ID. It never reads traceId. It never treats interrupt / resumed as correlation. It never reads Session.sessionId from a Session constructed without an ID.

There is no guardInbound. There is no inbound hook. Screen user text with a direct guard() call before ai.generate() or chat.send(). There is no guardApproval.

Never call createAgentContext inside a Genkit callback. Don’t also wrap these tools with @arcjet/guard/vercel-ai/v7. Don’t wrap Go or Python Genkit with this adapter.

OptionHelpersDescription
actionguardTool, guardMiddlewareGuard label and capture name. Use resource.verb in the past tense. For guardMiddleware, a string or a function of { toolName, input }. Middleware defaults to tool.invoked.
rulesguardTool, guardMiddlewareSDK rules, or a function of the parsed tool input (or { toolName, input } on the middleware). Omit to submit none.
metadataguardTool, guardMiddlewareNested JSON, or a function of the same input as rules.
sessionIdguardTool, guardMiddlewareCaller-owned fallback when the tool options or hook ctx do not carry one. Put the same ID on generate({ context }) and on guardMiddleware for tool-time correlation.
onGuardErrorguardTool, guardMiddleware"deny" (default) or "allow".
onDenyguardTool, guardMiddlewareReshape the denial object returned as toolResponse.output.

Inbound screening uses direct guard(), which takes label (not action) and fails open. See the following section.

On DENY the original ToolAction never runs. The model receives a completed toolResponse.output with this shape. It is not a throw. It is not interrupt().

{
arcjetDenied: true,
reason: "RATE_LIMIT", // or PROMPT_INJECTION, SENSITIVE_INFO, ERROR
message:
"Arcjet denied this call (RATE_LIMIT). It may be retried after 30 seconds.",
retryable: true,
retryAfterSeconds: 30,
}

You can import ArcjetDenialResult from @arcjet/guard/genkit/v1.

Only rate-limit denials set retryable: true and include retryAfterSeconds. Other reasons tell the model not to retry.

When Guard is unavailable and onGuardError is "deny", the model receives reason: "ERROR", retryable: true, and retryAfterSeconds: 5.

Screen inbound text, wrap lookup_order, and pass guardMiddleware on generate({ use }). Prefer omitting outputSchema on guarded tools.

import {
launchArcjet,
detectPromptInjection,
localDetectSensitiveInfo,
tokenBucket,
} from "@arcjet/guard";
import {
guardTool,
guardMiddleware,
genkitContext,
} from "@arcjet/guard/genkit/v1";
import { genkit, z } from "genkit";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
const ai = genkit({
// Configure your model plugin.
});
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
const detectPii = localDetectSensitiveInfo();
const inbound = detectPromptInjection();
const lookupOrder = guardTool(
arcjet,
ai.defineTool(
{
name: "lookup_order",
description: "Look up an order by number",
inputSchema: z.object({
orderNumber: z.string(),
note: z.string(),
}),
},
async ({ orderNumber, note }) => ({
orderNumber,
note,
status: "shipped",
}),
),
{
action: "order.looked-up",
onGuardError: "deny",
rules: (input) => [
lookupLimit({ key: input.orderNumber, requested: 1 }),
detectPii(input.note),
],
},
);
export async function runAgent(conversationId: string, userText: string) {
const appContext = { sessionId: conversationId };
const decision = await arcjet.guard({
label: "message.received",
rules: [inbound(userText)],
...genkitContext({ context: appContext }),
});
if (decision.conclusion === "DENY" || decision.hasFailedOpen()) {
throw new Error("message blocked");
}
return ai.generate({
prompt: userText,
tools: [lookupOrder],
use: [guardMiddleware(arcjet, { sessionId: conversationId })],
context: appContext,
});
}

There is no inbound hook, so there is no guardInbound. Put prompt-injection and other inbound rules in the application before ai.generate() or chat.send().

Middleware model is not Guard. It intercepts the model call, not user text.

Direct client.guard({ label, rules, ...genkitContext({ context }) }) is the inbound pattern. Act on that decision. Direct guard() fails open, so an ALLOW is not proof the rules ran. Gate inbound on decision.hasFailedOpen() if this call site must fail closed. guardTool and guardMiddleware already default to that.

On DENY, don’t call generate() / send().

import { detectPromptInjection } from "@arcjet/guard";
import { genkitContext } from "@arcjet/guard/genkit/v1";
import { arcjet } from "./arcjet.js";
const inbound = detectPromptInjection();
const appContext = { sessionId: conversationId };
const decision = await arcjet.guard({
label: "message.received",
rules: [inbound(userText)],
...genkitContext({ context: appContext }),
});
if (decision.conclusion === "DENY" || decision.hasFailedOpen()) {
throw new Error("message blocked");
}

interrupt() / defineInterrupt / @genkit-ai/middleware toolApproval / restartTool are human-in-the-loop (HITL) confirmation. They are not a policy gate. Same trap as Mastra requireApproval, Claude canUseTool, LangGraph interrupt(), and OpenAI needsApproval. There is no guardApproval. Don’t wrap them as Guard.

Use guardTool for authored ai.defineTool actions you own.

On DENY the original ToolAction never runs, so the inner handler never runs a side effect. The model receives an ArcjetDenialResult as a completed toolResponse.output. Don’t throw. Don’t call interrupt() or ToolInterruptError. finishReason is not "interrupted". Prefer omitting outputSchema on guarded tools. Scan free-text args (a note, reason, or body). An opaque orderNumber or tool-call ID will not trip email / phone / card / IP, so don’t pass it to localDetectSensitiveInfo. That helper runs on a local ML model backend.

import { z } from "genkit";
import { guardTool } from "@arcjet/guard/genkit/v1";
import { tokenBucket, localDetectSensitiveInfo } from "@arcjet/guard";
import { arcjet } from "./arcjet.js";
// `ai` is the instance `genkit()` returned.
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
const detectPii = localDetectSensitiveInfo();
const lookupOrder = guardTool(
arcjet,
ai.defineTool(
{
name: "lookup_order",
description: "Look up an order by number",
inputSchema: z.object({
orderNumber: z.string(),
note: z.string(),
}),
},
async ({ orderNumber, note }) => ({
orderNumber,
note,
status: "shipped",
}),
),
{
action: "order.looked-up",
onGuardError: "deny",
rules: (input) => [
lookupLimit({ key: input.orderNumber, requested: 1 }),
detectPii(input.note),
],
},
);

Deny inside ai.defineTool via guardTool, and generate()-wide via guardMiddleware

Section titled “Deny inside ai.defineTool via guardTool, and generate()-wide via guardMiddleware”

guardTool wraps the returned ToolAction callable and .run, not the inner defineTool handler. That action is the deny point for tools you own. Prefer omitting outputSchema on guarded tools. The helper overwrites the original registry key so generate() cannot look up the unguarded action.

guardMiddleware is a generateMiddleware tool hook. It is a plain { name, instantiate } object. A raw function becomes a model hook only and cannot deny. The hook denies by returning a completed ToolResponsePart without calling next(). It skips branded (guardTool) tools when it can look them up. Tools that cannot be looked up are still gated.

MCP and filesystem-injected tools skip an unwrapped handler. Hosted or injected tools are not a defineTool deny. returnToolRequests: true still hits guardTool if the caller invokes the wrapped action. guardMiddleware does not run if they never generate() the tool.

The tool-hook ctx from toRunOptions is only { metadata, resumed } – no async local storage context. Put the same ID on policy.sessionId when you need tool-time correlation through the hook.

import { guardMiddleware } from "@arcjet/guard/genkit/v1";
import { arcjet } from "./arcjet.js";
const appContext = { sessionId: conversationId };
await ai.generate({
prompt: userText,
tools: [lookupOrder],
use: [guardMiddleware(arcjet, { sessionId: conversationId })],
context: appContext,
});

genkitContext takes { context: appContext }, not a bare { sessionId }. Preference order is correlationId, then sessionId, then conversationId, then flowId / runId, then envelope copies, then init.sessionId. It never mints an ID. It never reads traceId. It never treats interrupt / resumed as correlation. It never reads Session.sessionId from a Session constructed without an ID. If nothing is a valid 1-256 printable-ASCII string, the call is uncorrelated rather than joined to a generated ID.

guardMiddleware does not see generate({ context }) on the tool-hook ctx. Pass the same caller-owned ID as sessionId on the middleware policy.

const appContext = { sessionId: conversationId };
await arcjet.guard({
label: "message.received",
...genkitContext({ context: appContext }),
});
  • There is no guardInbound. Screen prompt injection before ai.generate() or chat.send().
  • There is no guardApproval. interrupt() / defineInterrupt / @genkit-ai/middleware toolApproval / restartTool are human HITL confirmation, not policy.
  • Don’t treat middleware model as Guard.
  • Don’t turn a deny into interrupt(), ToolInterruptError, or finishReason: "interrupted".
  • Don’t wrap the inner defineTool handler. Wrap the returned ToolAction. Prefer omitting outputSchema on guarded tools.
  • Don’t pass a raw function as generate middleware. A raw function is a model hook only and cannot deny.
  • Don’t expect guardMiddleware to run when returnToolRequests: true never calls generate() for the tool. guardTool still gates an invoked wrapped action.
  • Don’t read traceId for correlation. Don’t treat interrupt / resumed as correlation. Don’t read Session.sessionId from a Session constructed without an ID.
  • Don’t wrap Go or Python Genkit with this adapter.
  • Don’t call createAgentContext inside a Genkit callback.
  • Don’t also wrap these tools with @arcjet/guard/vercel-ai/v7.
  • Don’t import @arcjet/guard/genkit. The path is @arcjet/guard/genkit/v1.

Key the bucket on a trusted identifier such as orderNumber. Don’t key it on free-text user input.

const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
const lookupOrder = guardTool(
arcjet,
ai.defineTool(
{
name: "lookup_order",
description: "Look up an order",
inputSchema: z.object({ orderNumber: z.string() }),
},
async ({ orderNumber }) => ({ orderNumber, status: "shipped" }),
),
{
action: "order.looked-up",
rules: (input) => [lookupLimit({ key: input.orderNumber, requested: 1 })],
},
);

Scan a free-text note for sensitive information

Section titled “Scan a free-text note for sensitive information”

Scan a note, reason, or body. An opaque orderNumber does not trip email, phone, card, or IP detection, so don’t pass it to localDetectSensitiveInfo.

import { localDetectSensitiveInfo, tokenBucket } from "@arcjet/guard";
const detectPii = localDetectSensitiveInfo();
const lookupOrder = guardTool(
arcjet,
ai.defineTool(
{
name: "lookup_order",
description: "Look up an order",
inputSchema: z.object({
orderNumber: z.string(),
note: z.string(),
}),
},
async ({ orderNumber, note }) => ({
orderNumber,
note,
status: "shipped",
}),
),
{
action: "order.looked-up",
rules: (input) => [
lookupLimit({ key: input.orderNumber, requested: 1 }),
detectPii(input.note),
],
},
);

guardTool and guardMiddleware default to onGuardError: "deny". If Guard cannot be evaluated, the wrapped tool does not run and generate() does not call next() for that tool.

Set onGuardError: "allow" only when executing without a complete security decision is acceptable, such as a read-only lookup. Direct guard() still fails open. For more information about fail-open versus fail-closed behavior, see Availability and fail behavior.