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.
Install
Section titled “Install”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.
npm i @arcjet/guard@git+https://github.com/arcjet/arcjet-js.git#41ef36816e7174f1b0288d28217e63fa14114307 @google/adkpnpm add @arcjet/guard@git+https://github.com/arcjet/arcjet-js.git#41ef36816e7174f1b0288d28217e63fa14114307 @google/adkyarn add @arcjet/guard@git+https://github.com/arcjet/arcjet-js.git#41ef36816e7174f1b0288d28217e63fa14114307 @google/adk@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! });Helpers
Section titled “Helpers”The integration exposes two surfaces:
guardPlugin()is the Runner-wide gate. Pass it first onnew Runner({ plugins })ornew InMemoryRunner({ plugins }). ItsbeforeToolCallbackdenies by returning a deny dict (ArcjetDenialResult). The original toolrunAsyncnever runs. Returnundefinedto execute. The callback doesn’t throw. There is noguardTool.googleAdkContext()reads a caller-owned ID you pass:correlationId, thensessionId, thenconversationId. It never mints an ID. It never readstraceId. It never reads an ADK-generatedinvocationId. It never readstoolContext.sessionIdorsession.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.
Helper options
Section titled “Helper options”| Option | Helpers | Description |
|---|---|---|
action | guardPlugin | Guard label and capture name. Use resource.verb in the past tense. A string, or a function of { toolName, input }. Defaults to tool.invoked. |
rules | guardPlugin | SDK rules, or a function of { toolName, input }. Omit to submit none. The guard call still happens. |
metadata | guardPlugin | Nested JSON, or a function of the same input as rules. |
sessionId | guardPlugin | Caller-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. |
onGuardError | guardPlugin | "deny" (default) or "allow". |
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 tool never runs. The payload shape is one
ArcjetDenialResult. The envelope is the deny dict from
beforeToolCallback:
guardPluginreturns that object to skip the toolrunAsync.undefinedexecutes 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.
Run a guarded runAsync
Section titled “Run a guarded runAsync”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), });}Screen user text before runAsync
Section titled “Screen user text before runAsync”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");}Human approval isn’t a policy gate
Section titled “Human approval isn’t a policy gate”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.
Deny Runner-wide via guardPlugin
Section titled “Deny Runner-wide via guardPlugin”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 })],});Correlation
Section titled “Correlation”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),});What not to use
Section titled “What not to use”- There is no
guardInbound. Screen prompt injection beforerunner.runAsync. - There is no
guardTool. The gate isbeforeToolCallbackonguardPlugin. - There is no
guardApproval.requireConfirmationandrequestConfirmationare human HITL confirmation, not policy. - Don’t use ADK
SecurityPluginas 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
requestConfirmationor a human approval pause. - Don’t mint a correlation ID. Don’t use
traceId, an ADK-generatedinvocationId,toolContext.sessionId, orsession.id. If the caller didn’t pass an ID, then leave the call uncorrelated. - Don’t put Arcjet after another
beforeToolCallbackplugin. The first deny dict wins. - Don’t throw from
beforeToolCallbackto signal a denial. - Don’t return
undefinedon a Guard error. Fail closed always returns the deny dict. - Don’t call
createAgentContextinside an ADK callback. - Don’t also wrap these tools with
@arcjet/guard/vercel-ai/v7or@arcjet/guard/genkit/v1. - Don’t import
@arcjet/guard/google-adk. The path is@arcjet/guard/google-adk/v2.
Common patterns
Section titled “Common patterns”Rate limit a tool by a trusted ID
Section titled “Rate limit a tool by a 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 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), ]; }, }), ],});Fail-closed default
Section titled “Fail-closed default”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.