Iframe Integration
This guide covers the iframe-based architecture used by all Ozwell frontend integrations, including security considerations, communication patterns, and custom implementation details.
The production loader is https://api.ozwell.ai/widget; its iframe and API use https://api.ozwell.ai. The loader at https://ozwellapi.os.mieweb.org/widget is for development.
How It Worksβ
Ozwell's frontend integrations render the chat interface inside an isolated iframe. This architecture provides:
Privacy & Security Benefitsβ
Conversation Privacyβ
π The conversation between users and Ozwell is private by default.
The host site cannot see, intercept, or log what is said in the chat. This creates a safe space where users can:
- Ask any question without embarrassment
- Explore topics freely without surveillance
- Trust that their dialogue stays between them and Ozwell
Sharing is always opt-in. Only when a user explicitly chooses to share information does it become visible to the host site.
Origin Isolationβ
The loader creates an iframe whose page is served from the Ozwell API origin (https://api.ozwell.ai/widget/frame/ in production). Its API calls are therefore same-origin with the Ozwell API, while the host page remains isolated.
- β Cannot access parent page DOM
- β Cannot read parent page cookies/storage
- β Cannot execute scripts in parent context
- β Cannot access parent page JavaScript variables
- β Cannot read conversation content
Sandbox Attributesβ
The iframe includes restrictive sandbox attributes:
<iframe
src="https://api.ozwell.ai/widget/frame/"
sandbox="allow-scripts allow-same-origin allow-forms"
></iframe>
| Attribute | Purpose |
|---|---|
allow-scripts | Required for chat functionality |
allow-same-origin | Required for API calls from iframe |
allow-forms | Enables form submission (file uploads) |
Embedding Policyβ
The widget response permits embedding from host websites:
Content-Security-Policy:
frame-ancestors *;
Communication Protocolβ
The host page and iframe communicate via the PostMessage API, implementing an inverted MCP postMessage transport pattern. See MCP postMessage Standard for the community proposals that inspired this design.
The loader can also expose an explicit subset of the host's page tools through the native WebMCP API, reusing the same handlers. The embedded assistant continues using postMessage. See Optional WebMCP Exposure for setup, browser requirements, and exposure controls.
For the proposed loader-managed controller using this same iframe transport, see Proposed page control option (not implemented). The current proof of concept wires the controller explicitly.
Message Formatβ
All messages follow this structure:
interface OzwellMessage {
type: string; // Message type identifier
payload?: unknown; // Message data
timestamp: number; // Unix timestamp
id: string; // Unique message ID (for request/response correlation)
}
Host β Iframe Messagesβ
| Type | Payload | Description |
|---|---|---|
ozwell:init | { apiKey, agentId, config } | Initialize the widget |
ozwell:open | β | Open the chat window |
ozwell:close | β | Close the chat window |
ozwell:send-message | { content } | Send a message immediately |
ozwell:compose | { content, replace? } | Bring host-selected content into the composer as an editable draft (nothing is sent until the user confirms) |
ozwell:set-context | { context } | Update context data |
ozwell:set-theme | { theme } | Change theme |
Iframe β Host Messagesβ
| Type | Payload | Description |
|---|---|---|
ozwell:ready | β | Widget initialized |
ozwell:opened | β | Chat window opened |
ozwell:closed | β | Chat window closed |
composed | length (top-level number) | Host-selected content was placed into the composer draft |
ozwell:user-share | { data } | User explicitly shared data |
ozwell:error | { code, message } | Error occurred |
β οΈ Privacy Note: There is no ozwell:message event. Conversation content is never relayed to the host siteβthis is by design to protect user privacy. ozwell:compose only prefills the composer; the user reviews and sends the draft explicitly, so nothing leaves the host page without user consent.
Treating the Embed Like a Browser: Page Inspection & Interactionβ
When Ozwell is embedded inside a host application such as an Enterprise Health EβChart, the assistant can treat the surrounding page like a browser tabβinspecting its content and interacting with itβby exposing the page's capabilities as MCP tools over the same postMessage transport.
The widget already runs the client half of an MCP handshake against the host:
- On load the widget sends
initialize, thennotifications/initialized, thentools/list. - The host responds to
tools/listwith the tools it wants the assistant to use. - When the assistant decides to call a tool, the widget sends
tools/call; the host executes it against the page and returns the result.
To let the assistant read and act on the EβChart, the host registers page toolsβno widget changes are required. Suggested tool shapes:
| Tool | Purpose |
|---|---|
page.getContent | Return the visible/relevant page content (or a section) as text the assistant can read |
page.getSelection | Return the user's current selection on the page |
page.click | Click an explicitly identified element (e.g. a "Hello World" button) |
page.fill | Set a value on an identified field |
// Host: answer the widget's MCP handshake with page tools.
const PAGE_TOOLS = [
{ name: 'page.getContent', description: 'Read the current E-Chart content', inputSchema: { type: 'object', properties: { section: { type: 'string' } } } },
{ name: 'page.getSelection', description: 'Read the user\'s current selection', inputSchema: { type: 'object', properties: {} } },
{ name: 'page.click', description: 'Click an element by id', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
];
// Only trust and reply to the exact widget origin, so a cross-origin document
// that later reuses this iframe's contentWindow can neither issue tool calls
// nor receive results.
const widgetOrigin = new URL(iframe.src).origin;
window.addEventListener('message', (event) => {
if (event.source !== iframe.contentWindow || event.origin !== widgetOrigin) return;
const msg = event.data;
if (msg?.jsonrpc !== '2.0') return;
const reply = (result) => iframe.contentWindow.postMessage({ jsonrpc: '2.0', id: msg.id, result }, widgetOrigin);
switch (msg.method) {
case 'initialize':
return reply({ protocolVersion: '2025-11-25', capabilities: {}, serverInfo: { name: 'echart-host', version: '1.0.0' } });
case 'tools/list':
return reply({ tools: PAGE_TOOLS });
case 'tools/call': {
const { name, arguments: args } = msg.params;
if (name === 'page.getContent') return reply({ content: [{ type: 'text', text: readEChart(args?.section) }] });
if (name === 'page.getSelection') return reply({ content: [{ type: 'text', text: String(window.getSelection()) }] });
if (name === 'page.click') { document.getElementById(args.id)?.click(); return reply({ content: [{ type: 'text', text: 'clicked' }] }); }
return iframe.contentWindow.postMessage({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'Unknown tool' } }, widgetOrigin);
}
}
});
Select Content in EβChart β Send to the Composerβ
Beyond assistant-driven tool calls, the user can push a selection straight into the composer. This keeps the conversation private (the draft only becomes a message when the user sends it) while making the embed feel integrated with Enterprise Health.
// Host: "Ask Ozwell about this" on an E-Chart selection.
function sendSelectionToOzwell() {
const content = String(window.getSelection()).trim();
if (!content) return;
// Target the exact widget origin so the selected text is never disclosed to
// another document that may have replaced the iframe.
iframe.contentWindow.postMessage({
source: 'ozwell-chat-parent',
type: 'ozwell:compose',
payload: { content }, // add `replace: true` to overwrite the current draft
}, new URL(iframe.src).origin);
}
// Confirm the widget received it.
window.addEventListener('message', (event) => {
if (event.data?.source === 'ozwell-chat-widget' && event.data.type === 'composed') {
console.log(`Added ${event.data.length} chars to the Ozwell composer`);
}
});
Custom Implementationβ
If you need full control, you can implement the iframe integration manually.
Basic Setupβ
<div id="ozwell-container">
<button id="ozwell-trigger">Chat</button>
<iframe
id="ozwell-iframe"
src="https://api.ozwell.ai/widget/frame/"
style="display: none; width: 400px; height: 600px; border: none;"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
allow="clipboard-write"
></iframe>
</div>
<script>
const iframe = document.getElementById('ozwell-iframe');
const trigger = document.getElementById('ozwell-trigger');
// Wait for iframe to load
iframe.addEventListener('load', () => {
// Initialize the widget
iframe.contentWindow.postMessage({
type: 'ozwell:init',
payload: {
apiKey: 'ozw_scoped_xxxxxxxx',
agentId: 'agent_xxxxxxxx',
config: {
theme: 'auto',
primaryColor: '#4f46e5'
}
},
timestamp: Date.now(),
id: crypto.randomUUID()
}, 'https://api.ozwell.ai');
});
// Toggle visibility
trigger.addEventListener('click', () => {
const isHidden = iframe.style.display === 'none';
iframe.style.display = isHidden ? 'block' : 'none';
iframe.contentWindow.postMessage({
type: isHidden ? 'ozwell:open' : 'ozwell:close',
timestamp: Date.now(),
id: crypto.randomUUID()
}, 'https://api.ozwell.ai');
});
// Listen for messages from iframe
window.addEventListener('message', (event) => {
// Verify origin
if (event.origin !== 'https://api.ozwell.ai') return;
const { type, payload } = event.data;
switch (type) {
case 'ozwell:ready':
console.log('Widget ready');
break;
case 'ozwell:user-share':
// Only fires when user explicitly shares data
console.log('User shared:', payload);
break;
case 'ozwell:error':
console.error('Widget error:', payload);
break;
// Note: No 'ozwell:message' event - conversations are private
}
});
</script>
Responsive Containerβ
<style>
#ozwell-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
}
#ozwell-iframe {
position: absolute;
bottom: 60px;
right: 0;
width: 400px;
height: 600px;
max-height: calc(100vh - 100px);
border: none;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
@media (max-width: 480px) {
#ozwell-iframe {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
max-height: none;
border-radius: 0;
}
}
#ozwell-trigger {
width: 56px;
height: 56px;
border-radius: 50%;
background: #4f46e5;
border: none;
cursor: pointer;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.4);
}
</style>
Embedding in Specific Contextsβ
Single Page Applications (SPAs)β
When the host page navigates without a full reload:
// Notify iframe of navigation
window.addEventListener('popstate', () => {
iframe.contentWindow.postMessage({
type: 'ozwell:set-context',
payload: {
context: {
page: window.location.pathname,
referrer: document.referrer
}
},
timestamp: Date.now(),
id: crypto.randomUUID()
}, 'https://api.ozwell.ai');
});
Multiple Iframes (Not Recommended)β
Only one Ozwell iframe should be active per page. If you need different agents on different pages, swap the agent ID rather than creating multiple iframes.
Troubleshootingβ
Iframe Not Loadingβ
- Check network tab for blocked requests
- Verify CSP headers on your site allow Ozwell's domains
- Check sandbox attributes β don't over-restrict
PostMessage Not Workingβ
- Verify origin in message handlers
- Check iframe src matches expected origin
- Ensure iframe has loaded before sending messages
Styling Issuesβ
/* Reset potential conflicts */
#ozwell-iframe {
all: unset;
display: block;
border: none;
background: transparent;
}
Security & Privacy Checklistβ
- Using scoped API key (not general-purpose)
- Verifying message origin in event handlers
- Domain restrictions configured for API key
- CSP headers allow Ozwell domains
- Sandbox attributes are properly restrictive
- Not attempting to intercept conversation content (respecting user privacy)
Next Stepsβ
- MCP postMessage Standard β How MCP proposals inspired this architecture
- CDN Embed β Quick script-based integration
- Framework Guides β React, Vue, Svelte
- Backend API β Server-side integration