Skip to main content

Cloudflare Developer Cheat Sheet

Copy-paste reference for everything you might reach for today. It's deliberately terse — for the why behind the five build-task primitives, see Level Up; for the industry-specific version of all this, see your playbook.

The platform moves fast

Every snippet below was checked against live Cloudflare docs on 2026-08-18. If your coding agent suggests something different, ask it to check developers.cloudflare.com rather than trusting either of you from memory — see Arm Your Agent.


Workers — the base

// workers/index.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === '/api/hello') {
return Response.json({ message: 'Hello from the edge!' });
}

return new Response('Not found', { status: 404 });
},
};

This repo routes with Hono instead of hand-rolled if chains — see workers/index.ts. Either works for a 60-minute build.

import { Hono } from 'hono';

const app = new Hono<{ Bindings: Env }>();

app.get('/api/items', (c) => c.json({ items: [] }));
app.post('/api/items', async (c) => {
const body = await c.req.json();
return c.json({ created: body }, 201);
});

export default app;

D1 — SQL database

npx wrangler d1 create my-db
# add the binding to wrangler.jsonc, then:
npx wrangler d1 migrations apply my-db --local # or --remote
// SELECT multiple rows
const { results } = await env.DB.prepare(
'SELECT id, title FROM items ORDER BY created_at DESC LIMIT ?',
).bind(20).all();

// SELECT single row
const item = await env.DB.prepare('SELECT * FROM items WHERE id = ?').bind(id).first();

// INSERT
const result = await env.DB.prepare(
'INSERT INTO items (title, content) VALUES (?, ?)',
).bind(title, content).run();
// result.meta.last_row_id is the new row's id

// UPSERT — use this for anything a team resubmits (e.g. a submission form)
await env.DB.prepare(`
INSERT INTO submissions (team_id, demo_url, updated_at) VALUES (?, ?, unixepoch())
ON CONFLICT(team_id) DO UPDATE SET demo_url = excluded.demo_url, updated_at = excluded.updated_at
`).bind(teamId, demoUrl).run();

// Batch (single round trip)
await env.DB.batch([
env.DB.prepare('INSERT INTO items (title) VALUES (?)').bind('Item 1'),
env.DB.prepare('INSERT INTO items (title) VALUES (?)').bind('Item 2'),
]);
Never SELECT * on a public endpoint

It's the easiest way to leak a column you forgot about — like everyone's email address. Name your columns.


R2 — object storage

npx wrangler r2 bucket create my-bucket
# add the binding to wrangler.jsonc
// Upload
await env.BUCKET.put('screenshots/team-3.png', imageData, {
httpMetadata: { contentType: 'image/png' },
customMetadata: { teamId: '3' },
});

// Download
const object = await env.BUCKET.get('screenshots/team-3.png');
if (object) {
return new Response(object.body, {
headers: { 'Content-Type': object.httpMetadata?.contentType ?? 'application/octet-stream' },
});
}

// List
const listed = await env.BUCKET.list({ prefix: 'screenshots/', limit: 100 });

// Delete
await env.BUCKET.delete('screenshots/team-3.png');

// Multipart upload from a form
const formData = await request.formData();
const file = formData.get('file') as File;
await env.BUCKET.put(file.name, file.stream(), { httpMetadata: { contentType: file.type } });

KV — key-value store

npx wrangler kv namespace create MY_KV
# add the binding to wrangler.jsonc
// Set (with optional TTL) — good for rate limits and dedupe keys
await env.KV.put('ratelimit:1.2.3.4', '1', { expirationTtl: 60 });

// Get
const data = await env.KV.get('user:123', 'json');

// Delete
await env.KV.delete('user:123');

// List
const keys = await env.KV.list({ prefix: 'ratelimit:' });

Workers AI — run models

Add "ai": { "binding": "AI" } to wrangler.jsonc. Every call in this repo goes through AI Gateway — pass src/config/event.ts's AI_OPTIONS, never call env.AI.run() bare.

import { AI_OPTIONS, MODELS } from '../src/config/event';

// Text generation
const response = await env.AI.run(
MODELS.fast,
{ messages: [{ role: 'user', content: 'What is Cloudflare?' }] },
AI_OPTIONS,
);
// response.response is the text

// Streaming — pipe straight through as the fetch response body
const stream = await env.AI.run(
MODELS.fast,
{ messages: [{ role: 'user', content: 'Tell me a story' }], stream: true },
AI_OPTIONS,
);
return new Response(stream as ReadableStream, {
headers: { 'Content-Type': 'text/event-stream' },
});

// Embeddings (for your own semantic search, distinct from AI Search below)
const embeddings = await env.AI.run('@cf/baai/bge-base-en-v1.5', { text: ['text to embed'] }, AI_OPTIONS);

AI Gateway options object — third argument to env.AI.run(), or { gateway: {...} } on any binding call that supports it:

interface AiGatewayOptions {
id: string; // gateway name — this repo's is "agenthack"
skipCache?: boolean; // default false
cacheTtl?: number; // seconds
cacheKey?: string; // custom cache key
collectLog?: boolean; // per-request logging toggle
metadata?: Record<string, unknown>; // shows up in the AI Gateway dashboard log
}

Model IDs actually used in this repo are in src/config/event.ts MODELS — don't hardcode a model string anywhere else. Full catalogue: developers.cloudflare.com/workers-ai/models/.


Durable Objects — raw stateful edge

Reach for this only if you're not using the Agents SDK below — for a chat agent or anything with per-team memory, the Agents SDK gives you the same durability plus WebSockets, SQL, scheduling and callable RPC for free.

// wrangler.jsonc
{
"durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "ChatRoom" }] },
"migrations": [{ "tag": "v1", "new_classes": ["ChatRoom"] }],
}
export class ChatRoom {
state: DurableObjectState;
sessions: WebSocket[] = [];

constructor(state: DurableObjectState) {
this.state = state;
}

async fetch(request: Request): Promise<Response> {
const { 0: client, 1: server } = new WebSocketPair();
server.accept();
this.sessions.push(server);
server.addEventListener('message', (event) => {
for (const session of this.sessions) session.send(event.data);
});
return new Response(null, { status: 101, webSocket: client });
}
}

// In your Worker:
const id = env.ROOMS.idFromName('room-1');
return env.ROOMS.get(id).fetch(request);

Agents SDK — stateful chat agents

// wrangler.jsonc — Durable Objects backing an Agent need new_sqlite_classes
{
"durable_objects": { "bindings": [{ "name": "MENTOR", "class_name": "MentorAgent" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["MentorAgent"] }],
}
import { Agent, routeAgentRequest } from 'agents';

interface MentorState {
openQuestions: number;
}

export class MentorAgent extends Agent<Env, MentorState> {
initialState: MentorState = { openQuestions: 0 };

onStart() {
// runs once when the Durable Object wakes up — create tables here
this.sql`CREATE TABLE IF NOT EXISTS messages (id TEXT PRIMARY KEY, role TEXT, text TEXT)`;
}

// Callable over WebSocket RPC: `const { stub } = useAgent(...); await stub.ask(q)`
async ask(question: string) {
this.setState({ openQuestions: this.state.openQuestions + 1 });
const row = this.sql<{ text: string }>`SELECT text FROM messages LIMIT 1`;
return { question, remembered: row };
}

// Delay (seconds), Date, or cron — see "A schedule" in Level Up for the full pattern
async armEscalationSweep() {
await this.schedule('*/10 * * * *', 'sweepEscalations', {});
}

async sweepEscalations() {
// this.sql and this.env are both available here, same as any other method
}
}

// workers/index.ts
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
return (
(await routeAgentRequest(request, env)) ??
new Response('Not found', { status: 404 })
);
},
};

Client (React):

import { useAgent } from 'agents/react';
import { useAgentChat } from '@cloudflare/ai-chat/react';

function MentorDock() {
const agent = useAgent({ agent: 'mentor', name: 'team-3' });
const { messages, sendMessage, status } = useAgentChat({ agent });

return (
<div>
{messages.map((m) => (
<div key={m.id}>{m.role}: {m.parts.map((p) => (p.type === 'text' ? p.text : null))}</div>
))}
<button disabled={status !== 'ready'} onClick={() => agent.setState({ openQuestions: 0 })}>
Clear
</button>
</div>
);
}

Full API (state, @callable(), this.mcp, sub-agents): developers.cloudflare.com/agents/runtime/agents-api/.


Workflows — durable multi-step execution

import { WorkflowEntrypoint, WorkflowStep, WorkflowEvent } from 'cloudflare:workers';

type Params = { submissionId: string };

export class JudgeWorkflow extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
const data = await step.do('fetch submission', async () => {
return { id: event.payload.submissionId, url: 'https://example.workers.dev' };
});

const score = await step.do(
'call an unreliable scoring API',
{ retries: { limit: 5, delay: '10 seconds', backoff: 'exponential' }, timeout: '15 minutes' },
async () => {
// any failure here retries automatically — step 1's result is never re-fetched
return { works: 4, agentic_depth: 3 };
},
);

return { ...data, score };
}
}
// wrangler.jsonc
{
"workflows": [{ "binding": "JUDGE", "name": "judge-workflow", "class_name": "JudgeWorkflow" }],
}

Trigger and poll from a Worker:

const instance = await env.JUDGE.create({ id: crypto.randomUUID(), params: { submissionId } });
const status = await instance.status(); // { status: 'running' | 'complete' | 'errored' | ... }

Human approval gate (HITL) — the scored rubric line

Two ways to pause for a human, depending on where the pause needs to live:

1. Whole-Workflow pause — use when the approval blocks a durable pipeline (this is how the Judge Workflow works): step.waitForEvent() suspends the instance, for free, for up to a year by default.

async run(event: WorkflowEvent<{ id: string }>, step: WorkflowStep) {
const caseFile = await step.do('prepare the case', async () => {
return { id: event.payload.id, recommendation: 'refund' }; // what a human needs to see
});

const decision = await step.waitForEvent<{ approved: boolean; by: string }>(
'await human decision',
{ type: 'case-decision', timeout: '2 hours' },
);

await step.do('record the outcome', async () => {
// persist BOTH caseFile.recommendation and decision — the contrast is the audit trail
});
}

Send the decision from a plain HTTP route once a host clicks approve/reject:

const instance = await env.JUDGE.get(instanceId);
await instance.sendEvent({ type: 'case-decision', payload: { approved: true, by: 'host@cf' } });

If nobody decides before the timeout, the Workflow times out without acting — the safe default, not a bug.

2. Single-tool pause — use when only one tool call inside a chat agent needs gating (e.g. agents-starter's handle_request), not the whole pipeline. Add needsApproval to an AI SDK tool definition; the agent pauses that one call and waits for the client to resolve it.

// server (inside your Agent's tool set)
tools: {
processRefund: tool({
description: 'Issue a refund',
inputSchema: z.object({ amount: z.coerce.number(), orderId: z.string() }),
needsApproval: async ({ amount }) => amount > 100, // gate only the risky ones
execute: async ({ amount, orderId }) => issueRefund(orderId, amount),
}),
}
// client
import { getToolApproval, isToolUIPart, getToolPartState } from '@cloudflare/ai-chat/react';

const { messages, addToolApprovalResponse } = useAgentChat({ agent });

messages
.flatMap((m) => m.parts)
.filter((p) => isToolUIPart(p) && getToolPartState(p) === 'waiting-approval')
.map((part) => {
const approval = getToolApproval(part);
return approval && (
<button onClick={() => addToolApprovalResponse({ id: approval.id, approved: true })}>
Approve
</button>
);
});

MCP — expose tools to any coding agent

McpAgent is deprecated

Cloudflare's current guidance (checked 2026-08-18) is createMcpHandler + McpServer from @modelcontextprotocol/server for new MCP servers. The older McpAgent/agents/mcp + @modelcontextprotocol/sdk pattern is feature-frozen — only still used to keep existing servers running during migration. Use the pattern below for anything new today.

// workers/mcp.ts — stateless, no Durable Object required
import { createMcpHandler } from 'agents/mcp/server';
import { McpServer } from '@modelcontextprotocol/server';
import { z } from 'zod';

function createServer() {
const server = new McpServer({ name: 'agenthack', version: '1.0.0' });

server.registerTool(
'get_brief',
{ description: 'Rules, build task, clock and rubric for Agent Hack Stockholm', inputSchema: {} },
async () => ({ content: [{ type: 'text', text: JSON.stringify({ /* from event.ts */ }) }] }),
);

server.registerTool(
'submit_project',
{
description: 'Submit or update your project for judging',
inputSchema: { teamId: z.string(), demoUrl: z.string().url() },
},
async ({ teamId, demoUrl }) => {
// same UPSERT as POST /api/submit
return { content: [{ type: 'text', text: `Submitted for team ${teamId}` }] };
},
);

return server;
}

export default {
fetch: (request: Request, env: Env, ctx: ExecutionContext) =>
createMcpHandler(createServer)(request, env, ctx),
};

As a client — an Agent (e.g. the Mentor) can call tools from another MCP server, including this event's own /mcp endpoint, or a team's registered handle_request:

import { Agent } from 'agents';
import { generateText } from 'ai';
import { createWorkersAI } from 'workers-ai-provider';

export class ToolAgent extends Agent<Env> {
async onStart() {
await this.addMcpServer('agenthack', 'https://agenthack.events-cloudflare.com/mcp');
}

async onRequest(request: Request) {
const workersai = createWorkersAI({ binding: this.env.AI });
const response = await generateText({
model: workersai(MODELS.fast),
prompt: 'What is the build task?',
tools: this.mcp.getAITools(), // every tool from every connected MCP server
});
return new Response(response.text);
}
}

Full reference: developers.cloudflare.com/agents/model-context-protocol/ · developers.cloudflare.com/agents/tools/mcp/.


AI Search — grounded, citable answers

env.AI.autorag() is superseded

That method lived on the AI binding and is no longer recommended. Use the dedicated ai_search_namespaces (or single-instance ai_search) binding below instead. Needs compatibility_date >= 2026-03-27. AI Search has no local emulator — set remote: true so wrangler dev proxies to your deployed instance.

// wrangler.jsonc
{
"compatibility_date": "2026-08-18",
"ai_search_namespaces": [{ "binding": "AI_SEARCH", "namespace": "default", "remote": true }],
}
// Create an instance and index a document — new instances have built-in storage,
// an R2 corpus is optional, not required.
const instance = await env.AI_SEARCH.create({ id: 'agenthack-docs' });
await instance.items.uploadAndPoll('cheat-sheet.md', cheatSheetMarkdown);

// Search — returns scored chunks with source references, so answers can cite them
const results = await env.AI_SEARCH.get('agenthack-docs').search({
messages: [{ role: 'user', content: 'what is the build task?' }],
ai_search_options: { retrieval: { max_num_results: 3 } },
});
// results.chunks[].text, .item.key (source path), .score

// Chat — retrieves AND generates in one call; set stream: true for SSE
const chat = await env.AI_SEARCH.get('agenthack-docs').chatCompletions({
messages: [{ role: 'user', content: 'what is the build task?' }],
model: MODELS.fast,
});
// chat.choices[0].message.content, chat.chunks (same shape as search)

Full reference: developers.cloudflare.com/ai-search/api/search/workers-binding/.


Browser Rendering (now "Browser Run") — headless browser

Renamed since this event started planning

The product is now called Browser Run. The old CDP/Puppeteer binding still works for custom automation; for the common cases below use the newer quickAction() method — no API token, one call. Needs compatibility_date >= 2026-03-24 and remote: true for wrangler dev (also no local emulator).

// wrangler.jsonc
{
"compatibility_date": "2026-08-18",
"browser": { "binding": "BROWSER", "remote": true },
}
// Screenshot
const screenshot = await env.BROWSER.quickAction('screenshot', { url: 'https://example.com' });

// Markdown (good context for a model, cheaper than raw HTML)
const markdown = await env.BROWSER.quickAction('markdown', { url: 'https://example.com' });

// Everything in one call — must request >= 2 formats
const snapshot = await env.BROWSER.quickAction('snapshot', {
url: 'https://example.com',
formats: ['screenshot', 'markdown'],
});

Other actions: pdf, content (HTML), json (AI-extracted structured data), scrape (CSS selectors), links. Full list: developers.cloudflare.com/browser-run/quick-actions/.


Quick wrangler.jsonc template — uncomment what you need

{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-project",
"main": "src/index.ts",
"compatibility_date": "2026-08-18",
"compatibility_flags": ["nodejs_compat"],

// Uncomment what you need:

// "d1_databases": [{
// "binding": "DB",
// "database_name": "my-db",
// "database_id": "your-id-here"
// }],

// "r2_buckets": [{
// "binding": "BUCKET",
// "bucket_name": "my-bucket"
// }],

// "kv_namespaces": [{
// "binding": "KV",
// "id": "your-id-here"
// }],

// "ai": { "binding": "AI" },

// Agents SDK — a chat agent, mentor, or anything with per-team state.
// "durable_objects": {
// "bindings": [{ "name": "MY_AGENT", "class_name": "MyAgent" }]
// },
// "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyAgent"] }],

// "workflows": [{
// "binding": "MY_WORKFLOW",
// "name": "my-workflow",
// "class_name": "MyWorkflow"
// }],

// AI Search has no local emulator — remote:true proxies wrangler dev to your instance.
// "ai_search_namespaces": [{
// "binding": "AI_SEARCH",
// "namespace": "default",
// "remote": true
// }],

// Browser Run also has no local emulator.
// "browser": { "binding": "BROWSER", "remote": true },

"observability": { "enabled": true },
}

Env type definition — every binding this project uses (see PLAN.md §3):

interface Env {
ASSETS: Fetcher; // Docusaurus static build
DB: D1Database; // teams, submissions, scores, ...
KV: KVNamespace; // rate limits, dedupe, cache
AI: Ai; // Workers AI, always via AI Gateway
AUTORAG: AiSearchNamespace; // AI Search — ai_search_namespaces binding
BUCKET: R2Bucket; // docs corpus + submission screenshots
MENTOR: DurableObjectNamespace; // Agents SDK: MentorAgent, one per team
SCOPER: DurableObjectNamespace; // Agents SDK: ScopingAgent
ROOM: DurableObjectNamespace; // live board + finale orchestrator
JUDGE: Workflow; // durable review pipeline
BROWSER: BrowserRun; // screenshots + smoke tests
}
The name AUTORAG predates the API rename

This repo's binding variable is still called AUTORAG for continuity with earlier planning — only the type and the wrangler config key changed (ai_search_namespaces, not an AI.autorag() call on the AI binding). Don't let the variable name fool you into reaching for the old method.