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.
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'),
]);
SELECT * on a public endpointIt'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 deprecatedCloudflare'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 supersededThat 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
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
}
AUTORAG predates the API renameThis 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.