npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

  1. Log in with the CLI:
    npx @arcjet/cli auth login
  2. Install versioned Agent Skills so your coding agent matches this SDK:
    npx @tanstack/intent@latest install
  3. Tell your agent what to protect — it handles the rest.

Manual setup

  1. Log in with the CLI (or at app.arcjet.com):
    npx @arcjet/cli auth login
  2. npm install @arcjet/guard
  3. Pass your key to launchArcjet({ key: process.env.ARCJET_KEY! })
  4. 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 milliseconds

flush() 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 global

guard(), 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 registered

unregisterArcjet() 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_INTEGER loses precision before it reaches the wire. Pass such values as strings.
  • BigInt cannot 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_RUN mode to observe behavior before switching to LIVE. 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 returned ALLOW only because a rule or the decision could not be processed, and inspect decision.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. Use decision.warnings for diagnostics and decision.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 bucket on 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. Exports guardTool and aiToolsContext for tool wrapping, alongside the helpers that are not tied to any SDK — createAgentContext, guardAction, captureAction, and securityMetadata:

    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. Exports guardTool, guardApproval, guardInbound, and arcjetHooks for Eve's four guard surfaces, alongside the eveAgentContext helper 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. Exports guardTool, guardHooks, and claudeAgentContext. There is no guardInbound (inbound is UserPromptSubmit on guardHooks) and no canUseTool helper (canUseTool is skipped by allowedTools, allow rules, and bypassPermissions / 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, beta managed-agents-2026-04-01). Exports guardEvents, guardCustomTool, and claudeManagedAgentsContext. 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, or guardTool. There is no guardInbound (inbound is guardEvents before sessions.events.send) and no confirmation helper (user.tool_confirmation / always_ask is HITL, not policy). Default always_allow cannot be gated: Anthropic-cloud bash/read/write and web_search / web_fetch run 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. Exports guardTool, guardProcessor, guardHooks, and mastraAgentContext. There is no guardInbound (channels already hit processInput) and no guardApproval (Mastra requireApproval is 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. Exports guardTool, guardToolNode, and langgraphAgentContext. This is not LangChain createAgent / wrapToolCall — that is @arcjet/guard/langchain/v1 — and createReactAgent is deprecated in LangGraph JS v1 — do not build on it. There is no guardInbound (screen before invoke or at the first graph node) and no guardInterrupt / guardApproval (interrupt() is human HITL, not policy).

    On DENY a guarded tool does not run and does not throw: it returns a structured ArcjetDenialResult, which ToolNode turns into a real ToolMessage the model reads. Because the tool did not throw, that message's status is success — the denial is in the payload (arcjetDenied: true), not the envelope. guardToolNode guards a ToolNode's tools in place and returns the same node, because ToolNode resolves 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 JS createAgent + createMiddleware({ wrapToolCall }) integration. Exports guardTool, guardMiddleware, and langchainContext. This is not LangGraph Graph API (StateGraph + ToolNode) — that is @arcjet/guard/langgraph/v1 — and not vercel-ai/v7. There is no guardInbound (screen before agent.invoke; SDK middleware that is not wrapToolCall is not Guard) and no guardApproval (humanInTheLoopMiddleware / interrupt() is human HITL, not policy). Policy sits on wrapToolCall only — do not deny in afterModel. 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. guardTool returns a plain ArcjetDenialResult; it does not throw and does not fabricate a ToolMessage. createAgent's baseHandler wraps a non-ToolMessage in a success ToolMessage. guardMiddleware wrapToolCall MUST return a real ToolMessage (content = JSON of the payload, tool_call_id = request.toolCall.id, name = request.toolCall.name). wrapToolCall's return is not passed through baseHandler; a bare object is the messages-reducer crash. Do not set status: "error". Do not throw (throws bubble and drop arcjetDenied). 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 text Agent + run() / Runner integration. Exports guardTool and openaiAgentsContext. This is not Realtime, Sandbox, hosted tools, computer / shell / apply_patch, MCP, or agent.asTool(). There is no guardInbound (screen before run(); SDK inputGuardrails are not Arcjet), no guardApproval (needsApproval is human HITL, not policy), and no guardHooks / guardToolNode (there is no ToolNode; hosted / MCP / handoffs skip authored execute).

    On DENY a guarded tool does not run and does not throw: it returns a structured ArcjetDenialResult. The runner stringifies that object onto a function_call_result with status: "completed" — the denial is in the payload (arcjetDenied: true), not a fabricated envelope. Throwing would hit the SDK errorFunction (a generic string, or ToolCallError when outputSchema / errorFunction: null). RunContext has no session / conversation id; put the id you already have on run(..., { 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 JS genkit() + ai.defineTool + ai.generate integration. Exports guardTool, guardMiddleware, and genkitContext. This is not Go / Python Genkit. There is no guardInbound (screen before generate() / chat.send(); middleware model is not Guard), no guardApproval (interrupt() / defineInterrupt / toolApproval is 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 ArcjetDenialResult as a completed toolResponse.output. interrupt() / ToolInterruptError is HITL — a denial is not finishReason: "interrupted". Wrapping the ToolAction (not the inner handler) is what keeps a denial off outputSchema validation, so a schema-mismatched ArcjetDenialResult still reaches the model. guardMiddleware is 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/sdk Agent + tool({ callback }) + Plugin / addHook integration. Exports guardTool, guardHooks, and strandsAgentContext. This is not the Python SDK. There is no guardInbound (screen before invoke() / stream()), no guardApproval / guardInterrupt (event.interrupt() is human HITL, not policy). Do not also wrap the same tool with @arcjet/guard/vercel-ai/v7 or @arcjet/guard/langgraph/v1.

    On DENY a guarded tool does not run and does not throw: it returns a plain ArcjetDenialResult from the authored callback. FunctionTool wraps that object in a JsonBlock. This helper does not fabricate a ToolResultBlock. guardHooks is 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 AI chat({ middleware }) + ChatMiddleware.onBeforeToolCall integration. Exports guardMiddleware and tanstackAiContext. This is not the Vercel AI SDK — do not also wrap with @arcjet/guard/vercel-ai/v7. There is no guardTool (a throw from execute is swallowed into { error } and is not a usable deny envelope), no guardInbound (screen with guard() before chat(); guard() fails open — check hasFailedOpen()), and no guardApproval (needsApproval / defineInterrupt / onInterruptBoundary is human HITL, not policy). After a human yes, Guard still runs. Do not name anything contentGuardMiddleware (TanStack already has that name). Docs live at /guards/tanstack-ai/.

    Put Arcjet first in the middleware array. onBeforeToolCall is first-win; if toolCacheMiddleware (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. Optional onDeny: "abort" stops the run with a reason string — the model does not get ArcjetDenialResult. The hook does not throw. Tools already branded by a sibling guardTool are skipped so Guard is not double-called. Inbound guard() before chat() does not brand tools and does not skip this gate. Correlation is a caller-owned id from helper options or chat({ context }). Never mint. Never ctx.threadId. Never traceId / requestId / streamId. Client tools and provider-native tools with no local execute are 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/adk Runner + BasePlugin.beforeToolCallback integration. Exports guardPlugin and googleAdkContext. This is not @google/genai and not the Python google-adk SDK. There is no guardTool (skip is the plugin return, not throw-from-execute), no guardInbound (screen with guard() before Runner.runAsync; guard() fails open — check hasFailedOpen()), and no guardApproval (requireConfirmation / requestConfirmation / SecurityPlugin CONFIRM is human HITL, not policy). After a human yes, Guard still runs. Do not use ADK SecurityPlugin as 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 skips runAsync and the model sees the payload. undefined lets 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, never undefined (unless onGuardError: "allow"). Tools already branded by a sibling guardTool are skipped so Guard is not double-called. Inbound guard() before Runner.runAsync does not b