Skip to content

Think

@cloudflare/think is an opinionated chat agent base class for Cloudflare Workers. It handles the full chat lifecycle — agentic loop, message persistence, streaming, tool execution, client tools, stream resumption, and extensions — all backed by Durable Object SQLite.

Think works as both a top-level agent (WebSocket chat to browser clients via useAgentChat) and a sub-agent (RPC streaming from a parent agent via chat()).

Quick start

Install

Terminal window
npm install @cloudflare/think @cloudflare/ai-chat agents ai @cloudflare/shell zod workers-ai-provider

Server

JavaScript
import { Think } from "@cloudflare/think";
import { createWorkersAI } from "workers-ai-provider";
import { routeAgentRequest } from "agents";
export class MyAgent extends Think {
getModel() {
return createWorkersAI({ binding: this.env.AI })(
"@cf/moonshotai/kimi-k2.5",
);
}
}
export default {
async fetch(request, env) {
return (
(await routeAgentRequest(request, env)) ||
new Response("Not found", { status: 404 })
);
},
};

That is it. Think handles the WebSocket chat protocol, message persistence, the agentic loop, message sanitization, stream resumption, client tool support, and workspace file tools.

Client

JavaScript
import { useAgent } from "agents/react";
import { useAgentChat } from "@cloudflare/ai-chat/react";
function Chat() {
const agent = useAgent({ agent: "MyAgent" });
const { messages, sendMessage, status } = useAgentChat({ agent });
return (
<div>
{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");
sendMessage({ text: input.value });
input.value = "";
}}
>
<input name="input" placeholder="Send a message..." />
<button type="submit">Send</button>
</form>
</div>
);
}

Configuration

JSONC
{
"$schema": "./node_modules/wrangler/config-schema.json",
// Set this to today's date
"compatibility_date": "2026-04-16",
"compatibility_flags": [
"nodejs_compat",
"experimental"
],
"ai": {
"binding": "AI"
},
"durable_objects": {
"bindings": [
{
"class_name": "MyAgent",
"name": "MyAgent"
}
]
},
"migrations": [
{
"new_sqlite_classes": [
"MyAgent"
],
"tag": "v1"
}
]
}

Think vs AIChatAgent

Both Think and AIChatAgent extend Agent and speak the same cf_agent_chat_* WebSocket protocol. They serve different goals.

AIChatAgent is a protocol adapter. You override onChatMessage and are responsible for calling streamText, wiring tools, converting messages, and returning a Response. AIChatAgent handles the plumbing — message persistence, streaming, abort, resume — but the LLM call is entirely your concern.

Think is an opinionated framework. It makes decisions for you: getModel() returns the model, getSystemPrompt() or configureSession() sets the prompt, getTools() returns tools. The default onChatMessage runs the complete agentic loop. You override individual pieces, not the whole pipeline.

ConcernAIChatAgentThink
Minimal subclass~15 lines (wire streamText + tools + system prompt + response)3 lines (getModel() only)
StorageFlat SQL tableSession: tree-structured messages, context blocks, compaction, FTS5
RegenerationDestructive (old response deleted)Non-destructive branching (old responses preserved)
Context managementManualContext blocks with LLM-writable persistent memory
Sub-agent RPCNot built inchat() with StreamCallback
Programmatic turnssaveMessages()saveMessages() + continueLastTurn()
CompactionmaxPersistedMessages (deletes oldest)Non-destructive summaries via overlays
SearchNot availableFTS5 full-text search per-session and cross-session

When to use AIChatAgent

  • You need full control over the LLM call (RAG, multi-model, custom streaming)
  • You want the Response return type for HTTP middleware or testing
  • You are building a simple chatbot with no memory requirements

When to use Think

  • You want to ship fast (3-line subclass with everything wired)
  • You need persistent memory (context blocks the model can read and write)
  • You need long conversations (non-destructive compaction)
  • You need conversation search (FTS5)
  • You are building a sub-agent system (parent-child RPC with streaming)
  • You need proactive agents (programmatic turns from scheduled tasks or webhooks)

Configuration overrides

Method / PropertyDefaultDescription
getModel()throwsReturn the LanguageModel to use
getSystemPrompt()"You are a helpful assistant."System prompt (fallback when no context blocks)
getTools(){}AI SDK ToolSet for the agentic loop
maxSteps10Max tool-call rounds per turn
configureSession()identityAdd context blocks, compaction, search, skills — refer to Sessions
messageConcurrency"queue"How overlapping submits behave — refer to Message concurrency
waitForMcpConnectionsfalseWait for MCP servers before inference
chatRecoverytrueWrap turns in runFiber for durable execution

Dynamic configuration

Think accepts a Config type parameter for per-instance typed configuration. Configuration is persisted in SQLite and survives hibernation and restarts.

JavaScript
export class MyAgent extends Think {
getModel() {
const tier = this.getConfig()?.modelTier ?? "fast";
const models = {
fast: "@cf/moonshotai/kimi-k2.5",
capable: "@cf/meta/llama-4-scout-17b-16e-instruct",
};
return createWorkersAI({ binding: this.env.AI })(models[tier]);
}
}
MethodDescription
configure(config: Config)Persist a typed configuration object
getConfig(): Config | nullRead the persisted configuration, or null if never configured

Expose configuration to the client via @callable:

JavaScript
import { callable } from "agents";
export class MyAgent extends Think {
getModel() {
/* ... */
}
@callable()
updateConfig(config) {
this.configure(config);
}
}

Session integration

Think uses Session for conversation storage. Override configureSession to add persistent memory, compaction, search, and skills:

JavaScript
import { Think, Session } from "@cloudflare/think";
export class MyAgent extends Think {
getModel() {
/* ... */
}
configureSession(session) {
return session
.withContext("soul", {
provider: { get: async () => "You are a helpful coding assistant." },
})
.withContext("memory", {
description: "Important facts learned during conversation.",
maxTokens: 2000,
})
.withCachedPrompt();
}
}

When configureSession adds context blocks, Think builds the system prompt from those blocks instead of using getSystemPrompt(). Think's this.messages getter reads directly from Session's tree-structured storage.

For the full Session API — context blocks, compaction, search, skills, and multi-session support — refer to the Sessions documentation.

Tools

Think provides built-in workspace file tools on every turn, plus integration points for custom tools, code execution, and dynamic extensions.

Tool merge order

On every turn, Think merges tools from multiple sources. Later sources override earlier ones if names collide:

  1. Workspace toolsread, write, edit, list, find, grep, delete (built-in)
  2. getTools() — your custom server-side tools
  3. Session toolsset_context, load_context, search_context (from configureSession)
  4. Extension tools — tools from loaded extensions (prefixed by extension name)
  5. MCP tools — from connected MCP servers
  6. Client tools — from the browser (refer to Client tools)
  7. Caller tools — from chat() options when used as a sub-agent

Built-in workspace tools

Every Think agent gets this.workspace — a virtual filesystem backed by Durable Object SQLite. Workspace tools are automatically available to the model with no configuration.

ToolDescription
readRead a file's content
writeWrite content to a file (creates parent directories)
editApply a find-and-replace edit to an existing file (supports fuzzy matching)
listList files and directories in a path
findFind files matching a glob pattern
grepSearch file contents by regex or fixed string
deleteDelete a file or directory

R2 spillover

By default, the workspace stores everything in SQLite. For large files, override workspace to add R2 spillover:

JavaScript
import { Think } from "@cloudflare/think";
import { Workspace } from "@cloudflare/shell";
export class MyAgent extends Think {
workspace = new Workspace({
sql: this.ctx.storage.sql,
r2: this.env.R2,
name: () => this.name,
});
getModel() {
/* ... */
}
}

This requires an R2 bucket binding:

JSONC
{
"$schema": "./node_modules/wrangler/config-schema.json",
"r2_buckets": [
{
"binding": "R2",
"bucket_name": "agent-files"
}
]
}

Custom tools

Override getTools() to add your own tools. These are standard AI SDK tool() definitions with Zod schemas:

JavaScript
import { Think } from "@cloudflare/think";
import { tool } from "ai";
import { z } from "zod";
export class MyAgent extends Think {
getModel() {
/* ... */
}
getTools() {
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.weather.com/v1/current?q=${city}&key=${this.env.WEATHER_KEY}`,
);
return res.json();
},
}),
};
}
}

Custom tools are merged with workspace tools automatically. If a custom tool has the same name as a workspace tool, the custom tool wins.

Tool approval

Tools can require user approval before execution using the needsApproval option:

TypeScript
getTools(): ToolSet {
return {
deleteFile: tool({
description: "Delete a file from the system",
inputSchema: z.object({ path: z.string() }),
needsApproval: async ({ path }) => path.startsWith("/important/"),
execute: async ({ path }) => {
await this.workspace.rm(path);
return { deleted: path };
},
}),
};
}

When needsApproval returns true, the tool call is sent to the client for approval. The conversation pauses until the client responds with CF_AGENT_TOOL_APPROVAL.

Per-turn tool overrides

The beforeTurn hook can restrict or add tools for a specific turn:

TypeScript
beforeTurn(ctx: TurnContext) {
return {
activeTools: ["read", "write", "getWeather"],
tools: { emergencyTool: this.createEmergencyTool() },
};
}

activeTools limits which tools the model can call. tools adds extra tools for this turn only (merged on top of existing tools).

MCP tools

Think inherits MCP client support from the Agent base class. MCP tools from connected servers are automatically merged into every turn.

Set waitForMcpConnections to ensure MCP servers are connected before inference runs:

JavaScript
export class MyAgent extends Think {
waitForMcpConnections = true; // default 10s timeout
// or: waitForMcpConnections = { timeout: 5000 };
getModel() {
/* ... */
}
}

Add MCP servers programmatically or via @callable methods:

JavaScript
import { callable } from "agents";
export class MyAgent extends Think {
getModel() {
/* ... */
}
@callable()
async addServer(name, url) {
return await this.addMcpServer(name, url);
}
@callable()
async removeServer(serverId) {
await this.removeMcpServer(serverId);
}
}

Code execution tool

Let the LLM write and run JavaScript in a sandboxed Worker. Requires @cloudflare/codemode and a worker_loaders binding.

Terminal window
npm install @cloudflare/codemode
JavaScript
import { Think } from "@cloudflare/think";
import { createExecuteTool } from "@cloudflare/think/tools/execute";
import { createWorkspaceTools } from "@cloudflare/think/tools/workspace";
export class MyAgent extends Think {
getModel() {
/* ... */
}
getTools() {
return {
execute: createExecuteTool({
tools: createWorkspaceTools(this.workspace),
loader: this.env.LOADER,
}),
};
}
}
JSONC
{
"$schema": "./node_modules/wrangler/config-schema.json",
"worker_loaders": [
{
"binding": "LOADER"
}
]
}

For richer filesystem access, pass a state backend:

JavaScript
import { createWorkspaceStateBackend } from "@cloudflare/shell";
createExecuteTool({
tools: myDomainTools,
state: createWorkspaceStateBackend(this.workspace),
loader: this.env.LOADER,
});

Browser tools

Give your agent access to the Chrome DevTools Protocol (CDP) for web page inspection, scraping, screenshots, and debugging. Requires @cloudflare/codemode and a Browser Run binding.

JavaScript
import { Think } from "@cloudflare/think";
import { createBrowserTools } from "@cloudflare/think/tools/browser";
export class MyAgent extends Think {
getModel() {
/* ... */
}
getTools() {
return {
...createBrowserTools({
browser: this.env.BROWSER,
loader: this.env.LOADER,
}),
};
}
}
JSONC
{
"$schema": "./node_modules/wrangler/config-schema.json",
"browser": {
"binding": "BROWSER"
},
"worker_loaders": [
{
"binding": "LOADER"
}
]
}

This adds two tools:

ToolDescription
browser_searchQuery the CDP protocol spec to discover commands, events, and types
browser_executeRun CDP commands against a live browser session (screenshots, DOM reads, JS evaluation)

For a custom Chrome endpoint, pass cdpUrl instead of browser:

JavaScript
createBrowserTools({
cdpUrl: "http://localhost:9222",
loader: this.env.LOADER,
});

For the full CDP helper API, refer to Browse the web.

Extensions

Extensions are dynamically loaded sandboxed Workers that add tools at runtime. The LLM can write extension source code, load it, and use the new tools on the next turn.

Extensions require a worker_loaders binding:

JavaScript
import { Think } from "@cloudflare/think";
export class MyAgent extends Think {
extensionLoader = this.env.LOADER;
getModel() {
/* ... */
}
}

Static extensions

Define extensions that load at startup:

JavaScript
export class MyAgent extends Think {
extensionLoader = this.env.LOADER;
getModel() {
/* ... */
}
getExtensions() {
return [
{
manifest: {
name: "math",
version: "1.0.0",
permissions: { network: false },
},
source: `({
tools: {
add: {
description: "Add two numbers",
parameters: { a: { type: "number" }, b: { type: "number" } },
execute: async ({ a, b }) => ({ result: a + b })
}
}
})`,
},
];
}
}

Extension tools are namespaced — a math extension with an add tool becomes math_add in the model's tool set.

LLM-driven extensions

Give the model createExtensionTools so it can load extensions dynamically:

JavaScript
import { createExtensionTools } from "@cloudflare/think/tools/extensions";
export class MyAgent extends Think {
extensionLoader = this.env.LOADER;
getModel() {
/* ... */
}
getTools() {
return {
...createExtensionTools({ manager: this.extensionManager }),
...this.extensionManager.getTools(),
};
}
}

This gives the model two tools:

  • load_extension — load a new extension from JavaScript source
  • list_extensions — list currently loaded extensions

Extension context blocks

Extensions can declare context blocks in their manifest. These are automatically registered with the Session:

TypeScript
getExtensions() {
return [{
manifest: {
name: "notes",
version: "1.0.0",
permissions: { network: false },
context: [
{ label: "scratchpad", description: "Extension scratch space", maxTokens: 500 },
],
},
source: `({ tools: { /* ... */ } })`,
}];
}

The context block is registered as notes_scratchpad (namespaced by extension name).

Custom workspace backends

The individual tool factories are exported for use with custom storage backends:

JavaScript
import {
createReadTool,
createWriteTool,
createEditTool,
createListTool,
createFindTool,
createGrepTool,
createDeleteTool,
createWorkspaceTools,
} from "@cloudflare/think/tools/workspace";

Implement the operations interface for your storage backend:

JavaScript
const myReadOps = {
readFile: async (path) => fetchFromMyStorage(path),
stat: async (path) => getFileInfo(path),
};
const readTool = createReadTool({ ops: myReadOps });

Lifecycle hooks

Think owns the streamText call and provides hooks at each stage of the chat turn. Hooks fire on every turn regardless of entry path — WebSocket chat, sub-agent chat(), saveMessages, and auto-continuation after tool results.

Hook summary

HookWhen it firesReturnAsync
configureSession(session)Once during onStartSessionyes
beforeTurn(ctx)Before streamTextTurnConfig or voidyes
beforeToolCall(ctx)When model calls a toolToolCallDecision or voidyes
afterToolCall(ctx)After tool executionvoidyes
onStepFinish(ctx)After each step completesvoidyes
onChunk(ctx)Per streaming chunkvoidyes
onChatResponse(result)After turn completes and message is persistedvoidyes
onChatError(error)On error during a turnerror to propagateno

Execution order

For a turn with two tool calls:

configureSession() ← once at startup, not per-turn
beforeTurn() ← inspect assembled context, override model/tools/prompt
┌── streamText ───────────────────────────────────┐
│ onChunk() onChunk() onChunk() ... │
│ │ │
│ beforeToolCall() → tool executes │
│ afterToolCall() │
│ │ │
│ onStepFinish() │
│ │ │
│ onChunk() onChunk() ... │
│ │ │
│ beforeToolCall() → tool executes │
│ afterToolCall() │
│ │ │
│ onStepFinish() │
└─────────────────────────────────────────────────┘
onChatResponse() ← message persisted, turn lock released

beforeTurn

Called before streamText. Receives the fully assembled context — system prompt, converted messages, merged tools, and model. Return a TurnConfig to override any part, or void to accept defaults.

TypeScript
beforeTurn(ctx: TurnContext): TurnConfig | void | Promise<TurnConfig | void>

TurnContext

FieldTypeDescription
systemstringAssembled system prompt (from context blocks or getSystemPrompt())
messagesModelMessage[]Assembled model messages (truncated, pruned)
toolsToolSetMerged tool set (workspace + getTools + session + MCP + client + caller)
modelLanguageModelThe model from getModel()
continuationbooleanWhether this is a continuation turn (auto-continue after tool result)
bodyRecord<string, unknown>Custom body fields from the client request

TurnConfig

All fields are optional. Return only what you want to change.

FieldTypeDescription
modelLanguageModelOverride the model for this turn
systemstringOverride the system prompt
messagesModelMessage[]Override the assembled messages
toolsToolSetExtra tools to merge (additive)
activeToolsstring[]Limit which tools the model can call
toolChoiceToolChoiceForce a specific tool call
maxStepsnumberOverride maxSteps for this turn
providerOptionsRecord<string, unknown>Provider-specific options

Examples

Switch to a cheaper model for continuation turns:

TypeScript
beforeTurn(ctx: TurnContext) {
if (ctx.continuation) {
return { model: this.cheapModel };
}
}

Restrict which tools the model can call:

TypeScript
beforeTurn(ctx: TurnContext) {
return { activeTools: ["read", "write", "getWeather"] };
}

Add per-turn context from the client body:

TypeScript
beforeTurn(ctx: TurnContext) {
if (ctx.body?.selectedFile) {
return {
system: ctx.system + `\n\nUser is editing: ${ctx.body.selectedFile}`,
};
}
}

beforeToolCall

Called when the model produces a tool call. Only fires for server-side tools (tools with execute).

TypeScript
beforeToolCall(ctx: ToolCallContext) {
console.log(`Tool called: ${ctx.toolName}`, ctx.args);
}
FieldTypeDescription
toolNamestringName of the tool being called
argsRecord<string, unknown>Arguments the model provided

afterToolCall

Called after a tool executes.

TypeScript
afterToolCall(ctx: ToolCallResultContext) {
this.env.ANALYTICS.writeDataPoint({
blobs: [ctx.toolName],
doubles: [JSON.stringify(ctx.result).length],
});
}
FieldTypeDescription
toolNamestringName of the tool that was called
argsRecord<string, unknown>Arguments the tool was called with
resultunknownThe result returned by the tool

onStepFinish

Called after each step completes in the agentic loop. A step is one streamText iteration — the model generates text, optionally calls tools, and the step ends.

TypeScript
onStepFinish(ctx: StepContext) {
console.log(
`Step ${ctx.stepType}: ${ctx.usage.inputTokens}in/${ctx.usage.outputTokens}out`,
);
}
FieldTypeDescription
stepType"initial" | "continue" | "tool-result"Why the step ran
textstringText generated in this step
toolCallsunknown[]Tool calls made
toolResultsunknown[]Tool results received
finishReasonstringWhy the step ended
usage{ inputTokens, outputTokens }Token usage for this step

onChunk

Called for each streaming chunk. High-frequency — fires per token. Use for streaming analytics, progress indicators, or token counting. Observational only.

onChatResponse

Called after a chat turn completes and the assistant message has been persisted. The turn lock is released before this hook runs, so it is safe to call saveMessages or other methods from inside.

Fires for all turn completion paths: WebSocket, sub-agent RPC, saveMessages, and auto-continuation.

TypeScript
onChatResponse(result: ChatResponseResult) {
if (result.status === "completed") {
console.log(`Turn ${result.requestId}: ${result.message.parts.length} parts`);
}
}
FieldTypeDescription
messageUIMessageThe persisted assistant message
requestIdstringUnique ID for this turn
continuationbooleanWhether this was a continuation turn
status"completed" | "error" | "aborted"How the turn ended
errorstring?Error message (when status is "error")

onChatError

Called when an error occurs during a chat turn. Return the error to propagate it, or return a different error. The partial assistant message (if any) is persisted before this hook fires.

TypeScript
onChatError(error: unknown) {
console.error("Chat turn failed:", error);
return new Error("Something went wrong. Please try again.");
}

Client tools

Think supports tools that execute in the browser. The client sends tool schemas in the chat request body, Think merges them with server tools, and when the LLM calls a client tool, the call is routed to the client for execution.

Defining client tools

On the client, pass clientTools to useAgentChat:

JavaScript
const { messages, sendMessage } = useAgentChat({
agent,
clientTools: {
getUserTimezone: {
description: "Get the user's timezone from their browser",
parameters: {},
execute: async () => {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
},
},
getClipboard: {
description: "Read text from the user's clipboard",
parameters: {},
execute: async () => {
return navigator.clipboard.readText();
},
},
},
});

Client tools are tools without an execute function on the server — they only have a schema. When the LLM produces a tool call for one, Think routes it to the client.

Approval flow

Handle approvals on the client with onToolCall:

JavaScript
const { messages, sendMessage, addToolResult } = useAgentChat({
agent,
onToolCall: ({ toolCall }) => {
if (toolCall.toolName === "read") {
return { approve: true };
}
// Others go through the UI approval flow
},
});

Auto-continuation

After a client tool result is received, Think automatically continues the conversation without a new user message. The continuation turn has continuation: true in the TurnContext, which you can use in beforeTurn to adjust model or tool selection.

Message concurrency

The messageConcurrency property controls how overlapping user submits behave when a chat turn is already active.

StrategyBehavior
"queue"Queue every submit and process them in order. Default.
"latest"Keep only the latest overlapping submit.
"merge"All overlapping user messages remain in history; the model sees them all in one turn.
"drop"Ignore overlapping submits entirely. Messages are not persisted.
{ strategy: "debounce", debounceMs?: number }Trailing-edge latest with a quiet window (default 750ms).
JavaScript
import { Think } from "@cloudflare/think";
export class SearchAgent extends Think {
messageConcurrency = "latest";
getModel() {
/* ... */
}
}

Multi-tab broadcast

Think broadcasts streaming responses to all connected WebSocket clients. When multiple browser tabs are connected to the same agent, all tabs see the streamed response in real time. Tool call states (pending, result, approval) are broadcast to all tabs.

Sub-agent RPC and programmatic turns

Think works as both a top-level agent and a sub-agent. When used as a sub-agent, the chat() method runs a full turn and streams events via a callback.

chat

TypeScript
async chat(
userMessage: string | UIMessage,
callback: StreamCallback,
options?: ChatOptions,
): Promise<void>

StreamCallback

MethodWhen it fires
onEvent(json)For each streaming chunk (JSON-serialized UIMessageChunk)
onDone()After the turn completes and the assistant message is persisted
onError(error)On error during the turn (if not provided, the error is thrown)

ChatOptions

FieldDescription
signalAbortSignal to cancel the turn mid-stream
toolsExtra tools to merge for this turn (highest merge priority)

Example: parent calling a child

JavaScript
import { Think } from "@cloudflare/think";
export class ParentAgent extends Think {
getModel() {
/* ... */
}
async delegateToChild(task) {
const child = await this.subAgent(ChildAgent, "child-1");
const chunks = [];
await child.chat(task, {
onEvent: (json) => {
chunks.push(json);
},
onDone: () => {
console.log("Child completed");
},
onError: (error) => {
console.error("Child failed:", error);
},
});
return chunks;
}
}
export class ChildAgent extends Think {
getModel() {
/* ... */
}
getSystemPrompt() {
return "You are a research assistant. Analyze data and report findings.";
}
}

Passing extra tools

The tools option adds tools for this turn only, with the highest merge priority:

JavaScript
import { tool } from "ai";
import { z } from "zod";
await child.chat("Summarize the report", callback, {
tools: {
fetchReport: tool({
description: "Fetch the report data",
inputSchema: z.object({}),
execute: async () => this.getReportData(),
}),
},
});

Aborting a sub-agent turn

Pass an AbortSignal to cancel mid-stream. When aborted, the partial assistant message is still persisted.

JavaScript
const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000);
await child.chat("Long analysis task", callback, {
signal: controller.signal,
});

saveMessages

Inject messages and trigger a model turn without a WebSocket connection. Use for scheduled responses, webhook-triggered turns, proactive agents, or chaining from onChatResponse.

TypeScript
async saveMessages(
messages:
| UIMessage[]
| ((current: UIMessage[]) => UIMessage[] | Promise<UIMessage[]>),
): Promise<SaveMessagesResult>

Returns { requestId, status } where status is "completed" or "skipped".

Static messages

JavaScript
await this.saveMessages([
{
id: crypto.randomUUID(),
role: "user",
parts: [{ type: "text", text: "Time for your daily summary." }],
},
]);

Function form

When multiple saveMessages calls queue up, the function form runs with the latest messages when the turn actually starts:

JavaScript
await this.saveMessages((current) => [
...current,
{
id: crypto.randomUUID(),
role: "user",
parts: [{ type: "text", text: "Continue your analysis." }],
},
]);

Scheduled responses

Trigger a turn from a cron schedule:

JavaScript
export class MyAgent extends Think {
getModel() {
/* ... */
}
async onScheduled() {
await this.saveMessages([
{
id: crypto.randomUUID(),
role: "user",
parts: [{ type: "text", text: "Generate the daily report." }],
},
]);
}
}

Chaining from onChatResponse

Start a follow-up turn after the current one completes:

TypeScript
async onChatResponse(result: ChatResponseResult) {
if (result.status === "completed" && this.needsFollowUp(result.message)) {
await this.saveMessages([{
id: crypto.randomUUID(),
role: "user",
parts: [{ type: "text", text: "Now summarize what you found." }],
}]);
}
}

continueLastTurn

Resume the last assistant turn without injecting a new user message. Useful after tool results are received or after recovery from an interruption.

TypeScript
protected async continueLastTurn(
body?: Record<string, unknown>,
): Promise<SaveMessagesResult>

Returns { requestId, status: "skipped" } if the last message is not an assistant message. The optional body parameter overrides the stored body for this continuation.

Chat recovery

Think can wrap chat turns in Durable Object fibers for durable execution. When a DO is evicted mid-turn, the turn can be recovered on restart.

JavaScript
export class MyAgent extends Think {
chatRecovery = true;
getModel() {
/* ... */
}
}

When chatRecovery is true, all four turn paths (WebSocket, auto-continuation, saveMessages, continueLastTurn) are wrapped in runFiber.

onChatRecovery

When an interrupted chat fiber is detected after DO restart, Think calls onChatRecovery:

JavaScript
export class MyAgent extends Think {
chatRecovery = true;
getModel() {
/* ... */
}
onChatRecovery(ctx) {
console.log(
`Recovering turn ${ctx.requestId}, partial: ${ctx.partialText.length} chars`,
);
return {
persist: true,
continue: true,
};
}
}

ChatRecoveryContext

FieldTypeDescription
streamIdstringThe stream ID of the interrupted turn
requestIdstringThe request ID of the interrupted turn
partialTextstringText generated before the interruption
partialPartsMessagePart[]Parts accumulated before the interruption
recoveryDataunknown | nullData from this.stash() during the turn
messagesUIMessage[]Current conversation history
lastBodyRecord<string, unknown>?Body from the interrupted turn
lastClientToolsClientToolSchema[]?Client tools from the interrupted turn

ChatRecoveryOptions

FieldTypeDescription
persistboolean?Whether to persist the partial assistant message
continueboolean?Whether to auto-continue with a new turn

With persist: true, the partial message is saved. With continue: true, Think calls continueLastTurn() after the agent reaches a stable state.

Stability detection

Think provides methods to check if the agent is in a stable state — no pending tool results, no pending approvals, no active turns.

hasPendingInteraction

Returns true if any assistant message has pending tool calls (tools without results or pending approvals).

TypeScript
protected hasPendingInteraction(): boolean

waitUntilStable

Returns a promise that resolves to true when the agent reaches a stable state, or false if the timeout is exceeded.

JavaScript
const stable = await this.waitUntilStable({ timeout: 30_000 });
if (stable) {
await this.saveMessages([
{
id: crypto.randomUUID(),
role: "user",
parts: [{ type: "text", text: "Now that you are done, summarize." }],
},
]);
}

Package exports

ExportDescription
@cloudflare/thinkThink, Session, Workspace — main class and re-exports
@cloudflare/think/tools/workspacecreateWorkspaceTools() — for custom storage backends
@cloudflare/think/tools/executecreateExecuteTool() — sandboxed code execution via codemode
@cloudflare/think/tools/extensionscreateExtensionTools() — LLM-driven extension loading
@cloudflare/think/extensionsExtensionManager, HostBridgeLoopback — extension runtime

Peer dependencies

PackageRequiredNotes
agentsyesCloudflare Agents SDK
aiyesVercel AI SDK v6
zodyesSchema validation (v4)
@cloudflare/shellyesWorkspace filesystem
@cloudflare/codemodeoptionalFor createExecuteTool

Acknowledgments

Think's design is inspired by Pi.

  • Sessions — context blocks, compaction, search, multi-session (the storage layer Think builds on)
  • Sub-agentssubAgent(), abortSubAgent(), deleteSubAgent() (the base Agent methods for spawning children)
  • Chat agentsAIChatAgent for when you need full control over the LLM call
  • Long-running agents — sub-agent delegation patterns for multi-week agent lifetimes
  • Durable executionrunFiber() and crash recovery (used by chatRecovery)
  • Browse the web — full CDP helper API reference