@arcjet/guard
v1.13.0
Published
Arcjet Guards SDK — AI guardrails for rate limiting, prompt injection detection, and sensitive info detection
Readme
@arcjet/guard
[Arcjet][arcjet] is the runtime security platform that ships in your AI code. Detect prompt injection, authorize agent tool calls, redact sensitive data, and block bots and abuse. Real-time security building blocks you call inside your app, before an action happens.
This is the [Arcjet][arcjet] Guards SDK for non-request protection — use it
for AI agent tool calls, MCP server handlers, queue workers, background jobs,
and anything else that doesn't have an HTTP request object. If you're protecting
HTTP routes, use a framework SDK
like @arcjet/next or @arcjet/node instead.
Why Arcjet?
Your app's AI features and agents take real actions, calling tools, reading data, hitting APIs. Arcjet runs inside that code and lets you enforce security on each action in real time, then audit what happened
Getting started
Quick setup with an AI agent
- Log in with the CLI:
npx @arcjet/cli auth login - Install versioned Agent Skills so your coding agent matches this SDK:
npx @tanstack/intent@latest install - Tell your agent what to protect — it handles the rest.
Manual setup
- Log in with the CLI (or at
app.arcjet.com):npx @arcjet/cli auth login npm install @arcjet/guard- Pass your key to
launchArcjet({ key: process.env.ARCJET_KEY! }) - Add a guard to your code — see the quick start below
npm package | GitHub source | [Other SDKs][sdks-github]
Features
Guards share some features with the request SDKs but are designed for non-HTTP contexts. Here's what's available where:
| Feature | Request SDKs | @arcjet/guard |
| ------------------------------- | :----------: | :-------------: |
| Rate Limiting | ✅ | ✅ |
| Prompt Injection Detection | ✅ | ✅ |
| Content Moderation | — | ✅ |
| Sensitive Information Detection | ✅ | ✅ |
| Custom Rules | — | ✅ |
| Bot Protection | ✅ | — |
| Shield WAF | ✅ | — |
| Email Validation | ✅ | — |
| Request Filters | ✅ | — |
| IP Analysis | ✅ | — |
- 🪣 Rate Limiting — token bucket, fixed window, and sliding window algorithms; model AI token budgets per user.
- 🛡️ Prompt Injection Detection — detect and block prompt injection attacks before they reach your LLM.
- 🧹 Content Moderation — detect and block harmful content in user text, tool results, or model outputs.
- 🕵️ Sensitive Information Detection — block PII, credit cards, and custom patterns from entering your AI pipeline.
- 🔧 Custom Rules — define your own local evaluation logic with arbitrary data.
Quick start
This example protects an AI tool call with token bucket rate limiting and prompt injection detection.
import { launchArcjet, tokenBucket, detectPromptInjection, policyInput } from "@arcjet/guard";
// Create the Arcjet client once at module scope
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
// Configure reusable rules
const limitRule = tokenBucket({ refillRate: 10, intervalSeconds: 60, maxTokens: 100 });
const piRule = detectPromptInjection();
// Per request — create rule inputs each time
const rl = limitRule({ key: userId, requested: tokenCount });
const decision = await arcjet.guard({
label: "tools.weather",
rules: [rl, piRule(userMessage)],
});
// Overall decision
if (decision.conclusion === "DENY") {
if (decision.reason === "RATE_LIMIT") {
throw new Error("Rate limited — try again later");
}
if (decision.reason === "PROMPT_INJECTION") {
throw new Error("Prompt injection detected — please rephrase");
}
throw new Error("Request denied");
}
// Check for failures (fail-open — errors don't cause denials). hasFailedOpen()
// is true only when the conclusion is ALLOW because a rule or the decision
// could not be processed — gate a fail-closed policy on it.
if (decision.hasFailedOpen()) {
console.warn("Allowed only because evaluation failed open", decision.errorResults());
}
// Remotely configured policies use explicit typed inputs. SERVER values are
// evaluated and retained by Arcjet; LOCAL values remain in SDK memory.
const policyDecision = await arcjet.guard({
label: "email.sent",
actor: userId,
inputs: {
recipient: policyInput.server.string(to),
subject: policyInput.local.string(subject),
},
});
// Remote results are keyed by policy/rule identity and remain separate from
// positional SDK rule results.
console.log(policyDecision.policyEvaluation, policyDecision.policyResults);
// Decision-level diagnostics (e.g. an invalid metadata key that was stripped).
// Warnings never change the conclusion.
for (const warning of decision.warnings) {
console.warn(`${warning.code}: ${warning.message}`);
}
// From a RuleWithInput — result for this specific submission
const r = rl.result(decision);
if (r) {
console.log(r.remainingTokens, r.maxTokens);
}
// From a RuleWithConfig — first denied result across all submissions
const denied = limitRule.deniedResult(decision);
if (denied) {
console.log(denied.remainingTokens); // 0
}
// Proceed with your AI tool call...Rate limiting
Token bucket
Use this when requests have variable cost — for example, an LLM endpoint
where each call consumes a different number of tokens. The bucket refills at
a steady rate and allows bursts up to maxTokens.
import { launchArcjet, tokenBucket } from "@arcjet/guard";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
const limitRule = tokenBucket({
bucket: "user-tokens", // Optional — defaults to "default-token-bucket"
refillRate: 2_000, // Refill 2,000 tokens per interval
intervalSeconds: 3600, // Refill every hour
maxTokens: 5_000, // Maximum 5,000 tokens in the bucket
});
const decision = await arcjet.guard({
label: "tools.chat",
rules: [limitRule({ key: userId, requested: tokenEstimate })],
});
if (decision.conclusion === "DENY" && decision.reason === "RATE_LIMIT") {
throw new Error("Rate limit exceeded");
}Fixed window
Use this when you need a hard cap per time period — the counter resets at the end of each window. Simple to reason about, but allows bursts at window boundaries. If that matters, use sliding window instead.
import { launchArcjet, fixedWindow } from "@arcjet/guard";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
const limitRule = fixedWindow({
bucket: "page-views", // Optional — defaults to "default-fixed-window"
maxRequests: 1000, // Maximum requests per window
windowSeconds: 3600, // 1-hour window
});
const decision = await arcjet.guard({
label: "api.search",
rules: [limitRule({ key: teamId })],
});Sliding window
Use this when you need smooth rate limiting without the burst-at-boundary problem of fixed windows. The server interpolates between the previous and current window, so limits are enforced across any rolling time span. Good default choice for API rate limits.
import { launchArcjet, slidingWindow } from "@arcjet/guard";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
const limitRule = slidingWindow({
bucket: "event-writes", // Optional — defaults to "default-sliding-window"
maxRequests: 500, // Maximum requests per interval
intervalSeconds: 60, // 1-minute rolling window
});
const decision = await arcjet.guard({
label: "api.events",
rules: [limitRule({ key: userId })],
});Prompt injection detection
Detect and block prompt injection attacks — attempts to override your AI model's instructions — before they reach your model. Also useful for scanning tool call results that contain untrusted input (e.g. a "fetch" tool that loads a webpage which could embed injected instructions).
import { launchArcjet, detectPromptInjection } from "@arcjet/guard";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
const piRule = detectPromptInjection();
const decision = await arcjet.guard({
label: "tools.chat",
rules: [piRule(userMessage)],
});
if (decision.conclusion === "DENY" && decision.reason === "PROMPT_INJECTION") {
throw new Error("Prompt injection detected — please rephrase your message");
}
const result = piRule.result(decision);
// Billing is undefined when the service does not report usage. Prompt
// injection uses model tokens; content moderation uses text_units.
console.log(result?.billing?.unit, result?.billing?.count);
// Forward to your AI model...Content moderation
Detect and block harmful content in user-supplied text before it is stored, displayed, or forwarded to another service. Also useful for scanning tool call results or model outputs.
import { launchArcjet, moderateContent } from "@arcjet/guard";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
const moderate = moderateContent();
const decision = await arcjet.guard({
label: "tools.chat",
rules: [moderate(userMessage)],
});
if (decision.conclusion === "DENY" && decision.reason === "MODERATE_CONTENT") {
throw new Error("Harmful content detected — please rephrase your message");
}
const result = moderate.result(decision);
// `detected` is true when harmful content was found. Billing is undefined
// when the service does not report usage. Content moderation uses text_units.
console.log(result?.detected, result?.billing?.unit, result?.billing?.count);Sensitive information detection
Detect and block PII in text content. Use allow / deny to filter which
entity types trigger a denial. Built-in entity types are
CREDIT_CARD_NUMBER, EMAIL, PHONE_NUMBER, and IP_ADDRESS.
import { launchArcjet, localDetectSensitiveInfo } from "@arcjet/guard";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
const si = localDetectSensitiveInfo({
deny: ["CREDIT_CARD_NUMBER", "PHONE_NUMBER"],
});
const decision = await arcjet.guard({
label: "tools.summary",
rules: [si(userMessage)],
});
if (decision.conclusion === "DENY" && decision.reason === "SENSITIVE_INFO") {
throw new Error("Sensitive information detected");
}On-device detection with additional entity types
The default backend detects the four built-in types locally with pattern
matching. To detect additional types — names, addresses, and government or
financial identifiers — pass a backend such as
@arcjet/sensitive-info-rampart,
which runs an on-device NER model. Detection still happens entirely locally;
only a SHA-256 hash of the text is sent to Arcjet.
import { launchArcjet, localDetectSensitiveInfo } from "@arcjet/guard";
import { rampart } from "@arcjet/sensitive-info-rampart";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
const si = localDetectSensitiveInfo({
deny: ["GIVEN_NAME", "SURNAME", "EMAIL", "SSN"],
backend: rampart(),
});
const decision = await arcjet.guard({
label: "tools.summary",
rules: [si(userMessage)],
});Custom rules
Define your own local evaluation logic with arbitrary key-value data. When
evaluate is provided, the SDK calls it locally before sending the request.
The function receives (config, input, { signal }) and must return
{ conclusion: "ALLOW" | "DENY" }.
import { launchArcjet, defineCustomRule } from "@arcjet/guard";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
const topicBlock = defineCustomRule<
{ blockedTopic: string },
{ topic: string },
{ matched: string }
>({
evaluate: (config, input) => {
if (input.topic === config.blockedTopic) {
return { conclusion: "DENY", data: { matched: input.topic } };
}
return { conclusion: "ALLOW" };
},
});
const rule = topicBlock({ data: { blockedTopic: "politics" } });
const decision = await arcjet.guard({
label: "tools.chat",
rules: [rule({ data: { topic: userTopic } })],
});Capture
Use capture() to record a fact about what your application did. Captures are
visibility data, never security decisions:
arcjet.capture({
action: "refund.issued",
correlationId: runId,
decisionId: decision.id,
metadata: {
invoice: { id: "inv_123", amount: 4200 },
refunded: true,
},
});Capture is best-effort and never blocks or throws into application code. The SDK keeps a bounded in-memory queue, sends batches on size or delay, drops the newest event when the queue is full, and never retries a failed batch.
A platform waitUntil hook does not change any of that. Events still batch; the
hook is handed a promise that settles once they have been sent, so the runtime
keeps the invocation alive long enough for the batch to go out.
Serverless and edge runtimes
A runtime that freezes or terminates between invocations can lose whatever is
still batched, so it needs telling that background work is outstanding. That is
all waitUntil does — it extends the invocation, it does not disable batching.
Thirty tool calls in one agent turn stay one request, not thirty, which matters
against a Worker's subrequest budget.
Pass waitUntil per call:
export default {
async fetch(request, env, ctx) {
arcjet.capture({
action: "refund.issued",
waitUntil: (promise) => ctx.waitUntil(promise),
});
return new Response("ok");
},
};Arcjet discovers Vercel's request context on its own, so waitUntil is not
needed there. Every other per-invocation hook — Cloudflare's ExecutionContext
included — has to be passed in, because a module-scoped client cannot reach it.
Where capture() is called too deep to reach the platform context, flush() at
the end of the handler instead:
export default {
async fetch(request, env, ctx) {
const response = await handle(request);
ctx.waitUntil(arcjet.flush());
return response;
},
};Draining
Call flush() during graceful shutdown to avoid losing the final batch:
await arcjet.flush(); // one-second deadline by default
await arcjet.flush(250); // custom deadline in millisecondsflush() is optional, repeatable, and does not close the client. If its deadline
expires, remaining events are dropped and the client stays usable.
Local failures use stable AJxxxx diagnostics. Pass a logger to receive every
diagnostic; without one, Arcjet logs once per code:
const arcjet = launchArcjet({
key: process.env.ARCJET_KEY!,
logger: {
// `@arcjet/logger` shape: the merging object comes first, the message
// second. `fields` carries `{ code, count? }`.
warn(fields, message) {
applicationLogger.warn(fields, message);
},
},
});Metadata has the same nested-JSON shape and limits as guard(). A key the SDK
cannot encode is reported locally as AJ1017 and also travels with that event in
local_warnings. A queue-full event or failed batch never reaches the server, so
those drops can only be reported locally.
Registering a client (optional)
Passing the client explicitly is the recommended path, and everything above does
exactly that. Registration is a shortcut for the case it cannot cover: code too
deep in an application to be handed a client, where capture() is often most
useful.
launchArcjet() never touches global state. Registering is always a separate,
explicit call:
// instrumentation.ts, or whatever runs at startup
import { launchArcjet, registerArcjet } from "@arcjet/guard";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
registerArcjet(arcjet); // now, and only now, something is globalguard(), capture() and flush() are then importable on their own, and reach
the registered client:
// deep in application code — nothing was passed down here
import { capture } from "@arcjet/guard";
export async function refund(id: string): Promise<void> {
await issueRefund(id);
capture({ action: "refund.issued", metadata: { invoice: id } });
}What happens with nothing registered
guard() returns a fail-open ALLOW carrying an error result, so
decision.hasFailedOpen() is true. It does not throw — these functions behave
exactly like the client methods they forward to, and the never-throw contract
holds.
const decision = await guard({ label: "refund", rules: [limit(input)] });
if (decision.hasFailedOpen()) {
// No rule was evaluated. Treat this as "policy did not run", not as a pass.
}capture() drops the event silently, and flush() resolves immediately.
Nothing is logged: the client that would have carried a logger is the thing
that is missing, so the only available sink would be an unconfigurable console
warning on a request path — noise an application cannot turn off. The decision
returned by guard() is the observable signal, and making the capture() case
observable is planned as an opt-in on the call itself.
Registering twice, and unregistering
Registration is version-checked. The slot is shared by every copy of
@arcjet/guard in the process, so a registration is only used by the exact
build that wrote it — the stored value is a live object whose internals are
guaranteed within one build and not across them. A copy that finds a
registration from another version leaves it alone and fails open, exactly as if
nothing were registered, and reports AJ3006 on its own logger. Two versions
in one process therefore do not share a client.
Registration is also guarded. A second client does not displace the first — the
attempt is reported as AJ3004 on the incumbent's logger, so a library or a
stray second launchArcjet() cannot quietly redirect an application's telemetry
to a different key. Registering the client that is already registered is a
silent no-op.
registerArcjet(a); // registered: a
registerArcjet(b); // warns; a stays registered
unregisterArcjet(); // nothing registeredunregisterArcjet() takes no argument and clears whatever is there. That
asymmetry is deliberate: requiring the client back would mean every teardown has
to keep hold of it, which is the problem registration exists to avoid. The cost
is that anything calling it clears the application's client and every free call
afterwards fails open — so libraries should not call it. Libraries take a
client explicitly. That is a convention, not something the SDK enforces.
An explicitly passed client always wins; the registered one is only consulted when none was passed.
Testing
@arcjet/guard/testing registers an in-memory client that records calls and
talks to nothing:
import { registerTestClient } from "@arcjet/guard/testing";
import { refund } from "./refund.ts";
test("refund captures an event", async () => {
using arcjet = registerTestClient();
await refund("inv_1");
assert.equal(arcjet.captures[0]?.action, "refund.issued");
});using unregisters the client at the end of the block, including when the test
fails part-way through. Note the await: the capture happens wherever the code
under test reaches it, so a test that forgets to await an async function asserts
before the event exists.
The using syntax needs Node.js 24 to run natively, or compilation through
TypeScript. Node.js 22 defines Symbol.dispose but cannot parse using. Call
unregister() from a finally instead:
test("refund captures an event", async () => {
const arcjet = registerTestClient();
try {
await refund("inv_1");
assert.equal(arcjet.captures[0]?.action, "refund.issued");
} finally {
arcjet.unregister();
}
});unregister() and [Symbol.dispose] are the same function under two names, so
neither can drift from the other. It is safe to call twice, so it also works
from an afterEach.
One related caveat: because [Symbol.dispose] appears in the published types, a
project compiling with skipLibCheck: false needs esnext.disposable in its
lib even if it never writes using. unregister() is unaffected either way.
It throws if a client is already registered, which surfaces a leak from an earlier test rather than letting this one assert against the wrong recorder.
Each recorded capture goes through the same validation and metadata encoding as
a real capture(), so a call the real client would drop is not recorded here
either. Recording itself is synchronous — once the code under test reaches
capture(), the event is there with no flushing or waiting.
guard() on the test client records the call and returns a fail-open ALLOW,
because no rule actually ran. It is not a mock server and does not let you stub
per-rule verdicts. One consequence worth knowing: helpers that fail closed on a
failed-open decision — guardTool, guardAction — will therefore deny
against this client.
Metadata
guard() and every rule accept metadata: an object of string keys mapped to
any JSON-serializable value, including nested objects and arrays. It is
attached to the decision for correlation and analytics.
const decision = await arcjet.guard({
label: "tools.weather",
rules: [limitRule({ key: userId })],
metadata: {
user: { id: userId, plan: "pro" },
toolName: "get_weather",
durationMs: 160,
success: true,
},
});Each top-level value is JSON-encoded by the SDK and stored verbatim. Server-enforced limits:
| Limit | Value | Over the limit |
| ------------------------ | ------------------------------ | ------------------ |
| Top-level keys | 128 | Extra keys dropped |
| Serialized bytes / value | 4 KiB | That key dropped |
| Nesting depth / value | 10 | That key dropped |
| Key names | letters, digits, -, ., _ | That key dropped |
Nothing here can fail a call or change a decision — metadata is excluded from
fingerprinting. Every dropped key is reported on decision.warnings: the server
warns once per key it drops, and the SDK adds a single warning naming every key
it could not encode (undefined, a function, a BigInt, a circular reference). A
metadata that is not a plain object is ignored entirely.
Metadata is untrusted and is not redacted — do not put secrets or PII in it.
Two JavaScript-specific notes:
- Numbers are IEEE-754 doubles, so an integer above
Number.MAX_SAFE_INTEGERloses precision before it reaches the wire. Pass such values as strings. BigIntcannot be JSON-encoded, so it is dropped with a warning. Convert it yourself.
Rule-level metadata is merged with guard()-level metadata shallowly: a
duplicate key's whole value is replaced, never deep-merged.
Some limits are the SDK's own, not the server's. The SDK drops keys once one request's metadata exceeds 768 KiB in total (keys plus JSON-encoded values, counted before compression). That ceiling sits well above anything the server would accept — its own caps allow roughly 512 KiB in a single map — and exists only so oversized metadata cannot push a request past the 1 MiB protocol limit, where it would be rejected outright and fail open.
Objects with a toJSON() method, including Date, are serialized by their
toJSON() result. The Python SDK has no equivalent protocol and drops such values
with a warning, so convert explicitly if both SDKs must agree on a value.
Decision inspection
Every .guard() call returns a Decision object. You can inspect it at
three levels of detail:
const rl = limitRule({ key: userId, requested: tokenCount });
const decision = await arcjet.guard({
label: "tools.weather",
rules: [rl, piRule(userMessage)],
});
// Overall decision
decision.conclusion; // "ALLOW" | "DENY"
decision.reason; // "RATE_LIMIT" | "PROMPT_INJECTION" | ... (only on DENY)
// Failure check (fail-open — errors don't cause denials)
decision.hasFailedOpen(); // true if ALLOW only because a rule/decision could not be processed
decision.errorResults(); // the results that errored
decision.warnings; // decision-level request-validation diagnostics
// Per-rule results — iterate all
for (const result of decision.results) {
console.log(result.type, result.conclusion);
}
// From a RuleWithInput — this specific submission's result
const r = rl.result(decision);
if (r) {
console.log(r.remainingTokens, r.maxTokens);
}
// From a RuleWithConfig — first denied result across all submissions
const denied = limitRule.deniedResult(decision);
if (denied) {
console.log(denied.remainingTokens); // 0
}Methods available on both RuleWithConfig and RuleWithInput:
| Method | RuleWithConfig (e.g. limit) | RuleWithInput (e.g. rl) |
| ------------------------ | ------------------------------- | ---------------------------------- |
| results(decision) | All results for this config | Single-element or empty array |
| result(decision) | First result (any conclusion) | This submission's result |
| deniedResult(decision) | First denied result | This submission's result if denied |
Best practices
Create the client and rule configs once at module scope, not per request. The client holds a persistent connection (HTTP/2 on Node.js); rule configs carry stable IDs used for server-side aggregation.
// Create the client once at module scope const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! }); // Configure reusable rules (also at module scope) const limitRule = tokenBucket({ refillRate: 10, intervalSeconds: 60, maxTokens: 100 }); // Per request — created each time const decision = await arcjet.guard({ label: "tools.weather", rules: [limitRule({ key: userId })], });Don't wrap
launchArcjet()in a helper function. This defeats connection reuse. Bad — creates a new client every call:function getArcjet() { return launchArcjet({ key: process.env.ARCJET_KEY! }); } const decision = await getArcjet().guard({ label: "tools.chat", rules: [ tokenBucket({ refillRate: 10, intervalSeconds: 60, maxTokens: 100 })({ key: userId, requested: 1, }), ], });Good — reuses the client:
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! }); const decision = await arcjet.guard({ label: "tools.chat", rules: [ tokenBucket({ refillRate: 10, intervalSeconds: 60, maxTokens: 100 })({ key: userId, requested: 1, }), ], });Start rules in
DRY_RUNmode to observe behavior before switching toLIVE. This lets you tune thresholds without affecting real traffic:const limitRule = tokenBucket({ mode: "DRY_RUN", refillRate: 10, intervalSeconds: 60, maxTokens: 100, });Handle failures explicitly. The SDK fails open — an errored rule does not cause a denial. Check
decision.hasFailedOpen()to detect when a decision returnedALLOWonly because a rule or the decision could not be processed, and inspectdecision.errorResults()for the details. Gate a fail-closed policy on it:if (decision.hasFailedOpen()) { // Evaluation degraded — decide whether to proceed or deny. console.error("Guard failed open", decision.errorResults()); }decision.hasError()still works but is deprecated: it conflated request diagnostics with errors. Usedecision.warningsfor diagnostics anddecision.errorResults()/decision.hasFailedOpen()for errors.Use labels to identify protection boundaries. Labels appear in the Arcjet dashboard and help correlate decisions with specific tool calls or API endpoints.
Use
bucketon rate limit rules to name your counters in the dashboard. Different configs sharing the same bucket name still get independent counters — a config hash is appended server-side.
SDK namespaces: core and integrations
@arcjet/guard exposes two import layers, plus @arcjet/guard/testing for the
in-memory test client:
Core guard (@arcjet/guard)
The fundamental client and rule builders. Use this to evaluate guards without any AI SDK integration:
import { launchArcjet, tokenBucket, detectPromptInjection } from "@arcjet/guard";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
const decision = await arcjet.guard({
label: "tools.chat",
rules: [
tokenBucket({ refillRate: 10, intervalSeconds: 60, maxTokens: 100 })({
key: userId,
requested: 1,
}),
detectPromptInjection()(userMessage),
],
});Vendor SDK integration (@arcjet/guard/<vendor-sdk>/v<major>)
Vendor-specific wrappers that integrate with particular SDKs, plus every agent
helper. Every wrapper policy accepts optional actor and inputs — static
values or resolvers over that adapter's native call (parsed input plus the
framework's trusted runtime or context, the same idea as Vercel AI's
(input, ctx)). Build each input with
policyInput so a remote Guard policy that declares those names can evaluate.
Omit them and the remote policy has nothing to read, so its rules do not fire.
Currently available:
@arcjet/guard/vercel-ai/v7— Vercel AI SDK v7 integration. ExportsguardToolandaiToolsContextfor tool wrapping, alongside the helpers that are not tied to any SDK —createAgentContext,guardAction,captureAction, andsecurityMetadata:import { guardTool, aiToolsContext, createAgentContext, guardAction, captureAction, securityMetadata, } from "@arcjet/guard/vercel-ai/v7"; import { policyInput } from "@arcjet/guard"; const ctx = createAgentContext({ correlationId: requestId, metadata: securityMetadata({ user: userId }), }); const tools = { getData: guardTool(arcjet, getDataTool, { action: "data.fetched", onGuardError: "deny", // default — blocks the call if Arcjet is unreachable actor: (_input, context) => String(context?.metadata?.userId), inputs: (input) => ({ query: policyInput.server.string(input.query) }), rules: [dataLimit({ key: userId, requested: 1 })], }), }; const result = await generateText({ // ... tools, toolsContext: aiToolsContext(ctx, tools), }); await guardAction( arcjet, ctx, { action: "data.updated", onGuardError: "deny", // default — blocks the call if Arcjet is unreachable rules: [updateLimit({ key: userId })], }, () => updateData(), ); captureAction(arcjet, ctx, { action: "audit.logged" });@arcjet/guard/vercel-eve/v0— Vercel Eve v0 integration. ExportsguardTool,guardApproval,guardInbound, andarcjetHooksfor Eve's four guard surfaces, alongside theeveAgentContexthelper that derives context from Eve's session:import { launchArcjet, tokenBucket } from "@arcjet/guard"; import { guardApproval, arcjetHooks } from "@arcjet/guard/vercel-eve/v0"; import { defineOpenAPIConnection } from "eve/connections"; import { defineHook } from "eve/hooks"; const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! }); const limit = tokenBucket({ refillRate: 10, intervalSeconds: 60, maxTokens: 10, }); // Gate a connection's operations export const ordersConnection = defineOpenAPIConnection({ description: "Orders API", spec: {/* ... */}, approval: guardApproval(arcjet, { action: "orders-api.read", onGuardError: "deny", // default — blocks the call if Arcjet is unreachable rules: (ctx) => [limit({ key: ctx.session.id, requested: 1 })], }), operations: { allow: ["GetOrder"] }, }); // Record agent lifecycle events export default defineHook(arcjetHooks(arcjet));@arcjet/guard/claude-agent-sdk/v0— Claude Agent SDK v0 integration. ExportsguardTool,guardHooks, andclaudeAgentContext. There is noguardInbound(inbound isUserPromptSubmitonguardHooks) and nocanUseToolhelper (canUseToolis skipped byallowedTools, allow rules, andbypassPermissions/acceptEdits):import { launchArcjet, detectPromptInjection, tokenBucket } from "@arcjet/guard"; import { guardTool, guardHooks } from "@arcjet/guard/claude-agent-sdk/v0"; import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk"; import { z } from "zod"; const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! }); const limit = tokenBucket({ refillRate: 10, intervalSeconds: 60, maxTokens: 10, }); const lookupOrder = guardTool( arcjet, tool( "lookup_order", "Look up an order", { orderNumber: z.string() }, async ({ orderNumber }) => ({ content: [{ type: "text", text: `${orderNumber}: shipped` }], }), ), { action: "order.looked-up", onGuardError: "deny", rules: (input) => [limit({ key: input.orderNumber, requested: 1 })], }, ); // The Claude CLI requires `sessionId` to be a UUID and refuses to create the // same one twice: a non-UUID exits with "Invalid session ID", and reusing an // id on a second `query()` exits with "already in use". So mint a UUID for // the conversation, then continue it with `resume` — which keeps the id the // adapter reads, so every turn lands on one Sequence. const sessionId = conversationId; // a UUID, e.g. crypto.randomUUID() for await (const message of query({ prompt: userText, options: { // First turn: `sessionId`. Later turns in the same conversation: // `resume: sessionId` instead. sessionId, mcpServers: { app: createSdkMcpServer({ name: "app", tools: [lookupOrder] }), }, hooks: guardHooks(arcjet, { sessionId, // `lookupOrder` guards itself through `guardTool`, so exclude it here // or PreToolUse gates it a second time — two round trips, two quota // units, for one invocation. Naming the server keeps the match exact, // so another server's tool of the same name stays gated. exclude: [{ server: "app", name: "lookup_order" }], inbound: { action: "message.received", rules: ({ prompt }) => [detectPromptInjection()(prompt)], }, }), }, })) { void message; }@arcjet/guard/claude-managed-agents/v0— Claude Managed Agents (hosted REST+SSE, betamanaged-agents-2026-04-01). ExportsguardEvents,guardCustomTool, andclaudeManagedAgentsContext. Anthropic runs the tool loop. There is no PreToolUse. This is not@arcjet/guard/claude-agent-sdk/v0— do not reuse that adapter, its hooks, orguardTool. There is noguardInbound(inbound isguardEventsbeforesessions.events.send) and no confirmation helper (user.tool_confirmation/always_askis HITL, not policy). Defaultalways_allowcannot be gated: Anthropic-cloud bash/read/write andweb_search/web_fetchrun on Anthropic. MCP: Anthropic is the client; Guard custom tools and MCP servers you host. Docs live at/guards/claude-managed-agents/(shared JS+Python page).Correlation is caller-owned. Never mint. Never treat Anthropic session/event ids as if we created them. Never
traceId.import { launchArcjet, detectPromptInjection, tokenBucket } from "@arcjet/guard"; import { claudeManagedAgentsContext, guardCustomTool, guardEvents, } from "@arcjet/guard/claude-managed-agents/v0"; const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! }); const ctx = claudeManagedAgentsContext({ correlationId: conversationId }); const limit = tokenBucket({ refillRate: 10, intervalSeconds: 60, maxTokens: 10, }); const verdict = await guardEvents( arcjet, { events: [{ type: "user.message", content: [{ type: "text", text: userText }] }], inbound: { action: "message.received", rules: ({ text }) => [detectPromptInjection()(text)], }, context: ctx, }, (body) => client.beta.sessions.events.send(session.id, body), ); if (!verdict.allowed) { throw new Error(verdict.message); } if (event.type === "agent.custom_tool_use") { const gated = await guardCustomTool( arcjet, { event, execute: (input) => lookupOrder(input), send: (result) => client.beta.sessions.events.send(session.id, { events: [result] }), }, { action: "order.looked-up", onGuardError: "deny", rules: (input) => [limit({ key: String(input["orderNumber"]), requested: 1 })], context: ctx, }, ); if (gated.allowed) { await client.beta.sessions.events.send(session.id, { events: [ { type: "user.custom_tool_result", custom_tool_use_id: event.id, content: [{ type: "text", text: JSON.stringify(gated.output) }], }, ], }); } }@arcjet/guard/mastra/v1— Mastra v1 integration. ExportsguardTool,guardProcessor,guardHooks, andmastraAgentContext. There is noguardInbound(channels already hitprocessInput) and noguardApproval(MastrarequireApprovalis human HITL, not policy):import { launchArcjet, detectPromptInjection, tokenBucket } from "@arcjet/guard"; import { guardTool, guardProcessor, guardHooks } from "@arcjet/guard/mastra/v1"; import { Agent } from "@mastra/core/agent"; import { createTool } from "@mastra/core/tools"; import { z } from "zod"; const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! }); const limit = tokenBucket({ refillRate: 10, intervalSeconds: 60, maxTokens: 10, }); const lookupOrder = guardTool( arcjet, createTool({ id: "lookup-order", description: "Look up an order", inputSchema: z.object({ orderNumber: z.string() }), execute: async ({ orderNumber }) => ({ orderNumber, status: "shipped" }), }), { action: "order.looked-up", onGuardError: "deny", rules: (input) => [limit({ key: input.orderNumber, requested: 1 })], }, ); export const agent = new Agent({ id: "support-agent", name: "support-agent", instructions: "Help the user.", model: "openai/gpt-4o", tools: { lookupOrder }, inputProcessors: [ guardProcessor(arcjet, { action: "message.received", rules: ({ text }) => [detectPromptInjection()(text)], }), ], hooks: guardHooks(arcjet), });@arcjet/guard/langgraph/v1— LangGraph Graph API (StateGraph+ToolNode) integration. ExportsguardTool,guardToolNode, andlanggraphAgentContext. This is not LangChaincreateAgent/wrapToolCall— that is@arcjet/guard/langchain/v1— andcreateReactAgentis deprecated in LangGraph JS v1 — do not build on it. There is noguardInbound(screen beforeinvokeor at the first graph node) and noguardInterrupt/guardApproval(interrupt()is human HITL, not policy).On DENY a guarded tool does not run and does not throw: it returns a structured
ArcjetDenialResult, whichToolNodeturns into a realToolMessagethe model reads. Because the tool did not throw, that message'sstatusissuccess— the denial is in the payload (arcjetDenied: true), not the envelope.guardToolNodeguards aToolNode's tools in place and returns the same node, becauseToolNoderesolves its tools through a closure captured when it was constructed:import { launchArcjet, tokenBucket } from "@arcjet/guard"; import { guardTool, guardToolNode } from "@arcjet/guard/langgraph/v1"; import { ToolNode } from "@langchain/langgraph/prebuilt"; import { tool } from "@langchain/core/tools"; import { z } from "zod"; const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! }); const limit = tokenBucket({ refillRate: 10, intervalSeconds: 60, maxTokens: 10, }); const lookupOrder = guardTool( arcjet, tool(async ({ orderNumber }) => ({ orderNumber, status: "shipped" }), { name: "lookup_order", description: "Look up an order", schema: z.object({ orderNumber: z.string() }), }), { action: "order.looked-up", onGuardError: "deny", rules: (input) => [limit({ key: input.orderNumber, requested: 1 })], }, ); export const tools = guardToolNode(arcjet, new ToolNode([lookupOrder]));
Screen inbound before invoke (or at the first graph node)
LangGraph has no first-class inbound channel, so there is no
guardInbound. Put prompt-injection (and other inbound rules) in the
application before graph.invoke, or in the graph's first node.
interrupt() is not a policy gate
interrupt() / interrupt_before=["tools"] is human-in-the-loop, not
policy. Same trap as Mastra requireApproval and Claude canUseTool.
There is no guardInterrupt.
ToolNode is the deny point for tools; hooks / HITL cannot enforce
Unwrapped and MCP tools run inside ToolNode. Graph hooks and HITL
pauses cannot stop tool.invoke. Use guardToolNode (or guardTool for
authored tools you invoke yourself).
guardToolNode guards the node's tools in place and hands the same node
back. That is not an optimisation: ToolNode's constructor captures
func: (input, config) => this.run(input, config), and run reads
this.tools, so a copy holding a fresh tools array would leave the original
node executing unguarded tools. Guarding in place also means a caller that
still holds the pre-wrap node cannot bypass Guard. Passing an array of tools
instead returns guarded copies and leaves your array untouched. Tools
appended after wrapping — MCP discovered mid-run — are guarded on the next
invoke.
If you invoke a guarded tool yourself rather than through ToolNode, read
the denial and build your own ToolMessage; do not push the denial object
straight into messages, because the graph's message reducer only accepts
real messages.
@arcjet/guard/langchain/v1— LangChain JScreateAgent+createMiddleware({ wrapToolCall })integration. ExportsguardTool,guardMiddleware, andlangchainContext. This is not LangGraph Graph API (StateGraph+ToolNode) — that is@arcjet/guard/langgraph/v1— and notvercel-ai/v7. There is noguardInbound(screen beforeagent.invoke; SDK middleware that is notwrapToolCallis not Guard) and noguardApproval(humanInTheLoopMiddleware/interrupt()is human HITL, not policy). Policy sits onwrapToolCallonly — do not deny inafterModel. Server-side provider tools and headless.implement()tools are out of scope. Docs live at/guards/langchain-js/; do not overwrite/guards/langchain/(the live Python page).Two denial envelopes — do not collapse them.
guardToolreturns a plainArcjetDenialResult; it does not throw and does not fabricate aToolMessage.createAgent'sbaseHandlerwraps a non-ToolMessage in a successToolMessage.guardMiddlewarewrapToolCallMUST return a realToolMessage(content= JSON of the payload,tool_call_id=request.toolCall.id,name=request.toolCall.name). wrapToolCall's return is not passed throughbaseHandler; a bare object is the messages-reducer crash. Do not setstatus: "error". Do not throw (throws bubble and droparcjetDenied). Already-branded tools are skipped so Guard is not double-called:import { launchArcjet, detectPromptInjection, tokenBucket } from "@arcjet/guard"; import { guardTool, guardMiddleware, langchainContext } from "@arcjet/guard/langchain/v1"; import { createAgent } from "langchain"; import { tool } from "@langchain/core/tools"; import { z } from "zod"; const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! }); const limit = tokenBucket({ refillRate: 10, intervalSeconds: 60, maxTokens: 10, }); const lookupOrder = guardTool( arcjet, tool(async ({ orderNumber }) => ({ orderNumber, status: "shipped" }), { name: "lookup_order", description: "Look up an order", schema: z.object({ orderNumber: z.string() }), }), { action: "order.looked-up", onGuardError: "deny", rules: (input) => [limit({ key: input.orderNumber, requested: 1 })], }, ); const inbound = detectPromptInjection(); const decision = await arcjet.guard({ label: "message.received", rules: [inbound(userText)], ...langchainContext({ configurable: { thread_id: conversationId } }), }); if (decision.conclusion === "DENY") { throw new Error("message blocked"); } if (decision.hasFailedOpen()) { throw new Error("inbound screening failed open"); } const agent = createAgent({ model, tools: [lookupOrder], middleware: [guardMiddleware(arcjet, { sessionId: conversationId })], }); await agent.invoke( { messages: [{ role: "user", content: userText }] }, { configurable: { thread_id: conversationId } }, );
Screen inbound before agent.invoke — there is no inbound hook. SDK middleware that is not wrapToolCall is not Guard.
LangChain createAgent has no first-class inbound channel, so there
is no guardInbound. Put prompt-injection (and other inbound rules)
in the application before agent.invoke. wrapModelCall /
beforeModel / afterModel intercept the model call, not user text.
They are not this policy gate.
humanInTheLoopMiddleware / interrupt is HITL, not a policy gate.
humanInTheLoopMiddleware / interrupt() / approve-edit-reject-respond
is human-in-the-loop, not policy. Same trap as Mastra requireApproval,
Claude canUseTool, LangGraph interrupt(), Genkit toolApproval,
and OpenAI Agents needsApproval. There is no guardApproval. Policy
sits on wrapToolCall only — do not deny in afterModel.
Deny inside tool() (and guardMiddleware's wrapToolCall). MCP and unwrapped tools skip an unwrapped handler.
The authored tool() handler is the deny point for tools you own.
MCP tools, runtime-discovered tools, and anything not wrapped with
guardTool skip that handler. guardMiddleware is the invoke()-wide
gate for those — its wrapToolCall denies by returning a real
ToolMessage without calling handler. wrapToolCall only sees
runtime.configurable.thread_id as of langchain 1.2.34.
@arcjet/guard/openai-agents/v0— OpenAI Agents textAgent+run()/Runnerintegration. ExportsguardToolandopenaiAgentsContext. This is not Realtime, Sandbox, hosted tools, computer / shell / apply_patch, MCP, oragent.asTool(). There is noguardInbound(screen beforerun(); SDKinputGuardrailsare not Arcjet), noguardApproval(needsApprovalis human HITL, not policy), and noguardHooks/guardToolNode(there is no ToolNode; hosted / MCP / handoffs skip authoredexecute).On DENY a guarded tool does not run and does not throw: it returns a structured
ArcjetDenialResult. The runner stringifies that object onto afunction_call_resultwithstatus: "completed"— the denial is in the payload (arcjetDenied: true), not a fabricated envelope. Throwing would hit the SDKerrorFunction(a generic string, orToolCallErrorwhenoutputSchema/errorFunction: null).RunContexthas no session / conversation id; put the id you already have onrun(..., { context }):import { launchArcjet, detectPromptInjection, tokenBucket } from "@arcjet/guard"; import { guardTool, openaiAgentsContext } from "@arcjet/guard/openai-agents/v0"; import { Agent, run, tool } from "@openai/agents"; import { z } from "zod"; const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! }); const limit = tokenBucket({ refillRate: 10, intervalSeconds: 60, maxTokens: 10, }); const lookupOrder = guardTool( arcjet, tool({ name: "lookup_order", description: "Look up an order", parameters: z.object({ orderNumber: z.string() }), execute: async ({ orderNumber }) => ({ orderNumber, status: "shipped" }), }), { action: "order.looked-up", onGuardError: "deny", rules: (input: { orderNumber: string }) => [limit({ key: input.orderNumber, requested: 1 })], }, ); const agent = new Agent({ name: "support-agent", instructions: "Help the user.", tools: [lookupOrder], }); const appContext = { sessionId: conversationId }; const inbound = detectPromptInjection(); const decision = await arcjet.guard({ label: "message.received", rules: [inbound(userText)], ...openaiAgentsContext({ context: appContext, conversationId }), }); if (decision.conclusion === "DENY") { throw new Error("message blocked"); } // `guard()` fails open, so an ALLOW is not proof the rules ran. Gate on // `decision.hasFailedOpen()` here if this call site must fail closed; the // agent helpers below already default to that. await run(agent, userText, { context: appContext });
Screen inbound before run() (SDK inputGuardrails are not Arcjet)
OpenAI Agents has no first-class inbound channel, so there is no
guardInbound. Put prompt-injection (and other inbound rules) in the
application before run(). SDK inputGuardrails / outputGuardrails /
defineToolInputGuardrail / defineToolOutputGuardrail are the SDK's
own tripwires, not this policy gate.
needsApproval is not a policy gate
needsApproval / requireApproval / onApproval is human-in-the-loop,
not policy. The run pauses; result.state.approve / reject. Same trap
as Mastra requireApproval, Claude canUseTool, and LangGraph
interrupt(). There is no guardApproval.
tool() execute is the deny point; hosted, MCP, and handoffs are not on that path
The runner executes authored function tools in toolExecution.ts via
invoke. Hosted tools, handoffs, computer / shell / apply_patch, and
MCP (mcpServers → mcpToFunctionTool) skip that authored-execute
path. agent_tool_start / agent_tool_end are void observe-only hooks;
they are not a deny. There is no guardHooks and no guardToolNode.
@arcjet/guard/genkit/v1— Genkit JSgenkit()+ai.defineTool+ai.generateintegration. ExportsguardTool,guardMiddleware, andgenkitContext. This is not Go / Python Genkit. There is noguardInbound(screen beforegenerate()/chat.send(); middlewaremodelis not Guard), noguardApproval(interrupt()/defineInterrupt/toolApprovalis human HITL, not policy). Do not also wrap the same tool with@arcjet/guard/vercel-ai/v7.On DENY a guarded tool does not run and does not throw: it returns a structured
ArcjetDenialResultas a completedtoolResponse.output.interrupt()/ToolInterruptErroris HITL — a denial is notfinishReason: "interrupted". Wrapping theToolAction(not the inner handler) is what keeps a denial offoutputSchemavalidation, so a schema-mismatchedArcjetDenialResultstill reaches the model.guardMiddlewareis the generate()-wide gate for filesystem / MCP / unwrapped tools:import { launchArcjet, detectPromptInjection, tokenBucket } from "@arcjet/guard"; import { guardTool, guardMiddleware, genkitContext } from "@arcjet/guard/genkit/v1"; import { genkit, z } from "genkit"; const ai = genkit({/* plugins, default model */}); const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! }); const limit = tokenBucket({ 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", onGuardError: "deny", rules: (input) => [limit({ key: input.orderNumber, requested: 1 })], }, ); const appContext = { sessionId: conversationId }; const inbound = detectPromptInjection(); const decision = await arcjet.guard({ label: "message.received", rules: [inbound(userText)], ...genkitContext({ context: appContext }), }); if (decision.conclusion === "DENY") { throw new Error("message blocked"); } await ai.generate({ prompt: userText, tools: [lookupOrder], use: [guardMiddleware(arcjet, { sessionId: conversationId })], context: appContext, });
Screen user text before generate() — there is no inbound hook. Middleware model is not Guard.
Genkit has no first-class inbound channel, so there is no
guardInbound. Put prompt-injection (and other inbound rules) in the
application before ai.generate() / chat.send(). The middleware
model hook intercepts the model call, not user text. It is not this
policy gate.
interrupt() / defineInterrupt / toolApproval are HITL, not a policy gate.
interrupt() / defineInterrupt / @genkit-ai/middleware
toolApproval / restartTool / finishReason === "interrupted" is
human-in-the-loop, not policy. The run pauses; you restartTool or
respond. Same trap as Mastra requireApproval, Claude canUseTool,
LangGraph interrupt(), and OpenAI Agents needsApproval. There is no
guardApproval.
Deny inside defineTool (and guardMiddleware's tool hook). MCP and filesystem-injected tools skip an unwrapped handler.
The authored defineTool handler is the deny point for tools you own.
Filesystem middleware tools, MCP tools, and anything not wrapped with
guardTool skip that handler. guardMiddleware is the generate()-wide
gate for those — its tool hook denies by returning a completed
ToolResponsePart without calling next(). returnToolRequests: true
means the app calls the tool itself; guardTool on the defineTool
handler still gates that. guardMiddleware does not run if they never
generate() the tool.
generate({ context }) is delivered to the authored handler via ALS.
The tool wrapper and middleware hook see options.context /
ctx.context when the caller passed it explicitly; put the same id on
policy.sessionId when you need tool-time correlation through the hook.
Never mint. Never use traceId. Never treat interrupt / resumed as
correlation.
@arcjet/guard/strands-agents/v1— Strands Agents JS@strands-agents/sdkAgent+tool({ callback })+ Plugin /addHookintegration. ExportsguardTool,guardHooks, andstrandsAgentContext. This is not the Python SDK. There is noguardInbound(screen beforeinvoke()/stream()), noguardApproval/guardInterrupt(event.interrupt()is human HITL, not policy). Do not also wrap the same tool with@arcjet/guard/vercel-ai/v7or@arcjet/guard/langgraph/v1.On DENY a guarded tool does not run and does not throw: it returns a plain
ArcjetDenialResultfrom the authoredcallback.FunctionToolwraps that object in aJsonBlock. This helper does not fabricate aToolResultBlock.guardHooksis the invoke-wide gate for MCP / unwrapped / vended tools:import { launchArcjet, detectPromptInjection, tokenBucket } from "@arcjet/guard"; import { guardTool, guardHooks, strandsAgentContext } from "@arcjet/guard/strands-agents/v1"; import { Agent, tool } from "@strands-agents/sdk"; import { z } from "zod"; const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! }); const limit = tokenBucket({ refillRate: 10, intervalSeconds: 60, maxTokens: 10, }); const lookupOrder = guardTool( arcjet, tool({ name: "lookup_order", description: "Look up an order", inputSchema: z.object({ orderNumber: z.string() }), callback: async ({ orderNumber }) => ({ orderNumber, status: "shipped" }), }), { action: "order.looked-up", onGuardError: "deny", rules: (input) => [limit({ key: input.orderNumber, requested: 1 })], }, ); const invocationState = { sessionId: conversationId }; const inbound = detectPromptInjection(); const decision = await arcjet.guard({ label: "message.received", rules: [inbound(userText)], ...strandsAgentContext({ invocationState }), }); if (decision.conclusion === "DENY") { throw new Error("message blocked"); } const agent = new Agent({ tools: [lookupOrder], plugins: [guardHooks(arcjet, { sessionId: conversationId })], }); await agent.invoke(userText, { invocationState });
Screen inbound before invoke() / stream() — there is no inbound hook.
Strands Agents has no first-class inbound channel, so there is no
guardInbound. Put prompt-injection (and other inbound rules) in the
application before agent.invoke() / stream(). Middleware / model
hooks are not this policy gate.
interrupt() is not a policy gate.
event.interrupt() is human-in-the-loop, not policy. Same trap as
Mastra requireApproval, Claude canUseTool, LangGraph interrupt(),
OpenAI Agents needsApproval, and LangChain
humanInTheLoopMiddleware. There is no guardApproval /
guardInterrupt.
Deny with BeforeToolCallEvent.cancel (and guardTool on authored callbacks). BeforeToolsEvent.cancel skips per-tool hooks — do not use it.
The authored callback is the deny point for tools you own. MCP,
vended tools, and anything not wrapped with guardTool skip that
callback. guardHooks is the invoke-wide gate for those. Official:
set event.cancel to a string; tool.stream() does not run;
AfterToolCallEvent still fires. Do not use BeforeToolsEvent.cancel
— a truthy value skips _toolExecutor.execute(), so per-tool hooks
never run.
Correlation is a field the integrator puts on invocationState
(correlationId, then sessionId, then requestId). Never mint.
Never read traceId. Never use SessionManager or agent.id.
@arcjet/guard/tanstack-ai/v0— TanStack AIchat({ middleware })+ChatMiddleware.onBeforeToolCallintegration. ExportsguardMiddlewareandtanstackAiContext. This is not the Vercel AI SDK — do not also wrap with@arcjet/guard/vercel-ai/v7. There is noguardTool(a throw fromexecuteis swallowed into{ error }and is not a usable deny envelope), noguardInbound(screen withguard()beforechat();guard()fails open — checkhasFailedOpen()), and noguardApproval(needsApproval/defineInterrupt/onInterruptBoundaryis human HITL, not policy). After a human yes, Guard still runs. Do not name anythingcontentGuardMiddleware(TanStack already has that name). Docs live at/guards/tanstack-ai/.Put Arcjet first in the middleware array.
onBeforeToolCallis first-win; iftoolCacheMiddleware(or anything else) skips first, Guard never runs. Default DENY is{ type: "skip", result: ArcjetDenialResult }so the tool never runs and the model sees the payload. OptionalonDeny: "abort"stops the run with a reason string — the model does not getArcjetDenialResult. The hook does not throw. Tools already branded by a siblingguardToolare skipped so Guard is not double-called. Inboundguard()beforechat()does not brand tools and does not skip this gate. Correlation is a caller-owned id from helper options orchat({ context }). Never mint. Neverctx.threadId. NevertraceId/requestId/streamId. Client tools and provider-native tools with no localexecuteare out of scope.import { launchArcjet, detectPromptInjection, tokenBucket } from "@arcjet/guard"; import { guardMiddleware, tanstackAiContext } from "@arcjet/guard/tanstack-ai/v0"; import { chat } from "@tanstack/ai"; const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! }); const limit = tokenBucket({ refillRate: 10, intervalSeconds: 60, maxTokens: 10, }); const appContext = { sessionId: conversationId }; const inbound = detectPromptInjection(); const decision = await arcjet.guard({ label: "message.received", rules: [inbound(userText)], ...tanstackAiContext({ context: appContext }), }); if (decision.conclusion === "DENY") { throw new Error("message blocked"); } if (decision.hasFailedOpen()) { throw new Error("inbound screening failed open"); } const stream = chat({ adapter, messages, tools: [lookupOrder], context: appContext, middleware: [ guardMiddleware(arcjet, { action: ({ toolName }) => `${toolName}.invoked`, rules: ({ toolName }) => [limit({ key: toolName, requested: 1 })], sessionId: conversationId, }), ], });
Screen inbound before chat() — there is no inbound hook.
TanStack AI has no first-class inbound channel, so there is no
guardInbound. Put prompt-injection (and other inbound rules) in the
application before chat(). Call guard() directly. guard() fails
open — callers must check hasFailedOpen(). TanStack's
contentGuardMiddleware redacts the stream; it is not this policy
gate.
needsApproval / defineInterrupt / onInterruptBoundary is HITL, not a policy gate.
needsApproval / defineInterrupt / onInterruptBoundary is
human-in-the-loop, not policy. 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, and LangChain humanInTheLoopMiddleware.
There is no guardApproval.
Deny inside guardMiddleware's onBeforeToolCall. There is no guardTool.
onBeforeToolCall is the deny point. Default DENY is
{ type: "skip", result: ArcjetDenialResult }. Optional
onDeny: "abort" returns { type: "abort", reason } (the denial
message string) and stops the run — the model does not get
ArcjetDenialResult. onDeny: "abort" applies to real DENY only;
unavailable stays skip. Do not throw from the hook. Put Arcjet
first — first-win composition means a preceding
toolCacheMiddleware skip skips Guard too. Sibling guardTool
brands are skipped; inbound guard() is a separate call and does
not skip this gate.
@arcjet/guard/google-adk/v2— Google ADK JS@google/adkRunner+BasePlugin.beforeToolCallbackintegration. ExportsguardPluginandgoogleAdkContext. This is not@google/genaiand not the Python google-adk SDK. There is noguardTool(skip is the plugin return, not throw-from-execute), noguardInbound(screen withguard()beforeRunner.runAsync;guard()fails open — checkhasFailedOpen()), and noguardApproval(requireConfirmation/requestConfirmation/SecurityPluginCONFIRM is human HITL, not policy). After a human yes, Guard still runs. Do not use ADKSecurityPluginas the Arcjet policy gate. Docs live at/guards/google-adk/.Put Arcjet first in
new Runner({ plugins }). PluginManager is first-win; if another plugin returns a value first, Guard never runs. DENY is a dictionary (ArcjetDenialResult) so ADK skipsrunAsyncand the model sees the payload.undefinedlets the tool execute. The callback does not throw — PluginManager treats a throw as a plugin error, not skip. On Guard error this helper fail-closes: it ALWAYS returns a deny dict, neverundefined(unlessonGuardError: "allow"). Tools already branded by a siblingguardToolare skipped so Guard is not double-called. Inboundguard()beforeRunner.runAsyncdoes not b
