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)

  1. The client sends a message via WebSocket
  2. AIChatAgent persists messages to SQLite and calls your onChatMessage method
  3. Your method returns a streaming Response (typically from streamText)
  4. Chunks stream back over WebSocket in real-time
  5. 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);
        }
    }
}