integrate-arcjet-guard-cloudflare-think

v2026.09.25

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.

GitHub
Install command
npx skhub add arcjet/integrate-arcjet-guard-cloudflare-think
Markdown
SKILL.md

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. 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 (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:

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:

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.

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

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 (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.

Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.25

Published

Sep 25, 2026

Category

Uncategorized

License

Apache-2.0

Source path

integrate-arcjet-guard-cloudflare-think

Default branch

main

Latest commit

4ae5d84

Tree SHA

ba84ae4