Cloudflare Think agent guard
Cloudflare Think
Think agents 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 AI agent runtime security platform. Discover the agents running in your organization, enforce policy across every action, prompt, and tool call, and keep the evidence to prove what happened. Detect prompt injection, authorize agent tool calls, redact PII, and block bots and abuse.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.
This adapter is Cloudflare Think @cloudflare/think plus
beforeToolCall. It isn’t Vercel AI SDK
(@arcjet/guard/vercel-ai/v7). Think uses the AI SDK under the
hood; that shared runtime is not a reason to mix adapters. Don’t
also wrap these tools with @arcjet/guard/vercel-ai/v7. There is
no guardTool. Think’s skip point is the hook: a
{ action: "block" | "substitute" } decision skips execute.
Install
Section titled “Install”Until @arcjet/guard/cloudflare-think/v0 is published, pin
@arcjet/guard to
b06e584d.
Published @arcjet/guard 1.12.0 doesn’t export
./cloudflare-think/v0.
npm install @cloudflare/thinknpm install @arcjet/guard@git+https://github.com/arcjet/arcjet-js.git#b06e584d491d4821f81c9e3083ed8f0c8da15ba7pnpm add @cloudflare/thinkpnpm add @arcjet/guard@git+https://github.com/arcjet/arcjet-js.git#b06e584d491d4821f81c9e3083ed8f0c8da15ba7yarn add @cloudflare/thinkyarn add @arcjet/guard@git+https://github.com/arcjet/arcjet-js.git#b06e584d491d4821f81c9e3083ed8f0c8da15ba7@cloudflare/think 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/cloudflare-think/v0. There is no unversioned
alias. @arcjet/guard/cloudflare-think doesn’t resolve. The
version segment is v0 because Think is pre-1.0.
@cloudflare/think (>=0.3.0 <1) is an optional peer. The
integration requires Node.js >=22.21.0 <23 || >=24.5.0.
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:
guardHooks()is the Think-wide gate. Assign itsbeforeToolCallon aThinksubclass. The hook denies by returning{ action: "substitute", output }by default, whereoutputis anArcjetDenialResult. OptionalonDeny: "block"returns{ action: "block", reason }on a realDENYonly. The original toolexecutenever runs. Returnvoidto execute. The hook doesn’t throw. There is noguardTool.cloudflareThinkContext()reads a caller-owned ID you pass:correlationId, thensessionId, thenconversationId. It never mints an ID. It never readstoolCallId. It never readsrequestId,traceId, a Durable Objectnameorid, or a Think-generated session id. 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
the Think turn starts. There is no guardApproval.
Don’t call createAgentContext inside a Think hook. Don’t also
wrap these tools with @arcjet/guard/vercel-ai/v7.
Helper options
Section titled “Helper options”| Option | Helpers | Description |
|---|---|---|
action | guardHooks | 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 | guardHooks | SDK rules, or a function of { toolName, input }. Omit to submit none. The guard call still happens. |
actor | guardHooks | Trusted identity, or a function resolved per call. Take it from authenticated application state, never from a model-produced argument. |
inputs | guardHooks | Named values built with policyInput.server.* or policyInput.local.*, or a function resolved per call. |
metadata | guardHooks | Nested JSON, or a function of the same input as rules. |
sessionId | guardHooks | Caller-owned fallback when the object you pass to cloudflareThinkContext doesn’t carry one. A string, or a function of the same input as rules. Prefer putting the ID on the context helper. |
onGuardError | guardHooks | "deny" (default) or "allow". |
onDeny | guardHooks | Default substitute. "block" is real DENY only. Fail-closed unavailability always substitutes. |
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 envelope is a Think
ToolCallDecision:
- Default substitute returns
{ action: "substitute", output }whereoutputis theArcjetDenialResult. The model reads that object as the tool result. onDeny: "block"is realDENYonly. It returns{ action: "block", reason }wherereasonis the denial message. The model reads that string as the tool result.
It isn’t a throw. It isn’t needsApproval. A throw is the
wrong envelope. void on error executes the tool.
import type { ArcjetDenialResult } from "@arcjet/guard/cloudflare-think/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,};
// Default envelopeconst substitute = { action: "substitute" as const, output: denial };
// Optional block envelopeconst block = { action: "block" as const, reason: denial.message };ArcjetDenialResult is a type export from
@arcjet/guard/cloudflare-think/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 in a substitute envelope. Fail closed
always substitutes on error, even when onDeny is "block". It
never throws. It never returns void on error.
Run a guarded Think agent
Section titled “Run a guarded Think agent”Screen inbound text and assign guardHooks().beforeToolCall on
the Think subclass:
import { launchArcjet, detectPromptInjection, localDetectSensitiveInfo, tokenBucket,} from "@arcjet/guard";import { cloudflareThinkContext, guardHooks,} from "@arcjet/guard/cloudflare-think/v0";import { Think } from "@cloudflare/think";import { tool } from "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();const inbound = detectPromptInjection();
const lookupOrderInput = z.object({ orderNumber: z.string(), note: z.string(),});
const hooks = guardHooks(arcjet, { action: "order.looked-up", onGuardError: "deny", rules: ({ toolName, input }) => { if (toolName !== "lookup_order") { return []; } const { orderNumber, note } = lookupOrderInput.parse(input); return [ lookupLimit({ key: orderNumber, requested: 1 }), detectPii(note), ]; },});
export class OrderAgent extends Think<Env> { getModel() { return "@cf/moonshotai/kimi-k2.7-code"; }
getSystemPrompt() { return "Look up orders with lookup_order."; }
getTools() { return { lookup_order: tool({ description: "Look up an order by number", inputSchema: lookupOrderInput, execute: ({ orderNumber, note }) => ({ orderNumber, note, status: "shipped", }), }), }; }
beforeToolCall = hooks.beforeToolCall;}
export async function screenInbound( conversationId: string, userText: string,) { const appContext = { sessionId: conversationId }; const decision = await arcjet.guard({ label: "message.received", rules: [inbound(userText)], ...cloudflareThinkContext({ context: appContext }), });
if (decision.conclusion === "DENY" || decision.hasFailedOpen()) { throw new Error("message blocked"); }}Screen user text before the Think turn
Section titled “Screen user text before the Think turn”There is no inbound hook, so there is no guardInbound. Put
prompt-injection and other inbound rules in the application before
the Think turn starts (chat(), a messenger reply, or
submitMessages()).
Direct
client.guard({ label, rules, ...cloudflareThinkContext({ 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(). guardHooks already defaults to fail
closed.
On DENY, don’t start the Think turn.
import { detectPromptInjection } from "@arcjet/guard";import { cloudflareThinkContext } from "@arcjet/guard/cloudflare-think/v0";import { arcjet } from "./arcjet.js";
const inbound = detectPromptInjection();const appContext = { sessionId: conversationId };
const decision = await arcjet.guard({ label: "message.received", rules: [inbound(userText)], ...cloudflareThinkContext({ 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”Think needsApproval is human-in-the-loop (HITL) confirmation.
It isn’t a policy gate. After a human yes, Guard still runs on
the tool call. Same trap as LangChain
humanInTheLoopMiddleware, Strands event.interrupt(), Genkit
interrupt(), OpenAI needsApproval, Mastra requireApproval,
TanStack needsApproval, Google ADK requireConfirmation, and
Claude canUseTool. There is no guardApproval. Don’t wrap HITL
as Guard. Don’t deny by pausing for a human. Policy sits on
beforeToolCall only.
On DENY the original tool never runs, so the inner execute
never runs a side effect. guardHooks skips with
{ action: "block" | "substitute" }. Don’t throw. Don’t call
needsApproval. 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.
Deny via beforeToolCall
Section titled “Deny via beforeToolCall”guardHooks is the Think beforeToolCall gate. Assign it on the
subclass.
The hook denies by returning { action: "block" | "substitute" }
without calling the tool. substitute carries an
ArcjetDenialResult as output. block carries a reason
string. Return void to execute. Policy sits on beforeToolCall
only.
There is no guardTool. Wrapping tool({ execute }) is the wrong
gate. Think wraps each server-side execute so the hook can skip
it. A Vercel AI SDK wrapper misses that path or double-wraps it.
Client tools have no server execute. They are not a deny point.
This isn’t Vercel AI SDK tool({ execute }). Don’t pass
guardTool from @arcjet/guard/vercel-ai/v7 to Think.
import { Think } from "@cloudflare/think";import { guardHooks } from "@arcjet/guard/cloudflare-think/v0";import { arcjet } from "./arcjet.js";
const hooks = guardHooks(arcjet, { sessionId: conversationId });
export class OrderAgent extends Think<Env> { beforeToolCall = hooks.beforeToolCall;}To return Think’s block envelope instead of substitute on a
real DENY:
guardHooks(arcjet, { sessionId: conversationId, onDeny: "block",});Fail closed still substitutes. onDeny: "block" does not apply
to unavailability.
If you also implement beforeToolCall on the subclass, call the
helper first and return its decision when it is a block or
substitute. Don’t run execute after a deny.
Correlation
Section titled “Correlation”cloudflareThinkContext reads a caller-owned ID from the object
you pass: correlationId first, then sessionId, then
conversationId. Prefer
cloudflareThinkContext({ context: appContext }). It never mints
an ID. It never reads toolCallId. Think always generates that
value. It never reads requestId, traceId, a Durable Object
name or id, or a Think-generated session id. Think persists
chat in Durable Object SQLite. That storage id 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 beforeToolCall context that has toolName and toolCallId
is treated as a Think envelope, so a top-level sessionId on
that object is ignored.
Put the same caller-owned ID on inbound guard() and as
sessionId on guardHooks.
const appContext = { sessionId: conversationId };
await arcjet.guard({ label: "message.received", ...cloudflareThinkContext({ context: appContext }),});
const hooks = guardHooks(arcjet, { sessionId: conversationId });What not to use
Section titled “What not to use”- There is no
guardInbound. Screen prompt injection before the Think turn starts. - There is no
guardTool. The gate isbeforeToolCallonguardHooks. - There is no
guardApproval.needsApprovalis human HITL confirmation, not policy. - Don’t treat this adapter as Vercel AI SDK
(
@arcjet/guard/vercel-ai/v7). Mixing those adapters is disallowed. - Don’t turn a deny into
needsApprovalor a human approval pause. - Don’t mint a correlation ID. Don’t use
toolCallId,requestId,traceId, a Durable Object id, or a Think-generated session id. If the caller didn’t pass an ID, then leave the call uncorrelated. - Don’t throw from
beforeToolCallto signal a denial. - Don’t return
voidon a Guard error. Fail closed always substitutes. - Don’t treat
onDeny: "block"as the default. Default DENY is substitute. - Don’t call
createAgentContextinside a Think hook. - Don’t also wrap these tools with
@arcjet/guard/vercel-ai/v7. - Don’t import
@arcjet/guard/cloudflare-think. The path is@arcjet/guard/cloudflare-think/v0.
Send values to a policy
Section titled “Send values to a policy”guardHooks takes actor and inputs, each as a value or a
function resolved per call. A resolver receives
{ toolName, input }, then the Think ToolCallContext.
actor and inputs are optional. Omit them and a remote rule
that declares those names never fires. A resolver that throws is
treated as a guard error and follows onGuardError.
Because the helper gates tools it did not wrap, including
runtime-discovered tools, input is unknown at the type level.
Narrow it before you read a field, and return nothing for a tool
the policy does not cover:
const hooks = guardHooks(arcjet, { action: ({ toolName }) => `${toolName}.invoked`, actor: currentUser.id, inputs: ({ toolName, input }) => { if (toolName !== "sendEmail") return {}; const { recipient, body } = input as { recipient: string; body: string; }; return { recipient: policyInput.server.string(recipient), allowed_recipients: policyInput.server.stringList( currentUser.allowedRecipients, ), body: policyInput.local.string(body), }; },});Build every input explicitly with policyInput.server.* or
policyInput.local.*. Plain values are rejected, and the adapter
never discovers arguments for you.
Derive the actor from authenticated application state, never from a model-produced tool input. A policy can be conditioned on the actor, so a model that controls it can leave its own policy scope.
The policy these calls feed declares recipient as a SERVER
string, allowed_recipients as a SERVER string list, and body
as a LOCAL string, then denies a recipient that isn’t on the
list:
package arcjet.guard
import rego.v1
deny contains "external-recipient" if { not input.values.recipient in input.values.allowed_recipients}For the names, kinds, and exposures a policy declares, see Policy contract. For more worked policies, see Policy examples.
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 hooks = guardHooks(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";import { guardHooks } from "@arcjet/guard/cloudflare-think/v0";
const lookupLimit = tokenBucket({ bucket: "lookups", refillRate: 10, intervalSeconds: 60, maxTokens: 10,});const detectPii = localDetectSensitiveInfo();
const hooks = guardHooks(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”guardHooks defaults to onGuardError: "deny". If Guard
cannot be evaluated, then beforeToolCall returns
{ action: "substitute", output } instead of calling the tool.
Fail closed always substitutes. onDeny: "block" does not apply
to unavailability. It never throws. It never returns void on
that 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.
Related
Section titled “Related”- Framework integrations
- Vercel AI SDK agent guard
- TanStack AI agent guard
- Mastra agent guard
- Vercel Eve agent guard
- Google ADK agent guard
- Agent guards
- Example:
examples/cloudflare-think-agent
on
david/cursor/cloudflare-think-agent-f8cc - Adapter:
b06e584don arcjet-js until@arcjet/guard/cloudflare-think/v0lands