Chat agents
Last updated Oct 22, 2026
Build AI-powered chat interfaces with AIChatAgent and useAgentChat. Messages are automatically persisted to SQLite, streams resume on disconnect, and tool calls work across server and client.
Overview
The @cloudflare/ai-chat package provides two primary APIs:
| Export | Import | Purpose |
|---|---|---|
AIChatAgent |
@cloudflare/ai-chat |
Server-side agent class with message persistence and streaming |
useAgentChat |
@cloudflare/ai-chat/react |
React hook for building chat UIs |
Advanced helpers are also available from @cloudflare/ai-chat/react, @cloudflare/ai-chat/types, and agents/chat; see Exports for the full package surface.
Built on the AI SDK ↗︎ and Cloudflare Durable Objects, you get:
- Automatic message persistence — conversations stored in SQLite, survive restarts
- Resumable streaming — disconnected clients resume mid-stream without data loss
- Real-time sync — messages broadcast to all connected clients via WebSocket
- Tool support — server-side, client-side, and human-in-the-loop tool patterns
- Data parts — attach typed JSON (citations, progress, usage) to messages alongside text
- Row size protection — automatic compaction when messages approach SQLite limits
Quick start
Install
npmyarnpnpmbun
npm i @cloudflare/ai-chat agents ai @ai-sdk/react @ai-sdk/provider
yarn add @cloudflare/ai-chat agents ai @ai-sdk/react @ai-sdk/provider
pnpm add @cloudflare/ai-chat agents ai @ai-sdk/react @ai-sdk/provider
bun add @cloudflare/ai-chat agents ai @ai-sdk/react @ai-sdk/provider
Server
import { AIChatAgent } from "@cloudflare/ai-chat";
import { createAI } from "agents/models/ai-sdk";
import { streamText, convertToModelMessages } from "ai";
export class ChatAgent extends AIChatAgent {
async onChatMessage() {
// Use any AI SDK provider, such as createAI from agents/models/ai-sdk, openai, anthropic, or google.
const ai = createAI({ binding: this.env.AI });
const result = streamText({
model: ai("@cf/zai-org/glm-4.7-flash"),
messages: await convertToModelMessages(this.messages),
});
return result.toUIMessageStreamResponse();
}
}
import { AIChatAgent } from "@cloudflare/ai-chat";
import { createAI } from "agents/models/ai-sdk";
import { streamText, convertToModelMessages } from "ai";
export class ChatAgent extends AIChatAgent {
async onChatMessage() {
// Use any AI SDK provider, such as createAI from agents/models/ai-sdk, openai, anthropic, or google.
const ai = createAI({ binding: this.env.AI });
const result = streamText({
model: ai("@cf/zai-org/glm-4.7-flash"),
messages: await convertToModelMessages(this.messages),
});
return result.toUIMessageStreamResponse();
}
}
Client
import { useAgent } from "agents/react";
import { useAgentChat } from "@cloudflare/ai-chat/react";
function Chat() {
const agent = useAgent({ agent: "ChatAgent" });
const { messages, sendMessage, status } = useAgentChat({ agent });
return (
<div>
{messages.map((msg) => (
<div key={msg.id}>
<strong>{msg.role}:</strong>
{msg.parts.map((part, i) =>
part.type === "text" ? <span key={i}>{part.text}</span> : null,
)}
</div>
))}
<form
onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.elements.namedItem("input");
sendMessage({ text: input.value });
input.value = "";
}}
>
<input name="input" placeholder="Type a message..." />
<button type="submit" disabled={status !== "ready"}>
Send
</button>
</form>
</div>
);
}
import { useAgent } from "agents/react";
import { useAgentChat } from "@cloudflare/ai-chat/react";
function Chat() {
const agent = useAgent({ agent: "ChatAgent" });
const { messages, sendMessage, status } = useAgentChat({ agent });
return (
<div>
{messages.map((msg) => (
<div key={msg.id}>
<strong>{msg.role}:</strong>
{msg.parts.map((part, i) =>
part.type === "text" ? <span key={i}>{part.text}</span> : null,
)}
</div>
))}
<form
onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.elements.namedItem(
"input",
) as HTMLInputElement;
sendMessage({ text: input.value });
input.value = "";
}}
>
<input name="input" placeholder="Type a message..." />
<button type="submit" disabled={status !== "ready"}>
Send
</button>
</form>
</div>
);
}
Wrangler configuration
// wrangler.jsonc
{
"ai": { "binding": "AI" },
"durable_objects": {
"bindings": [{ "name": "ChatAgent", "class_name": "ChatAgent" }],
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["ChatAgent"] }],
}
The new_sqlite_classes migration is required — AIChatAgent uses SQLite for message persistence and stream chunk buffering.
How it works
sequenceDiagram
participant Client as Client (useAgentChat)
participant Agent as AIChatAgent
participant DB as SQLite
Client->>Agent: CF_AGENT_USE_CHAT_REQUEST (WebSocket)
Agent->>DB: Persist messages
Agent->>Agent: onChatMessage()
loop Streaming response
Agent-->>Client: CF_AGENT_USE_CHAT_RESPONSE (chunks)
Agent->>DB: Buffer chunks
end
Agent->>DB: Persist final message
Agent-->>Client: CF_AGENT_CHAT_MESSAGES (broadcast to all clients)
- The client sends a message via WebSocket
AIChatAgentpersists messages to SQLite and calls youronChatMessagemethod- Your method returns a streaming
Response(typically fromstreamText) - Chunks stream back over WebSocket in real-time
- When the stream completes, the final message is persisted and broadcast to all connections
Server API
AIChatAgent
Extends Agent from the agents package. Manages conversation state, persistence, and streaming.
import { AIChatAgent } from "@cloudflare/ai-chat";
export class ChatAgent extends AIChatAgent {
// Access current messages
// this.messages: UIMessage[]
// Limit stored messages (optional)
maxPersistedMessages = 200;
async onChatMessage(onFinish, options) {
// onFinish: callback for streamText (cleanup is automatic)
// options.abortSignal: cancel signal
// options.body: custom data from client
// options.continuation: true for continuation turns
// Return a Response (streaming or plain text)
}
}
import { AIChatAgent } from "@cloudflare/ai-chat";
export class ChatAgent extends AIChatAgent {
// Access current messages
// this.messages: UIMessage[]
// Limit stored messages (optional)
maxPersistedMessages = 200;
async onChatMessage(onFinish, options?) {
// onFinish: callback for streamText (cleanup is automatic)
// options.abortSignal: cancel signal
// options.body: custom data from client
// options.continuation: true for continuation turns
// Return a Response (streaming or plain text)
}
}
onChatMessage
This is the main method you override. It receives the conversation context and should return a Response.
Streaming response (most common):
export class ChatAgent extends AIChatAgent {
async onChatMessage() {
const ai = createAI({ binding: this.env.AI });
const result = streamText({
model: ai("@cf/zai-org/glm-4.7-flash"),
system: "You are a helpful assistant.",
messages: await convertToModelMessages(this.messages),
});
return result.toUIMessageStreamResponse();
}
}
export class ChatAgent extends AIChatAgent {
async onChatMessage() {
const ai = createAI({ binding: this.env.AI });
const result = streamText({
model: ai("@cf/zai-org/glm-4.7-flash"),
system: "You are a helpful assistant.",
messages: await convertToModelMessages(this.messages),
});
return result.toUIMessageStreamResponse();
}
}
Plain text response:
export class ChatAgent extends AIChatAgent {
async onChatMessage() {
return new Response("Hello! I am a simple agent.", {
headers: { "Content-Type": "text/plain" },
});
}
}
Accessing custom body data and request ID:
export class ChatAgent extends AIChatAgent {
async onChatMessage(_onFinish, options) {
const { timezone, userId } = options?.body ?? {};
// Use these values in your LLM call or business logic
// options.requestId — unique identifier for this chat request,
// useful for logging and correlating events
console.log("Request ID:", options?.requestId);
if (options?.continuation) {
// This turn continues a previous assistant message after a tool result,
// continueLastTurn(), or recovery.
}
}
}
options.continuation is true for automatic continuations after tool results or approvals, calls to continueLastTurn(), and recovered turns. Use it to choose a different model, adjust your system prompt, or skip expensive context assembly for continuation turns.
this.messages
The current conversation history, loaded from SQLite. This is an array of UIMessage objects from the AI SDK. Messages are automatically persisted after each interaction.
maxPersistedMessages
Cap the number of messages stored in SQLite. When the limit is exceeded, the oldest messages are deleted. This controls storage only — it does not affect what is sent to the LLM.
export class ChatAgent extends AIChatAgent {
maxPersistedMessages = 200;
}
export class ChatAgent extends AIChatAgent {
maxPersistedMessages = 200;
}
To control what is sent to the model, use the AI SDK's pruneMessages():
import { streamText, convertToModelMessages, pruneMessages } from "ai";
export class ChatAgent extends AIChatAgent {
async onChatMessage() {
const ai = createAI({ binding: this.env.AI });
const result = streamText({
model: ai("@cf/zai-org/glm-4.7-flash"),
messages: pruneMessages({
messages: await convertToModelMessages(this.messages),
reasoning: "before-last-message",
toolCalls: "before-last-2-messages",
}),
});
return result.toUIMessageStreamResponse();
}
}
import { streamText, convertToModelMessages, pruneMessages } from "ai";
export class ChatAgent extends AIChatAgent {
async onChatMessage() {
const ai = createAI({ binding: this.env.AI });
const result = streamText({
model: ai("@cf/zai-org/glm-4.7-flash"),
messages: pruneMessages({
messages: await convertToModelMessages(this.messages),
reasoning: "before-last-message",
toolCalls: "before-last-2-messages",
}),
});
return result.toUIMessageStreamResponse();
}
}
waitForMcpConnections
Controls whether AIChatAgent waits for MCP server connections to settle before calling onChatMessage. This ensures this.mcp.getAITools() returns the full set of tools, especially after Durable Object hibernation when connections are being restored in the background.
| Value | Behavior |
|---|---|
{ timeout: 10_000 } |
Wait up to 10 seconds (default) |
{ timeout: N } |
Wait up to N milliseconds |
true |
Wait indefinitely until all connections ready |
false |
Do not wait (old behavior before 0.2.0) |
export class ChatAgent extends AIChatAgent {
// Default — waits up to 10 seconds
// waitForMcpConnections = { timeout: 10_000 };
// Wait forever
waitForMcpConnections = true;
// Disable waiting
waitForMcpConnections = false;
}
export class ChatAgent extends AIChatAgent {
// Default — waits up to 10 seconds
// waitForMcpConnections = { timeout: 10_000 };
// Wait forever
waitForMcpConnections = true;
// Disable waiting
waitForMcpConnections = false;
}
For lower-level control, call this.mcp.waitForConnections() directly inside your onChatMessage instead.
messageConcurrency
Controls how overlapping user submissions behave when a chat turn is already active or queued.
export class ChatAgent extends AIChatAgent {
messageConcurrency = "queue";
}
export class ChatAgent extends AIChatAgent {
messageConcurrency = "queue";
}
| Strategy | Behavior |
|---|---|
"queue" (default) |
Queue every submission and process in order |
"latest" |
Keep only the latest overlapping submission; superseded submissions still persist their user messages but do not start a model turn |
"merge" |
Queue overlapping submissions, then collapse their trailing user messages into one combined turn before the latest queued turn runs |
"drop" |
Ignore overlapping submissions entirely. Messages are not persisted. |
{ strategy: "debounce", debounceMs?: number } |
Trailing-edge latest with a quiet window (default 750ms) |
This setting only applies to sendMessage() submissions. Regenerations, tool continuations, approvals, clears, and programmatic saveMessages() calls keep their existing serialized behavior.
persistMessages and saveMessages
persistMessages stores messages in SQLite and broadcasts the update to all connected clients, but does not trigger a model turn. Use it when you want to inject messages into the conversation without starting a new response.
saveMessages persists messages and triggers onChatMessage() for a new response. It waits for any active chat turn to finish before starting, so scheduled or programmatic messages never overlap an in-flight stream.
// Store messages without triggering a response
await this.persistMessages(messages);
// Store messages AND trigger onChatMessage
const { requestId, status } = await this.saveMessages(messages);
// Store messages without triggering a response
await this.persistMessages(messages);
// Store messages AND trigger onChatMessage
const { requestId, status } = await this.saveMessages(messages);
saveMessages accepts either an array of messages or a function that derives the next message list from the latest persisted this.messages. Use the function form to avoid stale baselines when multiple calls queue up:
await this.saveMessages((messages) => [
...messages,
{
id: crypto.randomUUID(),
role: "user",
parts: [{ type: "text", text: "Summarize the latest data" }],
createdAt: new Date(),
},
]);
await this.saveMessages((messages) => [
...messages,
{
id: crypto.randomUUID(),
role: "user",
parts: [{ type: "text", text: "Summarize the latest data" }],
createdAt: new Date(),
},
]);
saveMessages returns { requestId, status, error? } where status is "completed" if the turn ran, "error" if the stream reported an error, "skipped" if the chat was cleared before it started, or "aborted" if an external AbortSignal cancelled it before completion. When status is "error", error contains the stream error message when available.
Pass options.signal to cancel a programmatic turn from outside the chat agent. This is useful when a parent tool call needs to cancel a child agent turn without knowing the internally generated request ID:
const controller = new AbortController();
const result = await this.saveMessages(
(messages) => [...messages, syntheticUserMessage],
{ signal: controller.signal },
);
if (result.status === "aborted") {
// Partial chunks already streamed are persisted.
}
const controller = new AbortController();
const result = await this.saveMessages(
(messages) => [...messages, syntheticUserMessage],
{ signal: controller.signal },
);
if (result.status === "aborted") {
// Partial chunks already streamed are persisted.
}
continueLastTurn() accepts the same options.signal argument. AbortSignal objects cannot cross Durable Object RPC boundaries, so construct the controller inside the Durable Object that calls saveMessages() or continueLastTurn(). The signal is in memory only. If the Durable Object hibernates mid-turn, the recovered turn runs without the original signal. Persist cancellation intent when cancellation must survive a restart.
onChatResponse
Called after a chat turn produces and persists an assistant message. The turn lock is released before this hook runs, so it is safe to call saveMessages from inside. Fires for turn paths that persist an assistant message: WebSocket chat requests, saveMessages, and auto-continuation. If a turn fails before producing any assistant parts, the error is surfaced through the original request instead.
export class ChatAgent extends AIChatAgent {
async onChatResponse(result) {
if (result.status === "completed") {
console.log("Turn completed:", result.requestId);
}
if (result.status === "error") {
console.error("Turn failed:", result.error);
}
}
}
