Skip to content

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.

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.

@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! });

The integration exposes two surfaces:

  • guardHooks() is the Think-wide gate. Assign its beforeToolCall on a Think subclass. The hook denies by returning { action: "substitute", output } by default, where output is an ArcjetDenialResult. Optional onDeny: "block" returns { action: "block", reason } on a real DENY only. The original tool execute never runs. Return void to execute. The hook doesn’t throw. There is no guardTool.
  • cloudflareThinkContext() reads a caller-owned ID you pass: correlationId, then sessionId, then conversationId. It never mints an ID. It never reads toolCallId. It never reads requestId, traceId, a Durable Object name or id, 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.

OptionHelpersDescription
actionguardHooksGuard label and capture name. Use resource.verb in the past tense. A string, or a function of { toolName, input }. Defaults to tool.invoked.
rulesguardHooksSDK rules, or a function of { toolName, input }. Omit to submit none. The guard call still happens.
actorguardHooksTrusted identity, or a function resolved per call. Take it from authenticated application state, never from a model-produced argument.
inputsguardHooksNamed values built with policyInput.server.* or policyInput.local.*, or a function resolved per call.
metadataguardHooksNested JSON, or a function of the same input as rules.
sessionIdguardHooksCaller-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.
onGuardErrorguardHooks"deny" (default) or "allow".
onDenyguardHooksDefault 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.

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 } where output is the ArcjetDenialResult. The model reads that object as the tool result.
  • onDeny: "block" is real DENY only. It returns { action: "block", reason } where reason is 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 envelope
const substitute = { action: "substitute" as const, output: denial };
// Optional block envelope
const 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.

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");
}
}

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");
}

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.

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.

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 });
  • There is no guardInbound. Screen prompt injection before the Think turn starts.
  • There is no guardTool. The gate is beforeToolCall on guardHooks.
  • There is no guardApproval. needsApproval is 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 needsApproval or 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 beforeToolCall to signal a denial.
  • Don’t return void on a Guard error. Fail closed always substitutes.
  • Don’t treat onDeny: "block" as the default. Default DENY is substitute.
  • Don’t call createAgentContext inside 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.

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.

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),
];
},
});

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.