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(), durable submitMessages() execution, continueLastTurn(), and auto-continuation after tool results.
| Hook | When it fires | Return | Async |
|---|---|---|---|
configureSession(session) |
Once during onStart |
Session |
yes |
beforeTurn(ctx) |
Before streamText |
TurnConfig or void |
yes |
beforeStep(ctx) |
Before each model step | StepConfig or void |
yes |
beforeToolCall(ctx) |
When model calls a tool | ToolCallDecision or void |
yes |
afterToolCall(ctx) |
After tool execution | void | yes |
onStepFinish(ctx) |
After each step completes | void | yes |
onChunk(ctx) |
Per streaming chunk | void | yes |
onChatResponse(result) |
After turn completes and message is persisted | void | yes |
onChatError(error, ctx?) |
On error during a turn | error to propagate | no |
classifyChatError(error, ctx?) |
On a turn error, when contextOverflow.reactive is enabled |
ChatErrorClassification or void |
no |
For a turn with two tool calls:
configureSession() ← once at startup, not per-turn
│
beforeTurn() ← inspect assembled context, override model/tools/prompt
│
┌── streamText ───────────────────────────────────┐
│ beforeStep() │
│ │ │
│ onChunk() onChunk() onChunk() ... │
│ │ │
│ beforeToolCall() → tool executes │
│ afterToolCall() │
│ │ │
│ onStepFinish() │
│ │ │
│ beforeStep() │
│ │ │
│ onChunk() onChunk() ... │
│ │ │
│ beforeToolCall() → tool executes │
│ afterToolCall() │
│ │ │
│ onStepFinish() │
└─────────────────────────────────────────────────┘
│
onChatResponse() ← message persisted, turn lock released
Called once during Durable Object initialization (onStart). Configure the Session with context blocks, compaction, search, and skills.
configureSession(session: Session): Session | Promise<Session>import { Think, Session } from "@cloudflare/think";
import { createCompactFunction } from "agents/experimental/memory/utils/compaction-helpers";
import { generateText } from "ai";
export class MyAgent extends Think<Env> {
getModel() {
/* ... */
}
configureSession(session: Session) {
return session
.withContext("soul", {
provider: { get: async () => "You are a helpful coding assistant." }
})
.withContext("memory", {
description: "Learned facts about the user.",
maxTokens: 1100
})
.onCompaction(
createCompactFunction({
summarize: (prompt) =>
generateText({ model: this.resolveModel(), prompt }).then(
(r) => r.text
)
})
)
.compactAfter(100_000)
.withCachedPrompt();
}
}When configureSession adds context blocks, Think builds the system prompt from those blocks instead of using getSystemPrompt(). See the Sessions documentation for the full API.
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.
beforeTurn(ctx: TurnContext): TurnConfig | void | Promise<TurnConfig | void>| Field | Type | Description |
|---|---|---|
system |
string |
Assembled system prompt (from context blocks or getSystemPrompt()) |
messages |
ModelMessage[] |
Assembled model messages (truncated) |
tools |
ToolSet |
Merged tool set (workspace + getTools + session + MCP + client + caller) |
model |
LanguageModel |
The resolved model (a string from getModel() is already resolved here) |
continuation |
boolean |
Whether this is a continuation turn (auto-continue after tool result) |
body |
Record<string, unknown> |
Custom body fields from the client request |
All fields are optional. Return only what you want to change.
| Field | Type | Description |
|---|---|---|
model |
ThinkModel |
Override the model for this turn — a model id string or a LanguageModel |
system |
string |
Override the system prompt |
messages |
ModelMessage[] |
Override the assembled messages |
tools |
ToolSet |
Extra tools to merge (additive) |
activeTools |
string[] |
Limit which tools the model can call |
toolChoice |
ToolChoice |
Force a specific tool call |
maxSteps |
number |
Override maxSteps for this turn |
stopWhen |
StopCondition | StopCondition[] |
Additional early-exit conditions |
sendReasoning |
boolean |
Send reasoning chunks for this turn |
maxOutputTokens |
number |
Maximum tokens to generate |
temperature |
number |
Sampling temperature |
topP |
number |
Nucleus sampling value |
topK |
number |
Top-K sampling value |
presencePenalty |
number |
Presence penalty |
frequencyPenalty |
number |
Frequency penalty |
stopSequences |
string[] |
Stop generation sequences |
seed |
number |
Sampling seed when supported |
maxRetries |
number |
Maximum retries for this turn |
timeout |
TimeoutConfiguration |
Timeout for this turn |
chatStreamStallTimeoutMs |
number |
Override the stream-stall watchdog for this turn (0 disables it); auto-resets after the turn. Useful for a turn with a known-slow tool — see Think configuration. |
headers |
Record<string, string> |
Additional provider request headers |
providerOptions |
Record<string, unknown> |
Provider-specific options |
experimental_transform |
StreamTextTransform | StreamTextTransform[] |
AI SDK stream transform(s) for this turn — inspect or rewrite stream parts (for example, emit source parts derived from tool results). Applied in order. |
Switch to a cheaper model for continuation turns:
beforeTurn(ctx: TurnContext) {
if (ctx.continuation) {
return { model: this.cheapModel };
}
}Restrict which tools the model can call:
beforeTurn(ctx: TurnContext) {
return { activeTools: ["read", "write", "getWeather"] };
}Add per-turn context from the client body:
beforeTurn(ctx: TurnContext) {
if (ctx.body?.selectedFile) {
return {
system: ctx.system + `\n\nUser is editing: ${ctx.body.selectedFile}`
};
}
}Override maxSteps based on conversation length:
beforeTurn(ctx: TurnContext) {
if (ctx.messages.length > 100) {
return { maxSteps: 3 };
}
}Stop early when a designated tool is called while retaining Think's maxSteps safety bound:
import { hasToolCall } from "ai";
beforeTurn() {
return { stopWhen: hasToolCall("finalAnswer") };
}stopWhen is additive: Think sends both stepCountIs(maxSteps) and your condition(s) to the AI SDK, so the loop ends when either one matches. Stop conditions are functions, so they can be returned from a Think subclass's beforeTurn, but not from sandboxed extension beforeTurn hooks over RPC.
Prune older tool calls from the model context with the AI SDK's pruneMessages:
import { pruneMessages } from "ai";
beforeTurn(ctx: TurnContext) {
return {
messages: pruneMessages({
messages: ctx.messages,
toolCalls: "before-last-2-messages"
})
};
}Scope pruning per-tool with the array form so client-side tool results survive across turns:
import { pruneMessages } from "ai";
beforeTurn(ctx: TurnContext) {
return {
messages: pruneMessages({
messages: ctx.messages,
toolCalls: [
{ type: "before-last-2-messages", tools: ["read_file", "search"] }
]
})
};
}Hide reasoning for internal continuation turns:
beforeTurn(ctx: TurnContext) {
if (ctx.continuation) {
return { sendReasoning: false };
}
}Disable retries and apply a streaming timeout for a recovery turn:
beforeTurn(ctx: TurnContext) {
if (ctx.body?.recovering) {
return {
maxRetries: 0,
timeout: { totalMs: 30_000, chunkMs: 5_000 }
};
}
}Force structured output for a turn (Vercel AI SDK Output.object). Combine with activeTools: [] because some providers (e.g. workers-ai-provider) strip tools when responseFormat: "json" is active:
import { Output } from "ai";
import { z } from "zod";
const ResultSchema = z.object({ severity: z.enum(["low", "high"]) });
beforeTurn(ctx: TurnContext) {
// Gate however your agent decides "this is the structured-answer turn":
// a body flag set by the caller, an internal phase enum, or a separate
// sub-agent invocation. `ctx.continuation === true` means "this turn
// was triggered by Think's auto-continuation after tool results", which
// is *not* the same as "terminal turn" — don't conflate them.
if (ctx.body?.mode === "structured-answer") {
return {
output: Output.object({ schema: ResultSchema }),
activeTools: []
};
}
}
outputis a turn-level setting only. The AI SDK'sprepareStepdoes not accept anoutputoverride, sobeforeStepcannot toggle structured output on a single step. If you need per-step structured output, run a separate turn (or a sub-agent call) withoutputset inbeforeTurn.
Called before each AI SDK step in the agentic loop. Think forwards this hook to streamText as prepareStep, so it receives the AI SDK's full prepare-step context and can return per-step overrides. Use beforeTurn for turn-wide assembly and beforeStep when the decision depends on the step number or previous step results.
beforeStep(ctx: PrepareStepContext): StepConfig | void | Promise<StepConfig | void>beforeStep fires between steps in the agentic loop: after the previous step's onStepFinish and before the next model call. For a tool-call → answer flow that means the order is:
beforeStep(ctx, stepNumber=0) ← ctx.steps = []
→ model emits tool-call → beforeToolCall → execute → afterToolCall
onStepFinish(step 0)
beforeStep(ctx, stepNumber=1) ← ctx.steps = [step 0]
→ model emits final text
onStepFinish(step 1)
PrepareStepContext<TOOLS> is the parameter of the AI SDK's PrepareStepFunction<TOOLS>.
| Field | Type | Description |
|---|---|---|
steps |
Array<StepResult<TOOLS>> |
Steps that have already completed |
stepNumber |
number |
Zero-based number of the step about to run |
model |
LanguageModel |
Model currently selected for this step |
messages |
ModelMessage[] |
Messages that will be sent to the model |
experimental_context |
unknown |
AI SDK experimental context for tool execution |
StepConfig<TOOLS> is the AI SDK's PrepareStepResult<TOOLS>. Return only the fields to override for the current step.
| Field | Type | Description |
|---|---|---|
model |
ThinkModel |
Override the model for this step — a model id string or a LanguageModel |
toolChoice |
ToolChoice<TOOLS> |
Force or disable tool calling for this step |
activeTools |
Array<keyof TOOLS> |
Limit which tools are available for this step |
system |
string | SystemModelMessage | SystemModelMessage[] |
Override the system message for this step |
messages |
ModelMessage[] |
Override the full message list for this step |
experimental_context |
unknown |
Override context passed to tool execution from this step on |
providerOptions |
ProviderOptions |
Provider-specific options for this step |
Force a search tool on the first step:
beforeStep(ctx: PrepareStepContext<typeof tools>): StepConfig<typeof tools> | void {
if (ctx.stepNumber === 0) {
return {
activeTools: ["search"],
toolChoice: { type: "tool", toolName: "search" }
};
}
}Switch to a cheaper model after tool results are available (assumes a fastSummaryModel field on your subclass):
beforeStep(ctx: PrepareStepContext): StepConfig | void {
if (ctx.steps.some((step) => step.toolResults.length > 0)) {
return { model: this.fastSummaryModel };
}
}Trim tool-heavy messages on later steps:
beforeStep(ctx: PrepareStepContext): StepConfig | void {
if (ctx.stepNumber < 2) return;
return {
messages: ctx.messages.slice(-8)
};
}The following are AI SDK boundary constraints surfaced through beforeStep, not Think-imposed limits:
- No
abortSignalin the context. IfbeforeStepdoes remote work (e.g. fetches a model from a registry), it cannot be cancelled by turn-level abort. Keep the hook fast and synchronous when possible. outputcannot be overridden per step.PrepareStepResultdoesn't includeoutput. Set structured output at the turn level viaTurnConfig.output(returned frombeforeTurn).maxStepscannot be overridden per step. Set it at the turn level viaTurnConfig.maxSteps.experimental_contextis typedunknown. Narrow it yourself.- Subclass-only.
beforeStepis not dispatched to extensions — the prepareStep event surface includes a liveLanguageModelinstance which is not JSON-safe to snapshot.
Called before the tool's execute function runs. Think wraps every server-side tool's execute so it can consult this hook and act on the returned ToolCallDecision. Only fires for tools with execute — client tools are handled on the client.
beforeToolCall(ctx: ToolCallContext): ToolCallDecision | void | Promise<ToolCallDecision | void>| Return value | Effect |
|---|---|
void / undefined |
Run the original execute with the original input |
{ action: "allow" } |
Same as void |
{ action: "allow", input } |
Run the original execute with the substituted input |
{ action: "block", reason? } |
Skip execute; the model sees reason (or a default string) as the tool's output |
{ action: "substitute", output, input? } |
Skip execute; the model sees output as the tool's output |
afterToolCall always fires after the decision resolves. For block and substitute, the substituted value flows through afterToolCall as success: true, output: ... (the model's perspective: it received a string back).
Note: when
allowsubstitutes the input,afterToolCall.inputstill reflects what the model emitted (the AI SDK records the original tool-call chunk), whileoutputreflects the result of executing with the substituted input. If you need to see the substituted input inafterToolCall, capture it inbeforeToolCalland stash it on the agent.
ToolCallContext<TOOLS> spreads the AI SDK's TypedToolCall<TOOLS> at the top level (so ctx.toolName and ctx.input work without unwrapping) and adds the per-call event extras from OnToolCallStartEvent.
| Field | Type | Description |
|---|---|---|
type |
"tool-call" |
Discriminator |
toolCallId |
string |
Unique id for this tool call |
toolName |
string |
Name of the tool being called |
input |
typed when TOOLS is passed |
Arguments the model provided (formerly args; renamed to match AI SDK) |
dynamic? |
boolean |
true for runtime-registered tools, false/absent for statically declared |
providerMetadata |
ProviderMetadata? |
Provider-specific metadata for this call |
stepNumber |
number | undefined |
Index of the current step where this tool call occurs |
messages |
ReadonlyArray<ModelMessage> |
Conversation messages visible at tool execution time |
abortSignal |
AbortSignal | undefined |
Aborts if the turn is cancelled |
Pass an explicit TOOLS generic to get full input typing:
import type { ToolCallContext } from "@cloudflare/think";
const tools = { search: tool({ inputSchema: z.object({ query: z.string() }), ... }) };
beforeToolCall(ctx: ToolCallContext<typeof tools>) {
if (ctx.toolName === "search") {
ctx.input.query; // typed as string
}
}Log all tool calls:
beforeToolCall(ctx: ToolCallContext) {
console.log(`Tool called: ${ctx.toolName}`, ctx.input);
}Block a tool when the agent is in a restricted mode:
beforeToolCall(ctx: ToolCallContext): ToolCallDecision | void {
if (this.isReadOnlyMode && ctx.toolName === "delete") {
return {
action: "block",
reason: "delete is disabled in read-only mode"
};
}
}Substitute a cached result without running execute:
async beforeToolCall(ctx: ToolCallContext): Promise<ToolCallDecision | void> {
if (ctx.toolName === "weather") {
const cached = await this.cache.get(`weather:${JSON.stringify(ctx.input)}`);
if (cached) return { action: "substitute", output: cached };
}
}Sanitize the model's input before execution (e.g. clamp a limit):
beforeToolCall(ctx: ToolCallContext): ToolCallDecision | void {
if (ctx.toolName === "search") {
const input = ctx.input as { query: string; limit?: number };
return {
action: "allow",
input: { ...input, limit: Math.min(input.limit ?? 10, 50) }
};
}
}- Substituted input is not re-validated. The AI SDK validates the model's emitted input against the tool's
inputSchemabeforeexecuteruns. WhenbeforeToolCallreturns{ action: "allow", input: ... }, that substituted input is passed straight through toexecutewithout going through the schema again. If you substitute, ensure the shape stays valid for the tool you're calling. stepNumberisundefinedinToolCallContext. The AI SDK'sToolExecutionOptionsdoesn't expose the current step index. The same field is populated onToolCallResultContext(sourced fromexperimental_onToolCallFinish).- Throwing from
beforeToolCallpropagates as a tool error — the AI SDK records it in the same way it would record anexecutefailure, andafterToolCallfires withsuccess: false, error: .... - Streaming tools (AsyncIterable returns). The AI SDK supports tools whose
executereturnsAsyncIterable<output>to emit preliminary results before a final value. The canonical form is an async generator (async function* execute(...) { … }). Think preserves preliminary streaming for that form even throughbeforeToolCall: each yielded value reaches the model as apreliminarytool-result, and the last as the final value. The non-canonicalasync function execute(...) { return makeIter(); }form returns aPromise<AsyncIterable>, which does not stream even in the raw AI SDK — Think collapses it to the last yielded value. Use anasync function*executeif you need preliminary streaming. - Blocking/substituting an
async function*tool emits onepreliminarychunk. To keep anasync function*tool streaming throughbeforeToolCall, its wrapper must commit to anAsyncIterableshape synchronously — before the (async) decision is known. The AI SDK turns every value sent through that iterable into apreliminarytool-result plus afinal, so ablock/substituteoutcome surfaces one extrapreliminary: truechunk to observers likeonChunk(a scalar-executetool never does). The model-visible final output is identical and correct; this only affects observation hooks for the narrow case of blocking/substituting a streaming tool, and it matches how any streaming tool that emits a single value already behaves. - Hook order:
beforeToolCall(subclass) → extensionbeforeToolCalldispatch → originalexecute(orblock/substituteshort-circuit) → AI SDK records the outcome →afterToolCall(subclass) → extensionafterToolCalldispatch.
Called after a tool's outcome is known — for real executions, for block (carries the reason as output), and for substitute (carries the substituted output). The discriminated success/output/error reflects what the model actually sees: thrown errors from the original execute become success: false; everything else (including blocked / substituted calls) is success: true.
afterToolCall(ctx: ToolCallResultContext): void | Promise<void>ToolCallResultContext<TOOLS> is backed by the AI SDK's OnToolCallFinishEvent<TOOLS> (the parameter of experimental_onToolCallFinish). It spreads the originating TypedToolCall<TOOLS> at the top level, plus the per-call event extras and a discriminated outcome:
| Field | Type | Description |
|---|---|---|
type |
"tool-call" |
Discriminator (carried over from the call) |
toolCallId |
string |
Unique id matching the originating ToolCallContext |
toolName |
string |
Name of the tool that was called |
input |
typed when TOOLS is passed |
Arguments the tool was called with |
dynamic? |
boolean |
true for runtime-registered tools |
stepNumber |
number | undefined |
Index of the current step |
messages |
ReadonlyArray<ModelMessage> |
Conversation messages visible at tool execution time |
durationMs |
number |
Wall-clock execution time of execute |
success |
boolean |
Discriminator: true on success, false on failure |
output |
typed per tool (when success is true) |
Whatever the tool's execute returned |
error |
unknown (when success is false) |
Whatever was thrown from execute |
When you pass an explicit TOOLS generic, narrowing on ctx.toolName (together with ctx.success) narrows ctx.output to that tool's inferred output type. Dynamic tools (runtime-registered, MCP) stay unknown:
afterToolCall(ctx: ToolCallResultContext<typeof tools>) {
if (ctx.toolName === "search" && ctx.success) {
ctx.output.results; // typed as the `search` tool's output
}
}Track tool usage and surface failures:
afterToolCall(ctx: ToolCallResultContext) {
if (ctx.success) {
this.env.ANALYTICS.writeDataPoint({
blobs: [ctx.toolName, "ok"],
doubles: [ctx.durationMs, JSON.stringify(ctx.output).length]
});
} else {
this.env.ANALYTICS.writeDataPoint({
blobs: [ctx.toolName, "error", String(ctx.error)],
doubles: [ctx.durationMs]
});
}
}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.
onStepFinish(ctx: StepContext): void | Promise<void>StepContext<TOOLS> is a re-export of the AI SDK's StepResult<TOOLS> (= OnStepFinishEvent<TOOLS>). The full step record is forwarded — nothing is dropped or renamed. Highlights:
| Field | Type | Description |
|---|---|---|
stepNumber |
number |
Zero-based index of this step |
text |
string |
Text generated in this step |
reasoning |
Array<ReasoningPart> |
Reasoning parts emitted by the model |
reasoningText |
string | undefined |
Concatenated reasoning text |
files |
Array<GeneratedFile> |
Files generated during the step |
sources |
Array<Source> |
Citations / sources used |
toolCalls |
Array<TypedToolCall<TOOLS>> |
Typed tool calls (same shape as ToolCallContext) |
toolResults |
Array<TypedToolResult<TOOLS>> |
Typed tool results (same shape as ToolCallResultContext) |
finishReason |
FinishReason |
Unified finish reason from the model |
rawFinishReason |
string | undefined |
Raw provider finish reason |
usage |
LanguageModelUsage |
inputTokens, outputTokens, totalTokens, reasoningTokens, cachedInputTokens |
warnings |
CallWarning[] | undefined |
Warnings from the provider |
request |
LanguageModelRequestMetadata |
Raw request metadata |
response |
LanguageModelResponseMetadata & { messages, body? } |
Raw response metadata + assistant/tool messages |
providerMetadata |
ProviderMetadata | undefined |
Provider-specific metadata (e.g. Anthropic cache accounting) |
Log step-level usage with cache accounting:
onStepFinish(ctx: StepContext) {
console.log(
`Step ${ctx.stepNumber} (${ctx.finishReason}): ` +
`${ctx.usage.inputTokens}in/${ctx.usage.outputTokens}out, ` +
`${ctx.usage.cachedInputTokens ?? 0} cached`
);
}Capture reasoning text and citations:
onStepFinish(ctx: StepContext) {
if (ctx.reasoningText) {
this.env.LOGS.writeDataPoint({ blobs: ["reasoning", ctx.reasoningText] });
}
for (const source of ctx.sources) {
this.env.LOGS.writeDataPoint({ blobs: ["source", source.url ?? ""] });
}
}Read provider-specific cache tokens (Anthropic):
onStepFinish(ctx: StepContext) {
const anthropic = ctx.providerMetadata?.anthropic as
| { cacheCreationInputTokens?: number; cacheReadInputTokens?: number }
| undefined;
if (anthropic) {
console.log(
`cache: ${anthropic.cacheCreationInputTokens ?? 0} created, ` +
`${anthropic.cacheReadInputTokens ?? 0} read`
);
}
}Called for each streaming chunk. High-frequency — fires per token. Override for streaming analytics, progress indicators, or token counting. Observational only.
onChunk(ctx: ChunkContext): void | Promise<void>ChunkContext<TOOLS> is the parameter type of the AI SDK's StreamTextOnChunkCallback<TOOLS>. The chunk field is a discriminated union of TextStreamPart variants — narrow on chunk.type for typed access:
| Field | Type | Description |
|---|---|---|
chunk |
Extract<TextStreamPart<TOOLS>, { type: "text-delta" | "reasoning-delta" | "source" | "tool-call" | "tool-input-start" | "tool-input-delta" | "tool-result" | "raw" }> |
The current chunk from the AI SDK stream |
Example — count text-delta tokens and forward reasoning to a logger:
onChunk(ctx: ChunkContext) {
switch (ctx.chunk.type) {
case "text-delta":
this.tokensStreamed += ctx.chunk.text.length;
break;
case "reasoning-delta":
console.log("[reasoning]", ctx.chunk.text);
break;
case "tool-call":
console.log(`[tool] ${ctx.chunk.toolName}`, ctx.chunk.input);
break;
}
}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 that persist an assistant message: WebSocket, sub-agent RPC, saveMessages(), durable submitMessages() execution, continueLastTurn(), and auto-continuation.
onChatResponse(result: ChatResponseResult): void | Promise<void>| Field | Type | Description |
|---|---|---|
message |
UIMessage |
The persisted assistant message |
requestId |
string |
Unique ID for this turn |
continuation |
boolean |
Whether this was a continuation turn |
status |
"completed" | "error" | "aborted" |
How the turn ended |
error |
string? |
Error message (when status is "error") |
Log turn completion:
onChatResponse(result: ChatResponseResult) {
if (result.status === "completed") {
console.log(
`Turn ${result.requestId} completed: ${result.message.parts.length} parts`
);
}
}Chain a follow-up turn:
async onChatResponse(result: ChatResponseResult) {
if (result.status === "completed" && this.shouldFollowUp(result.message)) {
await this.saveMessages([{
id: crypto.randomUUID(),
role: "user",
parts: [{ type: "text", text: "Now summarize what you found." }]
}]);
}
}Distinguish abort from error:
async onChatResponse(result: ChatResponseResult) {
if (result.status === "aborted") {
// Cancelled via chat-request-cancel or saveMessages({ signal })
// — partial chunks are persisted, the message is the partial
// assistant transcript at the moment of abort.
this.logAbortMetric(result.requestId);
} else if (result.status === "error") {
// Inference threw — `result.error` carries the error message.
console.error(`Turn ${result.requestId} errored: ${result.error}`);
}
}Called when an error occurs during a chat turn. Return the error to propagate it, or return a different error. The optional context describes where the failure happened and whether user messages were already persisted.
onChatError(error: unknown, ctx?: ChatErrorContext): unknownThe partial assistant message (if any) is persisted before this hook fires.
ChatErrorContext includes:
| Field | Type | Description |
|---|---|---|
requestId |
string | undefined |
Chat request ID, when available |
stage |
"parse" | "persist" | "turn" | "stream" | "recovery" | "transcript" |
Failure stage |
messagesPersisted |
boolean |
Whether incoming user messages were already stored |
classification |
ChatErrorClassification | undefined |
Set to "context_overflow" on the terminal onChatError when a context overflow could not be recovered (see classifyChatError); undefined otherwise |
Think also emits chat:request:failed on the agents:chat observability channel with the same stage and persistence information.
Log and transform errors:
onChatError(error: unknown, ctx?: ChatErrorContext) {
console.error("Chat turn failed:", ctx?.stage, error);
if (ctx?.classification === "context_overflow") {
return new Error("This conversation is too long to continue. Please start a new one.");
}
return new Error("Something went wrong. Please try again.");
}Called when an error occurs during a turn, before onChatError. Maps a raw provider error into a provider-agnostic category so Think can react without baking provider-specific strings into the framework — the same split as the tokenCounter you pass to compactAfter(). The app owns the mapping because it knows which provider and model it talks to.
classifyChatError(error: unknown, ctx?: ChatErrorContext): ChatErrorClassification | voidChatErrorClassification is "context_overflow" | "rate_limit" | "transient" | "fatal" | "unknown". Today this hook drives only context-overflow recovery: Think calls it when a turn errors and contextOverflow.reactive is enabled (if reactive is off, it is not called). Returning "context_overflow" runs the compact-and-retry backstop (see Context-window overflow recovery); if recovery cannot save the turn, that classification is surfaced on the terminal onChatError call via ChatErrorContext.classification. The other categories are reserved for future use — returning one today is a no-op (the turn terminalizes as usual) and is not forwarded to onChatError. Returning void (the default) keeps the existing terminal behavior.
The argument may be an Error, an AI SDK APICallError (with statusCode/responseBody), or — for in-stream provider errors that surface as a stream error part rather than a throw — the error message string. Narrow accordingly. (Think confirms provider context-overflow errors always surface as in-stream error parts, never thrown exceptions out of streamText, so this hook sees them whether you read the Error or the string form.)
The second argument is a ChatErrorContext: when consulted for overflow recovery it is { stage: "stream", requestId }, so a classifier can correlate the error with the in-flight turn — for example to call cancelChat(requestId) and bail out of recovery.
For the common case, assign the bundled defaultContextOverflowClassifier, which matches the context-overflow errors of Anthropic, OpenAI, Google, Bedrock, and others:
import { Think, defaultContextOverflowClassifier } from "@cloudflare/think";
export class MyAgent extends Think<Env> {
override classifyChatError = defaultContextOverflowClassifier;
}Or write your own, optionally delegating to the bundled classifier:
import type { ChatErrorClassification } from "@cloudflare/think";
import { defaultContextOverflowClassifier } from "@cloudflare/think";
classifyChatError(error: unknown): ChatErrorClassification | void {
if (error instanceof Error && /rate.?limit/i.test(error.message)) {
return "rate_limit";
}
return defaultContextOverflowClassifier(error);
}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, after the subclass hook has run, with a JSON-safe snapshot of the event. beforeStep is available to subclasses only and is not dispatched to extensions (it runs on the AI SDK's prepareStep boundary, where snapshotting non-serializable inputs like LanguageModel instances is not meaningful).
// extension source
({
tools: {
/* ... */
},
hooks: {
beforeTurn: async (snapshot, host) => {
/* may return TurnConfig */
},
beforeToolCall: async (snapshot, host) => {
/* observation */
},
afterToolCall: async (snapshot, host) => {
/* observation */
},
onStepFinish: async (snapshot, host) => {
/* observation */
},
onChunk: async (snapshot, host) => {
/* observation; high-frequency */
}
}
});The handler receives (snapshot, host) — symmetric with tool execute. host is the bridge (HostBridgeLoopback) when the extension was loaded with permissions that require it; otherwise null.
Snapshots are intentionally narrower than the subclass Context types — class instances, AbortSignals, and other non-JSON-clonable values can't cross the Workers RPC boundary.
| Hook | Snapshot fields |
|---|---|
beforeTurn |
{ system, toolNames, messageCount, continuation, body?, modelId } — see TurnContextSnapshot |
beforeToolCall |
{ toolName, toolCallId, input, stepNumber, dynamic? } |
afterToolCall |
{ toolName, toolCallId, input, stepNumber, durationMs, success, output? | error?, dynamic? } |
onStepFinish |
{ stepNumber, finishReason, text, reasoningText, toolCallCount, toolResultCount, usage, providerMetadata } |
onChunk |
{ type, text?, toolName?, toolCallId? } — minimal because this fires per token |
Only beforeTurn honors return values (it merges scalar TurnConfig fields back into the turn). The other extension hooks are observation-only — return values are discarded. Errors thrown from extension hooks are caught and logged; they do not abort the turn.
onChunk fires per streaming token. Subscribing in an extension means an RPC round trip per chunk. Use sparingly — prefer aggregating in onStepFinish instead unless you specifically need per-token reactivity.