Chapter 11: Custom UI Skin Using AI-Web
So far, ACME Banking has used the ready-made io.Assist Angular and React implementations. Those apps are the fastest way to add a polished assistant to an io.Connect environment.
Sometimes, however, a ready-made UI is not the right fit. Your product may need a different layout, a different interaction model, or a smaller surface that blends into an existing application. In those cases, you can use AI Web directly and build your own assistant interface.
In this chapter, you will complete a Browser-only app called io-assist-custom. The start project already includes the app scaffold, so you can focus on the AI Web integration instead of setting up another Vite app. The finished app will be a small chat application that:
- connects to io.Connect Browser
- starts MCP Web inside the same app
- initializes AI Web directly
- streams messages from the agent backend
- renders tool calls and tool results
- supports Working Context
- supports MCP Apps
- handles basic sampling and elicitation requests with custom dialogs
The UI will stay intentionally simple. The point of this chapter is not to build a full chat product. The point is to show where AI Web fits when you want to own the interface yourself.
Related API References
The custom UI uses AI Web directly and still relies on the same MCP, MCP Apps, and Working Context pieces from earlier chapters:
- AI Web API Reference
- AI Web Configuration
- AI Web Examples
- MCP Web Server API
- Working Context Schema Configuration
- MCP Core Tool Types
- MCP Apps Overview
What You Will Build
The start project already includes this app in the frontend workspace:
apps/io-assist-custom
The scaffold contains the Vite package, the HTML shell, styles, and the small chat rendering helpers. By the end of this chapter, the app will have this file structure:
apps/io-assist-custom
index.html
package.json
tsconfig.json
vite.config.ts
src
ai-web.ts
chat-view.ts
config.ts
dialogs.ts
main.ts
mcp.ts
styles.css
types.ts
utils.ts
The important file is:
src/ai-web.ts
That file is the center of the custom assistant. It initializes AI Web, configures MCP, lists agents and tools, streams user messages, receives tool events, and wires MCP Apps.
The other files keep the tutorial easy to read:
| File | Purpose |
|---|---|
main.ts | Starts io.Connect Browser, starts MCP Web, creates the assistant, and handles user submission. |
ai-web.ts | Owns the AI Web API usage and agent streaming. |
mcp.ts | Starts the app-hosted MCP Web server and exposes the get_clients tool. |
chat-view.ts | Renders the small chat UI. |
dialogs.ts | Shows basic sampling and elicitation dialogs. |
config.ts | Keeps URLs, agent name, user ID, and Working Context config in one place. |
types.ts | Keeps shared TypeScript types out of the teaching code. |
utils.ts | Contains small JSON and text helpers. |
Add The App Package
Open the scaffolded package:
apps/io-assist-custom/package.json
It already contains a small Vite app package:
{
"name": "io-assist-custom",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "vite",
"build": "tsc -b && vite build"
}
}
Install AI Web in the frontend project root:
cd io-assist-anywhere-start
npm install @interopio/ai-web
This installs the latest version available on npm. The project already keeps shared io.Connect packages in the root package.json, because several apps use the same browser, workspace, MCP, and Working Context packages.
The Vite config is already present:
import { defineConfig } from "vite";
export default defineConfig({
server: {
port: 4005,
host: "localhost",
},
});
The TypeScript config is already present too:
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
Review The Basic Chat Shell
The scaffold already includes:
apps/io-assist-custom/index.html
It contains a minimal chat layout:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ACME Assist Custom</title>
</head>
<body>
<main class="app-shell">
<header class="app-header">
<div>
<p class="eyebrow">ACME Banking</p>
<h1>Custom Assist</h1>
</div>
<span class="status" id="status">Starting</span>
</header>
<section class="chat-panel" aria-label="Assistant conversation">
<div class="messages" id="messages"></div>
<form class="composer" id="composer">
<textarea id="message-input" rows="2" placeholder="Ask about clients or portfolios"></textarea>
<button type="submit" id="send-button">Send</button>
</form>
</section>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
This is all the UI needs for the guide. The chat renderer will append messages into #messages, and main.ts will listen for submitted text.
Add Shared Configuration
Create:
apps/io-assist-custom/src/config.ts
Add the constants used by the assistant:
export const AGENT_SERVER_URL = "http://localhost:4111";
export const MCP_SANDBOX_PROXY_URL = "https://iointel-demos-mcp-apps-proxy.interop.io";
export const DEFAULT_AGENT_NAME = "io-agent";
export const USER_ID = "acme-custom-advisor";
export const WORKING_CONTEXT_CONFIG = {
schema: {
selectedClient: {
type: "object",
description: "The ACME Banking client currently selected in the workspace.",
source: {
context: {
location: { workspace: { target: "my" } },
path: "selectedClient",
},
},
},
},
} as const;
These values should look familiar:
AGENT_SERVER_URLpoints AI Web to the Mastra backend.MCP_SANDBOX_PROXY_URLenables MCP Apps.DEFAULT_AGENT_NAMEselects the guide agent.USER_IDis the resource ID used for the agent memory.WORKING_CONTEXT_CONFIGgives AI Web a fresh snapshot of the current workspace client context with every request.
Start MCP Web Inside The Custom App
The custom assistant uses the same Browser architecture as Chapter 8: MCP Web runs inside the assistant app.
Create:
apps/io-assist-custom/src/mcp.ts
Move the app-hosted MCP Web setup into this file:
import IOBrowser from "@interopio/browser";
import type { IoIntelMCPWeb } from "@interopio/mcp-web";
type IOConnectAPI = Awaited<ReturnType<typeof IOBrowser>>;
const MCP_SERVER_METHOD_NAME = "io.mcp.web.server";
const GET_CLIENTS_METHOD = "getClients";
const GET_CLIENTS_TOOL = "get_clients";
const getMCPWebServerConfig = (): IoIntelMCPWeb.Server.Config => ({
licenseKey: (import.meta as any).env.VITE_IO_INTELLIGENCE_LICENSE_KEY,
mcpCoreServer: {
tools: {
static: {
methods: [
{
availability: "constant",
name: GET_CLIENTS_TOOL,
config: {
title: "Get Clients",
description: "Returns the list of ACME Banking clients available in the platform.",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
outputSchema: {
type: "object",
properties: {
clients: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
portfolioId: { type: "string" },
firstName: { type: "string" },
lastName: { type: "string" },
segment: { type: "string" },
advisor: { type: "string" },
riskProfile: { type: "string" },
},
required: ["id", "portfolioId", "firstName", "lastName", "segment", "advisor", "riskProfile"],
},
},
},
required: ["clients"],
},
},
interop: {
methodName: GET_CLIENTS_METHOD,
},
},
],
},
},
},
});
export const startMCPWebServer = async (io: IOConnectAPI): Promise<void> => {
const hasMCPWebServer = io.interop.methods().some((method) => method.name === MCP_SERVER_METHOD_NAME);
if (hasMCPWebServer) {
console.info("MCP Web server is already available.");
return;
}
const { ServerFactory } = await import("@interopio/mcp-web");
await ServerFactory(io, getMCPWebServerConfig());
console.log("MCP Web server started in custom io.Assist skin");
};
- check whether MCP Web is already available
- dynamically import
@interopio/mcp-web - start MCP Web with the static
get_clientsmethod - delegate
get_clientsto the existing platformgetClientsinterop method
Create The AI Web Assistant
Now create the main teaching file:
apps/io-assist-custom/src/ai-web.ts
Start with the imports and public wrapper type:
import { IoAiWebFactory, type IoAiWeb } from "@interopio/ai-web";
import { IoIntelWorkingContextFactory } from "@interopio/working-context";
import { AGENT_SERVER_URL, DEFAULT_AGENT_NAME, MCP_SANDBOX_PROXY_URL, USER_ID, WORKING_CONTEXT_CONFIG } from "./config";
import type { AssistantView, IOConnectAPI, StreamEvent } from "./types";
import { getText, parseJSON, parseToolResult, stringify } from "./utils";
export interface CustomAssistant {
agentName: string;
toolCount: number;
sendMessage(text: string, userMessageId: string): Promise<void>;
}
export interface CustomAssistantOptions {
io: IOConnectAPI;
view: AssistantView;
onMCPAppMessage(text: string): void;
showConfirmDialog(params: {
title: string;
message: string;
confirmText: string;
cancelText: string;
}): Promise<boolean>;
showElicitationDialog(
serverName: string,
request: IoAiWeb.ElicitationRequestParams,
): Promise<IoAiWeb.ElicitationResponse>;
}
The CustomAssistant wrapper keeps the rest of the UI small. The UI only needs to know which agent is connected, how many tools are available, and how to send a message. The CustomAssistantOptions interface keeps the AI Web file independent from the DOM implementation by receiving the view and dialog functions as dependencies.
Next, initialize AI Web:
export async function createCustomAssistant(options: CustomAssistantOptions): Promise<CustomAssistant> {
let selectedAgent: IoAiWeb.Agents.Agent | undefined;
const aiWeb = await IoAiWebFactory(options.io, {
agentServer: {
baseUrl: AGENT_SERVER_URL,
},
context: {
factory: IoIntelWorkingContextFactory,
config: WORKING_CONTEXT_CONFIG,
},
mcp: {
clientsConfig: {
enforceStrictCapabilities: false,
capabilities: {
sampling: {
handler: (serverName, request) => handleSamplingRequest(serverName, request, selectedAgent, options.showConfirmDialog),
},
elicitation: {
handler: options.showElicitationDialog,
},
extensions: {
"io.modelcontextprotocol/ui": {
mimeTypes: ["text/html;profile=mcp-app"],
},
},
},
},
mcpApps: {
sandboxProxyUrl: MCP_SANDBOX_PROXY_URL,
displayMode: "workspace",
},
ioIntel: {
web: {
enabled: true,
},
},
},
});
window.aiWeb = aiWeb;
wireMCPAppEvents(aiWeb, options);
const agents = await aiWeb.agents.list();
selectedAgent = agents.find((agent) => agent.name === DEFAULT_AGENT_NAME) ?? agents[0];
if (!selectedAgent) {
throw new Error("No agent is available.");
}
const tools = await aiWeb.tools.list();
const threadId = crypto.randomUUID();
return {
agentName: selectedAgent.name,
toolCount: tools.length,
sendMessage: (text, userMessageId) => streamUserMessage(selectedAgent, text, userMessageId, threadId, options.view),
};
}
This is the most important part of the chapter.
The AI Web configuration does the same work that the ready-made io.Assist components did for you in earlier chapters:
| Configuration | Why it is needed |
|---|---|
agentServer.baseUrl | Connects AI Web to the Mastra backend through the AI Mastra Bridge. |
context.factory and context.config | Adds Working Context to each request so the agent sees the current workspace client selection. |
mcp.clientsConfig | Declares the MCP client capabilities supported by this custom UI. |
sampling.handler | Lets MCP tools ask the assistant to generate supporting text, after the user approves. |
elicitation.handler | Lets MCP tools ask the user for additional structured information. |
extensions["io.modelcontextprotocol/ui"] | Tells MCP servers this UI can receive MCP App resources. |
mcpApps | Enables MCP Apps and tells AI Web where the sandbox proxy is hosted. |
ioIntel.web.enabled | Enables the io.Intelligence MCP Web client integration. |
The last part of createCustomAssistant() calls:
const agents = await aiWeb.agents.list();
const tools = await aiWeb.tools.list();
This proves the custom UI is not using the io.Assist component. It is talking to AI Web directly.
Stream User Messages
Add a function that streams a user message through the selected agent:
async function streamUserMessage(
agent: IoAiWeb.Agents.Agent,
text: string,
userMessageId: string,
threadId: string,
view: AssistantView,
): Promise<void> {
const run = await agent.stream({
messages: [
{
id: userMessageId,
role: "user",
content: text,
},
],
memory: {
thread: threadId,
resource: USER_ID,
},
resourceId: USER_ID,
tools: {
autoIncludeEnabled: true,
},
});
await processResponseStream(run, view);
}
The tools.autoIncludeEnabled flag lets AI Web include the applicable MCP tools for this run. That is what allows prompts like show me my clients to invoke get_clients.
The memory and resourceId fields keep the interaction tied to one advisor session.
Render Stream Events
AI Web returns a stream of events. Your custom UI decides how to render each event.
Add a stream processor:
function processResponseStream(run: IoAiWeb.Agents.StreamResponse, view: AssistantView): Promise<void> {
let assistantMessageId = "";
let toolCallId = "";
const toolCallArgs = new Map<string, string>();
return new Promise((resolve, reject) => {
const subscription = run.subscribe({
next: (event: IoAiWeb.Agents.StreamEvent) => {
const streamEvent = event as StreamEvent;
switch (streamEvent.type) {
case "TEXT_MESSAGE_START":
assistantMessageId = streamEvent.messageId ?? crypto.randomUUID();
view.addMessage({ id: assistantMessageId, role: "assistant", content: "" });
break;
case "TEXT_MESSAGE_CONTENT":
case "TEXT_MESSAGE_CHUNK":
view.appendToMessage(assistantMessageId, streamEvent.delta ?? getText(streamEvent.content));
break;
case "TOOL_CALL_START":
toolCallId = streamEvent.toolCallId ?? crypto.randomUUID();
toolCallArgs.set(toolCallId, "");
view.addMessage({ id: toolCallId, role: "tool", content: `Using ${streamEvent.toolCallName ?? "tool"}` });
break;
case "TOOL_CALL_RESULT":
view.updateMessage(streamEvent.toolCallId ?? toolCallId, {
detail: `Result: ${stringify(parseToolResult(streamEvent.content))}`,
});
break;
}
},
error: (error: Error) => {
subscription.unsubscribe();
reject(error);
},
complete: () => {
subscription.unsubscribe();
resolve();
},
});
});
}
Keep the complete version from the solution code, which also handles tool arguments, run errors, and aborted streams.
This is the main difference between using io.Assist and using AI Web directly:
- io.Assist renders these events for you
- a custom AI Web UI receives the events and chooses how to display them
Handle Sampling And Elicitation
Earlier chapters enabled modals in io.Connect so io.Assist could handle sampling and elicitation with a consistent platform experience. In a custom UI, you are responsible for those interactions.
For this guide, use simple DOM dialogs.
The sampling handler asks for confirmation before allowing an MCP tool to request another assistant generation:
async function handleSamplingRequest(
serverName: string,
request: IoAiWeb.SamplingRequestParams,
agent: IoAiWeb.Agents.Agent | undefined,
showConfirmDialog: CustomAssistantOptions["showConfirmDialog"],
): Promise<IoAiWeb.SamplingSuccessResponse | IoAiWeb.SamplingErrorResponse> {
const accepted = await showConfirmDialog({
title: "Sampling request",
message: `${serverName} is asking the assistant to generate a supporting response.`,
confirmText: "Continue",
cancelText: "Cancel",
});
if (!accepted) {
return { code: -1, message: "Sampling request canceled by the user." };
}
if (!agent) {
return { code: -1, message: "No agent is available for sampling." };
}
const response = await agent.generate({
messages: request.messages.map((message) => ({
id: crypto.randomUUID(),
role: message.role,
content: getText(message.content),
})),
tools: {
autoIncludeEnabled: false,
},
});
return {
model: agent.modelId || "unknown",
role: "assistant",
content: {
type: "text",
text: response.text ?? "",
},
stopReason: "endTurn",
};
}
The elicitation handler can live in dialogs.ts. It receives the schema from the MCP tool, builds a small form, and returns one of three actions:
import type { IoAiWeb } from "@interopio/ai-web";
export function showConfirmDialog(params: {
title: string;
message: string;
confirmText: string;
cancelText: string;
}): Promise<boolean> {
return new Promise((resolve) => {
const overlay = createOverlay();
const dialog = document.createElement("div");
dialog.className = "dialog";
const title = document.createElement("h2");
title.textContent = params.title;
const message = document.createElement("p");
message.textContent = params.message;
const actions = document.createElement("div");
actions.className = "dialog-actions";
const cancelButton = createButton(params.cancelText, "button");
const confirmButton = createButton(params.confirmText, "button", "primary");
actions.append(cancelButton, confirmButton);
dialog.append(title, message, actions);
overlay.append(dialog);
document.body.append(overlay);
const close = (accepted: boolean): void => {
overlay.remove();
resolve(accepted);
};
cancelButton.addEventListener("click", () => close(false));
confirmButton.addEventListener("click", () => close(true));
});
}
export function showElicitationDialog(
serverName: string,
request: IoAiWeb.ElicitationRequestParams,
): Promise<IoAiWeb.ElicitationResponse> {
return new Promise((resolve) => {
const overlay = createOverlay();
const form = document.createElement("form");
form.className = "dialog";
const message = document.createElement("p");
message.textContent = `${serverName}: ${request.message}`;
const inputs = new Map<string, HTMLInputElement | HTMLSelectElement>();
const properties = request.requestedSchema.properties ?? {};
for (const [name, schema] of Object.entries(properties)) {
const field = createElicitationField(name, schema);
form.append(field.wrapper);
inputs.set(name, field.input);
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const content: Record<string, unknown> = {};
for (const [name, input] of inputs.entries()) {
content[name] = input instanceof HTMLInputElement && input.type === "checkbox"
? input.checked
: input.value;
}
overlay.remove();
resolve({ action: "accept", content });
});
form.append(message);
overlay.append(form);
document.body.append(overlay);
});
}
Keep the full solution version, which also includes Decline, Cancel, number inputs, boolean inputs, enum selects, and basic accessibility labels.
Wire MCP Apps
MCP Apps can send messages back to the assistant. They can also ask whether to replace an existing app preview.
Add this to ai-web.ts:
function wireMCPAppEvents(aiWeb: IoAiWeb.API, options: CustomAssistantOptions): void {
if (!aiWeb.mcpApps) {
return;
}
aiWeb.mcpApps.onAppCreated((app) => {
options.view.addMessage({
role: "system",
content: "Opened an MCP App preview for this chat.",
});
app.onMessage((text) => {
options.onMCPAppMessage(text);
});
});
aiWeb.mcpApps.onRecreateRequested((event) => {
void options.showConfirmDialog({
title: "Replace preview",
message: `${event.toolName} already has a preview. Replace it?`,
confirmText: "Replace",
cancelText: "Open new",
}).then((replace) => event.select(replace ? "recreate" : "newInstance"));
});
}
This is enough for the same Workspace Widget flow you tested in Chapter 7 and Chapter 8.
Keep Startup Small
Now main.ts can stay small and readable:
import IOBrowser from "@interopio/browser";
import IOWorkspaces from "@interopio/workspaces-api";
import { createCustomAssistant, type CustomAssistant } from "./ai-web";
import { createChatView } from "./chat-view";
import { showConfirmDialog, showElicitationDialog } from "./dialogs";
import { startMCPWebServer } from "./mcp";
import "./styles.css";
const view = createChatView();
let assistant: CustomAssistant | undefined;
let isStreaming = false;
view.onSubmit((text) => {
void sendUserMessage(text);
});
void initialize();
async function initialize(): Promise<void> {
const io = await IOBrowser({
libraries: [IOWorkspaces],
});
window.io = io;
await startMCPWebServer(io);
assistant = await createCustomAssistant({
io,
view,
onMCPAppMessage: (text) => void sendUserMessage(text),
showConfirmDialog,
showElicitationDialog,
});
view.addMessage({
role: "system",
content: `Connected to ${assistant.agentName}. ${assistant.toolCount} MCP tools available.`,
});
view.setStatus("Ready");
}
async function sendUserMessage(text: string): Promise<void> {
if (isStreaming) {
return;
}
if (!assistant) {
view.addMessage({ role: "system", content: "The assistant is not ready yet." });
return;
}
isStreaming = true;
view.setStreaming(true);
const userMessageId = crypto.randomUUID();
view.addMessage({ id: userMessageId, role: "user", content: text });
try {
await assistant.sendMessage(text, userMessageId);
} catch (error) {
const message = error instanceof Error ? error.message : "The assistant response failed.";
view.addMessage({ role: "system", content: message });
} finally {
isStreaming = false;
view.setStreaming(false);
}
}
The sequence matters:
- Connect to io.Connect Browser.
- Start MCP Web inside the app.
- Create the AI Web assistant.
- Let the user send messages.
That is the whole custom assistant lifecycle.
Register The Custom App
Open:
apps/io-cb-home/src/config.json
Add the new app definition to the apps array:
{
"name": "io-assist-custom",
"type": "window",
"details": {
"url": "http://localhost:4005"
},
"caption": "Custom ACME Banking assistant skin built directly with AI-Web. Provides a small chat interface that connects to the agent backend and the app-hosted MCP Web server.",
"customProperties": {
"includeInWorkspaces": true
}
}
The app is marked with includeInWorkspaces so it can be discovered and opened from the io.Connect Browser environment like the other guide apps.
Test The Custom Assistant
Start the agent backend:
cd agentic-backend
npm start
In another terminal, start the browser platform and apps:
cd io-assist-anywhere-start
npm start
Open the io.Connect Browser platform:
http://localhost:4200
Open io-assist-custom from the platform. In the custom assistant chat panel, confirm that the first system message says something like:
Connected to IO Agent. 6 MCP tools available.
Ask:
Hi! What can you do for me?
The assistant should answer as an io.Connect-aware assistant, not as a generic chatbot.
Then ask:
show me my clients
The assistant should call the get_clients MCP tool and show the ACME Banking client list.
Finally, test the MCP App path again:
show me the portfolio details for Amelia Reed. But before opening any workspace, I would like to get a preview
The result should match the earlier io.Assist implementations: the assistant should use the MCP system tools, find the right workspace, and show a Workspace Widget preview.
What You Have Built
You now have three assistant UI options in the guide:
- Angular io.Assist, using
@interopio/io-assist-ng - React io.Assist, using
@interopio/io-assist-react - a custom UI skin, using
@interopio/ai-webdirectly
The custom UI uses the same io.Intelligence building blocks:
- AI Mastra Bridge for the backend protocol
- AI Web for the browser-side assistant runtime
- MCP Web for local MCP capabilities
- MCP Apps for interactive tool UI
- Working Context for workspace-aware requests
- io.Connect Browser for app and workspace integration
This is the key takeaway: io.Assist is the fastest way to ship a complete assistant UI, but AI Web gives you the lower-level API when your product needs a custom experience.