Chat agents·续2

ChatRecoveryOptions:

Field Default Description
persist true Save the partial response as an assistant message
continue true Schedule a continuation via continueLastTurn()

Common return values:

  • {} — persist partial + auto-continue (default, works with providers that support assistant prefill)
  • { continue: false } — persist partial but do not auto-continue (handle continuation yourself)
  • { persist: false, continue: false } — do not persist the unsettled remainder and handle everything yourself (for example, retrieve a completed response from the provider)

Settled work is never dropped: persist: false only suppresses persistence of a partial that has nothing settled to lose. A partial that already carries settled tool results (completed, often non-idempotent work) is persisted regardless, so an app cannot accidentally discard completed tool calls — and never needs { persist: true } just to stay safe.

When recovery happens before any stream chunks were written, there is no partial assistant message to continue. If the latest persisted message is still the unanswered user message from the interrupted turn, the framework retries that turn automatically unless continue is false.

Use ctx.createdAt to skip stale recoveries:

override async onChatRecovery(
    ctx: ChatRecoveryContext,
): Promise<ChatRecoveryOptions> {
    if (Date.now() - ctx.createdAt > 2 * 60 * 1000) {
        return { continue: false };
    }
    return {};
}

Control automatic continuation

Durable bookkeeping remains active when automatic continuation is not appropriate.

  • Return { continue: false } when another model call is unsafe.
  • Persist cancellation intent and read it in onChatRecovery().
  • Record idempotency keys before external side effects.
  • Use recovery budgets with durable spend data to limit cost.

continueLastTurn

Appends to the last assistant message by re-calling onChatMessage with the saved request body. The response is streamed as a continuation — appended to the existing assistant message, not a new one. No synthetic user message is created.

protected continueLastTurn(
    body?: Record<string, unknown>,
    options?: SaveMessagesOptions,
): Promise<SaveMessagesResult>;

Called automatically by the default recovery path. Can also be called manually from scheduled callbacks or other entry points. The optional body parameter overrides the saved request body for this continuation. Pass options.signal to cancel the continuation while it is running.

Stashing recovery data

Use this.stash() inside onChatMessage to persist provider-specific data for recovery. The stash is stored in the fiber's SQLite row, separate from agent state, and available as ctx.recoveryData in onChatRecovery.

export class ChatAgent extends AIChatAgent {
    async onChatMessage(_onFinish, options) {
        const result = streamText({
            model: openai("gpt-5.4"),
            messages: await convertToModelMessages(this.messages),
            providerOptions: { openai: { store: true } },
            includeRawChunks: true,
            onChunk: ({ chunk }) => {
                if (chunk.type === "raw") {
                    const raw = chunk.rawValue;

if (raw?.type === "response.created" && raw.response?.id) {
                        this.stash({ responseId: raw.response.id });
                    }
                }
            },
        });
        return result.toUIMessageStreamResponse();
    }
}
export class ChatAgent extends AIChatAgent {
    async onChatMessage(_onFinish, options) {
        const result = streamText({
            model: openai("gpt-5.4"),
            messages: await convertToModelMessages(this.messages),
            providerOptions: { openai: { store: true } },
            includeRawChunks: true,
            onChunk: ({ chunk }) => {
                if (chunk.type === "raw") {
                    const raw = chunk.rawValue as {
                        type?: string;
                        response?: { id?: string };
                    };
                    if (raw?.type === "response.created" && raw.response?.id) {
                        this.stash({ responseId: raw.response.id });
                    }
                }
            },
        });
        return result.toUIMessageStreamResponse();
    }
}

Recovery strategies by provider

The right strategy depends on whether the provider supports assistant prefill and whether the response continues server-side after disconnection:

Provider Strategy Token cost
Workers AI continueLastTurn() — model continues via assistant prefill Low
OpenAI (Responses API) Retrieve completed response by ID — zero wasted tokens Zero
Anthropic Persist partial, send a synthetic user message to continue Medium

Recovering status on the client

While a turn is being recovered, the agent broadcasts a cf_agent_chat_recovering status frame so clients can show a "recovering…" indicator instead of looking frozen. It is set when a recovery continuation is scheduled and cleared on every terminal outcome, so the indicator never spins forever. Consume it through useAgentChat's isRecovering flag (see Return values). The signal is advisory and backward-compatible — clients that do not understand it ignore it.

Note

@cloudflare/ai-chat broadcasts the live signal but does not yet replay it on connect, so a client connecting mid-recovery is not re-told until it reconnects to an active stream. Think replays it on connect.

Transcript repairs — healing orphaned tool calls (preserved as errored results rather than deleted, so the record survives and the model does not silently re-run the tool) and normalizing malformed or missing tool inputs before a provider call — are emitted on the transcript observability channel.

For how chat recovery fits into the broader long-running agents story, refer to Long-running agents: Recovering interrupted LLM streams. For the underlying fiber API, refer to Durable Execution.

Client API

useAgentChat

React hook that connects to an AIChatAgent over WebSocket. Wraps the AI SDK's useChat with a native WebSocket transport.

import { useAgent } from "agents/react";
import { useAgentChat } from "@cloudflare/ai-chat/react";

function Chat() {
    const agent = useAgent({ agent: "ChatAgent" });
    const {
        messages,
        sendMessage,
        clearHistory,
        addToolOutput,
        addToolApprovalResponse,
        setMessages,
        status,
        isStreaming,
        isServerStreaming,
        isToolContinuation,
        isRecovering,
    } = useAgentChat({ agent });

// ...
}
import { useAgent } from "agents/react";
import { useAgentChat } from "@cloudflare/ai-chat/react";

function Chat() {
    const agent = useAgent({ agent: "ChatAgent" });
    const {
        messages,
        sendMessage,
        clearHistory,
        addToolOutput,
        addToolApprovalResponse,
        setMessages,
        status,
        isStreaming,
        isServerStreaming,
        isToolContinuation,
        isRecovering,
    } = useAgentChat({ agent });

// ...
}

Options

Option Type Default Description
agent ReturnType<typeof useAgent> Required Agent connection from useAgent
onToolCall ({ toolCall, addToolOutput }) => void — Handle client-side tool execution
tools Record<string, AITool> — Advanced: dynamically register client-executed tools from the browser
autoContinueAfterToolResult boolean true Auto-continue conversation after client tool results and approvals
resume boolean true Enable automatic stream resumption on reconnect
cancelOnClientAbort boolean false Cancel the server turn when generic client stream abort or cleanup occurs
body object \| () => object — Custom data sent with every request
prepareSendMessagesRequest (options) => { body?, headers? } — Advanced per-request customization
getInitialMessages (options) => Promise<UIMessage[]> or null — Custom initial message loader. Set to null to skip the HTTP fetch entirely (useful when providing messages directly)
syncMessagesToServer boolean true When true, setMessages pushes the transcript to the server. Set to false for hosts with server-authoritative transcript storage so setMessages updates the local view only

Return values

Property Type Description
messages UIMessage[] Current conversation messages
sendMessage (message) => void Send a message
clearHistory () => void Clear conversation (client and server)
addToolOutput ({ toolCallId, output }) => void Provide output for a client-side tool
addToolApprovalResponse ({ id, approved }) => void Approve or reject a tool requiring approval
setMessages (messages \| updater) => void Set messages directly (syncs to server)
status string "ready", "submitted", "streaming", or "error"
isStreaming boolean true while the agent is streaming or waiting on an active client tool
isServerStreaming boolean true while a server-initiated stream or active client-tool phase is in progress
isToolContinuation boolean true while an automatic continuation after a tool result or approval is running
isRecovering boolean true while a durable turn is being recovered (interrupted and resuming). Distinct from isStreaming — a recovering turn is not producing tokens yet. Render a "recovering…" hint; most UIs treat isStreaming \|\| isRecovering as "busy"

Use isToolContinuation when your UI should distinguish a fresh user submit from a continuation after a tool result. For example, show a typing indicator only for status === "submitted" && !isToolContinuation, while keeping loading controls disabled whenever isStreaming is true.

Non-React clients

useAgentChat is React-specific. For Vue, Svelte, or vanilla JavaScript, agents/chat/transport exports WebSocketChatTransport, which adapts an AgentClient WebSocket connection to the AI SDK transport interface. This entry point requires no React peer dependency.

import { useChat } from "@ai-sdk/vue";
import { AgentClient } from "agents/client";
import { WebSocketChatTransport } from "agents/chat/transport";

const agent = new AgentClient({
    agent: "ChatAgent",
    name: "user-123",
    host: window.location.host,
});

const { messages, sendMessage, status } = useChat({
    transport: new WebSocketChatTransport({
        agent,
        cancelOnClientAbort: true,
    }),
});
import { useChat } from "@ai-sdk/vue";
import { AgentClient } from "agents/client";
import { WebSocketChatTransport } from "agents/chat/transport";

const agent = new AgentClient({
    agent: "ChatAgent",
    name: "user-123",
    host: window.location.host,
});

const { messages, sendMessage, status } = useChat({
    transport: new WebSocketChatTransport({
        agent,
        cancelOnClientAbort: true,
    }),
});

The transport covers new turns, regenerated turns, and stream cancellation. It is a lower-level primitive than useAgentChat: loading persisted history, automatic stream resume after a reconnect, cross-tab transcript synchronization, and client-side tool continuations remain the React hook's responsibility. Implement whichever of those your client needs on top of the transport.

The vue-chat example ↗︎ shows a minimal Vue client, and ai-chat ↗︎ shows the full React integration for comparison.

Tools

AIChatAgent supports three tool patterns, all using the AI SDK's tool() function:

Pattern Where it runs When to use
Server-side Server (automatic) API calls, database queries, computations
Client-side Browser (via onToolCall) Geolocation, clipboard, camera, local storage
Approval Server (after user approval) Payments, deletions, external actions

Server-side tools

Tools with an execute function run automatically on the server:

import { streamText, convertToModelMessages, tool, stepCountIs } from "ai";
import { z } from "zod";
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: await convertToModelMessages(this.messages),
            tools: {
                getWeather: tool({
                    description: "Get weather for a city",
                    inputSchema: z.object({ city: z.string() }),
                    execute: async ({ city }) => {
                        const data = await fetchWeather(city);
                        return { temperature: data.temp, condition: data.condition };
                    },
                }),
            },
            stopWhen: stepCountIs(5),
        });

return result.toUIMessageStreamResponse();
    }
}
import { streamText, convertToModelMessages, tool, stepCountIs } from "ai";
import { z } from "zod";
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: await convertToModelMessages(this.messages),
            tools: {
                getWeather: tool({
                    description: "Get weather for a city",
                    inputSchema: z.object({ city: z.string() }),
                    execute: async ({ city }) => {
                        const data = await fetchWeather(city);
                        return { temperature: data.temp, condition: data.condition };
                    },
                }),
            },
            stopWhen: stepCountIs(5),
        });

return result.toUIMessageStreamResponse();
    }
}

Client-side tools

Define a tool on the server without execute, then handle it on the client with onToolCall. Use this for tools that need browser APIs.

Server:

tools: {
    getLocation: tool({
        description: "Get the user's location from the browser",
        inputSchema: z.object({}),
        // No execute — the client handles it
    });
}
tools: {
    getLocation: tool({
        description: "Get the user's location from the browser",
        inputSchema: z.object({}),
        // No execute — the client handles it
    });
}

Client:

const { messages, sendMessage } = useAgentChat({
    agent,
    onToolCall: async ({ toolCall, addToolOutput }) => {
        if (toolCall.toolName === "getLocation") {
            const pos = await new Promise((resolve, reject) =>
                navigator.geolocation.getCurrentPosition(resolve, reject),
            );
            addToolOutput({
                toolCallId: toolCall.toolCallId,
                output: { lat: pos.coords.latitude, lng: pos.coords.longitude },
            });
        }
    },
});
const { messages, sendMessage } = useAgentChat({
    agent,
    onToolCall: async ({ toolCall, addToolOutput }) => {
        if (toolCall.toolName === "getLocation") {
            const pos = await new Promise((resolve, reject) =>
                navigator.geolocation.getCurrentPosition(resolve, reject),
            );
            addToolOutput({
                toolCallId: toolCall.toolCallId,
                output: { lat: pos.coords.latitude, lng: pos.coords.longitude },
            });
        }
    },
});

When the LLM invokes getLocation, the stream pauses. The onToolCall callback fires, your code provides the output, and the conversation continues.

For SDKs or platforms where the browser decides the available tools at runtime, pass a tools object to useAgentChat. Tools with client-side execute functions are serialized and sent to the server automatically. On the server, options.clientTools and createToolsFromClientSchemas() remain supported for this dynamic tool pattern.

Tool approval (human-in-the-loop)

Use needsApproval for tools that require user confirmation before executing.

Server:

tools: {
    processPayment: tool({
        description: "Process a payment",
        inputSchema: z.object({
            amount: z.coerce.number(),
            recipient: z.string(),
        }),
        needsApproval: async ({ amount }) => amount > 100,
        execute: async ({ amount, recipient }) => charge(amount, recipient),
    });
}

Client:

import { getToolName, isToolUIPart } from "ai";
import {
    getToolApproval,
    getToolCallId,
    getToolPartState,
} from "@cloudflare/ai-chat/react";

const { messages, addToolApprovalResponse } = useAgentChat({ agent });

// Render pending approvals from message parts
{
    messages.map((msg) =>
        msg.parts
            .filter(
                (part) =>
                    isToolUIPart(part) && getToolPartState(part) === "waiting-approval",
            )
            .map((part) => (
                <div key={getToolCallId(part)}>
                    <p>Approve {getToolName(part)}?</p>
                    <button
                        onClick={() => {
                            const approval = getToolApproval(part);
                            if (!approval) return;
                            addToolApprovalResponse({
                                id: approval.id,
                                approved: true,
                            });
                        }}
                    >
                        Approve
                    </button>
                    <button
                        onClick={() => {
                            const approval = getToolApproval(part);
                            if (!approval) return;
                            addToolApprovalResponse({
                                id: approval.id,
                                approved: false,
                            });
                        }}
                    >
                        Reject
                    </button>
                </div>
            )),
    );
}