Skip to main content

Rich UI Element Embedding

Tools and Actions both support rich UI element embedding, allowing them to return HTML content and interactive iframes that display directly within chat conversations. This feature enables sophisticated visual interfaces, interactive widgets, charts, dashboards, and other rich web content — regardless of whether the function was triggered by the model (Tool) or by the user (Action).

When a function returns an HTMLResponse with the appropriate headers, the content will be embedded as an interactive iframe in the chat interface rather than displayed as plain text.

Tool Usage

To embed HTML content, your tool should return an HTMLResponse with the Content-Disposition: inline header:

from fastapi.responses import HTMLResponse

def create_visualization_tool(self, data: str) -> HTMLResponse:
"""
Creates an interactive data visualization that embeds in the chat.

:param data: The data to visualize
"""
html_content = """
<!DOCTYPE html>
<html>
<head>
<title>Data Visualization</title>
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
</head>
<body>
<div id="chart" style="width:100%;height:400px;"></div>
<script>
// Your interactive chart code here
Plotly.newPlot('chart', [{
y: [1, 2, 3, 4],
type: 'scatter'
}]);
</script>
</body>
</html>
"""

headers = {"Content-Disposition": "inline"}
return HTMLResponse(content=html_content, headers=headers)

Action Usage

Actions work exactly the same way. The rich UI embed is delivered to the chat via the event emitter:

Option A — HTMLResponse:

from fastapi.responses import HTMLResponse

async def action(self, body, __event_emitter__=None):
html = "<html><body><h1>Dashboard</h1></body></html>"
return HTMLResponse(content=html, headers={"Content-Disposition": "inline"})

Option B — Tuple with headers:

async def action(self, body, __event_emitter__=None):
html = "<h1>Interactive Chart</h1><script>...</script>"
return (html, {"Content-Disposition": "inline", "Content-Type": "text/html"})

Iframe Height and Auto-Sizing

Rich UI embeds are rendered inside a sandboxed iframe. The iframe needs to know how tall its content is in order to display without scrollbars. There are two mechanisms for this:

When allowSameOrigin is off (the default), the parent page cannot read the iframe's content height directly. Your HTML must report its own height by posting a message to the parent window:

<script>
function reportHeight() {
const h = document.documentElement.scrollHeight;
parent.postMessage({ type: 'iframe:height', height: h }, '*');
}
window.addEventListener('load', reportHeight);
// Also re-report when content changes size
new ResizeObserver(reportHeight).observe(document.body);
</script>

Add this script to the end of your <body> in every Rich UI embed. Without it, the iframe will stay at a small default height and your content will be cut off with a scrollbar.

Same-Origin Auto-Resize

When allowSameOrigin is on (via the user setting iframeSandboxAllowSameOrigin), the parent page can directly measure the iframe's content height and resize it automatically — no script needed in your HTML. However, this comes with security trade-offs (see below).

Sandbox and Security

Embedded iframes run inside a sandbox. The following sandbox flags are always enabled by default:

  • allow-scripts — JavaScript execution
  • allow-popups — Popups (e.g. window.open)
  • allow-downloads — File downloads

Two additional flags can be toggled by the user in Settings → Interface:

SettingDefaultDescription
Allow Iframe Same-Origin Access❌ OffAllows the iframe to access parent page context
Allow Iframe Form Submissions❌ OffAllows form submissions within embedded content

allowSameOrigin

This is the most important flag to be aware of. It is off by default for security reasons.

When off (default):

  • The iframe is fully isolated from the parent page
  • It cannot read cookies, localStorage, or DOM of the parent
  • The parent cannot read the iframe's content height (so you must use the postMessage pattern above)
  • This is the safest option and recommended for most use cases

When on:

  • The iframe can interact with the parent page's context
  • Auto-resizing works without any script in your HTML
  • Chart.js and Alpine.js dependencies are automatically injected if detected
  • ⚠️ Use with caution — only enable this when you trust the embedded content

Users can toggle this setting in Settings → Interface → Iframe Same-Origin Access.

Rendering Position

  • Tool embeds inside a tool call result render inline at the tool call indicator (the "View Result from..." line)
  • Action embeds and message-level embeds render below the message text content

Advanced Communication

The iframe and parent window can communicate beyond just height reporting. The following patterns are available:

Payload Requests

The iframe can request a data payload from the parent. This is useful for passing dynamic data into the embed after it loads:

<script>
// Request payload from parent
window.addEventListener('message', (e) => {
if (e.data?.type === 'payload') {
const data = e.data.payload;
// Use the payload data to populate your UI
console.log('Received payload:', data);
}
});

// Trigger the request
parent.postMessage({ type: 'payload', requestId: 'my-request' }, '*');
</script>

The parent responds with { type: 'payload', requestId: ..., payload: ... } containing the configured payload data.

Tool Args Injection (Tools Only)

When a Tool returns a Rich UI embed, the tool call arguments (the parameters the model passed to the tool) are automatically injected into the iframe's window.args. This allows your embedded HTML to access the tool's input:

<script>
window.addEventListener('load', () => {
// window.args contains the JSON arguments the model passed to this tool
const args = window.args;
if (args) {
document.getElementById('output').textContent = JSON.stringify(args, null, 2);
}
});
</script>
note

This only works for Tool embeds rendered via the tool call display. Action embeds do not have window.args since they are triggered by the user, not the model.

Auto-Injected Libraries

When allowSameOrigin is enabled, the iframe component auto-detects usage of certain libraries in your HTML and injects them automatically — no CDN <script> tags needed:

  • Alpine.js — Detected when any x-data, x-init, x-show, x-bind, x-on, x-text, x-html, x-model, x-for, x-if, x-effect, x-transition, x-cloak, x-ref, x-teleport, or x-id directives are found
  • Chart.js — Detected when new Chart( or Chart. appears in the HTML

This means you can write Alpine or Chart.js code directly in your HTML and it will just work when same-origin is enabled, without importing scripts.

Ping/Pong Connectivity

The iframe can test connectivity with the parent window using a simple ping/pong pattern:

<script>
window.addEventListener('message', (e) => {
if (e.data?.type === 'pong:ack') {
console.log('Parent is listening!');
}
});

// Send a pong to test connectivity
parent.postMessage({ type: 'pong' }, '*');
</script>

Rich UI Embeds vs Execute Event

Rich UI embeds and the execute event are complementary ways to create interactive experiences. Choose based on your needs:

Rich UI Embedexecute Event
Runs inSandboxed iframeMain page context (no sandbox)
PersistencePersistent — saved in chat historyEphemeral — gone on reload/navigate
Page accessIsolated from parent by defaultFull (DOM, cookies, localStorage)
FormsRequires allowForms setting enabledAlways works (no sandbox)
Best forPersistent visual content, dashboards, chartsTransient interactions, side effects, downloads, DOM manipulation

Use Rich UI embeds for persistent visual content you want to stay in the conversation. Use execute for transient interactions like custom dialogs, triggering downloads, or reading page state.

Use Cases

Rich UI embedding is perfect for:

  • Interactive dashboards — Real-time data visualization and controls
  • Charts and graphs — Interactive plotting with libraries like Plotly, D3.js, or Chart.js
  • Form interfaces — Complex input forms with validation and dynamic behavior
  • Media players — Video, audio, or interactive media content
  • Download triggers — Especially useful for iOS PWA where native download links are blocked
  • Custom widgets — Specialized UI components for specific tool functionality
  • External integrations — Embedding content from external services or APIs
  • Human-triggered visualizations — Actions that display results when a user clicks a button, e.g. generating a report or triggering a download

Full Sample Action

Complete working Sample Action with Rich UI embed

This Action returns a styled card with stats, including the recommended height-reporting script:

"""
title: Rich UI Demo Action
author: open-webui
version: 0.1.0
description: Demonstrates Rich UI embedding from an Action function.
"""

from pydantic import BaseModel, Field


class Action:
class Valves(BaseModel):
pass

def __init__(self):
self.valves = self.Valves()

async def action(self, body: dict, __user__=None, __event_emitter__=None) -> None:
from fastapi.responses import HTMLResponse

html = """
<!DOCTYPE html>
<html>
<head>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 24px;
color: #fff;
}
.card {
background: rgba(255,255,255,0.15);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 24px;
border: 1px solid rgba(255,255,255,0.2);
}
h1 { font-size: 1.4em; margin-bottom: 8px; }
p { opacity: 0.9; line-height: 1.5; margin-bottom: 12px; }
.badge {
display: inline-block;
background: rgba(255,255,255,0.25);
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85em;
font-weight: 600;
}
.stats {
display: flex;
gap: 16px;
margin-top: 16px;
}
.stat {
flex: 1;
text-align: center;
background: rgba(255,255,255,0.1);
border-radius: 12px;
padding: 12px;
}
.stat-value { font-size: 1.8em; font-weight: 700; }
.stat-label { font-size: 0.8em; opacity: 0.8; margin-top: 4px; }
</style>
</head>
<body>
<div class="card">
<h1>Rich UI Embed Demo</h1>
<p>This embed renders <strong>below</strong> the message text.</p>
<span class="badge">Action Embed</span>
<div class="stats">
<div class="stat">
<div class="stat-value">42</div>
<div class="stat-label">Answers</div>
</div>
<div class="stat">
<div class="stat-value">99%</div>
<div class="stat-label">Accuracy</div>
</div>
<div class="stat">
<div class="stat-value">0ms</div>
<div class="stat-label">Latency</div>
</div>
</div>
</div>
<script>
// Report height to parent so the iframe auto-sizes
function reportHeight() {
const h = document.documentElement.scrollHeight;
parent.postMessage({ type: 'iframe:height', height: h }, '*');
}
window.addEventListener('load', reportHeight);
new ResizeObserver(reportHeight).observe(document.body);
</script>
</body>
</html>
"""

return HTMLResponse(content=html, headers={"Content-Disposition": "inline"})

External Tool Example

For external tools served via HTTP endpoints:

@app.post("/tools/dashboard")
async def create_dashboard():
html = """
<div style="padding: 20px;">
<h2>System Dashboard</h2>
<canvas id="myChart" width="400" height="200"></canvas>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const ctx = document.getElementById('myChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: { /* your chart data */ }
});
</script>
</div>
"""

return HTMLResponse(
content=html,
headers={"Content-Disposition": "inline"}
)

The embedded content automatically inherits responsive design and integrates seamlessly with the chat interface, providing a native-feeling experience for users interacting with your tools.

CORS and Direct Tools

Direct external tools are tools that run directly from the browser. In this case, the tool is called by JavaScript in the user's browser. Because we depend on the Content-Disposition header, when using CORS on a remote tool server, the Open WebUI cannot read that header due to Access-Control-Expose-Headers, which prevents certain headers from being read from the fetch result. To prevent this, you must set Access-Control-Expose-Headers to Content-Disposition. Check the example below of a tool using Node.js:

const app = express();
const cors = require('cors');

app.use(cors())

app.get('/tools/dashboard', (req,res) => {
let html = `
<div style="padding: 20px;">
<h2>System Dashboard</h2>
<canvas id="myChart" width="400" height="200"></canvas>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const ctx = document.getElementById('myChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: { /* your chart data */ }
});
</script>
</div>
`
res.set({
'Content-Disposition': 'inline'
,'Access-Control-Expose-Headers':'Content-Disposition'
})
res.send(html)
})

More info about the header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Expose-Headers