integrate-arcjet-guard-cloudflare-think · git:20260922.4ae5d84 · 2026-09-22 · sha256 bdf74ae951484e99
integrate-arcjet-guard-cloudflare-think git:20260922.4ae5d84A
Immutable. This exact content is served forever at /api/v1/blob/bdf74ae951484e99.
---
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.