@synoped/ag-ui-vue
v0.1.2
Published
AG-UI protocol integration for Vue 3 — composables, state management, and frontend tool calling
Readme
@synoped/ag-ui-vue
Vue 3 composables for the AG-UI protocol. Connect your Vue app to any AG-UI compatible AI agent with reactive messages, streaming, frontend tool execution, and human-in-the-loop confirmation.
Install
npm install @synoped/ag-ui-vue vuevue >= 3.5 is a peer dependency.
Quick Start
The fastest way to get a chat working:
<script setup lang="ts">
import { useChat } from "@synoped/ag-ui-vue";
const { messages, status, send } = useChat({
url: "http://localhost:8000",
});
const input = ref("");
async function handleSend() {
const text = input.value.trim();
if (!text) return;
input.value = "";
await send(text);
}
</script>
<template>
<div v-for="msg in messages" :key="msg.id">
<strong>{{ msg.role }}:</strong> {{ msg.content }}
</div>
<input v-model="input" @keydown.enter="handleSend" />
<button @click="handleSend" :disabled="status !== 'ready'">Send</button>
</template>That's it. useChat handles the connection, message state, and streaming for you.
API
useChat — High-Level Chat
The main composable. Manages messages, status, tool execution, and confirmation in one call.
const {
messages, // ShallowRef<Message[]>
status, // Ref<'ready' | 'submitted' | 'streaming' | 'error'>
error, // ShallowRef<Error | null>
state, // ShallowRef<State> — shared agent state
reasoning, // ShallowRef<{ isActive: boolean; content: string }>
toolCallTrackers, // ShallowRef<Map<string, ToolCallTracker>>
pendingToolCalls, // ComputedRef<PendingToolCall[]>
send, // (content: string, opts?) => Promise<void>
stop, // () => void — abort the current run
approve, // (toolCallId: string) => void
reject, // (toolCallId: string, reason?: string) => void
agent, // the underlying AbstractAgent instance
} = useChat(options);Options
useChat({
url: "http://localhost:8000", // required — your AG-UI backend
headers: { Authorization: "..." }, // optional — custom headers
threadId: "thread-123", // optional — resume a conversation
initialMessages: [], // optional — seed messages
initialState: {}, // optional — seed state
forwardedProps: { userId: "abc" }, // optional — extra data sent each run
debug: true, // optional — log events
middleware: [myMiddleware], // optional — AG-UI client middleware
onError: (err) => {}, // optional — error callback
onFinish: (result) => {}, // optional — run complete callback
onCustomEvent: (event) => {}, // optional — custom event handler
onRawEvent: (event) => {}, // optional — raw event handler
});Status Lifecycle
ready → submitted → streaming → ready
↘ error- ready — idle, can send a message
- submitted — message sent, waiting for first response
- streaming — receiving text or tool call data
- error — something went wrong (check
error.value)
useAgent — Low-Level Agent Access
If you need full control over the agent without the chat layer:
import { useAgent } from "@synoped/ag-ui-vue";
const {
agent, // AbstractAgent — call agent.runAgent() yourself
messages, // ShallowRef<Message[]>
state, // ShallowRef<State>
isRunning, // Ref<boolean>
reasoning, // ShallowRef<{ isActive: boolean; content: string }>
toolCallTrackers, // ShallowRef<Map<string, ToolCallTracker>>
trackerStore, // internal tracker store
} = useAgent({ url: "http://localhost:8000" });
// Run manually
await agent.runAgent({
tools: [{ name: "search", description: "Search the web", parameters: {} }],
context: [],
});useFrontendTool — Register Browser-Side Tools
Let the agent call functions that run in the browser. Registered tools are automatically included in every send() call and cleaned up when the component unmounts.
<script setup lang="ts">
import { useChat, useFrontendTool } from "@synoped/ag-ui-vue";
const chat = useChat({ url: "http://localhost:8000" });
useFrontendTool({
tool: {
name: "get_location",
description: "Get the user's current city",
parameters: {
type: "object",
properties: {
format: { type: "string", enum: ["city", "full"] },
},
},
handler: async (args) => {
return JSON.stringify({ city: "Toronto", country: "Canada" });
},
},
chat,
});
</script>When the agent decides to call get_location, the handler runs locally in the browser and the result is sent back to the agent automatically. The agent then continues with the tool's output.
Tools with Confirmation
Add requireConfirmation: true to pause and ask the user before executing:
<script setup lang="ts">
import { useChat, useFrontendTool } from "@synoped/ag-ui-vue";
const chat = useChat({ url: "http://localhost:8000" });
const { pendingToolCalls, approve, reject } = chat;
useFrontendTool({
tool: {
name: "delete_file",
description: "Delete a file from the user's workspace",
parameters: {
type: "object",
properties: { path: { type: "string" } },
},
requireConfirmation: true,
handler: async (args) => {
// only runs after user approves
await deleteFile(args.path as string);
return "File deleted";
},
},
chat,
});
</script>
<template>
<!-- Confirmation UI -->
<div v-for="tc in pendingToolCalls" :key="tc.toolCallId">
<p>Agent wants to run <strong>{{ tc.toolName }}</strong></p>
<pre>{{ JSON.stringify(tc.args, null, 2) }}</pre>
<button @click="approve(tc.toolCallId)">Allow</button>
<button @click="reject(tc.toolCallId, 'Not allowed')">Deny</button>
</div>
</template>useAgentContext — Send Context to the Agent
Provide extra context that gets included with every agent run. Automatically cleaned up on unmount.
<script setup lang="ts">
import { useChat, useAgentContext } from "@synoped/ag-ui-vue";
const chat = useChat({ url: "http://localhost:8000" });
useAgentContext({
context: {
description: "Current page info",
value: JSON.stringify({
route: "/dashboard",
selectedItems: [1, 2, 3],
}),
},
chat,
});
</script>useAgentState — Typed Shared State
Read and write shared state between your app and the agent with type safety:
<script setup lang="ts">
import { useChat, useAgentState } from "@synoped/ag-ui-vue";
interface AppState {
counter: number;
theme: "light" | "dark";
}
const chat = useChat({
url: "http://localhost:8000",
initialState: { counter: 0, theme: "light" },
});
const { state, setState } = useAgentState<AppState>({
state: chat.state,
agent: chat.agent,
});
function increment() {
setState({ ...state.value, counter: state.value.counter + 1 });
}
</script>
<template>
<p>Counter: {{ state.counter }}</p>
<button @click="increment">+1</button>
</template>The agent can also update this state from its side. Changes flow both ways.
useToolConfirmation — Confirmation Helper
A convenience wrapper if you want to handle confirmation in a separate component:
<script setup lang="ts">
import { useToolConfirmation } from "@synoped/ag-ui-vue";
const props = defineProps<{ chat: ReturnType<typeof useChat> }>();
const { pendingToolCalls, approve, reject } = useToolConfirmation(props.chat);
</script>
<template>
<div v-for="tc in pendingToolCalls" :key="tc.toolCallId">
<p>{{ tc.toolName }}: {{ JSON.stringify(tc.args) }}</p>
<button @click="approve(tc.toolCallId)">Yes</button>
<button @click="reject(tc.toolCallId)">No</button>
</div>
</template>Adapters — AI Elements / Vercel AI SDK Compatibility
If you're using ai-elements-vue or components that expect Vercel AI SDK message shapes, these adapters bridge the gap:
import { useChat, toUIMessages, toChatStatus } from "@synoped/ag-ui-vue";
const chat = useChat({ url: "http://localhost:8000" });
// Convert AG-UI messages → ai-elements-vue compatible format
const uiMessages = computed(() =>
toUIMessages(chat.messages.value, chat.toolCallTrackers.value)
);
// Convert status → ai-elements-vue compatible status
const chatStatus = computed(() => toChatStatus(chat.status.value));toUIMessages(messages, trackers?)
Converts AG-UI Message[] into AdaptedUIMessage[] with parts and toolInvocations — the shape that ai-elements-vue components expect.
toChatStatus(status)
Maps internal status to the adapted format ('idle' becomes 'ready').
toToolUIParts(toolCalls, trackers)
Converts tool calls + tracker state into AdaptedToolUIPart[] for rendering tool invocation UI.
Plugin / Provider — App-Level Config
Two ways to share AG-UI config across your app:
Option A: Vue Plugin
// main.ts
import { createApp } from "vue";
import { createAGUI } from "@synoped/ag-ui-vue";
import App from "./App.vue";
const app = createApp(App);
app.use(createAGUI({ url: "http://localhost:8000" }));
app.mount("#app");<!-- Any descendant component -->
<script setup lang="ts">
import { useAGUI } from "@synoped/ag-ui-vue";
const { config } = useAGUI();
// config.url === 'http://localhost:8000'
</script>Option B: Composable Provider
<!-- Layout.vue -->
<script setup lang="ts">
import { useProvideAGUI } from "@synoped/ag-ui-vue";
const agui = useProvideAGUI({ url: "http://localhost:8000" });
</script><!-- ChildComponent.vue -->
<script setup lang="ts">
import { useAGUI } from "@synoped/ag-ui-vue";
const { config } = useAGUI();
</script>Full Example: Chat with Tools and Confirmation
<script setup lang="ts">
import { ref, computed } from "vue";
import {
useChat,
useFrontendTool,
useAgentContext,
toUIMessages,
} from "@synoped/ag-ui-vue";
const chat = useChat({ url: "http://localhost:8000" });
const { messages, status, send, pendingToolCalls, approve, reject, toolCallTrackers } = chat;
const input = ref("");
// Register a frontend tool
useFrontendTool({
tool: {
name: "get_weather",
description: "Get current weather for a city",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
handler: async (args) => {
const city = args.city as string;
return JSON.stringify({ city, temp: "22°C", condition: "Sunny" });
},
},
chat,
});
// Register a tool that needs user approval
useFrontendTool({
tool: {
name: "send_email",
description: "Send an email",
parameters: {
type: "object",
properties: {
to: { type: "string" },
subject: { type: "string" },
body: { type: "string" },
},
},
requireConfirmation: true,
handler: async (args) => {
// runs only after user approves
return "Email sent";
},
},
chat,
});
// Provide context about the current page
useAgentContext({
context: {
description: "User info",
value: JSON.stringify({ name: "Alice", plan: "pro" }),
},
chat,
});
// For ai-elements-vue compatibility
const uiMessages = computed(() =>
toUIMessages(messages.value, toolCallTrackers.value)
);
async function handleSend() {
const text = input.value.trim();
if (!text) return;
input.value = "";
await send(text);
}
</script>
<template>
<div class="chat">
<!-- Messages -->
<div v-for="msg in messages" :key="msg.id" :class="msg.role">
<strong>{{ msg.role }}:</strong> {{ msg.content }}
</div>
<!-- Pending confirmations -->
<div v-for="tc in pendingToolCalls" :key="tc.toolCallId" class="confirmation">
<p>Agent wants to call <strong>{{ tc.toolName }}</strong></p>
<pre>{{ JSON.stringify(tc.args, null, 2) }}</pre>
<button @click="approve(tc.toolCallId)">Approve</button>
<button @click="reject(tc.toolCallId)">Reject</button>
</div>
<!-- Input -->
<div class="input-row">
<input
v-model="input"
@keydown.enter="handleSend"
placeholder="Type a message..."
:disabled="status === 'submitted' || status === 'streaming'"
/>
<button @click="handleSend" :disabled="status !== 'ready'">
{{ status === 'streaming' ? 'Streaming...' : 'Send' }}
</button>
</div>
</div>
</template>How Tool Execution Works
When you register a frontend tool and the agent decides to call it, this happens automatically:
- Agent requests tool call — the backend streams a tool call event
- Args stream in — tracked in
toolCallTrackerswith stateinput-streaming→input-available - Confirmation check — if
requireConfirmationis set, execution pauses. The tool appears inpendingToolCalls. Your UI callsapprove()orreject() - Handler runs — the tool's
handlerfunction executes in the browser - Result sent back — a tool message is added and the agent re-runs with the result
- Repeat — if the agent calls more tools, the cycle continues until no more frontend tools are needed
Types
interface FrontendTool {
name: string;
description: string;
parameters: Record<string, unknown>; // JSON Schema
handler: (args: Record<string, unknown>) => Promise<string>;
requireConfirmation?: boolean;
}
type ToolCallState =
| "input-streaming" // args are being received
| "input-available" // args complete, ready to execute
| "approval-requested" // waiting for user confirmation
| "approval-responded" // user responded, executing
| "output-available" // handler returned successfully
| "output-error" // handler threw an error
| "output-denied"; // user rejected the tool call
interface ToolCallTracker {
toolCallId: string;
toolName: string;
args: string; // raw JSON string
state: ToolCallState;
output?: string;
error?: string;
}
interface PendingToolCall {
toolCallId: string;
toolName: string;
args: Record<string, unknown>; // parsed
state: ToolCallState;
}
interface AGUIConfig {
url: string;
headers?: Record<string, string>;
initialMessages?: Message[];
initialState?: State;
debug?: boolean;
middleware?: Middleware[];
}Example Backend (Python)
The repo includes a minimal Python backend using Google ADK + AG-UI:
# examples/backend/main.py
from google.adk.agents import Agent
from ag_ui_adk import ADKAgent, create_adk_app
from fastapi.middleware.cors import CORSMiddleware
adk_agent = Agent(
name="assistant",
model="gemini-2.0-flash",
instruction="You are a helpful assistant.",
)
agent = ADKAgent(adk_agent=adk_agent, app_name="my-app", user_id="default-user")
app = create_adk_app(agent)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)# Install deps
pip install google-adk ag-ui-adk fastapi uvicorn
# Set your API key
export GOOGLE_API_KEY=your-key-here
# Run
uvicorn main:app --host 0.0.0.0 --port 8000Or use Docker Compose:
docker compose --profile example up -dDevelopment
This project uses Docker Compose for development:
# Start dev container
docker compose up -d ag-ui-vue
# Run tests
docker compose exec ag-ui-vue npx vitest run
# Run tests in watch mode
docker compose exec ag-ui-vue npx vitest
# Type check
docker compose exec ag-ui-vue npx vue-tsc --noEmit
# Build
docker compose exec ag-ui-vue npx vite build
# Lint & format
docker compose exec ag-ui-vue npx eslint src/
docker compose exec ag-ui-vue npx prettier --write src/