@tanstack/ai 0.8+. Pin the pre-1.0 peer and import the versioned adapter path, keep keys out of chat context, screen text before chat(), don't treat needsApproval or contentGuardMiddleware as a gate, put guardMiddleware first in the middleware array, deny by skipping rather than throwing from execute, correlate on an ID you own, and run the same scanners in CI that you run in the editor.What is the TanStack AI security guide?
Use this guide on TanStack AI (@tanstack/ai >=0.8.0 <1).
TanStack AI runs the agent loop in chat(). You define tools with toolDefinition().server(), pass them to chat({ tools }), and the model decides which to call. Prompt injection is when untrusted text – a pasted message, a retrieved document, a tool result from an earlier turn – steers that decision toward a side effect you never intended.
You need two separate controls. Screen inbound text before chat(), then gate every tool call inside the run. TanStack AI gives you one place for the second control: the onBeforeToolCall hook on a chat middleware.
Arcjet Guard is a runtime policy layer for AI agents. You define rules – prompt-injection detectors, rate limits, PII checks, allowlists – and Guard allows or denies inbound text and tool calls before side effects run. The examples below use Guard for both controls through @arcjet/guard/tanstack-ai/v0.
Two things this adapter is not. It isn't the Vercel AI SDK adapter – tool({ execute }) and guardTool from @arcjet/guard/vercel-ai/v7 are a different tool type and a different gate. And it isn't TanStack Start HTTP protect(): route protection never sees chat(), so a check on the request doesn't cover the tool call the model makes three turns later.
Work through these eight topics in order. Each one is a control you can verify, not a slogan.
- Patch and lock dependencies. Stay on
@tanstack/ai0.x and import the versioned adapter path. - Keep model keys off the client and out of chat context.
- Screen user text with a direct
guard()call beforechat(). - Don't treat
needsApproval,defineInterrupt, orcontentGuardMiddlewareas a policy gate. - Gate every tool call with
guardMiddleware, first in themiddlewarearray. - Deny by skipping. Never throw from
execute– TanStack AI swallows it. - Correlate on an ID you own. Never on a generated
threadId. - Catch issues in the editor and in CI before they ship.
For the Guard helpers used in the examples, see the TanStack AI agent guard.
How do you keep TanStack AI dependencies safe?
Apply patches on a schedule you actually keep, and out of band for a critical advisory. Commit a lockfile so every environment installs the same graph, and turn on Dependabot, Renovate, or an equivalent bot for npm. Run npm audit in CI and fail on known high-severity issues you haven't waived.
@tanstack/ai is an optional peer of @arcjet/guard (>=0.8.0 <1), not a dependency of it. If it's already in your project at that range, install @arcjet/guard on its own so your existing pins don't move. The integration requires Node.js 22 or later.
npm install @arcjet/guard @tanstack/aiImport from the versioned path. The version segment is the TanStack AI major, and there is no unversioned alias – @arcjet/guard/tanstack-ai does not resolve.
import { guardMiddleware, tanstackAiContext,} from "@arcjet/guard/tanstack-ai/v0";TanStack AI is pre-1.0, which is exactly why the pin matters. A 0.9 can move the middleware contract without a major-version signal, so pin the minor and treat an upgrade as a review event that re-checks the gate still fires. See trivial packages.
How should TanStack AI secrets be handled?
Model credentials and ARCJET_KEY belong on the server, loaded at process start. Launch one Arcjet client at module scope and reuse it.
import { launchArcjet } from "@arcjet/guard";
export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });Don't put credentials in chat({ context }). That object is for caller-owned identifiers – a conversation ID, a tenant ID – and it's read by middleware and helpers throughout the run.
TanStack AI is a client-and-server framework, which makes the boundary worth stating plainly: tools that run on the server go through .server(), and only those have a handler for a middleware to gate. Client tools and provider-native tools have no local execute, so they are out of scope for this gate entirely. Anything you would refuse to hand a browser must not be reachable from a client tool.
Don't log raw tool arguments or the raw prompt. Tool arguments are model-generated from user text. See storing secrets in environment variables and redacting sensitive data from logs.
How do you screen inbound TanStack AI text?
TanStack AI has no inbound hook, so there is no guardInbound on this adapter. Screen in application code before chat(), while the user's text is still a string you control.
Direct guard() fails open: if Guard can't be evaluated, the call returns ALLOW. An ALLOW on its own is therefore not proof the rules ran. Gate on decision.hasFailedOpen() when this call site must fail closed. On a 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");}Note the field name. A direct guard() call takes label. A framework wrapper such as guardMiddleware takes action for the same slug.
A clean inbound score does not authorize a tool call. Inbound screening decides whether the model sees the prompt; the tool gate decides whether the refund runs. See prompt injection detection.
Why aren't needsApproval and contentGuardMiddleware policy gates?
Two different things in TanStack AI look like they could carry policy. Neither does.
needsApproval, defineInterrupt, and onInterruptBoundary are human-in-the-loop confirmation. They pause a run and wait for a person. That's useful when someone must review context, and it isn't a policy gate: they ask, they don't decide, and nothing evaluates a rule. The same trap appears as LangChain humanInTheLoopMiddleware, Strands event.interrupt(), Genkit interrupt(), OpenAI needsApproval, Mastra requireApproval, and Claude canUseTool. There is no guardApproval on this adapter.
contentGuardMiddleware is TanStack's own content guard. It redacts or blocks streamed text on the way out. That is a different job from deciding whether a tool may run, and it sits after the decision that matters. Don't wire Arcjet into it and don't treat it as the Arcjet gate.
Policy sits on onBeforeToolCall only. See human approval is not a security policy and Mastra guardrails vs an action gate, which describes the same distinction in another framework.
How do you gate TanStack AI tool calls?
There is no guardTool on this adapter, and the reason is worth understanding rather than memorising: TanStack AI swallows a throw from execute. A tool wrapper that signals a denial by throwing produces a run that continues as though nothing happened. That makes the tool handler the wrong gate no matter how carefully you write it.
The gate is guardMiddleware, passed on chat({ middleware }). Its onBeforeToolCall hook denies by returning { type: "skip", result }, where result is an ArcjetDenialResult. The original tool never runs. The hook doesn't throw.
Put it first in the middleware array. onBeforeToolCall uses the first decision any middleware returns. If another middleware skips ahead of Arcjet, Guard never runs.
import { localDetectSensitiveInfo, tokenBucket } from "@arcjet/guard";import { guardMiddleware } from "@arcjet/guard/tanstack-ai/v0";import { chat, toolDefinition } from "@tanstack/ai";import { z } from "zod";import { arcjet } from "./arcjet.js";
const lookupLimit = tokenBucket({ bucket: "lookups", refillRate: 10, intervalSeconds: 60, maxTokens: 10,});const detectPii = localDetectSensitiveInfo({ deny: ["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],});
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",}));
const appContext = { sessionId: conversationId };
const stream = 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), ]; }, }), ],});guardMiddleware is chat-wide, so rules receives { toolName, input } for every tool the model calls. Branch on toolName and return [] for the tools you aren't scoring – the guard call still happens, which is what records the decision.
Re-parse input with the tool's own schema rather than trusting its shape. The model produced those arguments from user text.
Key the rate limit on a trusted identifier such as orderNumber, never on free-text the user wrote. Scan the free-text fields – a note, a reason, a body. An opaque orderNumber or tool-call ID won't trip email, phone, card, or IP detection. localDetectSensitiveInfo runs on a local model backend, so the text never leaves your process. See detecting and redacting PII in LLM inputs and outputs.
Tools already branded by a preceding guard call skip the middleware gate, so you don't get a double-charged decision on the same call.
Should a TanStack AI denial skip the tool or abort the run?
The default is skip. On DENY the middleware returns { type: "skip", result } and the model receives an ArcjetDenialResult it can read and respond to.
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,};Only rate-limit denials set retryable: true and carry retryAfterSeconds. Other reasons tell the model not to retry, which stops it looping against a decision that will never change.
Set onDeny: "abort" when you want a denied call to stop the whole run rather than let the model narrate around it:
guardMiddleware(arcjet, { sessionId: conversationId, onDeny: "abort",});Choose deliberately. Skip keeps the conversation usable and lets the model explain what it couldn't do, which is usually right for a rate limit. Abort is right when continuing would be misleading – a SENSITIVE_INFO denial on the one tool the turn depended on, for instance, where a model that keeps talking will invent an answer.
onDeny applies to a policy DENY. It does not apply to the Guard-unavailable path, which onGuardError controls separately. guardMiddleware defaults to onGuardError: "deny", so an unreachable Guard skips the tool instead of calling it. Set "allow" only where executing without a complete security decision is acceptable, such as a read-only lookup. Direct guard() still fails open regardless.
How do you correlate a TanStack AI chat?
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 deliberately ignores threadId, requestId, streamId, and traceId. TanStack generates a threadId when you omit one, and correlating on a generated value produces a Sequence that looks joined but groups unrelated runs. If you didn't pass an ID, the call is uncorrelated – which is the honest outcome. Don't derive one from a generated value to fill the gap.
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 })],});A run that pauses on needsApproval, defineInterrupt, or onInterruptBoundary resumes through a later chat(). Pass the same caller-owned ID on that resume call, or the second half of the conversation detaches from the first. The interrupt and its resume value are not correlation sources.
Don't call createAgentContext inside a TanStack AI callback.
How does the editor and CI catch TanStack AI security bugs?
Turn on TypeScript strict, ESLint, and secret scanning in the editor. Trunk, Semgrep, TruffleHog, and Gitleaks all have editor plugins and CI jobs. Run the same scanners in CI that you run locally – an editor warning that isn't a CI failure gets ignored.
Fail the build on secrets in the diff and on known vulnerable dependencies. TypeScript catches an unversioned @arcjet/guard/tanstack-ai import, label passed to guardMiddleware, and a rules callback that destructures input without parsing it.
Two things automation won't catch. Nothing type-checks that guardMiddleware sits first in middleware, and nothing warns when a tool is defined as a client tool rather than a .server() tool – which quietly moves it outside the gate. Both are review items.
How do you verify the gate actually fires?
A missing decision is not a denial. If the model asks a clarifying question instead of calling the guarded tool, nothing is sent, no guard call happens, and no decision is returned. From the outside that is indistinguishable from a working gate.
This matters more here than in most frameworks, because the failure you're ruling out – a swallowed throw from execute – also produces silence. Read the decision in the Arcjet Console or your logs rather than concluding from the absence of a side effect.
Give a test agent a system prompt that tells it to complete the request without follow-up questions, and to quote retrieved values verbatim. A model that masks a card number itself leaves the rule nothing to detect, and Guard then correctly allows. See functional testing of security rules.
What do you do after this guide?
Confirm each item against one sensitive chat: a prompt that asks for a refund, a server tool that takes a free-text note, and a second middleware in the array to prove ordering behaves as you expect. Verify a denial reaches the model as a skip rather than vanishing.
Then apply AI agent runtime security for the sequence, budget, and identity controls that aren't TanStack-specific, and enforce token spend budgets for the cost side of an agent loop.
Frequently asked questions
What is the TanStack AI security guide?
Use this eight-item guide on @tanstack/ai 0.8+. Pin the pre-1.0 peer and import the versioned adapter path, keep keys out of chat context, screen text before chat(), don't treat needsApproval or contentGuardMiddleware as a gate, put guardMiddleware first in the middleware array, deny by skipping rather than throwing from execute, correlate on an ID you own, and run the same scanners in CI that you run in the editor.
Is there a guardTool for TanStack AI?
No. TanStack AI swallows a throw from execute, so a tool wrapper that signals a denial by throwing lets the run continue. Policy sits on onBeforeToolCall through guardMiddleware.
Is contentGuardMiddleware the Arcjet gate?
No. That is TanStack's own content guard, which redacts or blocks streamed text after the decision that matters. Policy sits on onBeforeToolCall only.
Should I use skip or abort on a denial?
Skip is the default and keeps the conversation usable, which suits a rate limit. Set onDeny: "abort" when continuing would mislead, such as a denial on the one tool the turn depended on.
Can I correlate on the TanStack threadId?
No. tanstackAiContext reads correlationId, then sessionId, then conversationId, then init.sessionId or init.correlationId. It ignores threadId, requestId, streamId, and traceId. Without a caller-owned ID the call is uncorrelated.
AI runtime security in your code
Protect your AI agent workflows with Arcjet
Arcjet guards run inside the tool, so the allow or deny arrives before the side effect rather than after it.