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.
Install
Section titled “Install”./tanstack-ai/v0.
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! });Helpers
Section titled “Helpers”The integration exposes two surfaces:
guardMiddleware()is the chat-wide gate. Pass it first onchat({ middleware }). ItsonBeforeToolCallhook denies by returning{ type: "skip", result }with anArcjetDenialResult. The original tool never runs. The hook doesn’t throw. OptionalonDeny: "abort"returns{ type: "abort", reason }and stops the run. Skip is the default. A throw fromexecuteis swallowed, so there is noguardTool.tanstackAiContext()reads a caller-owned ID from helper options orchat({ context }):correlationId, thensessionId, thenconversationId, theninit.sessionIdorinit.correlationId. It never mints an ID. It never readsthreadId,requestId,streamId, ortraceId. TanStack generates athreadIdwhen 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().
Helper options
Section titled “Helper options”| Option | Helpers | Description |
|---|---|---|
action | guardMiddleware | Guard label and capture name. Use resource.verb in the past tense. A string, or a function of { toolName, input }. Defaults to tool.invoked. |
rules | guardMiddleware | SDK rules, or a function of { toolName, input }. Omit to submit none. The guard call still happens. |
metadata | guardMiddleware | Nested JSON, or a function of the same input as rules. |
sessionId | guardMiddleware | Caller-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 } }). |
onGuardError | guardMiddleware | "deny" (default) or "allow". |
onDeny | guardMiddleware | Omit 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.
Denial payload
Section titled “Denial payload”On DENY the original tool never runs. The payload shape is one
ArcjetDenialResult. The default envelope is an
onBeforeToolCall skip:
guardMiddlewarereturns{ type: "skip", result }whereresultis theArcjetDenialResult.- 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.
Run a guarded chat()
Section titled “Run a guarded chat()”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), ]; }, }), ], });}Screen user text before chat()
Section titled “Screen user text before chat()”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");}Human approval isn’t a policy gate
Section titled “Human approval isn’t a policy gate”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",});Correlation
Section titled “Correlation”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 })],});What not to use
Section titled “What not to use”- There is no
guardInbound. Screen prompt injection beforechat(). - There is no
guardTool. TanStack AI swallows anexecutethrow, so a tool wrapper is the wrong gate. - There is no
guardApproval.needsApproval,defineInterrupt, andonInterruptBoundaryare human HITL confirmation, not policy. - Don’t treat TanStack
contentGuardMiddlewareas 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
needsApprovalor a human approval pause. - Don’t mint a correlation ID. Don’t use
threadId,requestId,streamId, ortraceId. If the caller didn’t pass an ID, then leave the call uncorrelated. - Don’t put Arcjet after another
onBeforeToolCallmiddleware. The first skip wins. - Don’t treat
onDeny: "abort"as the default. Default DENY is skip. - Don’t throw from
onBeforeToolCallorexecuteto signal a denial. A throw fromexecuteis swallowed. - Don’t call
createAgentContextinside 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.
Common patterns
Section titled “Common patterns”Rate limit a tool by a trusted ID
Section titled “Rate limit a tool by a trusted ID”Key the bucket on a trusted identifier such as orderNumber. Don’t
key it on free-text user input.
const lookupLimit = tokenBucket({ bucket: "lookups", refillRate: 10, intervalSeconds: 60, maxTokens: 10,});
const 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), ]; }, }), ],});Fail-closed default
Section titled “Fail-closed default”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.