Skip to content

Connect an AI chat frontend to Bub with AG-UI

The bub-ag-ui plugin connects Bub to a web chat frontend over AG-UI. You can stream replies and let Bub call tools in the browser. The integration is experimental.

This guide connects a chat UI and uses a weather tool to plan an afternoon in San Francisco. Start with the assistant-ui tab if you do not already have a frontend.

Install the plugin in the environment that runs Bub, then start the service:

Terminal window
bub install bub-ag-ui@main
bub ag-ui --help
bub ag-ui --host 127.0.0.1 --port 28201

Keep this terminal open. The frontend will send requests to http://127.0.0.1:28201/agent and receive streaming replies.

Choose one of the tabs below. Each example connects to the same Bub service, runs a get_weather tool in the browser, and saves the displayed conversation for page reloads. The weather data is a fixed demo forecast for San Francisco.

The tabs use these shared files. Copy the files needed by your frontend after creating its project.

Shared example files

Save the weather tool and data as src/scenario.json for CopilotKit, OpenUI, and AI Elements. The assistant-ui example includes them in its provider.

src/scenario.json
{
"name": "Weekend weather planner",
"tool": {
"name": "get_weather",
"description": "Return a fixed demonstration weather forecast for a city. This is sample data for UI compatibility testing, not live weather.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "City to look up"}},
"required": ["city"],
"additionalProperties": false
}
},
"result": {
"city": "San Francisco",
"temperature_c": 18,
"condition": "Partly cloudy",
"source": "Fixed demo forecast; not live weather"
},
"prompt": "Call get_weather for San Francisco. Using that demo forecast, suggest a relaxed afternoon with a bookstore, coffee, and a sunset walk. Reply in English with the heading 'A slower afternoon', a three-row Markdown table (Time / Plan), and a short weather note. Keep it under 100 words. Use only get_weather; do not delegate or call other tools."
}

For CopilotKit and OpenUI, add this server property to defineConfig({...}) in vite.config.ts. It serves the frontend on port 3000 and forwards /api/* to Bub, removing the /api prefix. The other two tabs include their own proxy configuration.

vite.config.ts (server property)
server: {
host: "localhost",
port: 3000,
strictPort: true,
proxy: {
"/api": {
target: "http://127.0.0.1:28201",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},

Save this storage helper as app/demo-session.ts for assistant-ui, or src/demo-session.ts for the other frontends. It keeps the thread ID, messages, and state in the browser’s sessionStorage.

demo-session.ts
import { HttpAgent, type Message } from "@ag-ui/client";
type DemoSession = {
threadId: string;
messages: Message[];
state: HttpAgent["state"];
};
// Demo storage for one browser tab. A deployed app supplies its own storage.
export function readDemoSession(key: string): DemoSession {
const saved = sessionStorage.getItem(key);
if (saved) return JSON.parse(saved) as DemoSession;
const session = { threadId: crypto.randomUUID(), messages: [], state: {} };
sessionStorage.setItem(key, JSON.stringify(session));
return session;
}
export function persistDemoSession(key: string, agent: HttpAgent) {
const save = () =>
sessionStorage.setItem(
key,
JSON.stringify({
threadId: agent.threadId,
messages: agent.messages,
state: agent.state,
}),
);
return agent.subscribe({
onRunFinalized: save,
});
}

assistant-ui provides an AG-UI runtime for the connection and browser-tool calls.

Use the official Starter, select the default template, and install the AG-UI runtime. Keep the Starter’s @assistant-ui/react version.

Terminal window
npx assistant-ui@latest create bub-assistant-ui
cd bub-assistant-ui
npm install @assistant-ui/react-ag-ui@0.1.2 @ag-ui/client@1.0.4

Save the shared helper as app/demo-session.ts. Add this rewrite to the configuration object in next.config.ts, keeping the Starter settings and withAui wrapper:

next.config.ts (configuration property)
async rewrites() {
return [{ source: "/api/:path*", destination: "http://127.0.0.1:28201/:path*" }];
},

Create app/BubRuntimeProvider.tsx to connect the runtime, restore displayed messages, and register the weather tool:

app/BubRuntimeProvider.tsx
app/BubRuntimeProvider.tsx
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { HttpAgent } from "@ag-ui/client";
import { useAgUiRuntime, fromAgUiMessages } from "@assistant-ui/react-ag-ui";
import {
AssistantRuntimeProvider,
ExportedMessageRepository,
type ThreadHistoryAdapter,
} from "@assistant-ui/react";
import { readDemoSession, persistDemoSession } from "./demo-session";
type Session = { agent: HttpAgent; history: ThreadHistoryAdapter };
function ConnectedRuntime({ session, children }: { session: Session; children: ReactNode }) {
const runtime = useAgUiRuntime({ agent: session.agent, adapters: { history: session.history } });
useEffect(
() =>
runtime.registerModelContextProvider({
getModelContext: () => ({
tools: {
get_weather: {
description: "Return a fixed demo forecast for San Francisco, not live weather.",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
execute: async () => ({
city: "San Francisco",
temperature_c: 18,
condition: "Partly cloudy",
source: "Fixed demo forecast; not live weather",
}),
},
},
}),
}),
[runtime],
);
return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>;
}
export function BubRuntimeProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [error, setError] = useState("");
useEffect(() => {
try {
const key = "bub-assistant-ui-demo";
const saved = readDemoSession(key);
const agent = new HttpAgent({
url: "/api/agent",
threadId: saved.threadId,
initialMessages: saved.messages,
initialState: saved.state,
});
const subscription = persistDemoSession(key, agent);
setSession({
agent,
history: {
load: async () => ({
...ExportedMessageRepository.fromArray(fromAgUiMessages(saved.messages)),
state: saved.state,
}),
// The SDK subscription saves the complete messages/state snapshot.
append: async () => {},
},
});
return () => subscription.unsubscribe();
} catch (cause) {
setError(String(cause));
}
}, []);
if (error) return <p role="alert">{error}</p>;
if (!session) return <p role="status">Loading conversation...</p>;
return <ConnectedRuntime session={session}>{children}</ConnectedRuntime>;
}

Replace app/assistant.tsx with the following and keep the existing app/page.tsx entry:

app/assistant.tsx
"use client";
import { BubRuntimeProvider } from "./BubRuntimeProvider";
import { Thread } from "@/components/assistant-ui/elements/thread.aui";
export const Assistant = () => (
<BubRuntimeProvider>
<div style={{ height: "100dvh" }}><Thread /></div>
</BubRuntimeProvider>
);
Terminal window
npm run dev -- --hostname localhost --port 3000

With one frontend running, open http://localhost:3000 and send this request:

Call get_weather for San Francisco, then suggest an afternoon plan using the demo forecast.

Bub calls the browser tool and uses its result to suggest an afternoon plan. The forecast is 18°C and partly cloudy; it is demo data, not live weather.

After the reply finishes, reload the same browser tab. Your messages should still be visible. In OpenUI, select the saved thread from the sidebar. Ask which city and temperature you discussed to continue the conversation.

The examples save the thread ID, messages, and state in sessionStorage. Reloading restores the displayed conversation. A new browser tab without saved messages cannot retrieve earlier display history from Bub; the AG-UI service only exposes POST /agent.

Bub manages the model’s conversation context. Keep the same threadId to continue it, and retain Bub’s default files or database storage across backend restarts. Browser storage restores the UI; it cannot recover deleted Bub context or unfinished tool calls. Keep the backend running until the reply finishes. The default turn timeout is 600 seconds, including time spent waiting for browser tools.

To start a new conversation, clear the example’s storage key and reload. Editing earlier messages and regenerating replies are unsupported; disable those actions if your UI template exposes them. For deployment, replace the demo storage with storage and user isolation suitable for your application.

If you are building your own UI, use @ag-ui/client for the connection and message state. This plain TypeScript example displays streamed text and creates a new thread on each reload.

Minimal browser chat

Create a Vite project:

Terminal window
npm create vite@latest bub-chat -- --template vanilla-ts
cd bub-chat
npm install
npm install @ag-ui/client@1.0.4

Replace src/main.ts:

src/main.ts
import { HttpAgent } from "@ag-ui/client";
const root = document.querySelector<HTMLDivElement>("#app")!;
root.innerHTML = `
<h1>Bub chat</h1>
<pre id="messages" aria-live="polite"></pre>
<form>
<label>Message <input name="message" required /></label>
<button>Send</button>
</form>
<p id="status" role="status"></p>
`;
const form = root.querySelector("form")!;
const input = root.querySelector("input")!;
const button = root.querySelector("button")!;
const messages = root.querySelector<HTMLPreElement>("#messages")!;
const status = root.querySelector<HTMLParagraphElement>("#status")!;
const agent = new HttpAgent({
url: "http://127.0.0.1:28201/agent",
threadId: crypto.randomUUID(),
});
agent.subscribe({
onMessagesChanged: ({ messages: current }) => {
messages.textContent = current
.map((message) => `${message.role}: ${message.content ?? ""}`)
.join("\n\n");
},
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const content = input.value.trim();
if (!content || button.disabled) return;
agent.addMessage({ id: crypto.randomUUID(), role: "user", content });
input.value = "";
button.disabled = true;
status.textContent = "Running...";
try {
await agent.runAgent({}, {
onRunErrorEvent: ({ event }) => {
status.textContent = event.message;
},
});
if (status.textContent === "Running...") status.textContent = "Ready";
} catch (error) {
status.textContent = String(error);
} finally {
button.disabled = false;
input.focus();
}
});

Start Vite and open http://localhost:3000:

Terminal window
npm run dev -- --host localhost --port 3000 --strictPort

This example calls Bub directly. Bub allows http://localhost:3000 by default. For another frontend origin, set BUB_AG_UI_ORIGINS before starting the backend:

Terminal window
export BUB_AG_UI_ORIGINS='["http://localhost:5173"]'

Match the scheme, hostname, and port exactly. localhost and 127.0.0.1 are different origins.

When listening on a non-loopback address, set BUB_AG_UI_TOKEN and send Authorization: Bearer <token> with requests. Keep the token on the application server. Configure BUB_AG_UI_ORIGINS if browsers connect to Bub directly.

A shared token represents one user identity. For multi-user authentication and gateway setup, follow the plugin README.

Symptom Check
ag-ui command is missing Run bub ag-ui --help in the environment where the plugin is installed.
Browser requests fail Check the frontend proxy, or match BUB_AG_UI_ORIGINS to the exact origin for direct connections.
Requests return 401 or 403 Check the Bearer header and authentication configuration.
No reply appears Test the model with bub run "Say hello" and check backend logs.
A tool call has no follow-up reply Check that the frontend returns the tool result and continues the conversation.
A run returns RUN_ERROR Read the event’s error message and check the backend logs.