Integrate Arcjet Guard into TanStack AI
@arcjet/guard's TanStack AI v0 namespace wraps the agent's existing Arcjet
client. It never talks to the Arcjet API itself. Two surfaces, one
decision rule:
- Tool calls →
guardMiddleware(). Achat({ middleware })middleware whoseonBeforeToolCallis the chat()-wide gate. Default DENY is{ type: "skip", result: ArcjetDenialResult }so the tool never runs and the model sees the payload. OptionalonDeny: "abort"returns{ type: "abort", reason }and stops the run — the model does not getArcjetDenialResult. Do not throw from the hook. Tools already branded by a siblingguardToolare skipped so Guard is not double-called. Inboundguard()beforechat()does not brand tools and does not skip this gate. - Correlation →
tanstackAiContext()reads a caller-owned id from the helper options orchat({ context }). It never mints a new id. It never readsctx.threadId(TanStack auto-generates it). It never readstraceId/requestId/streamId.
There is no guardTool. A throw from execute is swallowed into
{ error } and is not a usable deny envelope. There is no
guardInbound, no guardApproval, and nothing named
contentGuardMiddleware (TanStack already has that name).
This namespace is TanStack AI chat({ middleware }) +
onBeforeToolCall. Not the Vercel AI SDK. Do not also wrap with
@arcjet/guard/vercel-ai/v7 or @arcjet/guard/claude-managed-agents/v0.
Client tools and provider-native tools with no local execute are out
of scope.
Docs live at
docs.arcjet.com/guards/tanstack-ai/.
Do not overwrite any other /guards/... slug.
Put Arcjet first in the middleware array. onBeforeToolCall is
first-win; if toolCacheMiddleware (or anything else) skips first,
Guard never runs.
Screen inbound before chat() — there is no inbound hook.
There is 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. 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, LangChain humanInTheLoopMiddleware, and Google ADK
requireConfirmation. There is no
guardApproval.
Questions to ask the human first
Ask only what you cannot infer from the code; suggest defaults.
- Which tools are risky (external side effects, irreversible, spends
money, sends messages)? Those are gated by
guardMiddleware. - What limits? (e.g. "10 lookups/min per order" →
tokenBucket.) - Who is the user for metadata — an opaque user/tenant ID (never PII)?
Default: none. Pass it via
metadataon the policy. Put the conversation / session id you already have onchat({ context: { sessionId } }). That id is the correlation id. Do not usectx.threadId. - Is an Arcjet outage unacceptable? Every helper defaults to
onGuardError: "deny". Ask explicitly about inbound screening beforechat(): failing closed there means the chat does not run for the duration of the outage, so"allow"is a routine and legitimate choice at that one call site.guard()itself still fails open — checkhasFailedOpen().
The six things readers get wrong
- There is no
guardInbound. Screen prompt injection beforechat()withguard(). CheckhasFailedOpen().contentGuardMiddlewareis not Guard. needsApproval/defineInterrupt/onInterruptBoundaryis not a policy gate. It is HITL. After a human yes, Guard still runs.- The import path is versioned and there is no alias.
@arcjet/guard/tanstack-ai/v0.@arcjet/guard/tanstack-aidoes not resolve. There is no/v1until TanStack AI ships 1.x. Docs are/guards/tanstack-ai/. - Correlation is read, never minted. Do not call
createAgentContextinside a middleware callback — that generates a second id and splits the Sequence. Put the id you already chose onchat({ context }). Do not readctx.threadId,traceId,requestId, orstreamId. - Put Arcjet first.
onBeforeToolCallis first-win. IftoolCacheMiddlewareskips first, Guard never runs. - Do not add
guardTooland do not double-wrap with@arcjet/guard/vercel-ai/v7. TanStack AI is not the Vercel AI SDK. A throw fromexecutebecomes{ error }.
Step 1: Install and find the guard client
Install @arcjet/guard (required), plus @tanstack/ai (optional
peer, needed for @arcjet/guard/tanstack-ai/v0). Always use the
versioned path: @arcjet/guard/tanstack-ai/v0 resolves;
@arcjet/guard/tanstack-ai throws ERR_PACKAGE_PATH_NOT_EXPORTED.
The peer range is >=0.8.0 <1. Node 22+ — do not bump Node for this
adapter.
npm install @arcjet/guard @tanstack/ai
If the agent has no guard client yet, launch one once at module scope:
import { launchArcjet } from "@arcjet/guard";
export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
Step 2: Gate tool calls — Arcjet first
import { chat } from "@tanstack/ai";
import { toolCacheMiddleware } from "@tanstack/ai/middlewares";
import { guardMiddleware } from "@arcjet/guard/tanstack-ai/v0";
import { tokenBucket, localDetectSensitiveInfo, policyInput } from "@arcjet/guard";
import { arcjet } from "./arcjet.js";
const lookupLimit = tokenBucket({
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
const detectPii = localDetectSensitiveInfo();
const stream = chat({
adapter,
messages,
tools: [lookupOrder, ...mcpTools],
context: { sessionId: conversationId },
middleware: [
guardMiddleware(arcjet, {
action: ({ toolName }) => `${toolName}.invoked`,
actor: conversationId,
inputs: ({ toolName }) => ({
tool: policyInput.server.string(toolName),
}),
rules: ({ toolName, input }) => {
const note =
typeof input === "object" && input !== null && "note" in input
? String((input as { note?: unknown }).note ?? "")
: "";
return [
lookupLimit({ key: toolName, requested: 1 }),
...(note.length > 0 ? [detectPii(note)] : []),
];
},
sessionId: conversationId,
}),
toolCacheMiddleware(),
],
});
- Omit
rulesto submit none. The guard call still happens. - Optional
actorandinputs(static, or a resolver over this adapter's native call — parsed input plus trusted runtime/context) are forwarded on the guard call so a remote policy that declares those names can evaluate. Build each input withpolicyInput. - On DENY the original
executenever runs. Default delivery is{ type: "skip", result: { arcjetDenied: true, reason, message, retryable } }. onDeny: "abort"stops the chat run with{ type: "abort", reason }(the denialmessagestring). The model does not getArcjetDenialResult. Prefer default skip when the model should see the payload.onDeny: "abort"applies to real DENY only; unavailable stays skip.- Default
onGuardError: "deny"blocks the tool if Arcjet is unreachable. - ALLOW captures
outcome: "success"when the policy lets the tool run, not whenexecutefinishes.onBeforeToolCallcannot wrap the tool; a later tool throw does not flip that capture. - Tools already branded by a sibling
guardToolskip the middleware so Guard is not double-called. This namespace has noguardTool. Inboundguard()beforechat()does not stamp that brand.
Step 3: Screen inbound before chat
import { detectPromptInjection } from "@arcjet/guard";
import { tanstackAiContext } from "@arcjet/guard/tanstack-ai/v0";
import { arcjet } from "./arcjet.js";
const inbound = detectPromptInjection();
const decision = await arcjet.guard({
label: "message.received",
rules: [inbound(userText)],
...tanstackAiContext({ context: { sessionId: conversationId } }),
});
if (decision.conclusion === "DENY") {
throw new Error("message blocked");
}
if (decision.hasFailedOpen()) {
throw new Error("inbound screening failed open");
}
await chat({
adapter,
messages: [{ role: "user", content: userText }],
context: { sessionId: conversationId },
middleware: [guardMiddleware(arcjet, { sessionId: conversationId })],
});
There is no guardInbound. guard() fails open — always check
hasFailedOpen().
Step 4: Correlation
Put the id you already have on chat({ context }):
await chat({
adapter,
messages,
context: { sessionId: conversationId },
middleware: [guardMiddleware(arcjet, { sessionId: conversationId })],
});
Preference order: context.correlationId, then context.sessionId,
then context.conversationId, then init.sessionId /
init.correlationId. If none is a valid 1–256 printable-ASCII
string, the call is uncorrelated rather than joined to a generated
id nobody has.
Pass a caller-owned bag as tanstackAiContext({ context: appContext })
or chat({ context: appContext }). A bare object that also has
string requestId and streamId looks like TanStack's middleware
envelope, so top-level sessionId on that object is ignored.
Never mint a new id. Never read ctx.threadId (TanStack
auto-generates it). Never read traceId / requestId / streamId.
needsApproval resumes after a human yes — Guard still runs on the
tool call. Do not treat the interrupt or its resume value as
correlation.
Verify the integration
npm run typecheckpasses.- Exercise inbound PI (before
chat(), includinghasFailedOpen()), a middleware skip-deny, an abort-deny, abort+unavailable still skip, first-win ordering (Arcjet beforetoolCacheMiddleware), no-throw, never-mint, and fail-closed (an unreachable guard). Confirm the denial is{ type: "skip", result }(or abort) and the run is not an interrupt. - Confirm in the Arcjet dashboard that decisions share the
caller-owned session id as their correlation id — not
threadId. - Manual E2E with a real
ARCJET_KEYis still-to-verify until you run it.
A full working demo will land in
arcjet/examples tanstack-agent
as a later follow-up. Do not add an example under examples/ in the
JS SDK repo.
Note: capture events are fire-and-forget and batched, so events can lag the decisions they accompany by a few seconds. A dropped event is diagnosed, never thrown.