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)

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_context

The 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.

When to use this

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"})

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.

Practical Impact of Sandbox Settings

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 postMessage only — 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.

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>
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>

Prompt Submission

Rich UI embeds can interact with the chat input using three message types:

Message TypeBehavior
input:promptFills the chat input box with text (does not submit)
input:prompt:submitFills and submits the prompt to the chat
action:submitSubmits 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 allowSameOrigin is on, input:prompt:submit submits the prompt immediately — no user interaction required.
  • When allowSameOrigin is off (default), input:prompt:submit from 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:prompt and action:submit work the same regardless of origin — they only fill or submit text that the user can already see.
tip

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 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>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