Integrate Arcjet Guard into Cloudflare Think — delegate beforeToolCall to guardHooks so a DENY substitutes or blocks the tool, and read a caller-owned id via cloudflareThinkContext. Use when asked to add Arcjet to @cloudflare/think, Cloudflare Agents Think, rate limit those tools, screen inbound messages, or block prompt injection / PII. This is Cloudflare Think, not the Vercel AI SDK and not needsApproval HITL.
Scanned 9/23/2026
Install to Claude Code
npx -y skills add arcjet/skills --skill integrate-arcjet-guard-cloudflare-think --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Integrate Arcjet Guard Cloudflare Think?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/arcjet-integrate-arcjet-guard-cloudflare-think)More formats (shields.io, HTML) on the badges page.
---
name: integrate-arcjet-guard-cloudflare-think
description: Integrate Arcjet Guard into Cloudflare Think — delegate beforeToolCall to guardHooks so a DENY substitutes or blocks the tool, and read a caller-owned id via cloudflareThinkContext. Use when asked to add Arcjet to @cloudflare/think, Cloudflare Agents Think, rate limit those tools, screen inbound messages, or block prompt injection / PII. This is Cloudflare Think, not the Vercel AI SDK and not needsApproval HITL.
license: Apache-2.0
compatibility: Requires official @cloudflare/think >=0.3.0 <1 as a peer of published @arcjet/guard. Install with npm install or pnpm add. Local Node.js follows the @arcjet/guard runtime floor (>=22.21.0 <23 || >=24.5.0). Cloudflare Workers need nodejs_compat and compat date 2025-09-01 or later (minimum, not an exact pin). Path is /v0. Do not use @arcjet/guard/vercel-ai/v7.
metadata:
author: arcjet
type: core
library: "@arcjet/guard"
---
# 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](../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 the `Think` subclass **delegates to**. Delivery is a Think
`ToolCallDecision`: **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** → `cloudflareThinkContext` reads a caller-owned
id. It never mints. It never reads Durable Object ids, `toolCallId`,
`requestId`, or `traceId`.
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`](https://github.com/arcjet/examples/tree/david/cursor/cloudflare-think-agent-f8cc/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` / `inputs`** and 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 — never `void`).
- **`actor` is the authenticated caller**, never a model-produced
tool argument. `input` on the Think envelope is model-controlled.
Derive `actor` from authenticated server-side context (a signed
session, verified token, or Durable Object state you already
trust) — not from `ctx.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.
1. Which tools are **risky** (external side effects, irreversible,
spends money, sends messages)? Those are gated by
`guardHooks`. Client tools with no `execute` are out of
scope.
2. What **limits**? (e.g. "10 lookups/min per user" →
`tokenBucket`.)
3. 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 on
`guardHooks({ sessionId })`. That id is the correlation id, not
the user. Do not use the Durable Object id or `toolCallId`.
4. 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
1. **This is not the Vercel AI SDK.** Do not import
`@arcjet/guard/vercel-ai/v7` and do not wrap Think tools with
`guardTool` from that namespace. Think already owns
`streamText`.
2. **There is no `guardInbound`.** Screen with core `guard()`
before `chat()` / `submitMessages()`.
3. **`needsApproval` is HITL, not policy.**
4. **Fail closed = always return `block` or `substitute` on
error.** `void` executes the tool. A throw is not a denial.
Unavailable fail-closes with **substitute**, even when
`onDeny: "block"`.
5. **Default DENY is substitute** (structured `ArcjetDenialResult`).
`block` is the reason-string path and drops the fields. It
applies to real DENY only.
6. **`guardHooks` is not a mixin.** Delegate
`beforeToolCall` to `hooks.beforeToolCall(ctx)`. There is no
`guardThink(Think, …)`.
7. **Correlation is read, never minted.** Never Durable Object id,
never `toolCallId`, never `requestId`, never `traceId`.
8. **The import path is versioned.**
`@arcjet/guard/cloudflare-think/v0` resolves;
`@arcjet/guard/cloudflare-think` does not.
9. **Key rate limits on the authenticated caller**, not a
model-supplied order id. Same for `actor`.
10. **Omit `actor` / `inputs` and remote rules never fire.** A
resolver throw fail-closes. Import `policyInput` from
`@arcjet/guard`.
11. **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:
```bash
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**:
```ts
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.
```ts
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
```ts
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
1. `npm run typecheck` (or the project's type-check) passes.
2. Exercise inbound PI (before the turn, including
`hasFailedOpen()`), a substitute-deny (model sees
`ArcjetDenialResult`), a block-deny (reason string; real DENY
only), unavailable + `onDeny: "block"` still substitute, void
execute, no-throw, never-mint, omitted `actor` / `inputs`
(remote rules silent), a resolver throw (fail-closed
substitute), and fail-closed (an unreachable guard →
substitute, never void). Confirm `needsApproval` is never
treated as the gate and that tools are not also wrapped with
`@arcjet/guard/vercel-ai/v7`.
3. Confirm in the Arcjet Console / CLI that decisions share the
caller-owned session / conversation id — not a Durable Object id
or `toolCallId` — and that `actor` is the authenticated caller.
4. Manual E2E with a real `ARCJET_KEY` is still-to-verify until you
run it.
Worked example:
[`examples/cloudflare-think-agent`](https://github.com/arcjet/examples/tree/david/cursor/cloudflare-think-agent-f8cc/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.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!