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
Section titled “Install”Install the Guard SDK and Genkit:
npm install @arcjet/guard genkitImport 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! });Helpers
Section titled “Helpers”The integration exposes three surfaces:
guardTool()wraps theToolActionthatai.defineToolreturns. It replaces that callable and.run. It does not wrap the innerdefineToolhandler.outputSchemavalidation runs insideaction(), so wrapping the handler would throw on a schema-mismatched denial and failgenerate(). Prefer omittingoutputSchemaon guarded tools. The helper overwrites the original registry key becausegenerate()convertstoolsto name and schema defs and looks the live action up. OnDENYthe original action never runs. The model receives anArcjetDenialResultas a completedtoolResponse.output. It does not throw. It does not callinterrupt()orToolInterruptError.finishReasonis not"interrupted".guardMiddleware()is a plain{ name, instantiate }object with atoolhook. Pass it onai.generate({ use }). A raw function becomes a model hook only and cannot deny. The hook denies by returning a completedToolResponsePartwithout callingnext(). 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, thensessionId, thenconversationId, thenflowId/runId, then envelope copies, theninit.sessionId. It never mints an ID. It never readstraceId. It never treatsinterrupt/resumedas correlation. It never readsSession.sessionIdfrom 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.
Helper options
Section titled “Helper options”| Option | Helpers | Description |
|---|---|---|
action | guardTool, guardMiddleware | Guard 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. |
rules | guardTool, guardMiddleware | SDK rules, or a function of the parsed tool input (or { toolName, input } on the middleware). Omit to submit none. |
metadata | guardTool, guardMiddleware | Nested JSON, or a function of the same input as rules. |
sessionId | guardTool, guardMiddleware | Caller-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. |
onGuardError | guardTool, guardMiddleware | "deny" (default) or "allow". |
onDeny | guardTool, guardMiddleware | Reshape the denial object returned as toolResponse.output. |
Inbound screening uses direct guard(), which takes label (not action)
and fails open. See the following section.
Denial payload
Section titled “Denial payload”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.
Run a guarded generate()
Section titled “Run a guarded generate()”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, });}Screen user text before generate()
Section titled “Screen user text before generate()”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");}Human approval is not a policy gate
Section titled “Human approval is not a policy gate”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,});Correlation
Section titled “Correlation”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 }),});What not to use
Section titled “What not to use”- There is no
guardInbound. Screen prompt injection beforeai.generate()orchat.send(). - There is no
guardApproval.interrupt()/defineInterrupt/@genkit-ai/middlewaretoolApproval/restartToolare human HITL confirmation, not policy. - Don’t treat middleware
modelas Guard. - Don’t turn a deny into
interrupt(),ToolInterruptError, orfinishReason: "interrupted". - Don’t wrap the inner
defineToolhandler. Wrap the returnedToolAction. Prefer omittingoutputSchemaon guarded tools. - Don’t pass a raw function as
generatemiddleware. A raw function is a model hook only and cannot deny. - Don’t expect
guardMiddlewareto run whenreturnToolRequests: truenever callsgenerate()for the tool.guardToolstill gates an invoked wrapped action. - Don’t read
traceIdfor correlation. Don’t treatinterrupt/resumedas correlation. Don’t readSession.sessionIdfrom a Session constructed without an ID. - Don’t wrap Go or Python Genkit with this adapter.
- Don’t call
createAgentContextinside 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.
Common patterns
Section titled “Common patterns”Rate limit a tool per trusted id
Section titled “Rate limit a tool per trusted id”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), ], },);Fail-closed default
Section titled “Fail-closed default”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.
Related
Section titled “Related”- Framework integrations
- Vercel AI SDK agent guard
- LangGraph agent guard
- LangChain agent guard
- OpenAI Agents agent guard
- Vercel Eve agent guard
- Mastra agent guard
- Claude Agent SDK agent guard
- Agent guards
- Example:
genkit-agenton an arcjet/examples branch cut frommain. - Adapter:
4e416787