Cloudflare Workers runs on V8 isolates, not Node.js — no fs, no net, and half of what Claude Code “knows” about server-side JavaScript quietly doesn’t apply. A working CLAUDE.md for a Workers project has to say what runtime it’s actually running on before it says anything else, because the model’s default assumptions are wrong by default here in a way they aren’t for a plain Express or Next.js API route.
This guide covers a complete CLAUDE.md template for Workers — binding conventions for KV, D1, R2, Durable Objects, and Queues, wrangler.jsonc config discipline, how the official Cloudflare Skills plugin fits alongside a project-specific CLAUDE.md (most guides on this topic don’t explain the boundary), and hook-based deploy safety gates. Real community CLAUDE.md/AGENTS.md examples for Workers projects, including Cloudflare’s own hierarchical AGENTS.md for the workers-sdk monorepo, are browsable in our gallery.
Why Workers Needs an Explicit CLAUDE.md
Most AI coding assistants, Claude Code included, learned “server-side JavaScript” primarily from Node.js code. Workers breaks several of those assumptions at once, and none of them fail loudly — they fail as a deploy that works locally and then throws in production, or as code that silently no-ops:
- No Node.js runtime APIs by default.
fs,net,child_process, and most ofBufferdon’t exist. Claude Code will reach forfs.readFileout of habit when asked to load a config file, and it compiles fine locally undernodejs_compatshims before failing in ways that are hard to trace back to this cause. - Environment variables aren’t
process.env. Bindings and secrets arrive through theenvobject passed into the fetch handler, not the ambient global Node.js developers expect.process.envis either empty or only partially populated depending onnodejs_compatflags, and Claude Code will writeprocess.env.API_KEYfrom muscle memory unless told not to. - State doesn’t persist across requests the way Node.js server memory does. Every request can hit a different isolate. In-memory caches, singletons, and module-level “connect once” patterns that work fine in a long-running Node process behave unpredictably in Workers unless you explicitly reach for a Durable Object or an external store.
- The binding surface is the actual API, not a library you
npm install. KV, D1, R2, Durable Objects, and Queues are configured inwrangler.jsonc, not imported as SDK clients with connection strings. Claude Code without this context will try to install a database driver and construct a connection string for D1, which is not how D1 works.
None of this is a Workers weakness — it’s a genuinely different execution model, and the fix is the same one that works for any unusual runtime: state the actual model explicitly in CLAUDE.md instead of letting Claude Code infer it from Node.js-shaped training data.
Complete CLAUDE.md Template for Cloudflare Workers Projects
# [ProjectName] — Cloudflare Workers
## Runtime
- Cloudflare Workers (V8 isolates) — NOT Node.js. `nodejs_compat` flag is [on/off]: [what it actually enables in this project]
- Language: TypeScript, strict mode
- Framework: [Hono / itty-router / raw fetch handler]
- Config: `wrangler.jsonc` (NOT `wrangler.toml` — this project uses the JSON config format)
- compatibility_date: [YYYY-MM-DD] — do not bump without checking the changelog at developers.cloudflare.com/workers/configuration/compatibility-dates/
## Commands
- Local dev: `wrangler dev` (spins up Miniflare — accurately emulates KV/D1/Durable Objects locally)
- Type check: `tsc --noEmit`
- Deploy (staging): `wrangler deploy --env staging`
- Deploy (production): `wrangler deploy --env production` — requires explicit human confirmation, see Prohibited section
- Tail live logs: `wrangler tail`
- D1 migrations (local): `wrangler d1 migrations apply DB --local`
- D1 migrations (remote): `wrangler d1 migrations apply DB --remote` — production data, confirm before running
## Bindings (env object, NOT process.env)
This project's bindings, declared in `wrangler.jsonc`:
- `env.DB` — D1 database, binding name `DB`
- `env.CACHE` — KV namespace, binding name `CACHE`
- `env.ASSETS` — R2 bucket, binding name `ASSETS`
- `env.RATE_LIMITER` — Durable Object, class `RateLimiter`
- `env.QUEUE` — Queue producer, binding name `QUEUE`
- Secrets (`wrangler secret put`, never in `wrangler.jsonc`): [list secret names, not values]
NEVER write `process.env.X` for a binding or secret. Bindings only exist on the `env` parameter passed into the handler (`fetch(request, env, ctx)`), not as a global.
## Permitted Operations
- `wrangler dev`
- `wrangler deploy --env staging`
- `wrangler d1 migrations apply DB --local`
- `wrangler tail`
- Reading/editing `wrangler.jsonc` bindings (propose changes, do not silently add new bindings)
## Prohibited Commands (hooks enforce this — do not attempt)
- `wrangler deploy --env production` without explicit human confirmation in the same session
- `wrangler d1 migrations apply DB --remote` without explicit human confirmation
- `wrangler secret put` for any secret name not already listed above (adding secrets is a human decision)
- `wrangler kv key delete` / `wrangler r2 object delete` against production namespaces
- `wrangler d1 execute --remote` with raw DELETE/DROP/TRUNCATE statements
## Choosing a Storage Binding (state this so Claude stops guessing)
- **KV** — read-heavy, eventually-consistent, global cache. Config, feature flags, cached API responses. NOT for data that needs strong consistency or is written frequently from many locations at once (KV writes are eventually consistent globally, ~60s propagation).
- **D1** — relational data needing SQL queries, joins, or transactions within a single database. This project's source of truth for [users/orders/etc].
- **Durable Objects** — single-threaded, strongly consistent state that needs coordination: rate limiters, WebSocket session state, per-user or per-room counters. Each DO instance is pinned to one location and processes requests serially — do not propose a DO for something that's actually a good fit for D1 or KV.
- **R2** — binary/blob storage (files, images, exports). S3-compatible API, but access it through the `env.BUCKET` binding in Workers code, not the S3 SDK with credentials.
- **Queues** — background/async work that shouldn't block the response (webhook fan-out, batch processing). A Worker with a `queue()` handler consumes messages; do not simulate this with `ctx.waitUntil()` for anything that needs retry semantics.
## Code Conventions
- Fetch handler signature: `export default { async fetch(request, env, ctx) { ... } }` — no implicit globals for bindings
- Use `ctx.waitUntil()` for fire-and-forget work that must complete after the response is sent (e.g., writing an analytics event) — never `await` something the response doesn't need
- Durable Object classes: one responsibility per DO class, storage API calls (`this.state.storage`) wrapped in the class, never accessed from outside
- No top-level `await` for anything binding-dependent — bindings aren't available until the request handler runs
- Errors: return a proper `Response` with a status code from the handler; do not let unhandled promise rejections propagate silently (an isolate crash is not a stack trace you'll see easily)
## What NOT to Do
- Do not use `process.env` for bindings or secrets — use the `env` parameter
- Do not `npm install` a database client SDK for D1 — use the `env.DB` binding's native query methods
- Do not assume in-memory module-level state persists across requests
- Do not propose a Durable Object for something D1 or KV already handles well
- Do not run `wrangler deploy --env production` or any `--remote` D1/KV/R2 mutation without explicit confirmation in-session
Official Cloudflare Skills and the MCP Server — What They Cover, What CLAUDE.md Still Owns
Cloudflare ships an official Claude Code integration through the plugin marketplace:
# from the project root, where wrangler.jsonc lives
claude plugin marketplace add cloudflare/skills
claude plugin install cloudflare@cloudflare
This installs a set of Skills — Wrangler, Durable Objects, Workers Best Practices, and others — plus an MCP server for Cloudflare API operations. It’s a genuinely useful layer, and Cloudflare’s own setup docs recommend running Claude Code from the directory containing wrangler.jsonc so the agent can read your actual bindings. What that page stops short of is any concrete CLAUDE.md guidance — no binding-naming conventions, no example project layout, no deploy safety rules. That’s the gap this guide fills.
In practice, the two layers do different jobs and you want both:
- Cloudflare Skills — general Cloudflare platform knowledge, kept current against live docs (the Durable Objects skill in particular fetches
developers.cloudflare.com/durable-objects/rather than relying on training data, which matters because the API surface has changed more than once). This is Cloudflare teaching Claude Code how Cloudflare works. - CLAUDE.md — your project’s specific decisions: which binding names you actually use, which storage primitive owns which piece of state, what’s allowed to run against production, and the code conventions your team follows. Skills can’t know this because it’s project-specific, not platform-specific.
A CLAUDE.md that duplicates general Workers platform facts (what a Durable Object is, how KV consistency works) is wasted context once the Cloudflare Skills plugin is installed — keep the platform explanation in one sentence and spend the rest of the file on what only your project knows.
wrangler.jsonc Discipline: compatibility_date and the JSON-First Config Format
Two config details cause more silent breakage in Workers projects than anything else in the runtime itself, and both are easy to state once in CLAUDE.md and never revisit:
wrangler.jsonc over wrangler.toml. Cloudflare’s newer bindings and features (Workers AI model configuration, some Queues options, Vectorize) ship JSON-config-only before or instead of getting TOML support. A project still on wrangler.toml inherited from an older tutorial will hit features Claude Code can’t correctly configure in TOML because the option doesn’t exist there yet.
// wrangler.jsonc
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2026-08-01",
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [
{ "binding": "DB", "database_name": "my-worker-db", "database_id": "..." }
],
"kv_namespaces": [
{ "binding": "CACHE", "id": "..." }
],
"durable_objects": {
"bindings": [{ "name": "RATE_LIMITER", "class_name": "RateLimiter" }]
}
}
compatibility_date is a runtime behavior lock, not a version number. Cloudflare rolls out breaking runtime changes gated behind this date — bumping it can silently change how APIs behave, and leaving it stale can silently deny you fixes and new APIs. Claude Code has no way to know which behaviors changed between two dates unless you tell it explicitly not to touch this field without checking the compatibility flags changelog first. Add this line to CLAUDE.md verbatim:
## compatibility_date
- Current: [date]. Do NOT bump this without reading the changelog at
developers.cloudflare.com/workers/configuration/compatibility-dates/ and
listing which flags changed between the current and proposed date.
Hook-Based Deploy Safety Gates
The highest-consequence mistake in a Workers project isn’t bad code — Miniflare’s local emulation is accurate enough that most logic bugs get caught before deploy. It’s an agent running a --remote or --env production command against live data because nothing stopped it. A PreToolUse hook that inspects the command before it runs closes that gap the same way it does for Terraform/Kubernetes/Ansible in our IaC CLAUDE.md guide:
#!/bin/bash
# ~/.claude/hooks/workers-safety-gate.sh
# Reads CLAUDE_TOOL_INPUT from environment (JSON with command field)
CMD=$(echo "$CLAUDE_TOOL_INPUT" | jq -r '.command // empty')
# Block production deploys without explicit human confirmation flag
if echo "$CMD" | grep -qE 'wrangler deploy.*--env[= ]production'; then
if [ -z "$HUMAN_CONFIRMED_PROD_DEPLOY" ]; then
echo "BLOCKED: production deploy requires HUMAN_CONFIRMED_PROD_DEPLOY=1" >&2
exit 1
fi
fi
# Block remote D1 mutations outside of migrations
if echo "$CMD" | grep -qE 'wrangler d1 execute.*--remote' && echo "$CMD" | grep -qiE 'DELETE|DROP|TRUNCATE'; then
echo "BLOCKED: destructive remote D1 statement — run against --local first, migrate explicitly" >&2
exit 1
fi
# Block remote KV/R2 deletes
if echo "$CMD" | grep -qE 'wrangler (kv key delete|r2 object delete)'; then
echo "BLOCKED: remote KV/R2 delete requires human execution, not agent-run" >&2
exit 1
fi
# Block secret writes for undeclared secret names
if echo "$CMD" | grep -qE 'wrangler secret put'; then
echo "BLOCKED: secret writes are a human decision — run manually, not via agent" >&2
exit 1
fi
exit 0
Wire it in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/workers-safety-gate.sh" }]
}
]
}
}
AGENTS.md Version for Cross-Tool Teams
# [ProjectName] — AI Agent Context
## Runtime
Cloudflare Workers (V8 isolates), NOT Node.js. TypeScript strict. Config: wrangler.jsonc.
## Bindings (env parameter, never process.env)
- DB: D1 (relational source of truth)
- CACHE: KV (eventually consistent, read-heavy)
- RATE_LIMITER: Durable Object (strongly consistent, single-threaded coordination)
- ASSETS: R2 (blob storage)
- QUEUE: Queues (background work)
## Commands
- Dev: wrangler dev
- Deploy staging: wrangler deploy --env staging
- Deploy prod: wrangler deploy --env production (human-confirmed only)
- D1 migrate local: wrangler d1 migrations apply DB --local
## Known Pitfalls
- process.env does not populate bindings — use the env parameter
- No fs/net/child_process without nodejs_compat, and even then behavior differs from Node.js
- Module-level state does not persist across requests/isolates
- Durable Objects are for coordination, not a default database — check D1/KV fit first
Common Mistakes We See in Workers + Claude Code Projects
process.env for bindings. By far the most frequent failure. It works fine when nodejs_compat happens to populate a few variables from .dev.vars locally, then breaks in production where that shim behaves differently or the variable was never actually a binding. State the env parameter pattern explicitly and this stops happening.
Reaching for a Durable Object as a general-purpose database. Because DOs offer strong consistency, Claude Code sometimes proposes one for state that D1 already handles better — a DO is single-threaded and location-pinned, which makes it a poor fit for anything with meaningful read volume from multiple regions. Reserve DOs for genuine coordination problems (rate limiting, WebSocket rooms, sequential counters) and say so in CLAUDE.md.
Installing an npm database driver for D1. D1 is queried through the binding’s own methods (env.DB.prepare(...).bind(...).all()), not a pg/mysql2-style client with a connection string — there’s no TCP connection to make from inside a Workers isolate to begin with for most traditional drivers.
Stale compatibility_date silently blocking new APIs, or an unreviewed bump silently changing behavior. Both directions cause real incidents. Treat this field as a decision that requires reading the changelog, not a routine version bump.
wrangler.toml copied from an old tutorial into a project that needs JSON-only features. Newer bindings (some Workers AI configuration, Vectorize options) are JSON-config-first. If a project has both a stale wrangler.toml habit and a real need for a JSON-only feature, Claude Code will hit a wall it can’t explain from the TOML file alone.
Frequently Asked Questions
Does Claude Code need the Cloudflare MCP server, or is CLAUDE.md enough?
For most day-to-day coding — writing handlers, adjusting bindings in wrangler.jsonc, debugging locally — a good CLAUDE.md is enough on its own. The MCP server adds value when you want Claude Code to inspect or manage live Cloudflare account state (checking a KV namespace’s actual contents, looking up a Worker’s deployment history) rather than just writing code against declared bindings. They’re complementary, not competing.
How is Durable Objects state actually persisted — does it survive a redeploy?
Yes. A Durable Object’s storage (via the Storage API, backed by SQLite in newer DOs) persists independently of code deploys — redeploying your Worker doesn’t wipe DO state. What resets on redeploy is in-memory instance state (plain JavaScript fields on the DO class) unless you’ve explicitly written it to this.state.storage (or this.ctx.storage in the SQLite-backed API).
Can Claude Code run D1 migrations safely on its own?
Local migrations (--local) are safe to let an agent run freely — they operate on a local SQLite file with no production impact. Remote migrations (--remote) touch production data and should require explicit human confirmation in the same session, which is what the hook example above enforces. Never let an agent chain --local success directly into an unconfirmed --remote run.
Is Hono required, or does a raw fetch handler work fine with this template?
The template above is framework-agnostic — export default { fetch(request, env, ctx) } is the raw Workers contract either way. Hono adds routing, middleware, and RPC-style type sharing on top of it, which is worth it once a Worker has more than a handful of routes. If your project uses Hono, see our Hono CLAUDE.md example for its middleware and RPC conventions, which layer directly on top of the bindings section in this guide.
Real Cloudflare Workers CLAUDE.md/AGENTS.md examples — including Cloudflare’s own hierarchical AGENTS.md for the workers-sdk monorepo and the Hono edge framework’s CLAUDE.md — are browsable in our gallery. For infrastructure-level hook patterns in a similar vein, see our guide on Terraform, Kubernetes, and Ansible CLAUDE.md rules.