Chat agents·续3
Custom denial messages with addToolOutput
When a user rejects a tool, addToolApprovalResponse({ id, approved: false }) sets the tool state to output-denied with a generic message. To give the LLM a more specific reason for the denial, use addToolOutput with state: "output-error" instead:
const { addToolOutput } = useAgentChat({ agent });
// Reject with a custom error message
addToolOutput({
toolCallId: part.toolCallId,
state: "output-error",
errorText: "User declined: insufficient budget for this quarter",
});
const { addToolOutput } = useAgentChat({ agent });
// Reject with a custom error message
addToolOutput({
toolCallId: part.toolCallId,
state: "output-error",
errorText: "User declined: insufficient budget for this quarter",
});
This sends a tool_result to the LLM with your custom error text, so it can respond appropriately (for example, suggest an alternative or ask clarifying questions).
addToolApprovalResponse (with approved: false) auto-continues the conversation when autoContinueAfterToolResult is enabled (the default). addToolOutput with state: "output-error" does not auto-continue — call sendMessage() afterward if you want the LLM to respond to the error.
For more patterns, refer to Human-in-the-loop.
Custom request data
Include custom data with every chat request using the body option:
const { messages, sendMessage } = useAgentChat({
agent,
body: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
userId: currentUser.id,
},
});
const { messages, sendMessage } = useAgentChat({
agent,
body: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
userId: currentUser.id,
},
});
For dynamic values, use a function:
body: () => ({
token: getAuthToken(),
timestamp: Date.now(),
});
body: () => ({
token: getAuthToken(),
timestamp: Date.now(),
});
Access these fields on the server:
export class ChatAgent extends AIChatAgent {
async onChatMessage(_onFinish, options) {
const { timezone, userId } = options?.body ?? {};
// ...
}
}
export class ChatAgent extends AIChatAgent {
async onChatMessage(_onFinish, options) {
const { timezone, userId } = options?.body ?? {};
// ...
}
}
For advanced per-request customization (custom headers, different body per request), use prepareSendMessagesRequest:
const { messages, sendMessage } = useAgentChat({
agent,
prepareSendMessagesRequest: async ({ messages, trigger }) => ({
headers: { Authorization: `Bearer ${await getToken()}` },
body: { requestedAt: Date.now() },
}),
});
const { messages, sendMessage } = useAgentChat({
agent,
prepareSendMessagesRequest: async ({ messages, trigger }) => ({
headers: { Authorization: `Bearer ${await getToken()}` },
body: { requestedAt: Date.now() },
}),
});
Data parts
Data parts let you attach typed JSON to messages alongside text — progress indicators, source citations, token usage, or any structured data your UI needs.
Writing data parts (server)
Use createUIMessageStream with writer.write() to send data parts from the server:
import {
streamText,
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
} from "ai";
export class ChatAgent extends AIChatAgent {
async onChatMessage() {
const ai = createAI({ binding: this.env.AI });
const stream = createUIMessageStream({
execute: async ({ writer }) => {
const result = streamText({
model: ai("@cf/zai-org/glm-4.7-flash"),
messages: await convertToModelMessages(this.messages),
});
// Merge the LLM stream
writer.merge(result.toUIMessageStream());
// Write a data part — persisted to message.parts
writer.write({
type: "data-sources",
id: "src-1",
data: { query: "agents", status: "searching", results: [] },
});
// Later: update the same part in-place (same type + id)
writer.write({
type: "data-sources",
id: "src-1",
data: {
query: "agents",
status: "found",
results: ["Agents SDK docs", "Durable Objects guide"],
},
});
},
});
return createUIMessageStreamResponse({ stream });
}
}
import {
streamText,
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
} from "ai";
export class ChatAgent extends AIChatAgent {
async onChatMessage() {
const ai = createAI({ binding: this.env.AI });
const stream = createUIMessageStream({
execute: async ({ writer }) => {
const result = streamText({
model: ai("@cf/zai-org/glm-4.7-flash"),
messages: await convertToModelMessages(this.messages),
});
// Merge the LLM stream
writer.merge(result.toUIMessageStream());
// Write a data part — persisted to message.parts
writer.write({
type: "data-sources",
id: "src-1",
data: { query: "agents", status: "searching", results: [] },
});
// Later: update the same part in-place (same type + id)
writer.write({
type: "data-sources",
id: "src-1",
data: {
query: "agents",
status: "found",
results: ["Agents SDK docs", "Durable Objects guide"],
},
});
},
});
return createUIMessageStreamResponse({ stream });
}
}
Three patterns
| Pattern | How | Persisted? | Use case |
|---|---|---|---|
| Reconciliation | Same type + id → updates in-place |
Yes | Progressive state (searching → found) |
| Append | No id, or different id → appends |
Yes | Log entries, multiple citations |
| Transient | transient: true → not added to message.parts |
No | Ephemeral status (thinking indicator) |
Transient parts are broadcast to connected clients in real time but excluded from SQLite persistence and message.parts. Use the onData callback to consume them.
Reading data parts (client)
Non-transient data parts appear in message.parts. Use the UIMessage generic to type them:
import { useAgentChat } from "@cloudflare/ai-chat/react";
const { messages } = useAgentChat({ agent });
// Typed access — no casts needed
for (const msg of messages) {
for (const part of msg.parts) {
if (part.type === "data-sources") {
console.log(part.data.results); // string[]
}
}
}
import { useAgentChat } from "@cloudflare/ai-chat/react";
import type { UIMessage } from "ai";
type ChatMessage = UIMessage<
unknown,
{
sources: { query: string; status: string; results: string[] };
usage: { model: string; inputTokens: number; outputTokens: number };
}
>;
const { messages } = useAgentChat<unknown, ChatMessage>({ agent });
// Typed access — no casts needed
for (const msg of messages) {
for (const part of msg.parts) {
if (part.type === "data-sources") {
console.log(part.data.results); // string[]
}
}
}
Transient parts with onData
Transient data parts are not in message.parts. Use the onData callback instead:
const [thinking, setThinking] = useState(false);
const { messages } = useAgentChat({
agent,
onData(part) {
if (part.type === "data-thinking") {
setThinking(true);
}
},
});
const [thinking, setThinking] = useState(false);
const { messages } = useAgentChat<unknown, ChatMessage>({
agent,
onData(part) {
if (part.type === "data-thinking") {
setThinking(true);
}
},
});
On the server, write transient parts with transient: true:
writer.write({
transient: true,
type: "data-thinking",
data: { model: "glm-4.7-flash", startedAt: new Date().toISOString() },
});
writer.write({
transient: true,
type: "data-thinking",
data: { model: "glm-4.7-flash", startedAt: new Date().toISOString() },
});
onData fires on all code paths — new messages, stream resumption, and cross-tab broadcasts.
Resumable streaming
Streams automatically resume when a client disconnects and reconnects. No configuration is needed — it works out of the box.
When streaming is active:
- All chunks are buffered in SQLite as they are generated
- If the client disconnects, the server continues streaming and buffering
- When the client reconnects, it receives all buffered chunks and resumes live streaming
Generic client stream abort or cleanup stays local to the browser by default, so the server turn keeps running and can be resumed later. Calling stop() explicitly still cancels the server turn:
const { messages, stop } = useAgentChat({ agent });
return <button onClick={stop}>Stop</button>;
const { messages, stop } = useAgentChat({ agent });
return <button onClick={stop}>Stop</button>;
Set cancelOnClientAbort: true when your app intentionally wants the browser lifecycle to own the server lifecycle, such as request-lifetime or token-saving flows. Explicit stop() always cancels server work regardless of this option.
Disable with resume: false:
const { messages } = useAgentChat({ agent, resume: false });
const { messages } = useAgentChat({ agent, resume: false });
Storage management
Row size protection
Workers SQLite rows have a hard maximum size of 2 MB. To stay below that limit, AIChatAgent starts compacting a serialized message at roughly 1.8 MB, for example when a tool returns a very large output:
- Tool output compaction — Large tool outputs are replaced with an LLM-friendly summary that instructs the model to suggest re-running the tool
- Text truncation — If the message is still too large after tool compaction, text parts are truncated with a note
Compacted messages include metadata.compactedToolOutputs so clients can detect and display this gracefully.
Controlling LLM context vs storage
Storage (maxPersistedMessages) and LLM context are independent:
| Concern | Control | Scope |
|---|---|---|
| How many messages SQLite stores | maxPersistedMessages |
Persistence |
| What the model sees | pruneMessages() |
LLM context |
| Row size limits | Automatic compaction | Per-message |
export class ChatAgent extends AIChatAgent {
async onChatMessage() {
const result = streamText({
model: ai("@cf/zai-org/glm-4.7-flash"),
messages: pruneMessages({
// LLM context limit
messages: await convertToModelMessages(this.messages),
reasoning: "before-last-message",
toolCalls: "before-last-2-messages",
}),
});
return result.toUIMessageStreamResponse();
}
}
export class ChatAgent extends AIChatAgent {
async onChatMessage() {
const result = streamText({
model: ai("@cf/zai-org/glm-4.7-flash"),
messages: pruneMessages({
// LLM context limit
messages: await convertToModelMessages(this.messages),
reasoning: "before-last-message",
toolCalls: "before-last-2-messages",
}),
});
return result.toUIMessageStreamResponse();
}
}
Using different AI providers
AIChatAgent works with any AI SDK-compatible provider. The server code determines which model to use — the client does not need to change it manually.
Workers AI (Cloudflare)
import { createAI } from "agents/models/ai-sdk";
const ai = createAI({ binding: this.env.AI });
const result = streamText({
model: ai("@cf/zai-org/glm-4.7-flash"),
messages: await convertToModelMessages(this.messages),
});
import { createAI } from "agents/models/ai-sdk";
const ai = createAI({ binding: this.env.AI });
const result = streamText({
model: ai("@cf/zai-org/glm-4.7-flash"),
messages: await convertToModelMessages(this.messages),
});
OpenAI
import { createOpenAI } from "@ai-sdk/openai";
const openai = createOpenAI({ apiKey: this.env.OPENAI_API_KEY });
const result = streamText({
model: openai.chat("gpt-4o"),
messages: await convertToModelMessages(this.messages),
});
import { createOpenAI } from "@ai-sdk/openai";
const openai = createOpenAI({ apiKey: this.env.OPENAI_API_KEY });
const result = streamText({
model: openai.chat("gpt-4o"),
messages: await convertToModelMessages(this.messages),
});
Anthropic
import { createAnthropic } from "@ai-sdk/anthropic";
const anthropic = createAnthropic({ apiKey: this.env.ANTHROPIC_API_KEY });
const result = streamText({
model: anthropic("claude-sonnet-4-20250514"),
messages: await convertToModelMessages(this.messages),
});
import { createAnthropic } from "@ai-sdk/anthropic";
const anthropic = createAnthropic({ apiKey: this.env.ANTHROPIC_API_KEY });
const result = streamText({
model: anthropic("claude-sonnet-4-20250514"),
messages: await convertToModelMessages(this.messages),
});
Advanced patterns
Since onChatMessage gives you full control over the streamText call, you can use any AI SDK feature directly. The patterns below all work out of the box — no special AIChatAgent configuration is needed.
Dynamic model and tool control
Use prepareStep ↗︎ to change the model, available tools, or system prompt between steps in a multi-step agent loop:
import { streamText, convertToModelMessages, tool, stepCountIs } from "ai";
import { z } from "zod";
export class ChatAgent extends AIChatAgent {
async onChatMessage() {
const result = streamText({
model: cheapModel, // Default model for simple steps
messages: await convertToModelMessages(this.messages),
tools: {
search: searchTool,
analyze: analyzeTool,
summarize: summarizeTool,
},
stopWhen: stepCountIs(10),
prepareStep: async ({ stepNumber, messages }) => {
// Phase 1: Search (steps 0-2)
if (stepNumber <= 2) {
return {
activeTools: ["search"],
toolChoice: "required", // Force tool use
};
}
// Phase 2: Analyze with a stronger model (steps 3-5)
if (stepNumber <= 5) {
return {
model: expensiveModel,
activeTools: ["analyze"],
};
}
// Phase 3: Summarize
return { activeTools: ["summarize"] };
},
});
return result.toUIMessageStreamResponse();
}
}
import { streamText, convertToModelMessages, tool, stepCountIs } from "ai";
import { z } from "zod";
export class ChatAgent extends AIChatAgent {
async onChatMessage() {
const result = streamText({
model: cheapModel, // Default model for simple steps
messages: await convertToModelMessages(this.messages),
tools: {
search: searchTool,
analyze: analyzeTool,
summarize: summarizeTool,
},
stopWhen: stepCountIs(10),
prepareStep: async ({ stepNumber, messages }) => {
// Phase 1: Search (steps 0-2)
if (stepNumber <= 2) {
return {
activeTools: ["search"],
toolChoice: "required", // Force tool use
};
}
// Phase 2: Analyze with a stronger model (steps 3-5)
if (stepNumber <= 5) {
return {
model: expensiveModel,
activeTools: ["analyze"],
};
}
// Phase 3: Summarize
return { activeTools: ["summarize"] };
},
});
return result.toUIMessageStreamResponse();
}
}
prepareStep runs before each step and can return overrides for model, activeTools, toolChoice, system, and messages. Use it to:
- Switch models — use a cheap model for simple steps, escalate for reasoning
- Phase tools — restrict which tools are available at each step
- Manage context — prune or transform messages to stay within token limits
- Force tool calls — use
toolChoice: { type: "tool", toolName: "search" }to require a specific tool
Language model middleware
Use wrapLanguageModel ↗︎ to add guardrails, RAG, caching, or logging without modifying your chat logic:
import { streamText, convertToModelMessages, wrapLanguageModel } from "ai";
const guardrailMiddleware = {
wrapGenerate: async ({ doGenerate }) => {
const { text, ...rest } = await doGenerate();
// Filter PII or sensitive content from the response
const cleaned = text?.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED]");
return { text: cleaned, ...rest };
},
};
export class ChatAgent extends AIChatAgent {
async onChatMessage() {
const model = wrapLanguageModel({
model: baseModel,
middleware: [guardrailMiddleware],
});
const result = streamText({
model,
messages: await convertToModelMessages(this.messages),
});
return result.toUIMessageStreamResponse();
}
}
```ts import { streamText, convertToModelMessages, wrapLanguageModel } from "ai"; import type { LanguageModelV3Middleware } from "@ai-sdk/provider";
const guardrailMiddleware: LanguageModelV3Middleware = { wrapGenerate: async ({ doGenerate }) => { const { text, ...rest } = await doGenerate(); // Filter PII or sensitive content from the response const cleaned = text?.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED]"); return { text: cleaned, ...rest }; }, };
