Most AI agent frameworks assume the agent finishes its work inside a single request. The model thinks, calls a few tools, returns an answer, and the process exits. That assumption breaks the moment you ask an agent to do real work — research a topic across fifty sources, refactor a codebase, monitor a data feed for six hours. Somewhere in the middle the container gets evicted, the connection drops, or the platform hibernates your instance, and everything the agent learned disappears.
Project Think, announced during Cloudflare's Agents Week in August 2026, is Cloudflare's answer to that problem. It is a set of primitives for long-running agents — durable execution, sub-agents, sandboxed code execution and persistent sessions — plus an opinionated base class called Think that wires them all together.
This tutorial walks through building a real Think agent from an empty directory: a durable research assistant with persistent memory, custom tools, checkpointed background work and delegated sub-agents.
Preview note: Project Think is in preview as of August 2026. Cloudflare describes the API surface as stable but still evolving — expect changes before a stable release. Treat it as production-viable for internal tooling and prototypes, and pin your dependency versions.
Prerequisites
Before starting, ensure you have:
- Node.js 20+ with npm or pnpm
- A Cloudflare account with Workers enabled (the free plan is enough to follow along)
- TypeScript 5.5+ and comfort with async/await, classes and generics
- Basic familiarity with Cloudflare Workers and Durable Objects — you do not need to be an expert, but knowing that a Durable Object is a single addressable stateful instance will help
- Roughly 45 minutes
What You'll Build
By the end of this tutorial you will have a deployed agent that:
- Streams chat responses over WebSockets with zero manual wiring
- Remembers facts about the user across restarts and hibernation
- Exposes custom tools alongside a built-in workspace filesystem
- Runs a ten-step research task that resumes from a checkpoint if the instance is evicted
- Delegates work to isolated sub-agents running in parallel
- Wakes itself on a schedule to produce a daily briefing
Why Think Instead of the Existing Agents SDK
Cloudflare already shipped an Agents SDK, and it is not going away — Think builds on top of it. The distinction matters when choosing which to use.
The original AIChatAgent class handles routing and basic tool calling. You wire up the model, the message store, the streaming loop and the error handling yourself — roughly fifteen lines of boilerplate before your agent does anything useful.
Think inverts that. It ships an opinionated harness that owns the entire chat lifecycle — streaming, persistence, abort and cancel, resumable streams, error handling, a workspace filesystem — and asks you to override only what differs. A minimal Think agent is three lines. On top of that it adds four things AIChatAgent has no equivalent for:
| Capability | What it gives you |
|---|---|
| Durable fibers | Long-running work that checkpoints and resumes after a crash |
| Sub-agents | Child agents colocated via Durable Object Facets, each with its own isolated SQLite database |
| Context blocks | Persistent, model-writable memory that survives hibernation |
| Execution ladder | Five escalating compute tiers, from a virtual filesystem up to a full sandbox |
The primitives are also usable standalone. Packages like @cloudflare/codemode, @cloudflare/shell and @cloudflare/worker-bundler work without the Think base class if you want the pieces without the opinions.
Step 1: Project Setup
Create the project and install dependencies.
mkdir research-agent && cd research-agent
npm init -y
npm install @cloudflare/think @cloudflare/ai-chat agents ai @cloudflare/shell zod workers-ai-provider react react-dom
npm install -D wrangler @cloudflare/vite-plugin @cloudflare/workers-types @vitejs/plugin-react @tailwindcss/vite tailwindcss typescript viteNow the Worker configuration. Create wrangler.jsonc:
{
"name": "research-agent",
"compatibility_date": "2026-01-28",
"compatibility_flags": ["nodejs_compat"],
"ai": { "binding": "AI" },
"assets": {
"not_found_handling": "single-page-application",
"run_worker_first": ["/agents/*"]
},
"durable_objects": {
"bindings": [{ "class_name": "ResearchAgent", "name": "ResearchAgent" }]
},
"migrations": [{ "new_sqlite_classes": ["ResearchAgent"], "tag": "v1" }],
"main": "src/server.ts"
}Three lines here matter more than the rest:
"ai": { "binding": "AI" }exposes Workers AI inference to the agent asthis.env.AI.- The
durable_objectsbinding is what makes the agent addressable and stateful. Every conversation gets its own instance. "new_sqlite_classes"in the migration is mandatory. Think stores messages, sessions, memory and fiber checkpoints in the Durable Object's SQLite database. Registering the class as a plain Durable Object without SQLite will fail at runtime.
The run_worker_first: ["/agents/*"] rule ensures agent WebSocket upgrades hit the Worker rather than being served as static assets.
Next, vite.config.ts:
import { cloudflare } from "@cloudflare/vite-plugin";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), cloudflare(), tailwindcss()],
});And tsconfig.json, which just extends the config the SDK ships:
{
"extends": "agents/tsconfig"
}Step 2: Your First Think Agent
Create src/server.ts. This is the entire server.
import { Think } from "@cloudflare/think";
import { createWorkersAI } from "workers-ai-provider";
import { routeAgentRequest } from "agents";
export class ResearchAgent extends Think<Env> {
getModel() {
return createWorkersAI({ binding: this.env.AI })(
"@cf/moonshotai/kimi-k2.6",
);
}
getSystemPrompt() {
return "You are a research assistant with access to a workspace filesystem. Save findings to files as you work.";
}
}
export default {
async fetch(request: Request, env: Env) {
return (
(await routeAgentRequest(request, env)) ||
new Response("Not found", { status: 404 })
);
},
} satisfies ExportedHandler<Env>;That is a complete agent. The Think base class has already given you a WebSocket chat protocol, message persistence in SQLite, resumable streaming, workspace file tools, abort support and error handling — none of which appear in your code.
getModel() is the only genuinely required override. It returns any model compatible with the Vercel AI SDK's model interface, which means Workers AI, OpenAI, Anthropic or anything routed through Cloudflare's AI Gateway all work here. getSystemPrompt() is optional; skip it and you get a sensible default.
routeAgentRequest inspects the incoming request, maps it to the right Durable Object instance and hands off the connection. If the path does not match an agent route it returns null, which is why the fallback Response is there.
Step 3: The React Client
Create src/client.tsx:
import { createRoot } from "react-dom/client";
import { useAgent } from "agents/react";
import { useAgentChat } from "@cloudflare/ai-chat/react";
function Chat() {
const agent = useAgent({ agent: "ResearchAgent" });
const { messages, sendMessage, status } = useAgentChat({ agent });
return (
<div>
<h1>Research Agent</h1>
{messages.map((msg) => (
<div key={msg.id}>
<strong>{msg.role}:</strong>
{msg.parts.map((part, i) =>
part.type === "text" ? <span key={i}>{part.text}</span> : null,
)}
</div>
))}
<form
onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.elements.namedItem(
"input",
) as HTMLInputElement;
if (!input.value.trim()) return;
sendMessage({ text: input.value });
input.value = "";
}}
>
<input name="input" placeholder="Ask me to research something..." />
<button type="submit">Send</button>
</form>
<p>Status: {status}</p>
</div>
);
}
const root = document.getElementById("root");
if (root) {
createRoot(root).render(<Chat />);
}Note that messages is an array of message objects whose content lives in a parts array, not a flat string. This is the AI SDK v5 message shape — a single assistant message can contain text parts, tool-call parts and reasoning parts. Rendering only part.type === "text" keeps this example short; a real UI would branch on each part type.
Add index.html at the project root:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Research Agent</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client.tsx"></script>
</body>
</html>Run it:
npx vite devSend a message. Responses stream token by token, and the model already has file tools available — ask it to write something to a file and read it back, and it will.
Because Think uses the same WebSocket protocol as @cloudflare/ai-chat, any existing chat UI built against that protocol drops in without modification.
Step 4: Persistent Memory with Context Blocks
Streaming chat is table stakes. Memory that survives hibernation is where Think starts to differentiate.
A Durable Object hibernates when idle — the in-memory state is discarded and the instance is reconstructed on the next request. Anything you stored on the class instance is gone. Context blocks solve this by living in SQLite and being re-injected into the system prompt on every turn.
Override configureSession() in your agent class:
import type { Session } from "agents/experimental/memory/session";
configureSession(session: Session) {
return session
.withContext("soul", {
provider: {
get: async () =>
"You are a research assistant. Remember the user's domain, preferred sources and writing style.",
},
})
.withContext("memory", {
description: "Important facts about the user and their research interests.",
maxTokens: 2000,
})
.withCachedPrompt();
}Two different kinds of block are at work here.
The soul block is read-only from the model's perspective. Its provider.get() runs on every turn, so you can pull the value from a database, a feature flag or a per-tenant config — it is a dynamic system prompt, not a constant.
The memory block is writable. Declaring it with a description and a maxTokens budget causes Think to hand the model a set_context tool. When the user mentions they work in fintech and prefer primary sources, the model can call that tool, and the fact is persisted to SQLite. Next week, after the instance has hibernated a hundred times, the fact is still in the system prompt.
withCachedPrompt() marks the assembled prompt for provider-side prompt caching. Since context blocks sit at the stable front of the prompt while conversation messages grow at the end, this is exactly the shape prompt caching is designed for — expect a meaningful cost reduction on long conversations.
The maxTokens budget matters. Memory that grows without bound eventually crowds out the conversation itself. Think enforces the ceiling and asks the model to consolidate when the block gets full.
Step 5: Custom Tools
The workspace filesystem tools come free. Domain-specific capability is up to you. Override getTools():
import { tool } from "ai";
import { z } from "zod";
import type { ToolSet } from "ai";
getTools(): ToolSet {
return {
searchPapers: tool({
description: "Search academic papers by keyword and return titles, authors and abstracts.",
inputSchema: z.object({
query: z.string().describe("Search keywords"),
limit: z.number().min(1).max(25).default(10),
}),
execute: async ({ query, limit }) => {
const res = await fetch(
`https://api.crossref.org/works?query=${encodeURIComponent(query)}&rows=${limit}`,
);
const data = await res.json();
return data;
},
}),
};
}Standard Vercel AI SDK tool() definitions with Zod schemas — nothing Think-specific about the shape.
What Think adds is merging. Tools arrive from seven independent sources: the workspace filesystem, your getTools() return value, runtime extensions, session tools, skills, connected MCP servers and client-side tools registered by the browser. Think merges all of them into one tool set before each turn. You never assemble that list by hand.
Two practical consequences. First, name collisions are real — prefix your custom tools distinctively if you also connect MCP servers. Second, tool count grows quickly, and every tool definition costs prompt tokens on every turn. If you find yourself past thirty tools, that is the signal to look at Code Mode in Step 8.
Step 6: Durable Execution with Fibers
Here is the primitive that makes Think worth adopting.
Consider a research task that runs ten LLM calls in sequence, each taking twenty seconds. That is over three minutes of wall-clock time. In that window the Durable Object can be evicted for any number of reasons — a deploy, a platform maintenance event, memory pressure. With a plain async method, eviction loses everything and the user gets nothing.
A fiber is a durable function invocation. Think registers it in SQLite before execution begins, so the record of "this work should be happening" survives the process that was doing it.
async startResearch(topic: string) {
void this.runFiber("research", async (ctx) => {
const findings = [];
for (let i = 0; i < 10; i++) {
const result = await this.callLLM(`Research step ${i}: ${topic}`);
findings.push(result);
// Checkpoint: if evicted, we resume from here
ctx.stash({ findings, step: i, topic });
this.broadcast({ type: "progress", step: i });
}
return { findings };
});
}
async onFiberRecovered(ctx) {
if (ctx.name === "research" && ctx.snapshot) {
const { topic } = ctx.snapshot;
await this.startResearch(topic);
}
}Walk through what each piece does.
runFiber(name, fn) registers the fiber under a name and starts it. The void prefix is deliberate — you are not awaiting the result. The caller returns immediately, and the fiber continues in the background. The SDK keeps the agent alive for the fiber's duration automatically; there is no manual keepalive or waitUntil to configure.
ctx.stash(snapshot) writes a checkpoint. Pass whatever you would need to resume — accumulated findings, the current index, the original input. Stash after each expensive step, not on every line: each call is a SQLite write, and checkpointing a loop that iterates a thousand times per second will dominate your latency.
this.broadcast(message) pushes an update to every connected WebSocket client. This is how the user watches progress in real time rather than staring at a spinner for three minutes.
onFiberRecovered(ctx) is the recovery hook. After a crash or eviction, Think finds unfinished fibers in SQLite and calls this hook with the last stash. You decide what resumption means. The example above restarts the whole task, which is the simplest correct behaviour but discards completed work. A better version reads ctx.snapshot.step and resumes from the next index:
async onFiberRecovered(ctx) {
if (ctx.name !== "research" || !ctx.snapshot) return;
const { topic, findings, step } = ctx.snapshot;
void this.runFiber("research", async (fiberCtx) => {
const collected = [...findings];
for (let i = step + 1; i < 10; i++) {
const result = await this.callLLM(`Research step ${i}: ${topic}`);
collected.push(result);
fiberCtx.stash({ findings: collected, step: i, topic });
this.broadcast({ type: "progress", step: i, resumed: true });
}
return { findings: collected };
});
}One design rule follows from this: fiber bodies must be idempotent from the last checkpoint. If a step charges a credit card or sends an email, that side effect may replay on recovery. Put non-idempotent operations immediately after a stash, and guard them with an idempotency key.
Step 7: Sub-Agents
A single agent holding a hundred tools and a system prompt covering six domains performs worse than several focused agents. Think makes delegation cheap using Durable Object Facets — child Durable Objects colocated with the parent, each with its own isolated SQLite database.
import { Agent } from "agents";
export class SearchAgent extends Agent {
async search(query: string) {
/* focused search logic, own tools, own prompt */
}
}
export class CritiqueAgent extends Agent {
async analyze(text: string) {
/* focused critique logic */
}
}
export class Orchestrator extends Agent {
async handleTask(task: string) {
const searcher = await this.subAgent(SearchAgent, "search");
const critic = await this.subAgent(CritiqueAgent, "critique");
const [research, review] = await Promise.all([
searcher.search(task),
critic.analyze(task),
]);
return this.synthesize(research, review);
}
}The second argument to subAgent() is a stable name, not a random id. Calling subAgent(SearchAgent, "search") twice returns the same instance with the same accumulated state — sub-agents are addressable and persistent, not throwaway workers.
Because facets are colocated with the parent, the RPC calls are effectively local function calls. There is no network hop between Orchestrator and SearchAgent, which is what makes the Promise.all above genuinely parallel rather than two sequential round trips across a datacenter.
Each sub-agent keeps a completely separate SQLite database. The critique agent cannot read the search agent's conversation history unless you explicitly pass it. That isolation is a feature: it is what keeps each agent's context window focused.
Remember to register every sub-agent class in wrangler.jsonc migrations, or instantiation fails at runtime:
"migrations": [
{
"new_sqlite_classes": ["Orchestrator", "SearchAgent", "CritiqueAgent"],
"tag": "v1"
}
]Step 8: The Execution Ladder and Code Mode
Think organises compute into five escalating tiers. The design principle Cloudflare states is that the agent should be useful at Tier 0 alone, and each tier is purely additive.
| Tier | Environment | Powered by | Capability |
|---|---|---|---|
| 0 | Workspace | @cloudflare/shell | Durable virtual filesystem on SQLite and R2 — read, write, edit, search, diff |
| 1 | Dynamic Worker | @cloudflare/codemode | LLM-generated JavaScript in a sandboxed isolate, no network access |
| 2 | NPM resolution | @cloudflare/worker-bundler | Fetches and bundles packages with esbuild into the Dynamic Worker |
| 3 | Browser | Cloudflare Browser Run | Headless navigation, clicking, extraction, screenshots |
| 4 | Full sandbox | Cloudflare Sandbox | Real OS with toolchains — git, npm test, cargo build |
Wire the ladder into a single tools configuration:
import { Think } from "@cloudflare/think";
import { createWorkspaceTools } from "@cloudflare/think/tools/workspace";
import { createExecuteTool } from "@cloudflare/think/tools/execute";
import { createBrowserTools } from "@cloudflare/think/tools/browser";
import { createSandboxTools } from "@cloudflare/think/tools/sandbox";
export class ResearchAgent extends Think<Env> {
extensionLoader = this.env.LOADER;
getModel() {
/* ... */
}
getTools() {
return {
execute: createExecuteTool({
tools: createWorkspaceTools(this.workspace),
loader: this.env.LOADER,
}),
...createBrowserTools(this.env.BROWSER),
...createSandboxTools(this.env.SANDBOX),
};
}
}Each tier beyond Tier 0 needs its own binding in wrangler.jsonc — a Browser Run binding for Tier 3, a Sandbox binding for Tier 4. Add only the tiers you actually need; each one widens the blast radius of a compromised or confused model.
Code Mode
createExecuteTool is the interesting one, and it changes how the model uses tools.
The conventional loop is one tool call per model round trip. Find the files, wait. Read file one, wait. Read file two, wait. Scanning a hundred files costs a hundred round trips and a hundred prompt evaluations.
Code Mode replaces that with a single generated program running in a sandboxed Dynamic Worker:
// The LLM writes this. It runs in a sandboxed Dynamic Worker.
const files = await tools.find({ pattern: "**/*.ts" });
const results = [];
for (const file of files) {
const content = await tools.read({ path: file });
if (content.includes("TODO")) {
results.push({ file, todos: content.match(/\/\/ TODO:.*/g) });
}
}
return results;One model call, one execution, one result. Cloudflare's framing is that this collapses "100 round-trips to the model" into "a single program execution", with the token saving that implies.
The safety story is what makes it acceptable. The generated code runs in a Dynamic Worker — a fresh isolate with no network access. It reaches the outside world only through the tools object you passed to createExecuteTool. Model-generated code you never reviewed is executing, but its entire capability surface is the tool set you explicitly handed it.
Step 9: Scheduled Tasks
Agents that only act when spoken to are half an agent. getScheduledTasks() declares recurring turns:
import { defineScheduledTasks } from "@cloudflare/think";
getScheduledTasks() {
return defineScheduledTasks({
dailyBriefing: {
schedule: "every day at 09:00",
timezone: "Africa/Tunis",
prompt: "Review the research notes in the workspace and write a summary of what changed since yesterday to briefing.md.",
},
hourlyCheck: {
schedule: "every hour",
handler: async ({ idempotencyKey, scheduledFor }) => {
// Custom multi-step logic instead of a single prompt
},
},
});
}The scheduling DSL is deliberately readable: every <n> minutes, every <n> hours, every day at HH:mm, every weekday at HH:mm, every week on monday,wednesday at HH:mm.
Each task supplies exactly one of prompt or handler. A prompt creates a durable submission that runs through the normal agentic loop. A handler runs your own code and suits multi-step workflows that do not need the model at all.
Two behaviours worth knowing before you rely on this:
Wall-clock schedules require a timezone. Anything with a specific time needs an inline timezone, a task-level timezone, or a getDefaultTimezone() override. Without one, the task will not reconcile. Relative schedules like every hour are exempt.
There is no backfilling. If the Worker was unavailable when a task was due, Think runs the intended occurrence once when the late alarm fires, then schedules the next future run. An agent offline for a week does not wake up to seven queued briefings.
Optional per-task properties include metadata and retry: { maxAttempts }.
Step 10: Lifecycle Hooks
Think exposes four interception points around each turn, which is where observability, guardrails and dynamic configuration belong:
beforeTurn(ctx: TurnContext): TurnConfig | void {
console.log(`Turn starting: ${Object.keys(ctx.tools).length} tools available`);
}
onChatResponse(result: ChatResponseResult) {
console.log(`Turn ${result.status}: ${result.message.parts.length} parts`);
}beforeTurn() runs before the model is invoked and can return a TurnConfig to override settings for that turn only — swap to a cheaper model for simple queries, restrict the tool set based on user role, adjust the token budget. beforeStep() and onStepFinish() bracket each individual step inside a multi-step turn. onChatResponse() fires when the turn completes, successfully or not.
Replace the console.log calls with your logging or tracing layer before this reaches production — Workers logs are ephemeral, and per-turn token and latency data is exactly what you will want when costs surprise you.
Testing Your Implementation
Verify each layer independently rather than trusting the full stack at once.
Streaming and persistence. Run npx vite dev, send a message, and confirm tokens arrive incrementally. Hard-refresh the browser — the conversation should reload from SQLite rather than starting empty.
Memory. Tell the agent a fact about yourself, then run npx wrangler dev --remote in a fresh session and ask it back. If the fact is gone, check that your memory context block declares a description — without one the model never receives the set_context tool and has no way to write.
Fiber recovery. Start a long research task, then kill the dev server mid-run and restart it. onFiberRecovered should fire. Add a log line inside the hook to confirm, and inspect ctx.snapshot to verify your stash contains everything resumption needs.
Sub-agents. Call handleTask() and confirm both sub-agents complete. Verify isolation by writing to one sub-agent's session and confirming the other cannot read it.
Scheduled tasks. Temporarily set a task to every 2 minutes with a handler that logs, deploy, and watch npx wrangler tail. Restore the real schedule afterwards.
Deploy when each layer checks out:
npx wrangler deployTroubleshooting
"Cannot use SQL storage on Durable Object class" — the class is missing from new_sqlite_classes in your migrations, or was registered as a non-SQLite Durable Object in an earlier migration tag. Every agent and sub-agent class needs to be listed.
Memory resets after idle periods — you stored state on the class instance instead of in a context block. Instance properties do not survive hibernation. Anything that must persist goes through configureSession() or the Session API.
Fibers never recover — confirm you are calling ctx.stash() inside the fiber body. A fiber with no checkpoint has nothing to recover from, and onFiberRecovered receives an empty snapshot.
Scheduled tasks never fire — almost always a missing timezone on a wall-clock schedule. Add an inline timezone or implement getDefaultTimezone().
WebSocket connection fails in production — check that run_worker_first includes your agent route. Without it the static asset handler intercepts the upgrade request.
Tool name collisions — Think merges tools from seven sources. If a custom tool silently stops being called, an MCP server or extension probably registered the same name. Namespace your tools.
Token costs higher than expected — add withCachedPrompt() if you have not, audit your total tool count, and consider moving multi-step tool sequences to Code Mode.
Next Steps
- Add MCP servers. Think merges MCP tools into the same tool set, so connecting a Model Context Protocol server extends the agent without touching
getTools(). Our MCP server tutorial covers building one. - Compare harnesses. Read our Cloudflare Agents SDK tutorial to see what Think is opinionating away.
- Layer in Workflows. For orchestration spanning multiple agents and services, Cloudflare Workflows — now supporting far higher concurrency after Agents Week — complements fibers.
- Harden the tool surface. Model-generated code and broad tool access need guardrails; see our guide on AI agent guardrails and prompt injection.
- Explore self-authored extensions. Think agents can write their own extensions — TypeScript programs running in Dynamic Workers with declared network and workspace permissions, bundled and loaded at runtime. It is the most experimental corner of the platform and worth watching.
Conclusion
Project Think's contribution is not another chat abstraction. It is the recognition that a useful agent is a long-lived process, and that long-lived processes need infrastructure: checkpointing, recovery, isolation, persistent memory and graduated privilege.
The three lines that make up a minimal Think agent are the headline, but the durable fiber is the primitive that matters. It is what separates an agent that answers questions from one that does an hour of work and survives the platform restarting underneath it.
The preview status is real — pin your versions and expect API churn before stable. But the shape of the thing is right, and the primitives underneath the Think class are usable independently if the opinions do not fit.
At Noqta, we build production AI agent systems on Cloudflare and other edge platforms. If you are evaluating durable agent architecture for your team, we are happy to talk it through.