Build a chat agent with persistent memory, built-in file tools, and streaming — step by step.
By the end of this tutorial you will have a Think agent that:
- Streams responses to a React chat UI
- Has persistent memory the model can read and write
- Includes workspace file tools (read, ls, find, grep, write, edit, delete)
- Supports custom server-side tools
- Node.js 24+
- A Cloudflare account with Workers AI access
- Familiarity with TypeScript and Cloudflare Workers
mkdir my-think-agent && cd my-think-agent
npm init -yInstall dependencies:
npm install @cloudflare/think @cloudflare/computer agents ai zod react react-dom
npm install -D wrangler @cloudflare/vite-plugin @cloudflare/workers-types @vitejs/plugin-react @tailwindcss/vite tailwindcss typescript viteThink bundles workers-ai-provider, so you do not need to install or import it for the common case — getModel() can return a model id string (see below).
Create wrangler.jsonc:
Create 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()]
});Create tsconfig.json:
{
"extends": "agents/tsconfig"
}Create src/server.ts:
import { Think } from "@cloudflare/think";
import { routeAgentRequest } from "agents";
export class MyAgent extends Think<Env> {
getModel() {
// A string is resolved through Think's built-in workers-ai-provider off the
// `AI` binding. Use a "@cf/..." id for Workers AI, or a "provider/model"
// slug like "openai/gpt-5.5" to route through AI Gateway.
return "@cf/moonshotai/kimi-k2.7-code";
}
getSystemPrompt() {
return "You are a helpful assistant with access to a workspace filesystem.";
}
}
export default {
async fetch(request: Request, env: Env) {
return (
(await routeAgentRequest(request, env)) ||
new Response("Not found", { status: 404 })
);
}
} satisfies ExportedHandler<Env>;This is a working agent. Think automatically provides:
- WebSocket chat protocol (compatible with
useAgentChat) - Message persistence in SQLite
- Resumable streaming (page refresh replays buffered chunks)
- Workspace file tools (read, ls, find, grep, write, edit, delete)
- Abort/cancel support
- Error handling with partial message persistence
Create src/client.tsx:
import { createRoot } from "react-dom/client";
import { useAgent } from "agents/react";
import { useAgentChat } from "@cloudflare/think/react";
function Chat() {
const agent = useAgent({ agent: "MyAgent" });
const { messages, sendMessage, status } = useAgentChat({ agent });
return (
<div style={{ maxWidth: 600, margin: "0 auto", padding: 20 }}>
<h1>Think Agent</h1>
<div>
{messages.map((msg) => (
<div key={msg.id} style={{ margin: "12px 0" }}>
<strong>{msg.role}:</strong>{" "}
{msg.parts.map((part, i) =>
part.type === "text" ? <span key={i}>{part.text}</span> : null
)}
</div>
))}
</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="Send a message..."
style={{ width: "80%", padding: 8 }}
/>
<button type="submit" style={{ padding: 8 }}>
Send
</button>
</form>
<p style={{ fontSize: 12, color: "#666" }}>Status: {status}</p>
</div>
);
}
createRoot(document.getElementById("root")!).render(<Chat />);Create index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Think Agent</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client.tsx"></script>
</body>
</html>npx vite devOpen the browser and send a message. The agent responds with streaming text, and workspace file tools are available to the model automatically.
setMessagesis display-only on Think. Think is server-authoritative — its transcript is a projection of the Session tree, not a flat array the client owns.useAgentChatfrom@cloudflare/think/reacttherefore keepssetMessageslocal to the React view: edits update what's on screen but are not persisted and won't survive a refresh or reconnect (the server re-projects the authoritative history). To persist a clear, callclearHistory(). If you push a full transcript to Think anyway (for example, by using@cloudflare/ai-chat/reactagainst a Think server), the server ignores it and logs a one-time dev warning.
Override configureContext to give the model writable memory that survives restarts:
import type { ContextConfig } from "agents/context";
export class MyAgent extends Think<Env> {
getModel() {
return "@cf/moonshotai/kimi-k2.7-code";
}
configureContext(): ContextConfig[] {
return [
{
label: "soul",
provider: {
get: async () =>
"You are a helpful assistant. Remember important facts about the user."
}
},
{
label: "memory",
description: "Important facts about the user and conversation.",
maxTokens: 2000
}
];
}
}Now the model sees a MEMORY section in its system prompt and gets a set_context tool to update it. A block declared without a provider is auto-wired to durable per-agent SQLite, so facts written to memory survive DO hibernation and restarts.
When you declare context blocks, the system prompt is built from those blocks, with an overridden getSystemPrompt() at the top. The "soul" block above acts as the system identity: it is read-only and always appears first. The "memory" block is writable, and the model updates it when it learns something useful. The frozen system prompt is always persisted, so a cold wake reuses the exact prompt string the model already cached.
The assembled blocks are available as this.context once the Lifecycle has started. See the Context documentation for providers, tools, and frozen-prompt behavior, and the Sessions documentation for compaction, search, and multi-session support.
Override getTools() to add your own tools alongside the built-in workspace tools:
import { tool } from "ai";
import { z } from "zod";
export class MyAgent extends Think<Env> {
getModel() {
/* ... */
}
configureContext(): ContextConfig[] {
/* ... */
}
getTools(): ToolSet {
return {
getWeather: tool({
description: "Get the current weather for a city",
inputSchema: z.object({
city: z.string().describe("City name")
}),
execute: async ({ city }) => {
const res = await fetch(
`https://api.weatherapi.com/v1/current.json?key=${this.env.WEATHER_KEY}&q=${city}`
);
return res.json();
}
})
};
}
}Think merges tools from multiple sources automatically. On every turn, the model has access to:
- Workspace tools — read, ls, find, grep, write, edit, delete (built-in)
- Context tools — set_context, search_context (from
configureContext) - Your tools — from
getTools() - Skill tools — activate_skill, read_skill_resource, and optional run_skill_script (from
getSkills()) - MCP tools — from connected MCP servers (if any)
- Client tools — from the browser (if any)
Think provides hooks that fire on every turn, regardless of entry path:
import type {
TurnContext,
TurnConfig,
ChatResponseResult
} from "@cloudflare/think";
export class MyAgent extends Think<Env> {
getModel() {
/* ... */
}
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`);
}
}See Lifecycle Hooks for the full reference.
- Lifecycle Hooks — control model behavior, switch models per-turn, restrict tools
- Tools — workspace tools, code execution, extensions
- Client Tools — browser-side tools, approval flows, concurrency
- Sub-agents and Programmatic Turns — RPC streaming, scheduled turns, recovery
- Context — context blocks, providers, prompt assembly
- Sessions — compaction, search, attachments, multi-session
{ "name": "my-think-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": "MyAgent", "name": "MyAgent" }] }, "migrations": [{ "new_sqlite_classes": ["MyAgent"], "tag": "v1" }], "main": "src/server.ts" }