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)Custom Result Context
By default, when a tool returns an HTMLResponse, the LLM receives a generic message: "<tool_name>: Embedded UI result is active and visible to the user." This gives the model no information about what was actually generated.
To provide the LLM with actionable context about the embed, return a tuple of (HTMLResponse, context) where the second element is a str, dict, or list:
from fastapi.responses import HTMLResponse
def create_chart(self, data: str) -> tuple:
"""
Creates an interactive chart and returns context to the LLM.
:param data: The data to chart
"""
html_content = "<html>...</html>"
headers = {"Content-Disposition": "inline"}
# The LLM receives this context instead of the generic message
result_context = {
"status": "success",
"chart_type": "scatter",
"data_points": 42,
"description": "Scatter plot showing correlation between X and Y"
}
return HTMLResponse(content=html_content, headers=headers), result_contextThe context can be:
- A string — sent as-is to the LLM (e.g.,
"Generated a bar chart with 5 categories") - A dict — serialized as JSON for structured context
- A list — serialized as JSON for multiple items
If the second element is missing or not one of these types, the generic fallback message is used.
This is particularly useful when your tool generates dynamic content and the LLM needs to reference what was generated in follow-up conversation — for example, telling the LLM which parameters were used, what data is being displayed, or what actions the user can take next.
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"})Pipe Function Usage
When a Tool is called through Open WebUI's built-in tool calling layer (either native or legacy), the middleware automatically detects HTMLResponse results, extracts the HTML into embeds, and emits the "embeds" event. No extra work is needed.
However, when a Pipe function makes direct API calls to an external provider (e.g., Azure OpenAI, Anthropic), it bypasses the middleware entirely. The pipe manages the tool call cycle itself — sending tool definitions to the provider, receiving tool_calls in the response, executing the tool, and feeding the result back. In this scenario, the middleware never sees the HTMLResponse, so the pipe must emit the embed manually.
The Pattern
In your pipe's tool execution logic, detect when a tool returns an HTMLResponse, extract the HTML body, emit it via the event emitter, and return a text summary to the LLM:
from fastapi.responses import HTMLResponse
async def execute_tool(self, tool_call, tools, __event_emitter__):
tool = tools.get(tool_call.name)
if not tool:
return "Tool not found"
parsed_args = json.loads(tool_call.arguments) if tool_call.arguments else {}
result = await tool["callable"](**parsed_args)
# Detect HTMLResponse and emit as embed
if isinstance(result, HTMLResponse):
content_disposition = result.headers.get("Content-Disposition", "")
if "inline" in content_disposition:
html_content = result.body.decode("utf-8", "replace")
# Emit the embed so the frontend renders it
await __event_emitter__({
"type": "embeds",
"data": {"embeds": [html_content]},
})
# Return a text summary to the LLM (not the raw HTML)
return json.dumps({
"status": "success",
"message": f"{tool_call.name}: UI rendered successfully.",
})
# For non-HTML results, return as normal
return json.dumps(result)Why This Is Needed
Open WebUI's middleware processes tool results in process_tool_result(), which handles HTMLResponse detection, embed extraction, and event emission automatically. But that function is only called when the middleware orchestrates the tool call cycle. When a Pipe function handles tool calls itself (because it makes direct HTTP requests to an LLM provider), it must replicate the relevant parts of that logic.
Key Steps
- Detect
HTMLResponse— Check if the tool's return value is anHTMLResponsewithContent-Disposition: inline - Extract HTML — Decode the response body
- Emit
"embeds"event — Send the HTML via__event_emitter__so the frontend renders it as a Rich UI card - Return text to the LLM — The model should receive a text summary (not raw HTML) so it can continue the conversation naturally
Tuple Context Support
Just like with standard Tools, you can return a (HTMLResponse, context) tuple from your tool. In your pipe's execution logic, unpack it the same way:
if isinstance(result, tuple) and len(result) == 2 and isinstance(result[0], HTMLResponse):
html_response, result_context = result
# ... emit html_response.body as embed ...
# ... return result_context to the LLM instead of the generic message ...The Native Tool Calling Pipe is a community-built pipe that implements the full OpenAI-native tool calling cycle with streaming and multi-call support. It can be adapted for Azure OpenAI or other providers and serves as a practical reference for implementing this pattern.
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:
postMessage Height Reporting (Recommended)
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 executionallow-popups— Popups (e.g. window.open)allow-downloads— File downloads
Two additional flags can be toggled by the user in Settings → Interface:
| Setting | Default | Description |
|---|---|---|
| Allow Iframe Same-Origin Access | ❌ Off | Allows the iframe to access parent page context |
| Allow Iframe Form Submissions | ❌ Off | Allows 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.
When allowSameOrigin is off (default), the Rich UI iframe is heavily sandboxed. This means:
- Downloads from within the embed are difficult or impossible — especially on iOS, where sandboxed iframes cannot trigger file downloads at all
- JavaScript in the embed cannot interact with Open WebUI itself — the iframe has no access to the parent page's DOM, cookies, localStorage, or any Open WebUI APIs
- Cross-frame communication is limited to
postMessageonly — however, prompt submission works cross-origin with a user confirmation dialog
If your Rich UI embed needs to trigger downloads, interact with Open WebUI's frontend, or execute JavaScript that impacts the parent page, enabling same-origin iframe access is required. Enable it in Settings → Interface → Iframe Same-Origin Access.
As an alternative for ephemeral interactions that need full page access, consider using the execute event instead, which runs unsandboxed in the main page context.
If you want to see how far Rich UI can go when same-origin is enabled, take a look at the community Inline Visualizer v2 tool (also on the community site via the Show-and-tell discussion).
It demonstrates patterns that aren't in the basic docs:
- Live streaming HTML/SVG. The tool returns an empty wrapper; the model then emits markup inline between plain-text
@@@VIZ-START / @@@VIZ-ENDmarkers in its normal response. A same-origin observer inside the iframe tails the parent chat's DOM, extracts the growing block, and reconciles new nodes into the iframe as tokens arrive — so dashboards and diagrams paint live, token-by-token, instead of popping in at the end of the stream. - Bidirectional bridges.
sendPrompt(text)turns any clickable node into a follow-up user message.saveState(k, v)/loadState(k, fallback)proxies parentlocalStoragescoped per-message so sliders and toggles survive reloads.copyText,toast(msg, kind), andopenLinkround it out. - A shipped design system. Theme-aware CSS variables, a 9-ramp color palette, SVG utility classes, auto light/dark adaptation, and 230 localized strings across 46 languages — all delivered from a single tool with no core changes.
- Incremental DOM reconciliation. A safe-cut HTML parser flushes the longest valid prefix on every tick; the reconciler only appends new nodes so existing elements never re-mount and animations never re-trigger during the stream.
This is a useful reference when you're trying to decide whether a generative-UI / streaming-UI feature needs a core change or can live purely in plugin-land. (Spoiler: almost always the latter.)
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 above 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>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, orx-iddirectives are found - Chart.js — Detected when
new Chart(orChart.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>Prompt Submission
Rich UI embeds can interact with the chat input using three message types:
| Message Type | Behavior |
|---|---|
input:prompt | Fills the chat input box with text (does not submit) |
input:prompt:submit | Fills and submits the prompt to the chat |
action:submit | Submits the current text already in the chat input |
<script>
// Fill the chat input without submitting
parent.postMessage({ type: 'input:prompt', text: 'Analyze this data' }, '*');
// Fill and submit a prompt
parent.postMessage({ type: 'input:prompt:submit', text: 'Show me a summary' }, '*');
// Submit whatever is currently in the chat input
parent.postMessage({ type: 'action:submit', text: '' }, '*');
</script>Same-origin vs cross-origin behavior:
- When
allowSameOriginis on,input:prompt:submitsubmits the prompt immediately — no user interaction required. - When
allowSameOriginis off (default),input:prompt:submitfrom cross-origin iframes shows a confirmation dialog to the user before submitting. This prevents abuse while still enabling interactive embeds without requiring same-origin access. input:promptandaction:submitwork the same regardless of origin — they only fill or submit text that the user can already see.
This means your Rich UI embed can include interactive buttons (e.g., "Explain this chart", "Regenerate with different parameters") that submit prompts to the chat, without requiring the user to enable same-origin access. The user simply sees a confirmation dialog and clicks "Confirm" to proceed.
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 Embed | execute Event | |
|---|---|---|
| Runs in | Sandboxed iframe | Main page context (no sandbox) |
| Persistence | Persistent — saved in chat history | Ephemeral — gone on reload/navigate |
| Page access | Isolated from parent by default | Full (DOM, cookies, localStorage) |
| Forms | Requires allowForms setting enabled | Always works (no sandbox) |
| Best for | Persistent visual content, dashboards, charts | Transient 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>above</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