Skip to content

TanStack AI agent guard

TanStack AI chat() workflows call authored tools from the agent loop. Arcjet Guard sits at those boundaries so a policy can allow or deny the action before a side effect runs.

What is Arcjet? Arcjet is the runtime security platform that ships with your code. Enforce budgets, stop prompt injection, detect bots, and protect personal information with Arcjet's AI security building blocks.

Use protect() on HTTP routes. Use the helpers on this page for agent tools and other actions that have no HTTP request.

You need an Arcjet account and an ARCJET_KEY. Launch one client at module scope and reuse it.

Framework wrappers take an action string such as email.sent. That slug selects the matching remote policy and names the event in the Arcjet Console. Direct guard() calls use the field name label for the same slug. Don’t pass label to a wrapper such as guardTool().

You can submit SDK rules in code, rely on a published remote policy, or combine both. For more information about the decision model, see Agent guards.

Vercel AI SDK, LangChain, CrewAI, Eve, Mastra, LangGraph, OpenAI Agents, Genkit, Strands Agents, and Claude wrappers are on Framework integrations.

This adapter is TanStack AI chat({ middleware }) plus onBeforeToolCall skip. It isn’t Vercel AI SDK (@arcjet/guard/vercel-ai/v7). It isn’t TanStack Start HTTP protect(). TanStack’s own contentGuardMiddleware is TanStack’s content guard, not Arcjet Guard. Don’t use it as the Arcjet gate. There is no guardTool. TanStack AI swallows an execute throw, so a tool wrapper is the wrong gate. Don’t also wrap these tools with @arcjet/guard/vercel-ai/v7.

./tanstack-ai/v0.

Terminal window
npm install @arcjet/guard @tanstack/ai

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

Import helpers from the versioned path @arcjet/guard/tanstack-ai/v0. There is no unversioned alias. @arcjet/guard/tanstack-ai doesn’t resolve. The version segment is the TanStack AI SDK major. @tanstack/ai (>=0.8.0 <1) is an optional peer. The integration requires Node.js 22 or later.

Launch one client at module scope:

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

The integration exposes two surfaces:

  • guardMiddleware() is the chat-wide gate. Pass it first on chat({ middleware }). Its onBeforeToolCall hook denies by returning { type: "skip", result } with an ArcjetDenialResult. The original tool never runs. The hook doesn’t throw. Optional onDeny: "abort" returns { type: "abort", reason } and stops the run. Skip is the default. A throw from execute is swallowed, so there is no guardTool.
  • tanstackAiContext() reads a caller-owned ID from helper options or chat({ context }): correlationId, then sessionId, then conversationId, then init.sessionId or init.correlationId. It never mints an ID. It never reads threadId, requestId, streamId, or traceId. TanStack generates a threadId when you omit one; that value isn’t a correlation source. If the caller didn’t pass an ID, then the call is uncorrelated rather than joined to a generated ID.

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

Don’t call createAgentContext inside a TanStack AI callback. Don’t also wrap these tools with @arcjet/guard/vercel-ai/v7. Don’t use this adapter for TanStack Start HTTP protect().

OptionHelpersDescription
actionguardMiddlewareGuard label and capture name. Use resource.verb in the past tense. A string, or a function of { toolName, input }. Defaults to tool.invoked.
rulesguardMiddlewareSDK rules, or a function of { toolName, input }. Omit to submit none. The guard call still happens.
metadataguardMiddlewareNested JSON, or a function of the same input as rules.
sessionIdguardMiddlewareCaller-owned fallback when chat({ context }) doesn’t carry one. A string, or a function of the same input as rules. Prefer putting the ID on chat({ context: { sessionId } }).
onGuardErrorguardMiddleware"deny" (default) or "allow".
onDenyguardMiddlewareOmit for the default skip. Set "abort" to stop the chat run with { type: "abort", reason }.

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

On DENY the original tool never runs. The payload shape is one ArcjetDenialResult. The default envelope is an onBeforeToolCall skip:

  • guardMiddleware returns { type: "skip", result } where result is the ArcjetDenialResult.
  • Optional onDeny: "abort" returns { type: "abort", reason } and stops the chat run. Skip is the default.

It isn’t a throw. It isn’t needsApproval. It isn’t defineInterrupt or onInterruptBoundary. A throw from execute is swallowed.

import type { ArcjetDenialResult } from "@arcjet/guard/tanstack-ai/v0";
const denial: ArcjetDenialResult = {
arcjetDenied: true,
reason: "RATE_LIMIT", // or PROMPT_INJECTION, SENSITIVE_INFO, ERROR
message:
"Arcjet denied this call (RATE_LIMIT). It may be retried after 30 seconds.",
retryable: true,
retryAfterSeconds: 30,
};

ArcjetDenialResult is a type export from @arcjet/guard/tanstack-ai/v0 at the pin in Install.

Only rate-limit denials set retryable: true and include retryAfterSeconds. Other reasons tell the model not to retry.

When Guard is unavailable and onGuardError is "deny", the model receives reason: "ERROR", retryable: true, and retryAfterSeconds: 5.

Screen inbound text and pass guardMiddleware on chat({ middleware }):

import {
launchArcjet,
detectPromptInjection,
localDetectSensitiveInfo,
tokenBucket,
} from "@arcjet/guard";
import {
guardMiddleware,
tanstackAiContext,
} from "@arcjet/guard/tanstack-ai/v0";
import { chat, toolDefinition } from "@tanstack/ai";
import { z } from "zod";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
const detectPii = localDetectSensitiveInfo({
deny: ["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],
});
const inbound = detectPromptInjection();
const lookupOrderInput = z.object({
orderNumber: z.string(),
note: z.string(),
});
const lookupOrder = toolDefinition({
name: "lookup_order",
description: "Look up an order by number",
inputSchema: lookupOrderInput,
}).server(({ orderNumber, note }) => ({
orderNumber,
note,
status: "shipped",
}));
export async function runAgent(
conversationId: string,
userText: string,
adapter: object,
) {
const appContext = { sessionId: conversationId };
const decision = await arcjet.guard({
label: "message.received",
rules: [inbound(userText)],
...tanstackAiContext({ context: appContext }),
});
if (decision.conclusion === "DENY" || decision.hasFailedOpen()) {
throw new Error("message blocked");
}
return chat({
adapter,
messages: [{ role: "user", content: userText }],
tools: [lookupOrder],
context: appContext,
middleware: [
guardMiddleware(arcjet, {
action: "order.looked-up",
sessionId: conversationId,
onGuardError: "deny",
rules: ({ toolName, input }) => {
if (toolName !== "lookup_order") {
return [];
}
const { orderNumber, note } = lookupOrderInput.parse(input);
return [
lookupLimit({ key: orderNumber, requested: 1 }),
detectPii(note),
];
},
}),
],
});
}

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

TanStack contentGuardMiddleware isn’t Arcjet Guard. It redacts or blocks streamed text. Policy sits on onBeforeToolCall skip only.

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

On DENY, don’t call chat().

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

needsApproval, defineInterrupt, and onInterruptBoundary are human-in-the-loop (HITL) confirmation. They aren’t a policy gate. Same trap as LangChain humanInTheLoopMiddleware, Strands event.interrupt(), Genkit interrupt(), OpenAI needsApproval, Mastra requireApproval, and Claude canUseTool. There is no guardApproval. Don’t wrap HITL as Guard. Don’t deny by pausing for a human. Policy sits on onBeforeToolCall skip only.

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

Gate every tool call with the chat middleware

Section titled “Gate every tool call with the chat middleware”

guardMiddleware is the chat({ middleware }) gate. Put it first in the middleware array. onBeforeToolCall uses the first decision that a middleware returns. If another middleware skips first, then Guard never runs.

The hook denies by returning { type: "skip", result } without calling the tool. result is the ArcjetDenialResult. Optional onDeny: "abort" stops the run instead. Skip is the default. Policy sits on onBeforeToolCall only.

There is no guardTool. TanStack AI swallows an execute throw, so wrapping the tool handler is the wrong gate.

Client tools and provider-native tools with no local execute are out of scope. Already-branded tools skip the middleware gate so a preceding guard() isn’t double-called.

This isn’t Vercel AI SDK tool({ execute }). Don’t pass guardTool from @arcjet/guard/vercel-ai/v7 to TanStack AI. Don’t pass TanStack contentGuardMiddleware as the Arcjet gate.

import { chat } from "@tanstack/ai";
import { guardMiddleware } from "@arcjet/guard/tanstack-ai/v0";
import { arcjet } from "./arcjet.js";
const appContext = { sessionId: conversationId };
const stream = chat({
adapter,
messages: [{ role: "user", content: userText }],
tools: [lookupOrder],
context: appContext,
middleware: [guardMiddleware(arcjet, { sessionId: conversationId })],
});

To stop the run instead of skipping the tool, set onDeny: "abort":

guardMiddleware(arcjet, {
sessionId: conversationId,
onDeny: "abort",
});

tanstackAiContext reads a caller-owned ID from chat({ context }) or a bare app object: correlationId first, then sessionId, then conversationId, then init.sessionId or init.correlationId. It never mints an ID. It never reads threadId, requestId, streamId, or traceId. TanStack generates a threadId when you omit one. That auto-generated value isn’t a correlation source. Don’t derive an ID from it. Don’t mint one. If the caller didn’t pass an ID, then the call is uncorrelated rather than joined to a generated ID.

A run that pauses on needsApproval, defineInterrupt, or onInterruptBoundary resumes through a later chat(). Pass the same caller-owned ID on that resume call so later Guard decisions stay on the Sequence that started it. The interrupt and its resume value aren’t correlation sources.

Put the same ID on chat({ context }) and as sessionId on guardMiddleware.

const appContext = { sessionId: conversationId };
await arcjet.guard({
label: "message.received",
...tanstackAiContext({ context: appContext }),
});
await chat({
adapter,
messages: [{ role: "user", content: userText }],
context: appContext,
middleware: [guardMiddleware(arcjet, { sessionId: conversationId })],
});
  • There is no guardInbound. Screen prompt injection before chat().
  • There is no guardTool. TanStack AI swallows an execute throw, so a tool wrapper is the wrong gate.
  • There is no guardApproval. needsApproval, defineInterrupt, and onInterruptBoundary are human HITL confirmation, not policy.
  • Don’t treat TanStack contentGuardMiddleware as Guard. That is TanStack’s content guard, not Arcjet Guard.
  • Don’t treat this adapter as Vercel AI SDK (@arcjet/guard/vercel-ai/v7).
  • Don’t treat this adapter as TanStack Start HTTP protect().
  • Don’t turn a deny into needsApproval or a human approval pause.
  • Don’t mint a correlation ID. Don’t use threadId, requestId, streamId, or traceId. If the caller didn’t pass an ID, then leave the call uncorrelated.
  • Don’t put Arcjet after another onBeforeToolCall middleware. The first skip wins.
  • Don’t treat onDeny: "abort" as the default. Default DENY is skip.
  • Don’t throw from onBeforeToolCall or execute to signal a denial. A throw from execute is swallowed.
  • Don’t call createAgentContext inside a TanStack AI callback.
  • Don’t also wrap these tools with @arcjet/guard/vercel-ai/v7.
  • Don’t import @arcjet/guard/tanstack-ai. The path is @arcjet/guard/tanstack-ai/v0.

Key the bucket on a trusted identifier such as orderNumber. Don’t key it on free-text user input.

const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
const stream = chat({
adapter,
messages: [{ role: "user", content: userText }],
tools: [lookupOrder],
middleware: [
guardMiddleware(arcjet, {
action: "order.looked-up",
rules: ({ toolName, input }) => {
if (toolName !== "lookup_order") {
return [];
}
const { orderNumber } = z
.object({ orderNumber: z.string() })
.parse(input);
return [lookupLimit({ key: orderNumber, requested: 1 })];
},
}),
],
});

Scan a free-text note for sensitive information

Section titled “Scan a free-text note for sensitive information”

Scan a note, reason, or body. An opaque orderNumber doesn’t trip email, phone, card, or IP detection, so don’t pass it to localDetectSensitiveInfo.

import { localDetectSensitiveInfo, tokenBucket } from "@arcjet/guard";
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
const detectPii = localDetectSensitiveInfo({
deny: ["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],
});
const stream = chat({
adapter,
messages: [{ role: "user", content: userText }],
tools: [lookupOrder],
middleware: [
guardMiddleware(arcjet, {
action: "order.looked-up",
rules: ({ toolName, input }) => {
if (toolName !== "lookup_order") {
return [];
}
const { orderNumber, note } = z
.object({ orderNumber: z.string(), note: z.string() })
.parse(input);
return [
lookupLimit({ key: orderNumber, requested: 1 }),
detectPii(note),
];
},
}),
],
});

guardMiddleware defaults to onGuardError: "deny". If Guard cannot be evaluated, then onBeforeToolCall skips the tool instead of calling it. onDeny: "abort" applies to a policy DENY, not to that unavailable path.

Set onGuardError: "allow" only when executing without a complete security decision is acceptable, such as a read-only lookup. Direct guard() still fails open. For more information about fail-open versus fail-closed behavior, see Availability and fail behavior.