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.