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:

  1. All chunks are buffered in SQLite as they are generated
  2. If the client disconnects, the server continues streaming and buffering
  3. 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:

  1. Tool output compaction — Large tool outputs are replaced with an LLM-friendly summary that instructs the model to suggest re-running the tool
  2. 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 }; }, };