Integrate Arcjet Guard into Cloudflare Think
@arcjet/guard/cloudflare-think/v0 wraps the agent's existing Arcjet
client. It never talks to the Arcjet API itself. Shared Guard
fundamentals (client, rules, labels, decisions, capture, registration)
live in
../arcjet/references/guards_javascript.md.
Load that reference for anything that is not Think-specific.
Official @cloudflare/think >=0.3.0 <1 only — that is the first
release whose beforeToolCall returns a functional ToolCallDecision
(allow / block / substitute) and wraps every server-side tool's
execute so the hook runs first. There is no /v1 until Think ships
1.x. This is not the Vercel AI SDK (ai /
@arcjet/guard/vercel-ai/v7). Think owns streamText internally; do
not also wrap its tools with the AI SDK mix-in. Not TanStack Start HTTP
protect(). Not Cloudflare Workers HTTP protect() (@arcjet/node /
request-based).
Exports: guardHooks, cloudflareThinkContext. There is no
guardTool and no guardThink mixin. Skip is the
beforeToolCall return, not throw-from-execute. There is no
guardInbound and no guardApproval. No unversioned
@arcjet/guard/cloudflare-think alias.
Two surfaces, one decision rule:
- Tool calls →
guardHooks. Returns a{ beforeToolCall }object theThinksubclass delegates to. Delivery is a ThinkToolCallDecision: substitute ({ action: "substitute", output: ArcjetDenialResult }) so the model sees the payload, or block ({ action: "block", reason }) so the model sees a reason string.void/{ action: "allow" }executes. Do not throw — a throw is a raw exception /onChatError, not a denial. - Correlation →
cloudflareThinkContextreads a caller-owned id. It never mints. It never reads Durable Object ids,toolCallId,requestId, ortraceId.
Docs: https://docs.arcjet.com/guards/cloudflare-think/
Do not invent a second slug and do not overwrite any other
/guards/... page. Example (the main tree 404s; no example PR
is open, so cite this branch until
examples/cloudflare-think-agent exists on main):
examples/cloudflare-think-agent
(no verify fixture). Do not invent a second example name.
The gate is beforeToolCall block / substitute
Think wraps every server-side tool's execute and consults
beforeToolCall first (subclass hook, then extensions). Returning
block or substitute skips execute. Fail closed: always return
one of those on error — do not return void (that executes the
tool) and do not throw. Default DENY is substitute with
ArcjetDenialResult so the model sees { arcjetDenied: true, … }.
onDeny: "block" uses { action: "block", reason } instead and
drops the structured fields. onDeny: "block" applies to real
DENY only; unavailable still fail-closes with substitute.
Core guard() still fails open (hasFailedOpen()).
Client tools (no server execute) never enter this hook. Workspace
tools that Think auto-merges do, if they have execute.
needsApproval is not a policy gate
AI SDK needsApproval / confirmation-style tools / human resume
are human-in-the-loop. After a human yes, Guard still runs on the
tool call. Same trap as TanStack needsApproval, Mastra
requireApproval, Claude canUseTool, LangGraph interrupt(),
OpenAI Agents needsApproval, Genkit interrupt(), and Google ADK
requireConfirmation. There is no guardApproval.
Screen inbound before chat() / submitMessages()
There is no guardInbound. beforeTurn / beforeStep tune the
model call; they are not this policy gate. Call arcjet.guard() in
the application (or at the start of beforeTurn and act on the
decision) before the turn starts. Core guard() fails open:
ALLOW is not proof the rules ran. Gate on
decision.hasFailedOpen() if this call site must fail closed;
guardHooks already defaults to that.
actor / inputs — omit and remote rules never fire
A remote policy that declares actor or typed inputs only
evaluates those values when this call sends them. Pass actor and
inputs on guardHooks (and on inbound arcjet.guard()). Import
policyInput from @arcjet/guard, not from the adapter path.
Check installed types — do not pass fields a helper does not
declare.
- Omit
actor/inputsand a remote policy that requires them never fires. The guard call still happens; the remote rules just have nothing to read. - A resolver that throws fail-closes under the default
onGuardError: "deny"(unavailable substitute — nevervoid). actoris the authenticated caller, never a model-produced tool argument.inputon the Think envelope is model-controlled. Deriveactorfrom authenticated server-side context (a signed session, verified token, or Durable Object state you already trust) — not fromctx.input.
rules / action / metadata see { toolName, input }. actor
/ inputs resolvers also receive the Think beforeToolCall
context as the second argument.
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
guardHooks. Client tools with noexecuteare out of scope. - What limits? (e.g. "10 lookups/min per user" →
tokenBucket.) - Who is the user for metadata — an opaque user/tenant ID
(never PII)? Default: none. That same authenticated id is
actor. Put the conversation / session id you already have onguardHooks({ sessionId }). That id is the correlation id, not the user. Do not use the Durable Object id ortoolCallId. - Is an Arcjet outage unacceptable? Every helper defaults to
onGuardError: "deny". Ask explicitly about inbound screening before the turn: failing closed there means the agent does not answer, so"allow"is a routine and legitimate choice at that one call site.
The things readers get wrong
- This is not the Vercel AI SDK. Do not import
@arcjet/guard/vercel-ai/v7and do not wrap Think tools withguardToolfrom that namespace. Think already ownsstreamText. - There is no
guardInbound. Screen with coreguard()beforechat()/submitMessages(). needsApprovalis HITL, not policy.- Fail closed = always return
blockorsubstituteon error.voidexecutes the tool. A throw is not a denial. Unavailable fail-closes with substitute, even whenonDeny: "block". - Default DENY is substitute (structured
ArcjetDenialResult).blockis the reason-string path and drops the fields. It applies to real DENY only. guardHooksis not a mixin. DelegatebeforeToolCalltohooks.beforeToolCall(ctx). There is noguardThink(Think, …).- Correlation is read, never minted. Never Durable Object id,
never
toolCallId, neverrequestId, nevertraceId. - The import path is versioned.
@arcjet/guard/cloudflare-think/v0resolves;@arcjet/guard/cloudflare-thinkdoes not. - Key rate limits on the authenticated caller, not a
model-supplied order id. Same for
actor. - Omit
actor/inputsand remote rules never fire. A resolver throw fail-closes. ImportpolicyInputfrom@arcjet/guard. - Do not hand-wrap every Think tool with raw
guard().
Step 1: Install and find the guard client
Install published @arcjet/guard and the @cloudflare/think peer.
Use the package manager the project already uses — do not pin a
git SHA:
npm install @arcjet/guard @cloudflare/think
pnpm add @arcjet/guard @cloudflare/think
Peer range is @cloudflare/think >=0.3.0 <1. The versioned path
@arcjet/guard/cloudflare-think/v0 resolves; the unversioned
@arcjet/guard/cloudflare-think path does not. 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 — delegate beforeToolCall
Pass the current conversation's authenticated caller into
guardHooks. A Durable Object is often one-per-conversation — still
do not use this.ctx.id / this.name as actor or correlation.
import { Think } from "@cloudflare/think";
import { launchArcjet, policyInput, tokenBucket } from "@arcjet/guard";
import { guardHooks } from "@arcjet/guard/cloudflare-think/v0";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
// From auth middleware or Durable Object state you already trust.
// Not this.ctx.id / this.name, and not a model-produced argument.
const userId = authenticatedUserId;
const conversationId = authenticatedConversationId;
const hooks = guardHooks(arcjet, {
action: ({ toolName }) => `${toolName}.invoked`,
// Authenticated caller — never a model-produced order id / ctx.input.
actor: userId,
inputs: ({ toolName }) => ({
tool: policyInput.server.string(toolName),
}),
// Keyed on the authenticated caller, not the model-supplied order id.
rules: () => [lookupLimit({ key: userId, requested: 1 })],
sessionId: conversationId,
onGuardError: "deny",
// default DENY is substitute (ArcjetDenialResult). onDeny: "block"
// uses { action: "block", reason } on real DENY only. Unavailable
// still substitutes.
});
export class SupportAgent extends Think<Env> {
// needsApproval on a tool is HITL — not this policy gate
override beforeToolCall = hooks.beforeToolCall;
}
If the subclass already implements beforeToolCall, call
hooks.beforeToolCall(ctx) and return its ToolCallDecision. Do
not void past a DENY and do not also wrap with
@arcjet/guard/vercel-ai/v7.
Omit rules to submit none — the guard call still happens. ALLOW
captures outcome: "success" when the policy lets the tool run,
not when execute finishes.
Step 3: Screen inbound before the turn
import { detectPromptInjection, policyInput } from "@arcjet/guard";
import { cloudflareThinkContext } from "@arcjet/guard/cloudflare-think/v0";
const inbound = detectPromptInjection();
const decision = await arcjet.guard({
label: "message.received",
actor: userId,
inputs: { content: policyInput.server.string(userText) },
rules: [inbound(userText)],
...cloudflareThinkContext({ context: { sessionId: conversationId } }),
});
if (decision.conclusion === "DENY") {
throw new Error("message blocked");
}
if (decision.hasFailedOpen()) {
throw new Error("inbound guard unavailable");
}
// Then chat() / submitMessages() / the WebSocket turn.
There is no guardInbound. guard() fails open — always check
hasFailedOpen().
Step 4: Correlation
cloudflareThinkContext reads a caller-owned id. Preference:
correlationId, then sessionId, then conversationId on a
caller-owned wrap (cloudflareThinkContext({ context: appContext })),
then copies on a bare app object, then guardHooks({ sessionId }) /
init.correlationId. It never mints. It never reads the Durable
Object id / this.ctx.id / this.name. It never reads toolCallId,
requestId, traceId, or stream ids Think or the AI SDK mint. A
beforeToolCall context that has toolCallId and toolName is
Think's envelope — top-level sessionId on that object is ignored.
Do not invent a correlation id per turn. If nothing valid remains,
the call is uncorrelated rather than joined to a generated id.
Verify the integration
npm run typecheck(or the project's type-check) passes.- Exercise inbound PI (before the turn, including
hasFailedOpen()), a substitute-deny (model seesArcjetDenialResult), a block-deny (reason string; real DENY only), unavailable +onDeny: "block"still substitute, void execute, no-throw, never-mint, omittedactor/inputs(remote rules silent), a resolver throw (fail-closed substitute), and fail-closed (an unreachable guard → substitute, never void). ConfirmneedsApprovalis never treated as the gate and that tools are not also wrapped with@arcjet/guard/vercel-ai/v7. - Confirm in the Arcjet Console / CLI that decisions share the
caller-owned session / conversation id — not a Durable Object id
or
toolCallId— and thatactoris the authenticated caller. - Manual E2E with a real
ARCJET_KEYis still-to-verify until you run it.
Worked example:
examples/cloudflare-think-agent
(no verify fixture; main does not have this tree yet). Do not
invent a second example name. Do not add an example in this skills
repo.