Think supports tools that execute in the browser. The client sends tool schemas in the chat request body, Think merges them with server tools, and when the LLM calls a client tool, the call is routed to the client for execution.
- The client sends tool schemas as part of the chat request body
- Think merges client tools with server-side tools (workspace,
getTools(), session, MCP) - The LLM calls a client tool — the tool call chunk is sent to the client over WebSocket
- The client executes the tool and sends back a
CF_AGENT_TOOL_RESULTmessage - Think persists the result, broadcasts
CF_AGENT_MESSAGE_UPDATED, and optionally auto-continues
Client tools are tools without an execute function on the server — they only have a schema. When the LLM produces a tool call for one of these, Think sends the call to the client instead of executing it server-side.
On the client, pass clientTools to useAgentChat:
import { useAgent } from "agents/react";
import { useAgentChat } from "@cloudflare/ai-chat/react";
function Chat() {
const agent = useAgent({ agent: "MyAgent" });
const { messages, sendMessage } = useAgentChat({
agent,
clientTools: {
getUserTimezone: {
description: "Get the user's timezone from their browser",
parameters: {},
execute: async () => {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
},
getClipboard: {
description: "Read text from the user's clipboard",
parameters: {},
execute: async () => {
return navigator.clipboard.readText();
}
}
}
});
// ... render chat UI
}The parameters field is a JSON Schema object describing the tool's input. The execute function runs in the browser.
Tools can require user approval before execution. This works for both server-side and client-side tools.
Use needsApproval in the tool definition:
getTools(): ToolSet {
return {
calculate: tool({
description: "Perform a calculation",
inputSchema: z.object({
a: z.number(),
b: z.number(),
operator: z.enum(["+", "-", "*", "/"])
}),
needsApproval: async ({ a, b }) =>
Math.abs(a) > 1000 || Math.abs(b) > 1000,
execute: async ({ a, b, operator }) => {
const ops: Record<string, (x: number, y: number) => number> = {
"+": (x, y) => x + y, "-": (x, y) => x - y,
"*": (x, y) => x * y, "/": (x, y) => x / y
};
return { result: ops[operator](a, b) };
}
})
};
}When needsApproval returns true:
- Think sends the tool call to the client with a pending approval state
- The conversation pauses
- The client shows an approval UI and sends
CF_AGENT_TOOL_APPROVAL(approve or deny) - If approved, the tool executes and the conversation continues
- If denied, the denial reason is returned to the model as the tool result
useAgentChat provides approval helpers:
const { messages, sendMessage, addToolResult } = useAgentChat({
agent,
onToolCall: ({ toolCall }) => {
// Auto-approve safe tools
if (toolCall.toolName === "read") {
return { approve: true };
}
// Others go through the UI approval flow
}
});See Client Tools Continuation for the full protocol reference.
After a client tool result is received, Think can automatically continue the conversation without a new user message. This is the default behavior — when all pending tool results are received, Think starts a new model turn with the tool results in context.
The continuation turn has continuation: true in the TurnContext, which you can use in beforeTurn to adjust model or tool selection:
beforeTurn(ctx: TurnContext) {
if (ctx.continuation) {
return { model: this.cheapModel };
}
}The messageConcurrency property controls how overlapping user submits behave when a chat turn is already active.
import { Think } from "@cloudflare/think";
import type { MessageConcurrency } from "@cloudflare/think";
export class MyAgent extends Think<Env> {
override messageConcurrency: MessageConcurrency = "queue"; // default
getModel() {
/* ... */
}
}| Strategy | Behavior |
|---|---|
"queue" |
Queue every submit and process them in order. Default. |
"latest" |
Keep only the latest overlapping submit. Superseded submits still persist their user messages but do not start their own model turn. |
"merge" |
Like "latest", but all overlapping user messages remain in the conversation history. The model sees them all in one turn. |
"drop" |
Ignore overlapping submits entirely. Messages are not persisted. |
{ strategy: "debounce", debounceMs?: number } |
Trailing-edge latest with a quiet window (default 750ms). |
Concurrency strategies only apply to submit-message requests. Regenerations, tool continuations, approvals, clears, saveMessages, and continueLastTurn keep their serialized behavior.
For a search-as-you-type UI where each keystroke sends a new query:
export class SearchAgent extends Think<Env> {
override messageConcurrency: MessageConcurrency = "latest";
getModel() {
/* ... */
}
}For a collaborative editor where multiple users type simultaneously:
export class CollabAgent extends Think<Env> {
override messageConcurrency: MessageConcurrency = "merge";
getModel() {
/* ... */
}
}For a debounced input where the model only responds after the user stops typing:
export class DebouncedAgent extends Think<Env> {
override messageConcurrency: MessageConcurrency = {
strategy: "debounce",
debounceMs: 1000
};
getModel() {
/* ... */
}
}Think broadcasts streaming responses to all connected WebSocket clients. When multiple browser tabs are connected to the same agent:
- All tabs see the streamed response in real time
- Tool call states (pending, result, approval) are broadcast to all tabs
- The tab that resumes a stream is excluded from the broadcast to avoid duplicates
CF_AGENT_MESSAGE_UPDATEDevents are sent to all tabs after tool results and message persistence