Skip to content

Google ADK agent guard

Google Agent Development Kit (ADK) TypeScript LlmAgent workflows call authored FunctionTool handlers from Runner.runAsync. Arcjet Guard sits at those boundaries so a 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, CrewAI, Eve, Mastra, LangGraph, OpenAI Agents, OpenAI Agents Python, Genkit, LangChain JS, Strands Agents, TanStack AI, and Claude wrappers are on Framework integrations.

This adapter is Google ADK JS @google/adk v2 plus Runner BasePlugin.beforeToolCallback. It isn’t Google GenAI (@google/genai). It isn’t Python ADK. There is no guardTool. The gate is the plugin callback: a deny dict skips the tool runAsync; undefined executes. Don’t also wrap these tools with @arcjet/guard/vercel-ai/v7 or @arcjet/guard/genkit/v1.

Until @arcjet/guard/google-adk/v2 is published, pin @arcjet/guard to 41ef3681. Published @arcjet/guard 1.11.0 doesn’t export ./google-adk/v2.

@google/adk is a peer of @arcjet/guard, not a dependency of it. If your project already has it in the range that follows, install @arcjet/guard on its own so your pins don’t move.

Import helpers from the versioned path @arcjet/guard/google-adk/v2. There is no unversioned alias. @arcjet/guard/google-adk doesn’t resolve. The version segment is the ADK major. @google/adk (>=2 <3) is an optional peer. The integration requires Node.js 22 or later.

Launch one client at module scope:

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

The integration exposes two surfaces:

  • guardPlugin() is the Runner-wide gate. Pass it first on new Runner({ plugins }) or new InMemoryRunner({ plugins }). Its beforeToolCallback denies by returning a deny dict (ArcjetDenialResult). The original tool runAsync never runs. Return undefined to execute. The callback doesn’t throw. There is no guardTool.
  • googleAdkContext() reads a caller-owned ID you pass: correlationId, then sessionId, then conversationId. It never mints an ID. It never reads traceId. It never reads an ADK-generated invocationId. It never reads toolContext.sessionId or session.id. If the caller didn’t pass an ID, then the call is uncorrelated rather than joined to a generated ID.

There is no guardTool. There is no guardInbound. There is no inbound hook. Screen user text with a direct guard() call before runner.runAsync. There is no guardApproval.

Don’t call createAgentContext inside an ADK callback. Don’t also wrap these tools with @arcjet/guard/vercel-ai/v7 or @arcjet/guard/genkit/v1.

OptionHelpersDescription
actionguardPluginGuard label and capture name. Use resource.verb in the past tense. A string, or a function of { toolName, input }. Defaults to tool.invoked.
rulesguardPluginSDK rules, or a function of { toolName, input }. Omit to submit none. The guard call still happens.
metadataguardPluginNested JSON, or a function of the same input as rules.
sessionIdguardPluginCaller-owned fallback when the object you pass to googleAdkContext doesn’t carry one. A string, or a function of the same input as rules. Prefer putting the ID on runAsync and the context helper.
onGuardErrorguardPlugin"deny" (default) or "allow".

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

On DENY the original tool never runs. The payload shape is one ArcjetDenialResult. The envelope is the deny dict from beforeToolCallback:

  • guardPlugin returns that object to skip the tool runAsync.
  • undefined executes the tool.

It isn’t a throw. It isn’t requireConfirmation or requestConfirmation. A throw is the wrong envelope. An undefined-on-error executes the tool.

import type { ArcjetDenialResult } from "@arcjet/guard/google-adk/v2";
const denial: ArcjetDenialResult = {
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,
};

ArcjetDenialResult is a type export from @arcjet/guard/google-adk/v2 at the pin in Install.

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. Fail closed always returns that deny dict on error. It never throws. It never returns undefined on error.

Screen inbound text and pass guardPlugin first on the Runner plugins list:

import {
launchArcjet,
detectPromptInjection,
localDetectSensitiveInfo,
tokenBucket,
} from "@arcjet/guard";
import {
guardPlugin,
googleAdkContext,
} from "@arcjet/guard/google-adk/v2";
import {
FunctionTool,
InMemoryRunner,
LlmAgent,
createUserContent,
} from "@google/adk";
import { z } from "zod";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
const detectPii = localDetectSensitiveInfo();
const inbound = detectPromptInjection();
const lookupOrderInput = z.object({
orderNumber: z.string(),
note: z.string(),
});
const lookupOrder = new FunctionTool({
name: "lookup_order",
description: "Look up an order by number",
parameters: lookupOrderInput,
execute: ({ orderNumber, note }) => ({
orderNumber,
note,
status: "shipped",
}),
});
const agent = new LlmAgent({
name: "order_agent",
model: "gemini-flash-latest",
instruction: "Look up orders with lookup_order.",
tools: [lookupOrder],
});
export async function runAgent(
conversationId: string,
userText: string,
) {
const appContext = { sessionId: conversationId };
const decision = await arcjet.guard({
label: "message.received",
rules: [inbound(userText)],
...googleAdkContext(appContext),
});
if (decision.conclusion === "DENY" || decision.hasFailedOpen()) {
throw new Error("message blocked");
}
const runner = new InMemoryRunner({
agent,
appName: "orders",
plugins: [
guardPlugin(arcjet, {
action: "order.looked-up",
sessionId: conversationId,
onGuardError: "deny",
rules: ({ toolName, input }) => {
if (toolName !== "lookup_order") {
return [];
}
const { orderNumber, note } = lookupOrderInput.parse(input);
return [
lookupLimit({ key: orderNumber, requested: 1 }),
detectPii(note),
];
},
}),
],
});
return runner.runAsync({
userId: conversationId,
sessionId: conversationId,
newMessage: createUserContent(userText),
});
}

There is no inbound hook, so there is no guardInbound. Put prompt-injection and other inbound rules in the application before runner.runAsync.

Direct client.guard({ label, rules, ...googleAdkContext(appContext) }) is the inbound pattern. Act on that decision. Direct guard() fails open, so an ALLOW isn’t proof the rules ran. If this call site must fail closed, then gate inbound on decision.hasFailedOpen(). guardPlugin already defaults to fail closed.

On DENY, don’t call runAsync.

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

requireConfirmation and requestConfirmation are human-in-the-loop (HITL) confirmation. They aren’t a policy gate. Same trap as LangChain JS humanInTheLoopMiddleware, Strands event.interrupt(), Genkit interrupt(), OpenAI needsApproval, Mastra requireApproval, TanStack needsApproval, and Claude canUseTool. There is no guardApproval. Don’t wrap HITL as Guard. Don’t use ADK SecurityPlugin as the Arcjet policy gate. Don’t deny by pausing for a human. Policy sits on beforeToolCallback only.

On DENY the original tool never runs, so the inner execute never runs a side effect. guardPlugin skips with a deny dict. Don’t throw. Don’t call requestConfirmation. Don’t pause for a human to approve a denied call. Scan free-text args (a note, reason, or body). An opaque orderNumber or tool-call ID doesn’t trip email, phone, card, or IP detection, so don’t pass it to localDetectSensitiveInfo. That helper runs on a local ML model backend.

guardPlugin is the Runner plugins gate. Put it first in the plugin list. beforeToolCallback uses the first dictionary that a plugin returns. If another plugin returns a dict first, then Guard never runs.

The hook denies by returning a deny dict without calling the tool. That dict is the ArcjetDenialResult. Return undefined to execute. Policy sits on beforeToolCallback only.

There is no guardTool. Wrapping FunctionTool.execute is the wrong gate. The plugin callback is the skip point.

This isn’t Google GenAI. This isn’t Python ADK. Don’t pass guardTool from @arcjet/guard/vercel-ai/v7 or @arcjet/guard/genkit/v1 to Google ADK.

import { InMemoryRunner } from "@google/adk";
import { guardPlugin } from "@arcjet/guard/google-adk/v2";
import { arcjet } from "./arcjet.js";
const runner = new InMemoryRunner({
agent,
appName: "orders",
plugins: [guardPlugin(arcjet, { sessionId: conversationId })],
});

googleAdkContext reads a caller-owned ID from the object you pass: correlationId first, then sessionId, then conversationId. It never mints an ID. It never reads traceId. It never reads an ADK-generated invocationId. It never reads toolContext.sessionId or session.id. ADK can generate a session or invocation ID when you omit one. That auto-generated value isn’t a correlation source. Don’t derive an ID from it. Don’t mint one. If the caller didn’t pass an ID, then the call is uncorrelated rather than joined to a generated ID.

A run that pauses on requestConfirmation resumes through a later runAsync. Pass the same caller-owned ID on that resume call so later Guard decisions stay on the Sequence that started it. The confirmation payload isn’t a correlation source.

Put the same ID on runAsync({ sessionId }) and as sessionId on guardPlugin.

const appContext = { sessionId: conversationId };
await arcjet.guard({
label: "message.received",
...googleAdkContext(appContext),
});
await runner.runAsync({
userId: conversationId,
sessionId: conversationId,
newMessage: createUserContent(userText),
});
  • There is no guardInbound. Screen prompt injection before runner.runAsync.
  • There is no guardTool. The gate is beforeToolCallback on guardPlugin.
  • There is no guardApproval. requireConfirmation and requestConfirmation are human HITL confirmation, not policy.
  • Don’t use ADK SecurityPlugin as the Arcjet policy gate.
  • Don’t treat this adapter as Google GenAI (@google/genai).
  • Don’t treat this adapter as Python ADK.
  • Don’t turn a deny into requestConfirmation or a human approval pause.
  • Don’t mint a correlation ID. Don’t use traceId, an ADK-generated invocationId, toolContext.sessionId, or session.id. If the caller didn’t pass an ID, then leave the call uncorrelated.
  • Don’t put Arcjet after another beforeToolCallback plugin. The first deny dict wins.
  • Don’t throw from beforeToolCallback to signal a denial.
  • Don’t return undefined on a Guard error. Fail closed always returns the deny dict.
  • Don’t call createAgentContext inside an ADK callback.
  • Don’t also wrap these tools with @arcjet/guard/vercel-ai/v7 or @arcjet/guard/genkit/v1.
  • Don’t import @arcjet/guard/google-adk. The path is @arcjet/guard/google-adk/v2.

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 runner = new InMemoryRunner({
agent,
appName: "orders",
plugins: [
guardPlugin(arcjet, {
action: "order.looked-up",
rules: ({ toolName, input }) => {
if (toolName !== "lookup_order") {
return [];
}
const { orderNumber } = z
.object({ orderNumber: z.string() })
.parse(input);
return [lookupLimit({ key: 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 doesn’t trip email, phone, card, or IP detection, so don’t pass it to localDetectSensitiveInfo.

import { localDetectSensitiveInfo, tokenBucket } from "@arcjet/guard";
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
const detectPii = localDetectSensitiveInfo();
const runner = new InMemoryRunner({
agent,
appName: "orders",
plugins: [
guardPlugin(arcjet, {
action: "order.looked-up",
rules: ({ toolName, input }) => {
if (toolName !== "lookup_order") {
return [];
}
const { orderNumber, note } = z
.object({ orderNumber: z.string(), note: z.string() })
.parse(input);
return [
lookupLimit({ key: orderNumber, requested: 1 }),
detectPii(note),
];
},
}),
],
});

guardPlugin defaults to onGuardError: "deny". If Guard cannot be evaluated, then beforeToolCallback returns the deny dict instead of calling the tool. It never throws. It never returns undefined on that path.

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.