Skip to content

Think

Last updated View as MarkdownAgent setup

@cloudflare/think lets you build a stateful AI chat agent — one that streams replies, remembers the conversation, and calls tools — by extending a single base class. You provide a model with getModel(), and Think wires up the rest of the chat lifecycle for you: the agentic loop (the model calls tools, reads the results, and keeps going until it has an answer), message persistence, streaming, 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 (a child agent that another agent drives over RPC via chat()).

Quick start

Install

npm i @cloudflare/think @cloudflare/ai-chat agents ai @cloudflare/shell zod workers-ai-provider

Think supports AI SDK v6 and v7. Use ai@^6 with @ai-sdk/react@^3, or use ai@^7 with @ai-sdk/react@^4. Keep the AI SDK packages on matching major versions throughout your project.

Server

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.6",
		);
	}
}

export default {
	async fetch(request, env) {
		return (
			(await routeAgentRequest(request, env)) ||
			new Response("Not found", { status: 404 })
		);
	},
};
import { Think } from "@cloudflare/think";
import { createWorkersAI } from "workers-ai-provider";
import { routeAgentRequest } from "agents";

export class MyAgent extends Think<Env> {
	getModel() {
		return createWorkersAI({ binding: this.env.AI })(
			"@cf/moonshotai/kimi-k2.6",
		);
	}
}

export default {
	async fetch(request: Request, env: Env) {
		return (
			(await routeAgentRequest(request, env)) ||
			new Response("Not found", { status: 404 })
		);
	},
} satisfies ExportedHandler<Env>;

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

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>
	);
}
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",
					) as HTMLInputElement;
					sendMessage({ text: input.value });
					input.value = "";
				}}
			>
				<input name="input" placeholder="Send a message..." />
				<button type="submit">Send</button>
			</form>
		</div>
	);
}

Configuration

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

[ai]
binding = "AI"

[[durable_objects.bindings]]
class_name = "MyAgent"
name = "MyAgent"

[[migrations]]
new_sqlite_classes = ["MyAgent"]
tag = "v1"

Tracing

Think uses wrapAISDK() internally to instrument model turns, tool calls, and approval lifecycle segments. You do not need to wrap the AI SDK or configure an adapter. To send invoke_agent, chat, execute_tool, and tool_approval spans to Workers Observability, turn on Workers traces:

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "observability": {
    "traces": {
      "enabled": true
    }
  }
}
[observability.traces]
enabled = true

Traces appear in the Agents view in the Cloudflare Dashboard. You can inspect conversations and trace timelines. To turn tracing off, set observability.traces.enabled to false. You do not need to change the Think agent class.

For span attributes, payload controls, exporting traces, and direct AI SDK v6 or v7 setup, refer to Tracing.

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.

Concern AIChatAgent Think
Minimal subclass ~15 lines (wire streamText + tools + system prompt + response) 3 lines (getModel() only)
Storage Flat SQL table Session: tree-structured messages, context blocks, compaction, FTS5
Regeneration Destructive (old response deleted) Non-destructive branching (old responses preserved)
Context management Manual Context blocks with LLM-writable persistent memory
Sub-agent RPC Not built in chat() with StreamCallback
Programmatic turns saveMessages() saveMessages(), submitMessages(), continueLastTurn()
Compaction maxPersistedMessages (deletes oldest) Non-destructive summaries via overlays
Search Not available FTS5 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)
  • You need durable async submission for webhook or RPC callers

Choose a turn API

Think has several ways to start or continue a turn. They all funnel through one public entry point — runTurn(options) — and the older methods remain as convenience shortcuts.

runTurn()

runTurn() is the unified turn-admission API. One method, three modes, selected by options.mode:

Mode Use when Returns Shortcut for
"wait" (default) The caller can block until the model response is finished Promise<TurnResult> saveMessages()
"submit" The caller needs fast, durable acceptance and a later status Promise<SubmitMessagesResult> submitMessages()
"stream" The caller wants the response streamed to a callback (RPC) Promise<void> chat()

The input accepts a string, a UIMessage, an array of messages, or — in wait and stream modes — a function (current) => UIMessage[] evaluated at admission. (submit does not accept function input.)

export class Assistant extends Think {
	async examples(inboundEventId) {
		// wait — block for the result
		const result = await this.runTurn({ input: "Summarize the latest thread" });
		if (result.status === "completed") {
			// result.message is the assistant message; result.continuation is false
		}

		// submit — durable acceptance, check status later
		const submission = await this.runTurn({
			mode: "submit",
			input: "Process this webhook",
			idempotencyKey: inboundEventId, // dedupe; safe to retry
		});
		// submission.accepted is true on first accept; submission.status is "pending"

		// stream — drive a callback (the same surface as chat())
		await this.runTurn({
			mode: "stream",
			input: "Stream me",
			callback: {
				onStart({ requestId }) {},
				onEvent(json) {}, // UIMessageChunk JSON
				onDone() {},
				onError(error) {},
			},
		});

		// continuation — continue the last assistant turn instead of sending input
		await this.runTurn({ continuation: true });
	}
}
export class Assistant extends Think<Env> {