Examples
These examples show common AI Web integration patterns using the released API surface and the current app reference implementations in this repository.
Minimal Initialization
Use this pattern when you want a custom assistant frontend with only agent and thread features to start.
import IOBrowser from "@interopio/browser";
import { IoAiWebFactory } from "@interopio/ai-web";
const io = await IOBrowser();
const aiWeb = await IoAiWebFactory(io, {
agentServer: {
baseUrl: "http://localhost:4111",
},
});
const agents = await aiWeb.agents.list();
const agent = agents[0];
const thread = await aiWeb.threads.create({
agentId: agent.id,
resourceId: "user-42",
title: "New conversation",
});
const result = await agent.generate({
messages: "What can you help me with?",
memory: {
thread: thread.id,
resource: "user-42",
},
resourceId: "user-42",
});
console.log(result.text);
Enable MCP Connectivity
Use MCP configuration when you want AI Web to discover tools and resources from the io.Intelligence MCP server and additional remote MCP servers.
const aiWeb = await IoAiWebFactory(io, {
agentServer: {
baseUrl: "http://localhost:4111",
},
mcp: {
clientsConfig: {
enforceStrictCapabilities: false,
capabilities: {},
},
ioIntel: {
web: {
hasPriority: false,
},
remote: {
streamableHttp: {
url: "http://localhost:8989/mcp",
name: "primary-remote-server",
},
},
},
remoteServers: [
{
streamableHttp: {
url: "http://localhost:8081/mcp",
name: "secondary-mcp-server",
},
},
],
},
});
const tools = await aiWeb.tools.list();
const resources = await aiWeb.resources.list();
This mirrors the reference app pattern where one frontend combines the io.Intelligence MCP endpoint with additional remote servers.
Add Sampling and Elicitation Handlers
Use MCP capability handlers when an MCP server needs the host application to participate in model sampling or user confirmation flows.
const aiWeb = await IoAiWebFactory(io, {
agentServer: {
baseUrl: "http://localhost:4111",
},
mcp: {
clientsConfig: {
capabilities: {
sampling: {
handler: async (serverName, params) => {
console.log("sampling request", serverName, params);
return {
model: "gpt-4",
role: "assistant",
content: {
type: "text",
text: "Sample response from the host application",
},
stopReason: "endTurn",
};
},
},
elicitation: {
handler: async (serverName, params) => {
console.log("elicitation request", serverName, params);
return {
action: "accept",
content: {
approved: true,
},
};
},
},
},
},
},
});
Enable Working Context
Use Working Context when assistant runs should reflect the user's current workspace or business selection.
import { IoIntelWorkingContextFactory } from "@interopio/working-context";
const aiWeb = await IoAiWebFactory(io, {
agentServer: {
baseUrl: "http://localhost:4111",
},
context: {
factory: IoIntelWorkingContextFactory,
config: {
schema: {
clientId: {
type: "string",
source: {
context: {
location: { workspace: { target: "my" } },
path: "clientId",
},
},
},
portfolioId: {
type: "string",
source: {
context: {
location: { workspace: { target: "my" } },
path: "portfolioId",
},
},
},
},
},
},
});
The reference application uses the same pattern with a larger schema that pulls from global contexts and the current workspace.
Enable MCP Apps
Use MCP Apps when tool results may return interactive UI resources.
const aiWeb = await IoAiWebFactory(io, {
agentServer: {
baseUrl: "http://localhost:4111",
},
mcp: {
clientsConfig: {
capabilities: {
extensions: {
"io.modelcontextprotocol/ui": {
mimeTypes: ["text/html;profile=mcp-app"],
},
},
},
},
mcpApps: {
sandboxProxyUrl: "http://localhost:5206/index.html",
displayMode: "workspace",
},
},
});
The sandbox proxy is a critical piece of infrastructure — see The Sandbox Proxy below for a full explanation, a ready-made implementation, and guidance on building your own.
For workspace mode to work on io.Connect Desktop, the platform must have the in-memory app store enabled in system.json. This requires io.Connect Desktop 10.0 or later — versions below 10.0 do not support workspace mode. If you are on an unsupported version, set displayMode: 'inline' above.
The Sandbox Proxy
If you are building a custom React assistant, the AppRenderer component from @mcp-ui/client manages MCP App resource fetching and the render lifecycle for you. You still need to supply a sandbox proxy URL via the sandbox.url prop — use the ready-made proxy below or follow the Building a Custom Proxy guide.
import { AppRenderer } from '@mcp-ui/client';
<AppRenderer
client={mcpClient}
toolName="io_connect_get_workspace_widget"
sandbox={{ url: new URL('/mcp-sandbox-proxy.html', window.location.origin) }}
toolInput={toolInput}
toolResult={toolResult}
onMessage={async (params) => ({ isError: false })}
/>
Every MCP App is an HTML page delivered from the MCP server. Loading that HTML directly inside your application's frame would mix untrusted third-party code with your own origin. The sandbox proxy solves this: it is a self-contained HTML page that sits between AI Web and the MCP App HTML and enforces an isolation boundary.
AI Web (host)
│ postMessage / io.Connect interop
▼
sandbox-proxy.html ← outer frame, trusted origin, bridges messages
│ srcdoc + sandbox="allow-scripts"
▼
<inner iframe> ← isolated frame, untrusted MCP App HTML
The proxy is NOT sandboxed itself. Only the inner iframe that holds the MCP App HTML is sandboxed (sandbox="allow-scripts" by default). This is the security boundary.
Using the Ready-Made Proxy
io.Intelligence ships a ready-made proxy. Copy the HTML below, save it as a static file inside your application (e.g. public/mcp-sandbox-proxy.html), and point sandboxProxyUrl at it:
const aiWeb = await IoAiWebFactory(io, {
mcp: {
mcpApps: {
sandboxProxyUrl: "/mcp-sandbox-proxy.html",
displayMode: "workspace",
},
},
});
The proxy handles both inline and workspace modes automatically — the right transport is chosen based on a ?mode=workspace query parameter that AI Web appends.
<!--
Unified MCP Sandbox Proxy
─────────────────────────
Single file that handles both communication modes:
• Inline mode (default) — postMessage / window.parent.postMessage
• Workspace mode (?mode=workspace) — io.Connect interop methods
The host selects the mode by appending ?mode=workspace to the URL when
opening the proxy inside an io.Connect workspace window. No parameter (or
any value other than "workspace") falls through to inline mode.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP Sandbox Proxy</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; }
#inner-frame-container { width: 100%; height: 100%; }
#inner-frame-container iframe { width: 100%; height: 100%; border: none; display: block; }
/* Loading overlay — shown in workspace mode while io.Connect initialises */
#loading-overlay {
position: fixed; inset: 0; background: #787777; z-index: 9999;
animation: loading-pulse 2s ease-in-out infinite; transition: opacity 0.35s ease;
}
#loading-overlay.hidden { animation: none; opacity: 0; pointer-events: none; }
@keyframes loading-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.70; } }
</style>
</head>
<body>
<div id="loading-overlay" style="display: none;"></div>
<div id="inner-frame-container"></div>
<script>
(function () {
'use strict';
// ─── Constants ────────────────────────────────────────────────────────────────
const PROXY_TO_HOST_METHOD = 'mcp.app.proxy.to.host';
const HOST_TO_PROXY_METHOD = 'mcp.app.host.to.proxy';
const IO_CONNECT_CDN = 'https://unpkg.com/@interopio/browser@4.2.5/dist/browser.umd.js';
const READY_NOTIFICATION = 'ui/notifications/sandbox-proxy-ready';
const RESOURCE_NOTIFICATION = 'ui/notifications/sandbox-resource-ready';
// ─── JSON-RPC 2.0 validation ──────────────────────────────────────────────────
const isJsonRpcMessage = (msg) => {
if (typeof msg !== 'object' || msg === null || Array.isArray(msg)) return false;
if (msg.jsonrpc !== '2.0') return false;
const hasMethod = typeof msg.method === 'string' && msg.method.length > 0;
const hasResult = 'result' in msg;
const hasError = 'error' in msg;
return hasMethod || hasResult || hasError;
};
// ─── Mode detection ───────────────────────────────────────────────────────────
// The host appends ?mode=workspace when opening proxy in a workspace window.
const IS_WORKSPACE_MODE = new URLSearchParams(window.location.search).get('mode') === 'workspace';
// ─── Shared mutable state ─────────────────────────────────────────────────────
let innerIframe = null;
let sendToHost = null; // assigned by the active mode initialiser
// ─── Loading overlay (workspace mode only) ────────────────────────────── ──────
const showLoadingOverlay = () => {
const el = document.getElementById('loading-overlay');
if (el) el.style.display = '';
};
const hideLoadingOverlay = () => {
const el = document.getElementById('loading-overlay');
if (!el) return;
el.classList.add('hidden');
setTimeout(() => el.remove(), 400);
};
// ─── CSP / sandbox builders ───────────────────────────────────────────────────
const toOrigins = (domains) => (domains ?? []).map(d => d.trim()).filter(Boolean);
const buildCSPString = (csp) => {
if (!csp) return null;
const connect = toOrigins(csp.connectDomains);
const resource = toOrigins(csp.resourceDomains);
const frame = toOrigins(csp.frameDomains);
const base = toOrigins(csp.baseUriDomains);
const directives = [
"default-src 'self'",
resource.length ? `script-src 'self' 'unsafe-inline' 'unsafe-eval' ${resource.join(' ')}` : "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
resource.length ? `style-src 'self' 'unsafe-inline' ${resource.join(' ')}` : "style-src 'self' 'unsafe-inline'",
resource.length ? `img-src 'self' data: blob: ${resource.join(' ')}` : "img-src 'self' data: blob:",
];
if (connect.length) directives.push(`connect-src 'self' ${connect.join(' ')}`);
if (resource.length) directives.push(`font-src 'self' ${resource.join(' ')}`);
directives.push(frame.length ? `frame-src ${frame.join(' ')}` : "frame-src 'none'");
directives.push(base.length ? `base-uri ${base.join(' ')}` : "base-uri 'self'");
return directives.join('; ');
};
const buildSandboxAttribute = (_permissions, customSandbox) => customSandbox ?? 'allow-scripts';
const buildAllowAttribute = (permissions) => {
if (!permissions) return '';
return [
permissions.camera && 'camera=(self)',
permissions.microphone && 'microphone=(self)',
permissions.geolocation && 'geolocation=(self)',
permissions.clipboardWrite && 'clipboard-write=(self)',
].filter(Boolean).join('; ');
};
// ─── Inner iframe ─────────────────────────────────────────────────────────────
const injectCSPIntoHtml = (html, csp) => {
const cspString = buildCSPString(csp);
if (!cspString) return html;
const tag = `<meta http-equiv="Content-Security-Policy" content="${cspString}">`;
if (html.includes('<head>')) return html.replace('<head>', `<head>\n ${tag}`);
if (html.includes('<html>')) return html.replace('<html>', `<html>\n<head>\n ${tag}\n</head>`);
return `<!DOCTYPE html><html><head>${tag}</head><body>${html}</body></html>`;
};
const createInnerIframe = (html, csp, permissions, customSandbox) => {
const container = document.getElementById('inner-frame-container');
if (innerIframe) { container.removeChild(innerIframe); innerIframe = null; }
const iframe = document.createElement('iframe');
iframe.setAttribute('sandbox', buildSandboxAttribute(permissions, customSandbox));
const allow = buildAllowAttribute(permissions);
if (allow) iframe.setAttribute('allow', allow);
iframe.srcdoc = injectCSPIntoHtml(html, csp);
container.appendChild(iframe);
innerIframe = iframe;
};
const sendToGuest = (message) => {
if (!innerIframe?.contentWindow) return;
innerIframe.contentWindow.postMessage(message, '*');
};
const isInternalNotification = (message) =>
message?.method === READY_NOTIFICATION || message?.method === RESOURCE_NOTIFICATION;
const handleResourceReady = ({ html, csp, permissions, sandbox } = {}) => {
if (!html) { console.error('[Sandbox Proxy] No HTML in sandbox-resource-ready'); return; }
createInnerIframe(html, csp, permissions, sandbox);
};
// ─── INLINE MODE ─────────────────────────────────────────────────────────────
// Host ↔ Proxy: window.postMessage
let _hostOrigin = null;
const sendToHost_inline = (message) => {
if (!_hostOrigin) return;
window.parent.postMessage(message, _hostOrigin);
};
const handleMessage_inline = ({ source, origin, data: message }) => {
if (source === window.parent) {
if (!_hostOrigin) _hostOrigin = origin;
if (origin !== _hostOrigin) return;
if (message?.method === RESOURCE_NOTIFICATION) { handleResourceReady(message.params); return; }
if (innerIframe) sendToGuest(message);
return;
}
if (innerIframe && source === innerIframe.contentWindow && !isInternalNotification(message)) {
sendToHost(message);
}
};
const initInline = () => {
sendToHost = sendToHost_inline;
window.addEventListener('message', handleMessage_inline);
// Use '*' here — host origin is unknown until the first message arrives.
window.parent.postMessage({ jsonrpc: '2.0', method: READY_NOTIFICATION, params: {} }, '*');
};
// ─── WORKSPACE MODE ───────────────────────────────────────────────────────────
// Host ↔ Proxy: io.Connect interop
let _io = null;
let _proxyId = null;
let _parentChatId = null;
let _workspaceInitialized = false;
const sendToHost_workspace = (message) => {
if (!_io || !isJsonRpcMessage(message)) return;
const target = _parentChatId ? { windowId: _parentChatId } : undefined;
_io.interop
.invoke(PROXY_TO_HOST_METHOD, { message, proxyId: _proxyId }, target)
.catch(err => console.error('[Sandbox Proxy] Failed to invoke host method:', err));
};
const cleanupWorkspace = () => {
if (!_io) return;
window.removeEventListener('message', handleInnerFrameMessage_workspace);
try { _io.interop.unregister(HOST_TO_PROXY_METHOD); } catch (_) {}
};
const handleMessageFromHost_workspace = ({ message } = {}) => {
if (!message || !isJsonRpcMessage(message)) return;
if (message.method === RESOURCE_NOTIFICATION) { handleResourceReady(message.params); return; }
if (innerIframe) sendToGuest(message);
};
const handleInnerFrameMessage_workspace = ({ source, data: message }) => {
if (innerIframe && source === innerIframe.contentWindow && !isInternalNotification(message)) {
sendToHost(message);
}
};
const initWorkspace = async () => {
if (_workspaceInitialized) return;
_workspaceInitialized = true;
try {
_io = await window.IOBrowser();
} catch (err) {
console.error('[Sandbox Proxy] Failed to initialize io.Connect:', err);
return;
}
let myWindow = null;
try {
myWindow = await _io.windows.my();
_proxyId = myWindow?.id;
} catch (_) {}
if (myWindow) {
try {
const ctx = await myWindow.getContext();
const parentChatId = ctx?.parentChatId;
_parentChatId = parentChatId ?? null;
if (parentChatId) {
_io.windows.onWindowRemoved(async (removed) => {
if (removed.id === parentChatId) await myWindow.close();
});
}
} catch (err) {
console.error('[Sandbox Proxy] Error subscribing to parent window close:', err);
}
}
try {
await _io.interop.register(HOST_TO_PROXY_METHOD, handleMessageFromHost_workspace);
} catch (err) {
console.error('[Sandbox Proxy] Failed to register io method:', err);
return;
}
sendToHost = sendToHost_workspace;
window.addEventListener('message', handleInnerFrameMessage_workspace);
sendToHost_workspace({ jsonrpc: '2.0', method: READY_NOTIFICATION, params: {} });
hideLoadingOverlay();
};
// ─── io.Connect CDN loader (workspace mode only) ──────────────────────────────
const loadIOConnectScript = (callback) => {
const script = document.createElement('script');
script.src = IO_CONNECT_CDN;
script.onload = callback;
script.onerror = () => console.error('[Sandbox Proxy] Failed to load io.Connect script');
document.head.appendChild(script);
};
// ─── Entry point ───────────────────────────────────────────────────── ─────────
const start = () => {
if (IS_WORKSPACE_MODE) {
showLoadingOverlay();
loadIOConnectScript(initWorkspace);
} else {
initInline();
}
};
window.addEventListener('beforeunload', cleanupWorkspace);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})();
</script>
</body>
</html>
How the Two Transports Work
AI Web communicates with the proxy differently depending on display mode. If you are building a custom proxy, you need to implement both.
Inline Mode (window.postMessage)
When an app renders inline in the chat thread, AI Web creates an <iframe src="sandboxProxyUrl"> directly in the DOM. Every message travels over window.postMessage:
| Direction | Mechanism |
|---|---|
| Host → Proxy | proxyIframe.contentWindow.postMessage(msg, proxyOrigin) |
| Proxy → Host | window.parent.postMessage(msg, hostOrigin) |
| Proxy → Guest | innerIframe.contentWindow.postMessage(msg, '*') |
| Guest → Proxy | window.addEventListener('message', ...) |
The proxy learns the host origin from the first message it receives and uses that origin for all subsequent replies. Never use '*' when replying to the host.
Workspace Mode (io.Connect Interop)
When an app renders as a standalone io.Connect window, standard postMessage does not reach across windows. AI Web uses io.Connect interop instead.
At startup, AI Web registers your sandboxProxyUrl as an in-memory io.Connect application named sandbox-proxy, appending ?mode=workspace to the URL. Each workspace app instance is opened as a separate io.Connect window running that app definition.
Two interop methods carry all traffic:
| io.Connect Method | Direction | Role |
|---|---|---|
mcp.app.proxy.to.host | Proxy → Host | Registered by AI Web. Every proxy window invokes this method to deliver guest-to-host messages. The envelope includes { message, proxyId } so AI Web can route to the correct host. |
mcp.app.host.to.proxy | Host → Proxy | Registered by each proxy window on startup. AI Web invokes it targeting a specific proxy window by windowId to deliver host-to-guest messages. |
The proxy also reads its io.Connect window context on startup to find parentChatId. This is the window ID of the host chat window that opened this proxy. It uses parentChatId as the interop target when calling mcp.app.proxy.to.host, so multiple io.Assist instances running side by side do not receive each other's app messages.
Message Flow: Startup Handshake
Both modes follow the same handshake sequence:
- Proxy initialises — sets up the appropriate transport.
- Proxy sends
ui/notifications/sandbox-proxy-ready— tells AI Web the bridge is live and ready to receive the app HTML. - AI Web sends
ui/notifications/sandbox-resource-ready— delivers the MCP App HTML, optional CSP config, and iframe permissions. - Proxy creates the inner sandboxed iframe using
srcdoc, injecting the CSP meta tag if provided. - Normal MCP UI protocol starts —
ui/initialize,ui/notifications/tool-input,ui/notifications/tool-result, and all subsequent messages flow through.
Building a Custom Proxy
If you need custom sandboxing rules, a different delivery mechanism, or integration with a non-io.Connect environment, implement a proxy page that satisfies the following contract.
Required: Mode Detection
const IS_WORKSPACE_MODE =
new URLSearchParams(window.location.search).get('mode') === 'workspace';
AI Web appends ?mode=workspace when registering the in-memory app for workspace windows. Inline mode has no parameter.
Required: Proxy-Ready Notification
Send this as the very first message on startup in both modes. AI Web waits for it before sending the app HTML.
// Inline
window.parent.postMessage(
{ jsonrpc: '2.0', method: 'ui/notifications/sandbox-proxy-ready', params: {} },
'*', // '*' is only acceptable here because no host origin is known yet
);
// Workspace — send via io.Connect (see below)
io.interop.invoke('mcp.app.proxy.to.host', {
message: { jsonrpc: '2.0', method: 'ui/notifications/sandbox-proxy-ready', params: {} },
proxyId: myWindowId,
});
Required: Workspace Transport (io.Connect)
Without this, workspace mode does not work. The proxy must initialize io.Connect, register its inbound method, and invoke the host method to send outbound messages.
const io = await window.IOBrowser();
const myWindow = await io.windows.my();
const proxyId = myWindow.id;
// Read which host window launched this proxy
const ctx = await myWindow.getContext();
const parentChatId = ctx?.parentChatId ?? null;
// Register inbound method — AI Web calls this to deliver messages to the proxy
await io.interop.register('mcp.app.host.to.proxy', ({ message }) => {
if (message?.method === 'ui/notifications/sandbox-resource-ready') {
createInnerIframe(message.params);
return;
}
innerIframe?.contentWindow?.postMessage(message, '*');
});
// Send outbound (guest → host)
function sendToHost(message) {
const target = parentChatId ? { windowId: parentChatId } : undefined;
io.interop.invoke('mcp.app.proxy.to.host', { message, proxyId }, target);
}
Required: Inner Iframe Creation
Create the inner frame with srcdoc, always with a sandbox attribute. If csp config was provided in the sandbox-resource-ready notification, inject it as a <meta http-equiv="Content-Security-Policy"> tag.
function createInnerIframe({ html, csp, permissions }) {
const iframe = document.createElement('iframe');
iframe.setAttribute('sandbox', 'allow-scripts');
iframe.srcdoc = csp ? injectCspMeta(html, csp) : html;
document.body.appendChild(iframe);
innerIframe = iframe;
}
Required: Message Forwarding
Forward all messages bidirectionally, but filter out the two internal proxy notifications (sandbox-proxy-ready and sandbox-resource-ready) which are not meant for the guest.
// Host → Guest (inline)
window.addEventListener('message', ({ source, data }) => {
if (source === window.parent) {
if (data?.method === 'ui/notifications/sandbox-resource-ready') {
createInnerIframe(data.params);
return;
}
innerIframe?.contentWindow?.postMessage(data, '*');
}
});
// Guest → Host
window.addEventListener('message', ({ source, data }) => {
if (source !== innerIframe?.contentWindow) return;
const internal = ['ui/notifications/sandbox-proxy-ready', 'ui/notifications/sandbox-resource-ready'];
if (internal.includes(data?.method)) return;
sendToHost(data);
});
Summary Checklist
| Requirement | Why |
|---|---|
Mode detection from ?mode=workspace | Chooses correct transport |
ui/notifications/sandbox-proxy-ready on startup | AI Web waits before sending app HTML |
Handle ui/notifications/sandbox-resource-ready | Creates the inner sandboxed iframe |
Inner iframe with sandbox="allow-scripts" | Security isolation boundary |
window.postMessage inline transport | Required for inline display mode |
io.Connect mcp.app.proxy.to.host invocation | Required for workspace display mode |
io.Connect mcp.app.host.to.proxy registration | Required for workspace display mode |
Read parentChatId from window context | Correct routing when multiple hosts are open |
| Filter internal proxy notifications on guest forwarding | Prevents guest seeing internal handshake messages |
Stream a Run Into a Custom UI
Use streaming when you want full control over the chat experience and render tokens or tool events as they arrive.
const [agent] = await aiWeb.agents.list();
const stream = await agent.stream({
messages: [
{
role: "user",
content: "Summarize the current risk exposure.",
},
],
memory: {
thread: "thread-123",
resource: "user-42",
},
resourceId: "user-42",
});
stream.subscribe({
next(event) {
switch (event.type) {
case "TEXT_MESSAGE_CONTENT":
renderTextDelta(event);
break;
case "TOOL_CALL_START":
showToolCall(event);
break;
case "TOOL_CALL_RESULT":
showToolResult(event);
break;
}
},
complete() {
finishRun();
},
error(error) {
showError(error);
},
});
This is the pattern to use when AI Web is powering a custom frontend rather than a prebuilt component.