agents-vue

Vue 3 composable for Cloudflare Agents — a port of @cloudflare/ai-chat/react's useAgentChat.
1,227 2
Install
npm install agents-vue
Latest Version:0.5.0
Vue:3
License:MIT
Last Updated:Oct 18, 2026
Links: GitHub  ·  npm
Maintainer: ymansurozer

agents-vue

Vue 3 composable for Cloudflare Agents — a port of @cloudflare/ai-chat/react's useAgentChat to the Vue Composition API and @ai-sdk/vue.

agents-vue is a Vue 3 port of @cloudflare/ai-chat's useAgentChat React hook. The protocol, semantics, and most of the algorithmic structure come directly from the upstream React implementation. This package adapts it to the Vue Composition API and @ai-sdk/vue.

Status

Covers the surface needed for a working chat UI — streaming responses, client-side tools, tool approvals, stream resumption, broadcast handling — at parity with upstream @cloudflare/ai-chat 0.12.x on resume/recovery: resume dedupe, isRecovering, cancelOnClientAbort, pre-stream cf_agent_stream_pending, and connectionError. Deeper upstream features (the useAgent connection hook, the cross-tab observer, isServerStreaming / isStreaming flags) are on the roadmap.

0.3.0 moves the transport to upstream's own WebSocketChatTransport from agents/chat/transport and fixes four client-side bugs — see CHANGELOG.

Install

pnpm add agents-vue

Peer dependencies (must also be installed):

Peer Version
vue ^3.5.0
@ai-sdk/vue ^4.0.0
ai ^7.0.0
agents >=0.27.0 <1.0.0

Usage

<script setup lang="ts">
import { useAgentChat } from "agents-vue";

const { chat, addToolOutput, addToolApprovalResponse, clearHistory, stop } = useAgentChat({
  client: {
    agent: "MyAgent",          // Agent class name
    host: "agent.example.com", // your worker host
    name: "session-id",        // identifies the Durable Object instance
  },
});
</script>

<template>
  <div v-for="message in chat.messages" :key="message.id">
    <strong>{{ message.role }}:</strong>
    <template v-for="(part, i) in message.parts" :key="i">
      <span v-if="part.type === 'text'">{{ part.text }}</span>
    </template>
  </div>

  <button @click="chat.sendMessage({ text: 'hi' })">Send</button>
  <button @click="stop()">Stop</button>
  <button @click="clearHistory()">Clear</button>
</template>

chat is the underlying @ai-sdk/vue Chat instance — chat.messages, chat.status, chat.sendMessage() are all reactive and standard. The composable layers WebSocket-aware overrides for addToolOutput, addToolApprovalResponse, clearHistory, and stop, plus full-history sync, broadcast merging, and stream resumption.

SSR

agents-vue is client-only. The composable opens a WebSocket on construction and will throw in Node / SSR contexts. Wrap call sites in <ClientOnly> (Nuxt) or defer with onMounted.

<!-- Nuxt -->
<ClientOnly>
  <ChatComponent />
</ClientOnly>

API

useAgentChat<AgentT, ChatMessage, State>(options)

Returns:

Field Type Description
chat Chat<ChatMessage> @ai-sdk/vue chat instance — reactive messages, status, error, plus sendMessage, regenerate.
socket AgentClient<AgentT, State> Underlying PartySocket. Typed RPC stub when AgentT is provided.
addToolOutput function Submit a tool result. Sends cf_agent_tool_result and triggers continuation if enabled.
addToolApprovalResponse function Resolve a human-in-the-loop tool approval. Sends cf_agent_tool_approval and triggers continuation.
clearHistory function Clear local state and broadcast cf_agent_chat_clear to the server.
stop function Abort the active stream and any pending tool continuation. Always cancels the server turn (explicit cancel).
isRecovering Ref<boolean> Reactive — true while the server is recovering a durable chat turn (distinct from streaming, so a UI can show "recovering…"). Driven by cf_agent_chat_recovering.
connectionError Ref<AgentConnectionError | null> Reactive — the terminal WebSocket connection failure, or null. Cleared on reconnect.

Options

Option Type Default Description
client AgentClientOptions<State> — Required. Connection config — agent, host, name, basePath.
initialMessages ChatMessage[] [] Seed messages on mount.
body object | () => object | Promise<object> — Extra request body — static or per-request function.
tools Record<string, AgentClientTool> — Client-side tool definitions. Schemas auto-extracted and sent with each request.
onToolCall ({ toolCall, addToolOutput }) => void | Promise<void> — Custom tool-call handler. Overrides per-tool execute.
autoContinueAfterToolResult boolean true Server resumes the stream after a tool result.
resume boolean true Resume in-flight streams on socket reconnect.
cancelOnClientAbort boolean false When true, a generic client-side abort cancels the server turn; when false (default) such aborts are local-only and the server turn keeps running and can resume. Explicit stop() always cancels.
onOpen (event: Event) => void — WebSocket open.
onClose (event: CloseEvent) => void — WebSocket close.
onSocketError (event: Event) => void — Connection-level error.
onError (error: Error) => void — Chat-turn error from the AI SDK.
onStateUpdate (state: State, source: "server" | "client") => void — The agent's state, on connect and on every change the server broadcasts.
onFrame (frame: unknown) => boolean | void — Every socket frame, parsed, before the chat protocol reads it. Return true to consume it.
mergeMessages (onScreen, stored) => ChatMessage[] merge by id How a reconnect's read of the stored transcript joins the one on screen.
resyncOnReconnect boolean true A replacement socket re-reads get-messages and merges it. Turn it off for a server that pushes its stored transcript to each connection as it opens.
sendOnlyNewMessages boolean false A send carries only the messages after the last one the server is known to hold, named as afterMessageId. The server must rebuild the rest.

onFrame — your own frames

The chat protocol parser narrows a frame to the fields cf_agent_* messages have and drops every other one, so a frame of your own would arrive with its contents removed. onFrame runs first and sees the frame whole; returning true consumes it, and the protocol handling never sees it.

useAgentChat({
  client: { /* ... */ },
  onFrame: (frame) => {
    if (!isRecord(frame) || frame.type !== "my_app_event") return;
    showBanner(frame);
    return true; // consumed
  },
});

mergeMessages — what a reconnect does to the transcript

AIChatAgent pushes no snapshot on connect, so a replacement socket re-reads the stored transcript, unless resyncOnReconnect is off — and that read is a round trip of its own: it can be issued before a turn is persisted and land after the socket has already delivered the finished answer. The default merges by id with the stored copy winning and anything only on screen kept after it. Pass your own policy to decide differently.

import type { UIMessage } from "ai";

const textLength = (message: UIMessage) =>
  message.parts.reduce((total, part) => total + (part.type === "text" ? part.text.length : 0), 0);

/** The stored copy can be behind the stream, so the longer of the two wins. */
const fresher = (stored: UIMessage, onScreen: UIMessage | undefined) =>
  onScreen && textLength(onScreen) > textLength(stored) ? onScreen : stored;

useAgentChat({
  client: { /* ... */ },
  mergeMessages: (onScreen, stored) => {
    const live = new Map(onScreen.map((message) => [message.id, message]));
    const storedIds = new Set(stored.map((message) => message.id));

    return [
      ...stored.map((message) => fresher(message, live.get(message.id))),
      // Anything only on screen — a question typed while the socket was down — is kept after.
      ...onScreen.filter((message) => !storedIds.has(message.id)),
    ];
  },
});

resyncOnReconnect — a server that pushes its own snapshot

AIChatAgent sends a connecting client nothing of the transcript, which is why a replacement socket re-reads get-messages. A server that sends each connection its stored transcript as it opens, as a cf_agent_chat_messages frame, makes that read a second copy of the same transcript. With resyncOnReconnect: false a replacement socket reads nothing, and the frame is applied as any broadcast is, as a replace: mergeMessages is never called, so a merge of your own belongs in onFrame.

Only a reconnect is affected. The first open's get-messages seed runs unless initialMessages is passed, with this option on or off, so a client whose server pushes the transcript on every connection passes both:

useAgentChat({
  client: { /* ... */ },
  initialMessages: [], // no read on the first open
  resyncOnReconnect: false, // and none on a reconnect
});

sendOnlyNewMessages — sending what the server does not have

By default every send carries the whole transcript, so a request grows with the session. With sendOnlyNewMessages: true it carries only the messages after the last one the server is known to hold — usually just the new question — and names that message as afterMessageId, beside messages in the request body. Known to hold means seen arriving from the server: initialMessages, the get-messages read, a broadcast, a cf_agent_message_updated frame, or a stream that finished without an error with a part in its reply. A message only this client has never counts. A request that rewrites a message in place carries it too: an edited question, or the assistant message a bare sendMessage() resubmits after an approval or a tool output. A regenerate, and a send before the server is known to hold anything, carry the full list and no afterMessageId.

A client tool's output is not part of any request: it reaches the server in a cf_agent_tool_result frame, with the option on or off. What changes is the next request. A question typed after a tool output carries only the question, where the full list re-sent the output along with everything else; only a request a bare sendMessage() makes carries the rewritten assistant message.

The server's half. A request with afterMessageId holds only part of the transcript, so the server must rebuild the full list before it persists the request or broadcasts it — AIChatAgent broadcasts a request's list to the other clients as it arrived. When the first message sent is one it already stores, at index k, the request rewrites in place, and the full list is its stored messages before k, then the ones sent. Otherwise the messages sent are new and go after the whole stored transcript, less any it already stores, so a client that missed messages another client added does not erase them. The rebuild reads afterMessageId only to know the list is partial: its value, the last message the client saw the server hold, is a hint, and can name a reply the server never stored. A request without the field carries the full list, as before. AIChatAgent does none of this on its own: it takes a request's list for the whole transcript and deletes the stored messages missing from it, which is why the option is off by default.

function rebuild(stored: UIMessage[], sent: UIMessage[], afterMessageId?: string): UIMessage[] {
  if (afterMessageId === undefined) return sent;
  const rewriteAt = stored.findIndex((message) => message.id === sent[0]?.id);
  if (rewriteAt >= 0) return [...stored.slice(0, rewriteAt), ...sent];
  const storedIds = new Set(stored.map((message) => message.id));
  return [...stored, ...sent.filter((message) => !storedIds.has(message.id))];
}

Client-side tools

useAgentChat({
  client: { /* ... */ },
  tools: {
    weather: {
      description: "Get the weather for a city",
      parameters: {
        type: "object",
        properties: { city: { type: "string" } },
        required: ["city"],
      },
      execute: async ({ city }) => ({ temp: 70, conditions: "clear" }),
    },
  },
});

Tool schemas are auto-extracted from any tool with an execute function and sent to the server with each request. The server invokes the tool by name; execute runs client-side; the output flows back via addToolOutput.

Tool approvals (human-in-the-loop)

const { addToolApprovalResponse } = useAgentChat({ /* ... */ });

// When the model requests approval for a destructive tool:
await addToolApprovalResponse({ id: approvalId, approved: true });

The composable matches the approval id against a tool-approve part in chat.messages and forwards cf_agent_tool_approval to the server. If no match exists it warns to the console and bails — no SDK crash.

WebSocketChatTransport

No longer exported from here. Since 0.3.0 the composable wires upstream's own transport, so import it from agents directly if you are building a non-@ai-sdk/vue flow over the same cf_agent_* wire protocol:

import { WebSocketChatTransport } from "agents/chat/transport";

const transport = new WebSocketChatTransport({
  agent,         // an AgentClient
  prepareBody,   // optional
});

Coverage vs upstream

agents-vue covers the surface real apps use. Wire protocol is identical to upstream — same cf_agent_* message shapes, same resumable streaming, same broadcast transitions, same tool continuation flow.

Feature Status
useAgentChat composable ✅
WebSocketChatTransport (upstream's, from agents/chat/transport) ✅
Streaming responses ✅
Client-side tools with execute ✅
Tool approvals (HITL) ✅
body option (static / sync / async) ✅
Stream resumption (resumable streaming) ✅
Partial-hydration replay rebuild ✅
Broadcast handling (multi-tab) ✅
Tool approval continuation merge across broadcasts ✅
Resume dedupe (fallback-ACK at most once per socket) ✅
isRecovering durable-recovery hint (cf_agent_chat_recovering) ✅
cancelOnClientAbort (local-only abort, default) ✅
Pre-stream cf_agent_stream_pending (extended resume probe) ✅
connectionError (terminal WebSocket close) ✅
Reconnect resume serialization (in-flight gate, agents 0.17.2 / #1837) ✅
setAgent(), which invalidates the transport's resume generation ✅
connectionState, from the socket's own open/close ✅
onStateUpdate, forwarded to the AgentClient ✅
Initial get-messages seed, and a re-read on a replacement socket ✅
Client-side tool calls dispatched off the rendered transcript ✅
Early-continuation branch (observe the announced turn, or launch — never both) ✅
Continuation armed before the tool frame is sent ✅
An unmatchable streaming tail dropped rather than appended ✅
useAgent connection hook 🚧 roadmap
Cross-tab observer 🚧 roadmap
isServerStreaming / isStreaming flags 🚧 roadmap

Contributing

pnpm install
pnpm exec playwright install chromium
pnpm test       # chromium (vitest-browser-vue)
pnpm typecheck
pnpm lint
pnpm build

Releases are driven by changelogen with conventional commits.

License

MIT — see LICENSE. Includes attribution to the original Cloudflare React implementation in @cloudflare/ai-chat.

Related Packages

xxai-agent

AI Agent JS SDK

2,483 0
@buni.ai/chatbot-vue

Vue 3 composables for BuniAI chat widget

4,699
@aibind/vue

AI SDK bindings for Vue 3 — reactive composables for streaming, structured outpu...

1,366 19
@aibind/nuxt

AI SDK bindings for Nuxt — composables with defaults, server handlers, and agent...

1,296 19
@standardagents/vue

Vue SDK for Standard Agents

29,444

Version History

Version Released Vue License
0.5.0 ^3.5.0 MIT
0.4.0 ^3.5.0 MIT
0.3.0 ^3.5.0 MIT
0.2.1 ^3.3.4 MIT
0.2.0 ^3.3.4 MIT