Integrate Arcjet Guard into Google ADK JS
@arcjet/guard's Google ADK v2 namespace wraps the agent's existing Arcjet
client. It never talks to the Arcjet API itself. Two surfaces, one
decision rule:
- Tool calls →
guardPlugin(). A RunnerBasePluginwhosebeforeToolCallbackis the run-wide gate. DENY is a dictionary (ArcjetDenialResult) so ADK skipsrunAsyncand the model sees the payload.undefinedlets the tool execute. Do not throw from the callback — PluginManager treats a throw as a plugin error, not skip. Tools already branded by a siblingguardToolare skipped so Guard is not double-called. Inboundguard()beforeRunner.runAsyncdoes not brand tools and does not skip this gate. The plugin does not screen inbound (onUserMessageCallback/beforeModelCallbackare no-ops) so a precedingguard()does not double-call. - Correlation →
googleAdkContext()reads a caller-owned id from helper options (guardPlugin({ sessionId })) or a wrap (googleAdkContext({ context: appContext })). ADKContexthas no nestedcontextfield. Durable sessionstateloses to helper options. It never mints a new id. It never readsinvocationId(ADK always generates it). It never readstraceId. It never readstoolContext.sessionId/session.id(session auto-ids).
There is no guardTool. Skip is the plugin return, not
throw-from-execute. There is no guardInbound, no guardApproval,
and this namespace does not use ADK SecurityPlugin as the Arcjet
policy gate.
This namespace is Google ADK JS Runner +
BasePlugin.beforeToolCallback. Not @google/genai. Not the
Python SDK. Do not also wrap with @arcjet/guard/vercel-ai/v7 or
@arcjet/guard/claude-managed-agents/v0.
Docs live at
docs.arcjet.com/guards/google-adk/.
Do not overwrite any other /guards/... slug.
Put Arcjet first in new Runner({ plugins }). PluginManager is
first-win; if another plugin (including SecurityPlugin) returns a
value first, Guard never runs.
Screen inbound before Runner.runAsync — there is no inbound hook.
There is no first-class inbound deny-dict channel, so there is no
guardInbound. Put prompt-injection (and other inbound rules) in the
application before runner.runAsync(). Call guard() directly.
guard() fails open — callers must check hasFailedOpen().
onUserMessageCallback replaces the user message; it is not this
policy gate.
requireConfirmation / requestConfirmation is HITL, not a policy gate.
requireConfirmation / toolContext.requestConfirmation /
SecurityPlugin CONFIRM is human-in-the-loop. After a human yes,
Guard still runs on the tool call. Same trap as Mastra
requireApproval, Claude canUseTool, LangGraph interrupt(),
Genkit toolApproval, OpenAI Agents needsApproval, LangChain
humanInTheLoopMiddleware, and TanStack needsApproval. There is no
guardApproval.
Questions to ask the human first
Ask only what you cannot infer from the code; suggest defaults.
- Which tools are risky (external side effects, irreversible, spends
money, sends messages)? Those are gated by
guardPlugin. - What limits? (e.g. "10 lookups/min per order" →
tokenBucket.) - Who is the user for metadata — an opaque user/tenant ID (never PII)?
Default: none. Pass it via
metadataon the policy. Put the conversation / session id you already have on helper options (guardPlugin({ sessionId })). That id is the correlation id and wins over durable sessionstate. Do not useinvocationIdortoolContext.sessionId. ADKContexthas no nestedcontextfield. - Is an Arcjet outage unacceptable? Every helper defaults to
onGuardError: "deny". Ask explicitly about inbound screening beforeRunner.runAsync: failing closed there means the run does not start for the duration of the outage, so"allow"is a routine and legitimate choice at that one call site.guard()itself still fails open — checkhasFailedOpen().
The six things readers get wrong
- There is no
guardInbound. Screen prompt injection beforeRunner.runAsyncwithguard(). CheckhasFailedOpen().onUserMessageCallbackis not Guard. requireConfirmation/requestConfirmation/SecurityPluginCONFIRM is not a policy gate. It is HITL. After a human yes, Guard still runs. Do not useSecurityPluginas the Arcjet gate.- The import path is versioned and there is no alias.
@arcjet/guard/google-adk/v2.@arcjet/guard/google-adkdoes not resolve. Docs are/guards/google-adk/. - Correlation is read, never minted. Do not call
createAgentContextinside a plugin callback — that generates a second id and splits the Sequence. Put the id you already chose onguardPlugin({ sessionId }). That wins over durablestate. Do not readinvocationId,traceId, ortoolContext.sessionId. Do not expecttoolContext.context— ADKContexthas no such field. - Put Arcjet first. PluginManager is first-win. If another plugin returns a dict first, Guard never runs.
- Do not add
guardTooland do not double-wrap with@arcjet/guard/vercel-ai/v7or@arcjet/guard/claude-managed-agents/v0. Google ADK JS is not the Vercel AI SDK. Skip is the plugin return.
Step 1: Install and find the guard client
Install @arcjet/guard (required), plus @google/adk (optional
peer, needed for @arcjet/guard/google-adk/v2). Always use the
versioned path: @arcjet/guard/google-adk/v2 resolves;
@arcjet/guard/google-adk throws ERR_PACKAGE_PATH_NOT_EXPORTED.
The peer range is >=2 <3. Node 22+ — do not bump Node for this
adapter.
npm install @arcjet/guard @google/adk
If the agent has no guard client yet, launch one once at module scope:
import { launchArcjet } from "@arcjet/guard";
export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
Step 2: Gate tool calls — Arcjet first
import { Runner } from "@google/adk";
import { guardPlugin } from "@arcjet/guard/google-adk/v2";
import { tokenBucket, localDetectSensitiveInfo, policyInput } from "@arcjet/guard";
import { arcjet } from "./arcjet.js";
const lookupLimit = tokenBucket({
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
const detectPii = localDetectSensitiveInfo();
const runner = new Runner({
appName: "my_app",
agent,
sessionService,
plugins: [
guardPlugin(arcjet, {
action: ({ toolName }) => `${toolName}.invoked`,
actor: conversationId,
inputs: ({ toolName }) => ({
tool: policyInput.server.string(toolName),
}),
rules: ({ toolName, input }) => {
const note =
typeof input === "object" && input !== null && "note" in input
? String((input as { note?: unknown }).note ?? "")
: "";
return [
lookupLimit({ key: toolName, requested: 1 }),
...(note.length > 0 ? [detectPii(note)] : []),
];
},
sessionId: conversationId,
}),
],
});
- Omit
rulesto submit none. The guard call still happens. - Optional
actorandinputs(static, or a resolver over this adapter's native call — parsed input plus trusted runtime/context) are forwarded on the guard call so a remote policy that declares those names can evaluate. Build each input withpolicyInput. - On DENY the original
runAsyncnever runs. Delivery is{ arcjetDenied: true, reason, message, retryable }— the dict ADK treats as skip. - Default
onGuardError: "deny"blocks the tool if Arcjet is unreachable. A Guard error ALWAYS returns a deny dict, neverundefined. - ALLOW captures
outcome: "success"when the policy lets the tool run, not whenrunAsyncfinishes.beforeToolCallbackcannot wrap the tool; a later tool throw does not flip that capture. - Tools already branded by a sibling
guardToolskip the plugin so Guard is not double-called. This namespace has noguardTool. Inboundguard()beforeRunner.runAsyncdoes not stamp that brand.
Step 3: Screen inbound before Runner.run
import { detectPromptInjection } from "@arcjet/guard";
import { googleAdkContext } from "@arcjet/guard/google-adk/v2";
import { arcjet } from "./arcjet.js";
const inbound = detectPromptInjection();
const decision = await arcjet.guard({
label: "message.received",
rules: [inbound(userText)],
...googleAdkContext({ context: { sessionId: conversationId } }),
});
if (decision.conclusion === "DENY") {
throw new Error("message blocked");
}
if (decision.hasFailedOpen()) {
throw new Error("inbound screening failed open");
}
for await (const event of runner.runAsync({
userId,
sessionId: conversationId,
newMessage: { parts: [{ text: userText }] },
})) {
void event;
}
There is no guardInbound. guard() fails open — always check
hasFailedOpen(). The plugin does not implement inbound screening,
so this call does not double-call Guard.
Step 4: Correlation
Put the id you already have on helper options:
guardPlugin(arcjet, { sessionId: conversationId });
Preference order: caller wrap context.correlationId /
context.sessionId / context.conversationId (only for
googleAdkContext({ context: appContext }) — ADK Context has no
such field), then init.sessionId / init.correlationId (helper
options; wins over durable state), then the same keys on session
state. If none is a valid 1–256 printable-ASCII string, the call
is uncorrelated rather than joined to a generated id nobody has.
A toolContext that has invocationId is ADK's Context envelope, so
top-level sessionId on that object is ignored.
Never mint a new id. Never read invocationId (ADK always generates
it). Never read traceId. Never read toolContext.sessionId /
session.id. requireConfirmation resumes after a human yes —
Guard still runs on the tool call. Do not treat the confirmation or
its resume value as correlation.
Verify the integration
npm run typecheckpasses.- Exercise inbound PI (before
Runner.runAsync, includinghasFailedOpen()), a plugin deny-dict skip, undefined execute, first-plugin short-circuit (Arcjet first), no-throw, never-mint, and fail-closed (an unreachable guard → deny dict, neverundefined). Confirm the denial is a dict and the run is not a confirmation / HITL pause. - Confirm in the Arcjet dashboard that decisions share the
caller-owned session id as their correlation id — not
invocationIdor an ephemeral session id. - Manual E2E with a real
ARCJET_KEYis still-to-verify until you run it.
A full working demo will land in
arcjet/examples google-adk-agent
as a later follow-up. Do not add an example under examples/ in the
JS SDK repo.
Note: capture events are fire-and-forget and batched, so events can lag the decisions they accompany by a few seconds. A dropped event is diagnosed, never thrown.