Skip to content

Latest commit

 

History

History

think

README.md

@cloudflare/think

An opinionated chat agent base class for Cloudflare Workers. Handles the full chat lifecycle — agentic loop, streaming, persistence, client tools, stream resumption — all backed by Durable Object SQLite.

Works as both a top-level agent (WebSocket chat protocol for browser clients) and a sub-agent (RPC streaming from a parent agent).

Experimental — the API surface is stable but may evolve before graduating out of experimental.

Quick start

Create an explicit Worker entry:

import { Think } from "@cloudflare/think";
import { routeAgentRequest } from "agents";

export class MyAgent extends Think<Env> {
  getModel() {
    return "@cf/moonshotai/kimi-k2.7-code";
  }

  getSystemPrompt() {
    return "You are a helpful coding assistant.";
  }
}

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

Export the class from the Worker entry and configure its binding and migration:

{
  "main": "src/server.ts",
  "compatibility_date": "2026-06-11",
  "compatibility_flags": ["nodejs_compat"],
  "ai": { "binding": "AI" },
  "durable_objects": {
    "bindings": [{ "class_name": "MyAgent", "name": "MyAgent" }]
  },
  "migrations": [{ "new_sqlite_classes": ["MyAgent"], "tag": "v1" }]
}

Think handles the WebSocket chat protocol, message persistence, the agentic loop, message sanitization, stream resumption, client tool support, and workspace file tools. Connect from the browser with useAgentChat from @cloudflare/think/react.

Messengers

Think can own messenger ingress directly. Declare providers with getMessengers() and import provider implementations from subpaths so unused Chat SDK adapters are not bundled.

import { Think } from "@cloudflare/think";
import { ThinkMessengerStateAgent } from "@cloudflare/think/messengers";
import telegramMessenger from "@cloudflare/think/messengers/telegram";

export { ThinkMessengerStateAgent };

export class SupportAgent extends Think<Env> {
  getMessengers() {
    return {
      telegram: telegramMessenger({
        token: this.env.TELEGRAM_BOT_TOKEN,
        userName: "support_bot",
        secretToken: this.env.TELEGRAM_WEBHOOK_SECRET_TOKEN
      })
    };
  }
}

The root Think agent handles the webhook route with this precedence: sub-agent routing, Think internal routes, messenger routes, then user onRequest. By default, telegram maps to /messengers/telegram/webhook, direct messages and mentions are routed to the agent, and new mentions subscribe the thread so later mentions in the same thread are still observed. Ordinary subscribed-thread messages and button actions are opt-in with respondTo: ["subscribed-thread", "action"]. Each Chat SDK thread runs in its own Think sub-agent to avoid accidental context sharing. Each root agent owns one Chat SDK runtime for all configured messengers, so multi-provider agents do not fight over Chat SDK singleton state.

Use conversation: "self" when all messenger traffic should share the root agent's memory. Use a custom conversation(event) resolver to route by thread, channel, tenant, or user.

Messenger state uses agents/chat-sdk under the hood. Export ThinkMessengerStateAgent from the Worker module so sub-agent routing can resolve it; production apps do not need to add a separate durable object binding or migration for this facet-only class. Test harnesses may still need explicit bindings.

Inbound messenger replies use the streamed chat() path by default: the root agent starts an idempotent fiber, resolves the conversation target, calls target.chat(message, callback), and lets the provider delivery policy post or edit messages. Recovery snapshots store only serializable event/thread data, so interrupted replies can either resume before streaming starts or post the configured interruption message after streaming has begun. submitMessages() remains the right primitive for non-streaming programmatic sends, scheduled digests, or background work.

During a messenger turn, getMessengerContext() returns the initiating messenger context even after assistant messages are persisted. Telegram webhook verification is explicit: provide secretToken, a custom verifyWebhook, or verifyWebhook: false when intentionally running without verification. Custom messengers built with chatSdkMessenger() must make the same choice explicitly. Delivery failures use a generic user-facing error by default so internal exception details are not posted into external chats.

Provider-neutral events include thread, author, message, action, capabilities, and attachment metadata. Attachment bytes are only fetched when a provider supplies a safe fetch function. Telegram is the first provider implementation; future Slack, Discord, or Teams entrypoints implement the same messenger contract from their own subpaths.

Common messenger options:

Option Default Description
path /messengers/{id}/webhook Webhook path handled before user onRequest
respondTo ["direct-message", "mention"] Event kinds that should start a Think reply
subscribeOnMention true Subscribe Chat SDK threads after a new mention
conversation "thread" Use one Think sub-agent per Chat SDK thread; set "self" to use the root agent
channelSpeakerLabel fullName || userName || userId Channel (non-DM) messages are rendered as SpeakerName: text. Pass a formatter to customize or suppress labels. DMs never get a prefix.
verifyWebhook required Verification function, or false to opt out explicitly
delivery provider defaults Streaming limits, text splitting, and safe user-facing failure messages

Agent tools

Think subclasses can be dispatched as agent tools from another Agent. The parent uses runAgentTool() or agentTool() from agents/agent-tools; the child Think instance owns its own messages, resumable stream, tools, and storage.

import { Think } from "@cloudflare/think";
import { agentTool } from "agents/agent-tools";
import { z } from "zod";

export class Researcher extends Think<Env> {
  getSystemPrompt() {
    return "Research the requested topic and end with a concise summary.";
  }
}

export class Assistant extends Think<Env> {
  getTools() {
    return {
      research: agentTool(Researcher, {
        description: "Research one topic in depth.",
        inputSchema: z.object({ query: z.string().min(3) }),
        displayName: "Researcher"
      })
    };
  }
}

The parent broadcasts agent-tool-event frames for live UI rendering and keeps the child facet until clearAgentToolRuns() deletes retained runs.

See the full Agent Tools guide for rendering, drill-in, and cleanup patterns.

Built-in workspace

Every Think agent gets this.workspace: a Workspace from @cloudflare/computer, stored in the agent's own SQLite. The @cloudflare/computer tools (read, ls, find, grep, write, edit, delete) are available to the model on every turn.

export class MyAgent extends Think<Env> {
  getModel() { ... }
  // this.workspace is ready to use, and the workspace tools join every turn
}

The model gets an exec tool once the Workspace has execution backends. Configure the backends, and the modules they offer, on the Workspace:

import { Workspace } from "@cloudflare/computer";
import { CodemodeBackend } from "@cloudflare/computer/backends/codemode";
import { createGitModule } from "@cloudflare/computer/modules/git";

export class MyAgent extends Think<Env> {
  override workspace = new Workspace({
    storage: this.ctx.storage,
    backends: [
      new CodemodeBackend({
        loader: this.env.LOADER,
        modules: { "codemode:git": createGitModule() }
      })
    ]
  });
}

To change a built-in tool, return a tool with the same name from getTools(). To hide tools for a turn, return activeTools from beforeTurn.

Give a backend a codemode:tools module, and code the model runs with exec can import the agent's tools; files come from node:fs:

import { createToolBindings } from "@cloudflare/computer/modules/tools";
import { forAISDKTools } from "@cloudflare/computer/tools/ai-sdk";

new CodemodeBackend({
  loader: this.env.LOADER,
  modules: {
    "codemode:tools": createToolBindings(forAISDKTools(() => this.getTools()))
  }
});
import { getWeather } from "codemode:tools";
export default async () => (await getWeather({ city: "Lisbon" })).text;

Every Think agent can hand out its own Workspace: getWorkspace(stub) from @cloudflare/computer calls the agent's __getWorkspaceStub() over RPC.

Agent Skills

Think supports Agent Skills. A skill is a directory with a SKILL.md file. Return skill sources from getSkills(); Think adds the skill catalog to the prompt and exposes activate_skill and read_skill_resource tools when skills are available.

Keep skills as files in the agent's Workspace. Every /workspace/.agents/skills/<name>/SKILL.md is a skill, and the files beside it are its resources:

import { Think, skills } from "@cloudflare/think";

export class MyAgent extends Think<Env> {
  getSkills() {
    return [
      skills.workspace(this.workspace),
      skills.r2(this.env.SKILLS_BUCKET, { prefix: "skills/" })
    ];
  }
}
/workspace/.agents/skills/release-notes/SKILL.md
/workspace/.agents/skills/release-notes/scripts/format-release-notes.js
/workspace/.agents/skills/release-notes/references/style-guide.md

Sources are listed again before every turn, so a skill written, edited, or deleted in the Workspace takes effect on the next turn. skills.r2() reads the same layout from a bucket, and skills.manifest(...) defines skills in code. Sources are applied in order: the first to provide a skill name wins, and duplicate or failing sources are skipped with a logged warning.

Each SKILL.md is checked against the spec and loaded the way the spec's guide for agents recommends: a skill whose name breaks the format or does not match its directory still loads, with a logged warning. Skills with no description or unreadable frontmatter are skipped and logged. To check a skill before writing it, parse it with skills.parseSkill() and require warnings to be empty. Unknown frontmatter keys are kept in skill.extraFields.

Text resources are returned directly; binary assets are returned as base64. read_skill_resource accepts { name, path } or a qualified path such as release-notes/references/style-guide.md. Skills from a Workspace also report their directory, so a model with the Workspace exec tool can run their scripts in place.

The parser, sources, and catalog live in agents/skills and work without Think; @cloudflare/think re-exports the module as skills and wires getSkills() into the turn.

Skills are on-demand instructions, not always-on system prompt text. The model sees the catalog first, then calls activate_skill when a user task matches a skill description. Use a context block from configureContext() for behavior that should apply to every turn, especially when the agent also uses skills. An overridden getSystemPrompt() stays at the top of the system prompt, above the context blocks and the skills catalog. When skills change mid-conversation, the frozen system prompt stays the same; the new catalog reaches the model as a reminder until the next compaction or history clear.

Skill script runner

For skills outside a Workspace (for example in R2), getSkillScriptRunner() enables run_skill_script. It is experimental; prefer keeping skills in a Workspace and running scripts with exec. The runner runs JavaScript, Python, and Bash scripts under scripts/. A JavaScript script must be one self-contained module; compile TypeScript or multi-file scripts before storing the skill.

export default async function run(input, ctx) {
  const guide = ctx.files["references/style-guide.md"]; // bundled text resources
  const summary = await ctx.tools.call("summarize", { input }); // explicit tools
  await ctx.output.writeFile("notes.md", summary); // scratch artifact
  return { ok: true };
}

ctx is { skill, files, workspace, tools, output } (SkillRunContext): ctx.files holds bundled text resources by relative path, ctx.workspace is gated by the workspace permission, ctx.tools exposes only the tools the runner was given, and ctx.output.writeFile(name, content) returns scratch artifacts without mutating the workspace. Python and Bash scripts instead use the path-based contract: /input.json, /context.json, bundled resources under /skill, and /output for artifacts (Python supports both def run(input, ctx) and CLI-style scripts).

If workspaceInstance is provided, scripts get read-only workspace access by default. Workspace writes, tools, and network access are opt-in. Scripts default to a 30 second timeout, which can be overridden with timeout. Python scripts run as Python Dynamic Workers; Bash scripts run through just-bash.

Script execution requires a Worker Loader binding:

{
  "worker_loaders": [{ "binding": "LOADER" }]
}

Exports

Export Description
@cloudflare/think Think, Session — main class + re-exports
agents/sessions Sessions, Session, createCompactFunction, attachments
agents/context ContextBlocks, providers, ContextConfig — prompt assembly
@cloudflare/think/messengers Messenger contracts, Chat SDK bridge, state agent, delivery
@cloudflare/think/messengers/telegram Telegram messenger provider and delivery helpers
@cloudflare/think/tools/fetch createFetchTools() — opt-in allowlisted HTTP reads
@cloudflare/think/tools/execute createExecuteTool() — sandboxed code execution via codemode
@cloudflare/think/tools/extensions createExtensionTools() — LLM-driven extension loading
@cloudflare/think/extensions ExtensionManager, HostBridgeLoopback — extension runtime

Think

Configuration

Method / Property Default Description
getModel() throws Return the LanguageModel to use
getSystemPrompt() careful assistant operating prompt Instructions at the top of the system prompt, above context blocks. The default is used only with no context blocks
getTools() {} AI SDK ToolSet for the agentic loop
getMessengers() {} Messenger ingress and delivery declarations
getScheduledTasks() {} Code-declared recurring prompts
getScheduledTasksScope() "root" Which instances arm declared tasks — "root" or "all" (sub-agents too)
getDefaultTimezone() undefined Default timezone for wall-clock schedules
maxSteps 10 Max tool-call rounds per turn (property)
sendReasoning true Send reasoning chunks to chat clients
messageMetadata undefined Default writer for server-authored assistant-message metadata (override per turn via TurnConfig)
configureSession() identity Configure the default session handle: compaction and search
configureContext() [] Declare prompt context blocks. See Session and context
hydrationByteBudget 32 MiB Byte budget for startup transcript hydration. Charges each row its full stored size, including the continuation rows a large message is split across
mediaEviction true Media eviction policy: aged media leaves the conversation and is preserved as a Workspace file. false keeps aged media in the conversation
getSkills() [] First-class Agent Skills sources
getSkillScriptRunner() null Optional runner for run_skill_script
getExtensions() [] Sandboxed extension declarations (load order)
extensionLoader undefined WorkerLoader binding — enables extensions
fetchTools false Opt-in allowlisted HTTP read tools (fetch_url + per-binding fetch_<name>). Set to a config object; see Fetch tool
includeMcpTools true Automatically convert connected MCP tools to AI SDK tools and merge them into model turns
waitForMcpConnections false Wait for MCP connections to settle before inference
chatRecovery Always on Durable recovery configuration. See ChatRecoveryConfig for all options and defaults
chatStreamStallTimeoutMs 0 (off) Opt-in inactivity watchdog that routes a stalled model stream into bounded recovery instead of leaving the client spinning indefinitely
contextOverflow undefined Opt-in mid-turn context-overflow handling: { reactive?, maxRetries?, proactive? }. Requires classifyChatError + a session compaction function. See Context-window overflow recovery

On each turn, Think appends a small capability block to the assembled system prompt. The block is based on the tools available for that turn, so models learn about workspace tools, context-loading tools, extension tools, sandboxed execution, MCP/client tools, and delegated-agent tools only when they are actually exposed.

Think always enables Durable Object eviction recovery. This is separate from client resumable streaming: resumable streaming handles browser disconnect/reconnect while the object keeps running, while durable chat recovery handles turns interrupted by process restarts, deploys, or object eviction. chatRecovery = false is no longer supported; assign an object only to tune recovery.

chatStreamStallTimeoutMs is a separate, opt-in safety net for a different failure: a model stream that parks without ever throwing (no chunk, no error, no done), which otherwise leaves the client spinning forever. When set, if no UI-message-stream chunk arrives within the window the watchdog aborts the turn and a chat:stream:stalled observability event fires. The stall is then routed into the same bounded recovery path as a deploy/eviction interruption: the settled partial is preserved and a continuation is scheduled, so a transient hang recovers automatically. A persistently hanging provider still terminalizes once the recovery budget is exhausted — and it exhausts through the same path as deploy recovery, so your configured terminalMessage is shown, onExhausted fires, and the chat:recovery:exhausted event is emitted (you do not get the raw "Chat stream stalled…" error). When the stalled turn is a sub-agent dispatched via runAgentTool(), a recovering stall closes the RPC stream without firing onError/onDone — the scheduled continuation owns the real terminal outcome, so the parent observes a (slightly delayed) completion rather than an error, exactly as it would for a deploy-interrupted child. It is off by default because it measures the gap between stream chunks, which includes server-side tool execution time (no chunks flow while a tool runs) — set it comfortably above your slowest model time-to-first-token and slowest tool, e.g. 120_000, or you will abort healthy long turns. For a turn you know will invoke a slow tool, return { chatStreamStallTimeoutMs } from beforeTurn (a TurnConfig field) to raise or disable (0) the watchdog for that one turn instead of permanently widening the global window; it auto-resets afterward.

Override onChatRecovery(ctx) when you need provider-specific recovery. The default behavior persists partial assistant output and continues or retries when safe:

import type {
  ChatRecoveryContext,
  ChatRecoveryOptions
} from "@cloudflare/think";

export class MyAgent extends Think<Env> {
  override chatRecovery = {
    maxAttempts: 6,
    terminalMessage: "The assistant was interrupted. Please try again."
  };

  override async onChatRecovery(
    ctx: ChatRecoveryContext
  ): Promise<ChatRecoveryOptions> {
    console.log("Recovering", ctx.incidentId, ctx.attempt, ctx.recoveryKind);
    return {};
  }
}

When a turn is interrupted mid-flight, an unsettled tool call left in the transcript is repaired before the next provider call so the model does not re-run it (and the provider does not 400 with AI_MissingToolResultsError). The default flips it to an errored tool result; override repairInterruptedToolPart to customize the repaired shape — for example, convert an interrupted client-resolved ask_user (a question with no server execute) into a plain text part carrying the prompt so the model treats it as ordinary conversation:

protected override repairInterruptedToolPart(
  part: UIMessage["parts"][number]
): UIMessage["parts"][number] {
  const record = part as Record<string, unknown>;
  if (record.type === "tool-ask_user") {
    const input = record.input as { prompt?: string } | undefined;
    if (input?.prompt) return { type: "text", text: input.prompt };
  }
  return super.repairInterruptedToolPart(part);
}

While a turn is being recovered, Think broadcasts a cf_agent_chat_recovering status (and replays it on connect) so clients can show a "recovering…" indicator instead of looking frozen — surface it on the client with useAgentChat's isRecovering flag. It is set when a recovery continuation is scheduled and cleared on every terminal outcome, so the indicator never spins forever. To record recovery counts or reasons in your own analytics, subscribe to the chat:recovery:* observability events and route them to your sink.

Context-window overflow recovery

Compaction (compactAfter()) is checked between turns. A single long, tool-heavy turn can grow the prompt past the model's context window mid-turn, before the next check — the provider then rejects the request ("prompt is too long" / context_length_exceeded). Two opt-in, provider-agnostic layers recover from this (both off by default; both reuse your session's compaction function):

import { Think, defaultContextOverflowClassifier } from "@cloudflare/think";

export class MyAgent extends Think<Env> {
  override contextOverflow = {
    // Reactive: compact + re-run a turn that overflows (bounded by maxRetries;
    // terminalizes via onChatError if it cannot help).
    reactive: true,
    // Proactive: compact mid-turn before a step crosses 90% of the window.
    proactive: { maxInputTokens: 200_000 }
  };

  // Teach Think which errors are overflows. The bundled classifier covers the
  // common providers; assign it directly or wrap it to add your own categories.
  override classifyChatError = defaultContextOverflowClassifier;
}

Use either layer alone or both together. The proactive guard keys off model-reported usage.inputTokens (no provider strings); the reactive backstop catches anything that still overflows. The two caps are independent: maxRetries (default 1) bounds reactive compact-and-retries, while proactive.maxCompactions (default 1) bounds in-place compactions per turn. Both emit a chat:context:compacted observability event. Recovery is only as effective as your compaction configuration — a no-op compaction cannot rescue an over-budget turn. See the Think docs for details.

Scheduled tasks

Use getScheduledTasks() for code-declared recurring prompts or deterministic scheduled handlers. Think reconciles these declarations on startup, stores durable one-shot schedules for the next occurrence, and re-arms the next occurrence after each run.

import { Think } from "@cloudflare/think";
import type { ThinkScheduledTasks } from "@cloudflare/think";

export class Assistant extends Think<Env> {
  getDefaultTimezone() {
    return "Europe/London";
  }

  getScheduledTasks(): ThinkScheduledTasks {
    return {
      weeklyCommitReport: {
        schedule: "every week on monday at 09:00",
        prompt:
          "Compile all my GitHub commits for the last week and write a concise summary."
      },
      workout: {
        schedule: "every day at 08:00 in Europe/London",
        prompt: "Start my workout."
      },
      customerDigest: {
        schedule: "every day at 09:00",
        timezone: "America/New_York",
        metadata: { workflowName: "customer-digest" },
        retry: { maxAttempts: 3 },
        handler: async ({
          idempotencyKey,
          scheduledFor,
          scheduleKind,
          timezone
        }) => {
          await this.env.DIGEST_WORKFLOW.create({
            id: idempotencyKey,
            params: { scheduledFor, scheduleKind, timezone }
          });
        }
      }
    };
  }
}

The DSL is intentionally small: every <n> minutes, every <n> hours, every day at HH:mm, every weekday at HH:mm, and every week on monday,wednesday at HH:mm. Wall-clock schedules require either an inline timezone, a task timezone, or getDefaultTimezone(). If an alarm is late, Think runs the intended occurrence once and schedules the next future occurrence; it does not backfill missed runs.

The return type annotation gives TypeScript literal checks for schedule strings. Think also validates scheduled tasks at runtime during startup reconciliation, so dynamically built objects still fail before schedules are persisted.

Each task must define exactly one of prompt or handler. Prompt tasks create a durable submission with submitMessages(). Handler tasks receive { taskId, scheduledFor, scheduledForDate, occurrenceKey, idempotencyKey, schedule, scheduleKind, timezone, metadata } and are intended for app-owned work such as creating a Workflow run or writing a run ledger. Delivery is at-least-once; use idempotencyKey or occurrenceKey for your own durable idempotency.

Declared tasks are armed on the root agent only. Because getScheduledTasks() is normally a static declaration, it returns the same tasks on every instance of the class, so arming it on sub-agents as well would dispatch each occurrence once per live sub-agent on top of the root. Override getScheduledTasksScope() to return "all" when a class genuinely declares different tasks per sub-agent — each sub-agent then owns an independent schedule.

Static declarations reconcile on startup. If getScheduledTasks() reads product-owned data that can change while the Durable Object is live, call internal_reconcileScheduledTasks() after updating that data. During reconciliation Think records the task row before creating the underlying Agent schedule, so a schedule_id may be temporarily empty if the object is interrupted mid-reconcile; the next reconcile repairs that pending row. The task retry option retries the prompt or handler action before the failure is logged. The next occurrence is still scheduled after the action succeeds or exhausts its retries, so failed occurrences do not block future runs.

Lifecycle hooks

Think owns the streamText call. Hooks fire on every turn regardless of entry path (WebSocket, chat(), saveMessages(), durable submitMessages() execution, continueLastTurn(), auto-continuation).

Hook When it fires Return
beforeTurn(ctx) Before streamText — see assembled context TurnConfig overrides or void
beforeStep(ctx) Before each model step StepConfig overrides or void
beforeToolCall(ctx) Before tool's execute runs ToolCallDecision or void
afterToolCall(ctx) After tool execution (success or failure) void
onStepFinish(ctx) After each step completes void
onChunk(ctx) Per streaming chunk (high-frequency) void
onChatResponse(result) After turn completes + message persisted void
onChatError(error, ctx) On error during a turn error to propagate
classifyChatError(error, ctx) On a turn error, when contextOverflow.reactive is on ChatErrorClassification or void

onChatError receives ctx.stage, ctx.requestId, and ctx.messagesPersisted so apps can distinguish pre-persist request failures from stream failures. The same failures emit chat:request:failed observability events. ctx.classification is set to "context_overflow" on the terminal onChatError when a context overflow could not be recovered, and undefined otherwise.

classifyChatError maps a raw provider error to a provider-agnostic category ("context_overflow" | "rate_limit" | "transient" | "fatal" | "unknown"). Think ships no provider-specific matching in core: the app owns it, the same split as the summarize function passed to createCompactFunction(). Today it drives only context-overflow recovery: it is consulted when a turn errors and contextOverflow.reactive is enabled, and only "context_overflow" is acted on (other categories are reserved for future use). For the common case, assign the exported defaultContextOverflowClassifier (it matches the context-overflow errors of Anthropic, OpenAI, Google, Bedrock, and others).

The AI SDK-derived contexts spread the SDK's own types at the top level — no information is dropped:

Context Backed by
PrepareStepContext<TOOLS> Parameters<PrepareStepFunction<TOOLS>>[0] (steps, stepNumber, model, messages, experimental_context)
ToolCallContext<TOOLS> TypedToolCall<TOOLS> + per-call extras from OnToolCallStartEvent (stepNumber, messages, abortSignal)
ToolCallResultContext<TOOLS> TypedToolCall<TOOLS> + per-call extras (toolExecutionMs, messages, stepNumber) + toolOutput; deprecated durationMs/success/output/error aliases remain
StepContext<TOOLS> StepResult<TOOLS> (full step incl. reasoning, sources, files, usage, providerMetadata, request, response, warnings)
ChunkContext<TOOLS> Parameters<StreamTextOnChunkCallback<TOOLS>>[0] (discriminated TextStreamPart)

beforeStep is wired to the AI SDK's prepareStep callback. Return a StepConfig to override model, toolChoice, activeTools, instructions, messages, experimental_context, or providerOptions for the current step. The previous system name remains as a deprecated alias. The AI SDK does not expose output or maxSteps per step — set those at the turn level via TurnConfig (returned from beforeTurn). beforeStep is subclass-only; it is not dispatched to extensions because the prepareStep event surface includes a live LanguageModel instance which is not JSON-safe to snapshot.

TurnConfig also accepts sendReasoning to override whether reasoning chunks are emitted for the current UI message stream. The instance-level sendReasoning property defaults to true; return { sendReasoning: false } from beforeTurn to hide reasoning for a single turn, for example on internal continuation turns.

TurnConfig.messageMetadata writes server-authored metadata onto the assistant message a turn persists — the same AI SDK messageMetadata callback base AIChatAgent + streamText accept, now forwarded through Think. It is called with each stream part; return a JSON-serializable object (typically from the start and/or finish part) and each return is shallow-merged into the message's metadata. An auto-continuation is its own turn: beforeTurn runs again with ctx.continuation: true, and the continuation persists as a separate assistant message with its own metadata. Its return value is broadcast to clients and persisted, so it must not carry server-only secrets. Set the instance-level messageMetadata property for turn-independent metadata (e.g. stamping a createdAt timestamp on every assistant message); return messageMetadata from beforeTurn to override it for a single turn. Because it is a function, configure it from a Think subclass — sandboxed extension hooks cannot provide it over RPC.

TurnConfig also accepts stable AI SDK streamText call settings such as maxOutputTokens, temperature, stopSequences, seed, maxRetries, timeout, and headers. Use them to tune model behavior per turn, for example disabling retries or adding a chunk timeout during recovery flows.

TurnConfig.stopWhen accepts AI SDK stop conditions such as hasToolCall("finalAnswer") for ending a turn early. Think composes these with its own maxSteps bound, so a custom condition can stop before the cap without removing the safety limit. Because stop conditions are functions, return stopWhen from a Think subclass's beforeTurn; sandboxed extension hooks cannot provide it over RPC.

TurnConfig.repairToolCall repairs a complete tool call that the AI SDK cannot parse or validate. Return the original raw call with a corrected input JSON string, or null when it cannot be repaired. The AI SDK revalidates the returned call before Think's beforeToolCall hook and tool execution. Configure this function from a Think subclass; sandboxed extension hooks cannot provide it over RPC.

TurnConfig also accepts an output field that is forwarded to streamText as the AI SDK's structured-output spec. Combine with activeTools: [] for providers (e.g. workers-ai-provider) that strip tools when responseFormat: "json" is active. Use telemetry to pass the AI SDK's per-call telemetry settings through to streamText; the previous experimental_telemetry name remains as a deprecated alias. Trace payload storage is separately controlled by the agent fields storeMessages (chat messages) and storeTools (tool arguments/results); both default to false. Stored messages follow the OpenTelemetry GenAI schemas: { role, parts }, { type, content } for text/reasoning, tool_call / tool_call_response for tools, and finish_reason on model output.

Per-tool hooks are wired so beforeToolCall fires before execute (Think wraps every tool's execute) and afterToolCall fires after (via the AI SDK's onToolExecutionEnd) with toolExecutionMs and toolOutput. Deprecated durationMs and success/output/error aliases remain for compatibility. beforeToolCall can return a ToolCallDecision to:

  • { action: "allow", input? } — run the original execute, optionally with a substituted input.
  • { action: "block", reason? } — skip execute; the model sees reason as the tool's output.
  • { action: "substitute", output } — skip execute; the model sees output as the tool's output.

Pass an explicit TOOLS generic when you want full input typing:

import type {
  PrepareStepContext,
  StepContext,
  ToolCallContext,
  ToolCallResultContext
} from "@cloudflare/think";

const tools = { search: tool({ inputSchema: z.object({ query: z.string() }), ... }) };

beforeStep(ctx: PrepareStepContext<typeof tools>) {
  if (ctx.stepNumber === 0) {
    return {
      activeTools: ["search"],
      toolChoice: { type: "tool", toolName: "search" }
    };
  }
}

beforeToolCall(ctx: ToolCallContext<typeof tools>) {
  if (ctx.toolName === "search") {
    ctx.input.query; // typed as string
    // Clamp the model's `limit` before the tool runs.
    return {
      action: "allow",
      input: { ...ctx.input, limit: Math.min(ctx.input.limit ?? 10, 50) }
    };
  }
}

afterToolCall(ctx: ToolCallResultContext<typeof tools>) {
  if (ctx.toolOutput.type === "tool-result") {
    console.log(`${ctx.toolName} ok in ${ctx.toolExecutionMs}ms`, ctx.toolOutput.output);
  } else {
    console.error(`${ctx.toolName} failed:`, ctx.toolOutput.error);
  }
}

onStepEnd(ctx: StepContext<typeof tools>) {
  // Provider-specific cache accounting (Anthropic example)
  const anthropic = ctx.providerMetadata?.anthropic as
    | { cacheCreationInputTokens?: number; cacheReadInputTokens?: number }
    | undefined;
  console.log("cache read:", anthropic?.cacheReadInputTokens ?? 0);
}

Field rename note: the per-tool contexts use the AI SDK's input and toolOutput (formerly args/result in earlier Think versions). Deprecated success/output/error aliases remain, but prefer narrowing on ctx.toolOutput.type.

Extension hook subscriptions

Extensions can subscribe to beforeTurn, beforeToolCall, afterToolCall, onStepFinish, and onChunk via their manifest's hooks array. Think dispatches to extension-side handlers in load order with a JSON-safe snapshot of the event. beforeStep is available to subclasses only and is not dispatched to extensions.

// extension source (loaded via getExtensions())
({
  tools: {/* ... */},
  hooks: {
    beforeToolCall: async (snapshot, host) => {
      /* observation */
    },
    afterToolCall: async (snapshot, host) => {
      await host?.writeFile(
        `logs/${snapshot.toolName}.json`,
        JSON.stringify(snapshot)
      );
    },
    onStepFinish: async (snapshot, host) => {
      /* observation */
    }
    // onChunk is also supported but fires per token — use sparingly.
  }
});

The handler signature is (snapshot, host) => void, symmetric with tool execute. Errors from extension hooks are caught and logged; they do not abort the turn. Only beforeTurn honors return values — the other extension hooks are observation-only. See docs/think/lifecycle-hooks.md for the full snapshot shapes.

beforeTurn example

export class MyAgent extends Think<Env> {
  getModel() { ... }

  // Switch to a cheaper model for continuation turns
  beforeTurn(ctx: TurnContext) {
    if (ctx.continuation) {
      return { model: this.cheapModel };
    }
  }
}

TurnConfig — what you can override per-turn

interface TurnConfig {
  model?: LanguageModel; // override model
  instructions?: string; // override instructions prompt
  /** @deprecated Prefer instructions. */
  system?: string;
  messages?: ModelMessage[]; // override assembled messages
  tools?: ToolSet; // extra tools to merge (additive)
  activeTools?: string[]; // limit which tools the model can call
  toolChoice?: ToolChoice; // force a specific tool
  maxSteps?: number; // override maxSteps for this turn
  stopWhen?: StopCondition | StopCondition[]; // additional early-exit conditions
  sendReasoning?: boolean; // send reasoning chunks for this turn
  messageMetadata?: MessageMetadataCallback; // write assistant-message metadata for this turn
  maxOutputTokens?: number;
  temperature?: number;
  topP?: number;
  topK?: number;
  presencePenalty?: number;
  frequencyPenalty?: number;
  stopSequences?: string[];
  seed?: number;
  maxRetries?: number;
  timeout?: TimeoutConfiguration;
  headers?: Record<string, string | undefined>;
  providerOptions?: Record<string, unknown>;
  repairToolCall?: ToolCallRepairFunction;
  telemetry?: TelemetrySettings;
  /** @deprecated Prefer telemetry. */
  experimental_telemetry?: TelemetrySettings;
}

Client tools

Think supports client-defined tools that execute in the browser. The client sends tool schemas in the chat request body, and Think merges them with server tools automatically.

When the LLM calls a client tool, the tool call chunk is sent to the client. The client executes it and sends back CF_AGENT_TOOL_RESULT. Think applies the result, persists the updated message, broadcasts CF_AGENT_MESSAGE_UPDATED, and optionally auto-continues the conversation.

Tool approval flows are also supported via CF_AGENT_TOOL_APPROVAL.

Session and context

Think splits conversation storage from prompt assembly. agents/sessions stores messages; agents/context builds the system prompt. Think wires both during onStart.

configureSession() configures the default session handle: compaction and search.

import { createCompactFunction, type Session } from "agents/sessions";

export class MyAgent extends Think<Env> {
  getModel() { ... }

  configureSession(session: Session) {
    return session
      .onCompaction(
        createCompactFunction({
          summarize: (prompt) => this.summarize(prompt),
          keepRecentTokens: 20_000
        })
      )
      .compactAfter(100_000);
  }
}

configureContext() declares the prompt blocks:

import type { ContextConfig } from "agents/context";

export class MyAgent extends Think<Env> {
  configureContext(): ContextConfig[] {
    return [
      { label: "soul", provider: { get: async () => "You are helpful." } },
      { label: "memory", description: "Learned facts", maxTokens: 2_000 }
    ];
  }
}

A block declared without a provider is auto-wired to durable per-agent SQLite, so memory above is writable through the set_context tool with no extra wiring. The frozen system prompt is always persisted, so there is nothing to opt into: 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 Context for providers, tools, and frozen-prompt behavior.

Upgrading from 0.17

A subclass written against 0.17 keeps compiling and running. configureSession(session) still accepts the withContext() / withCachedPrompt() chain, and this.session still carries the context methods (addContext, getContextBlock, replaceContextBlock, refreshSystemPrompt, freezeSystemPrompt, tools()), which now forward to this.context. They are deprecated; move blocks into configureContext() and read this.context at your own pace. Blocks from both hooks are merged, configureContext() first.

Two things do change on upgrade:

  • Storage migrates on first wake and cannot be rolled back. Each Durable Object lifts its assistant_* tables into cf_agents_session_*, verifies every row, and drops the old tables. An object that has woken on this version has an empty conversation if you roll back; rolling forward again is safe. Canary the deploy if you need a rollback path.
  • Hydration has no message-count floor. hydrationByteBudget (now 32 MiB) is a hard ceiling that charges each row its stored size plus attachments, so a run of very large messages can hydrate fewer than four. getHistory() still reads the full path.

Annotate a configureSession override with Session imported from @cloudflare/think. The Session class exported by agents/sessions is the raw storage handle and is not assignable to Think's.

sessionAttachments is gone (Sessions stores media out of the row on its own), MediaEvictionConfig.externalizeToWorkspace is ignored (bytes are always preserved), and WorkspaceLike.writeFileBytes is optional (a workspace without it disables media eviction and skills projection with a one-time warning).

Dynamic context blocks

Blocks can be added at runtime, for example by extensions:

await this.context.addBlock({ label: "notes", description: "User notes" });
await this.context.refreshSystemPrompt(); // rebuild the prompt

this.context.removeBlock("notes");
await this.context.refreshSystemPrompt();

Message storage

Sessions stores MESSAGES; it is not a file store. A message rides in one SQLite row until its serialized JSON exceeds the 1.5 MiB row budget; a message larger than that is split across continuation rows and reassembled on read. Nothing is truncated and no message is too large to store, so there is no size error to handle and nothing to configure.

Splitting never reclaims database space — the continuation rows live in the same Durable Object as the message — and Sessions imposes no upper bound on a single message, so one very large write can consume a meaningful share of the 10 GB an object has. Bounding untrusted input is the application's job. Files belong in the Workspace; put a reference to one in the message. This is a storage detail: invisible to the model, and unrelated to media eviction.

Media eviction

Media eviction is a context-window technique, not a storage one. Re-sending a screenshot the model has already looked at on every later turn is pure cost, so once a message has aged past mediaEviction.keepRecentMessages (8 by default) on the active path, Think takes the media out of the conversation and leaves a marker in its place:

[evicted image/png, 812004 bytes; preserved at /attachments/evicted/msg_01H8-0.png]

The bytes are written to the Workspace at that path, raw and with their real mime type — not as a data: URL string. The workspace read tool recognises image/*, so when the agent decides it needs the picture again it reads the path out of the marker and the actual image goes back into context. Eviction is visible to the model and lossy on purpose.

Passes are bounded (maxRowsPerPass, 64 by default) and run in the background after a turn or a hydration read, rescheduling while a backlog remains. Only payloads of at least minPartBytes are evicted, and keepRecentMessages is clamped to the four messages the model replays at full fidelity. Once a row is rewritten the Sessions attachment reference is dropped and the blob is reaped, so the bytes exist in exactly one place: the Workspace file.

mediaEviction: false keeps aged media in the conversation, so the model keeps seeing it. It does not change where Sessions keeps the bytes.

Startup hydration reads a recent window bounded by hydrationByteBudget (32 MiB by default). The budget charges each row its stored bytes plus the attachment bytes it re-inflates, so it bounds isolate memory rather than the on-disk footprint.

MCP integration

Think inherits MCP client support from the Agent base class. MCP tools are automatically converted to AI SDK tools and merged into every turn. Set waitForMcpConnections to ensure MCP servers are connected before the inference loop runs:

export class MyAgent extends Think<Env> {
  waitForMcpConnections = true; // or { timeout: 10_000 }
}

If you expose MCP tools through Code Mode or another mechanism outside Think's automatic tool set, disable direct exposure to the model:

export class MyAgent extends Think<Env> {
  includeMcpTools = false;
  waitForMcpConnections = true;
}

This suppresses only Think's automatic this.mcp.getAITools() merge. Connection registration, restoration, discovery, waiting, raw listing and calls, Code Mode access, and explicit this.mcp.getAITools() calls are unchanged. It also avoids converting a large MCP catalog to Zod when another mechanism exposes the tools. activeTools: [] is not an equivalent optimization because Think assembles and converts tools before beforeTurn runs.

Choosing a turn API

Use browser chat through useAgentChat when a user drives the conversation. Use saveMessages() when server code controls the trigger and can wait for the model response. Use submitMessages() when a caller needs fast durable acceptance, idempotent retries, cancellation, and later status inspection. Use getScheduledTasks() when code should create recurring prompt submissions or deterministic scheduled handlers.

Use subAgent(...).chat() for direct streaming RPC to a specific child when your code owns forwarding and replay policy. Use agentTool() or runAgentTool() when a parent agent delegates work to a retained child and you want event replay, abort bridging, and UI drill-in.

Use startFiber() from agents outside Think when the durable unit is an application-owned job around a turn, such as accepting a webhook once, restoring provider state, and posting a visible reply. submitMessages() owns Think's conversation admission; managed fibers own external side effects and recovery policy around that turn.

See Choosing a turn API and Programmatic Submissions for the full API comparison.

Sub-agent streaming via RPC

When used as a sub-agent (via this.subAgent()), the chat() method runs a full turn and streams events via a callback:

interface StreamCallback {
  onStart(event: { requestId: string }): void | Promise<void>;
  onEvent(json: string): void | Promise<void>;
  onDone(): void | Promise<void>;
  onError(error: string): void | Promise<void>;
  // Optional. The attempt was interrupted (a stream-stall watchdog abort routed
  // into bounded recovery) and a scheduled continuation — in a later isolate,
  // without this callback — owns the final outcome. NOT done, NOT a terminal
  // error. Defaults to a no-op, so existing implementers are unaffected.
  onInterrupted?(): void | Promise<void>;
}

const agent = await this.subAgent(MyAgent, "thread-1");
await agent.chat("Summarize the project", relay);

onStart exposes the request id for RPC-safe cancellation. Call agent.cancelChat(requestId, reason) if the parent needs to stop the child turn after it has started.

onInterrupted matters for a chat()-driven turn that is interrupted and recovers: the RPC promise resolves cleanly (the isolate is still alive), so a consumer that keys off the clean resolve would mis-read it as success and finalize whatever partial it had streamed. Treat onInterrupted as "not done, not failed — a continuation owns the answer": keep the channel open, show a recovering state, or re-attach, rather than finalizing the partial. (The built-in messenger delivery already does this — it surfaces an "interrupted, please retry" reply instead of posting the truncated partial.) Note: a deploy/eviction interruption kills the isolate before this can fire — the caller sees a transport break instead; onInterrupted covers the in-isolate stall→recovery path.

Tools belong to the child agent; define them with getTools() or use agentTool() / runAgentTool() for parent-child orchestration.

Dynamic configuration

configure() and getConfig() persist a JSON-serializable config blob in SQLite — useful for private server-side settings that should survive hibernation and restarts. Pass the config shape as a method generic for typed call sites:

import { Think } from "@cloudflare/think";

type MyConfig = { modelTier: "fast" | "capable"; systemPrompt: string };

const MODEL_IDS = {
  fast: "@cf/meta/llama-3.1-8b-instruct",
  capable: "@cf/moonshotai/kimi-k2.7-code"
} as const;

export class MyAgent extends Think<Env> {
  getModel() {
    const tier = this.getConfig<MyConfig>()?.modelTier ?? "fast";
    return MODEL_IDS[tier];
  }
}

For values you want broadcast to connected clients, use state / setState from Agent instead.

Production features

  • WebSocket protocol — wire-compatible with useAgentChat from @cloudflare/think/react
  • Built-in workspace — every agent gets this.workspace with file tools auto-wired
  • Lifecycle hooks — beforeTurn, beforeStep, onStepFinish, onChunk, onChatResponse fire on every turn
  • Stream resumption — page refresh replays buffered chunks via ResumableStream
  • Client tools — accept tool schemas from clients, handle results and approvals
  • Durable submissions — accept webhook/RPC-triggered turns with idempotent retry and status inspection
  • Messengers — receive Chat SDK webhooks and deliver streamed replies with provider-safe recovery
  • Auto-continuation — debounce-based continuation after tool results
  • MCP integration — MCP tools auto-merged by default, with an opt-out for alternative exposure paths
  • Abort/cancel — pass an AbortSignal or send a cancel message
  • Multi-tab broadcast — all connected clients see the stream (resume-aware exclusions)
  • Partial persistence — on error, the partial assistant message is saved
  • Message sanitization — strips ephemeral provider metadata before storage
  • Row size enforcement — compacts tool outputs exceeding 1.8MB

Code execution tool

Deprecated. Use exec with codemode:tools (see Built-in workspace).

Let the LLM write and run JavaScript in a sandboxed Worker:

import { createAITools } from "@cloudflare/computer/tools/ai-sdk";
import { createExecuteTool } from "@cloudflare/think/tools/execute";

getTools() {
  return {
    // tools.* in the sandbox includes the workspace tools
    execute: createExecuteTool(this, {
      tools: createAITools({ workspace: this.workspace })
    })
  };
}

Requires @cloudflare/codemode and a worker_loaders binding in wrangler.jsonc.

Fetch tool

Give the model a conservative, read-only way to read HTTP resources. It is off by default — set the fetchTools property (static config) or call createFetchTools() inside getTools() (dynamic/per-tenant). It registers a generic fetch_url tool when a public allowlist is configured, plus one fetch_<name> tool per binding target.

export class DocsAgent extends Think<Env> {
  getModel() { ... }

  fetchTools = {
    allowlist: ["https://developers.cloudflare.com/**"],
    bindings: {
      docsApi: {
        binding: this.env.DOCS_API, // a service binding / Fetcher
        allowlist: ["/v1/docs/**"],
        headers: { "x-agent": "think" }
      }
    }
  };
}

For per-tenant allowlists computed at request time, build the tools in getTools() (it runs every turn) instead of using the static property:

import { createFetchTools } from "@cloudflare/think/tools/fetch";

getTools() {
  return {
    ...createFetchTools({ allowlist: this.allowedOriginsForTenant() })
  };
}

Behavior and safety:

  • Read-only — GET only. Mutations belong in explicit, approval-gated actions, not here.
  • Allowlisted — every request must match the configured allowlist; private, loopback, link-local, and *.internal targets are blocked even if the allowlist is misconfigured.
  • Bounded — maxBytes caps the download, maxModelChars truncates the model-facing text, and response: "workspace" (or spillToWorkspace: true with auto) writes large or binary bodies to a workspace file instead of bloating the transcript.
  • Header-safe — only headers in modelHeaderAllowlist (default accept, accept-language, range) may be set by the model; fixed binding headers are server-side only and are stripped on cross-origin redirects.
  • Markdown-first — a weighted default Accept header (text/markdown → text/plain → application/json → text/html → */*) nudges content-negotiating endpoints to return clean markdown instead of HTML. Override per call (the model can set accept) or globally via defaultAccept (set to "" to disable).
  • Redirects — followed only when the final URL is still allowlisted (followRedirects); binding targets never follow cross-origin redirects.

Results are structured { ok, ... } values: success carries status, finalUrl, contentType, bytes, truncated, and the body/json/path; failures carry a code (disallowed_url, disallowed_redirect, timeout, non_2xx, unsupported_content_type, invalid_json, too_large, request_failed). A tool:fetch observability event fires for each call, including blocked attempts.

Allowlist semantics:

  • A bare origin (https://example.com) matches that origin and every subpath under it.
  • Patterns are globs: ** matches any characters (including /), * matches any character except /. A pattern with an explicit path and no glob matches that path literally (e.g. https://x.com/v1 matches only /v1, not /v1/a).
  • Matching ignores the query string and fragment (only scheme + host + port + path are compared); the original query/fragment are still sent.
  • Binding allowlists should be path-based (/v1/docs/**); a model-supplied absolute URL is only allowed if it matches the binding's allowlist.
  • json responses are bounded by maxBytes (not maxModelChars, which only truncates text). For large JSON APIs, lower maxBytes or use response: "workspace".

You do not need new machinery to gate egress: beforeToolCall can block/substitute a fetch, and channel tools(...) policy can narrow the available tools.

When to use what

  • Fetch tool — read a known, allowlisted URL or service binding. No code generation.
  • Code execution tool — compose or transform several calls in sandboxed code (set globalOutbound to control its network access).
  • Browser Run (@cloudflare/think/tools/browser) — rendered pages, auth flows, screenshots, CDP automation.
  • Typed tools / agentTool() — call a WorkerEntrypoint/Durable Object method with a typed schema, or delegate work to a sub-agent. Do not route these through fetch.

Extensions

Dynamic tool loading at runtime. The LLM can write extension source code, load it as a sandboxed Worker, and use the new tools on the next turn.

import { ExtensionManager } from "@cloudflare/think/extensions";
import { createExtensionTools } from "@cloudflare/think/tools/extensions";

const extensions = new ExtensionManager({ loader: this.env.LOADER });

getTools() {
  return {
    ...createExtensionTools({ manager: extensions }),
    ...extensions.getTools()
  };
}

Runtime dependencies

Package Notes
agents Cloudflare Agents SDK peer dependency
ai Vercel AI SDK v7 peer dependency
zod Schema validation peer dependency
@cloudflare/computer Workspace and workspace tools peer dependency
@cloudflare/codemode Code execution, createExecuteTool, and JS skill scripts
@chat-adapter/telegram Required for Telegram messengers

Acknowledgments

Think's design is inspired by pi.