Iframe mode turns on when your dashboard_manifest.json declares "mode": "iframe" and every page names an HTML file via "src" (the validator rejects iframe pages without one). Ship those files in a dashboard/ directory; the platform serves them on a per-plugin sandboxed origin inside an iframe and gives you a JS bridge (YourBotSDK) for backend RPC, theming and lifecycle.
{
"mode": "iframe",
"pages": [
{"id": "overview", "title": "Overview", "src": "index.html"}
]
}
my_plugin/
├── manifest.json
├── dashboard_manifest.json # the JSON above
├── __main__.py
└── dashboard/
├── index.html # page "src", served relative to dashboard/
├── style.css
└── app.js
The sandbox CSP allows same-origin assets only, so bundle every stylesheet, script, image and font inside dashboard/ and reference them with relative paths. The one exception is the bridge script itself: /static/yourbot-sdk.js is served on the sandboxed origin, so load it directly. Pages may embed third-party <iframe>s only for domains you list in proxy_domains_requested.
Minimal HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>body { color: var(--yourbot-text); background: transparent; }</style>
</head>
<body>
<h1>My plugin</h1>
<div id="stat">Loading…</div>
<script src="/static/yourbot-sdk.js"></script>
<script>
YourBotSDK.ready(async function () {
const ctx = YourBotSDK.getContext();
// ctx = { plugin_id, server_id, page_id, user: {id, display_name, role} }
const res = await YourBotSDK.metrics.total("messages_sent", "30d");
document.getElementById("stat").textContent = (res.total || 0) + " messages";
YourBotSDK.resize(); // ask the iframe host to resize to fit content
});
</script>
</body>
</html>
YourBotSDK JS API
Every method returns a Promise. The bridge only forwards allow-listed RPC namespaces (kv, metrics, sql, dashboard). The kv, metrics and sql calls run host-side against the same per-install storage your Python handlers see: they never wake your plugin code, but they need a live pool worker for your plugin and they enforce the same capabilities as plugin-side calls (storage:kv for kv, storage:sql for sql, none for metrics). Reads need the viewer role; writes (kv.set, kv.setMany, kv.delete, kv.increment, kv.decrement, metrics.record, sql.execute and save_* dashboard handlers) need manager.
| Call | Purpose |
|---|---|
| YourBotSDK.ready(fn) | Run fn after the bridge handshake completes. |
| YourBotSDK.getContext() | Sync — {plugin_id, server_id, page_id, user}. |
| YourBotSDK.getUser() | Sync — the viewer: {id, display_name, role}; role is viewer, manager or owner. |
| YourBotSDK.getTheme() | Sync — the current theme's CSS variable values as a map. |
| YourBotSDK.on(event, fn) / off(event, fn) | Listen for "ready", "theme" or "navigate". The theme event arrives once, at the handshake. |
| YourBotSDK.resize(height?) | Tell the host iframe to resize. Auto-detects content if no height passed; the host clamps heights to 200–5000px, so taller dashboards need internal scrolling. |
| YourBotSDK.navigateTo(pageId) | Switch to another page in your dashboard manifest. |
| YourBotSDK.rpc(method, params) | Call a Python @plugin.on_dashboard handler; resolves to the handler's return dict. |
| YourBotSDK.kv.get / set / delete / list / listValues / getMany / setMany / increment / decrement / count | Direct KV access (needs storage:kv). Resolves to result objects: get → {ok, value}; set / setMany / delete → {ok}; list → {ok, keys} (limit cap 1000); listValues → {ok, values} (limit cap 100); getMany → {ok, values} (max 50 keys); increment / decrement → {ok, value}; count → {ok, count}. |
| YourBotSDK.metrics.query / total / record | Pull metrics for charts or record a data point. total → {ok, total}, so unwrap before rendering. No capability required; record needs a manager viewer. |
| YourBotSDK.sql.query / execute | SQL access (needs storage:sql). query → {ok, rows, truncated} (limit cap 1000); execute → {ok, rowcount}. |
| YourBotSDK.openPlatformPopup(path, features?) | Open a platform path (e.g. /buy/…) in a popup resolved against the parent dashboard's origin. Returns the popup window, or null before the handshake. Pass a path, not a full URL. |
Bridge calls are rate-limited at 600/min per viewer session (cached reads don't count against it), and rejected errors carry a machine-readable .code (e.g. RPC_TIMEOUT). If no worker is running for your plugin yet, kv / metrics / sql calls fail with a “no running worker” error until it starts.
Theme variables
After the handshake the bridge sets these CSS custom properties on your iframe's :root so your UI blends in without reverse-engineering colors:
:root {
--yourbot-bg; --yourbot-surface; --yourbot-surface-alt;
--yourbot-border; --yourbot-gold; --yourbot-text;
--yourbot-muted; --yourbot-success; --yourbot-error;
--yourbot-warning; --yourbot-info;
}
Legacy --maid-* aliases are also set for older dashboards. Delivery order matters: ready fires before the theme message lands, so inside a ready callback getTheme() is still empty and the CSS variables are not yet set. Register an on("theme") listener (it fires once, right after ready) and do theme-dependent work there; plain CSS that uses var(--yourbot-*) just works once the variables land.
Drop-in style pack new
Manifest widgets are themed automatically. For iframe dashboards the platform ships a small CSS + JS pack so your custom HTML matches the host without copying styles. Bundle both files into your zip under dashboard/sdk/ (grab the current copies from /static/sdk/ui.css?v=2 and /static/sdk/ui.js?v=2) and reference them relative to dashboard/:
<link rel="stylesheet" href="sdk/ui.css">
<script src="sdk/ui.js" defer></script>
What's in the pack:
.maid-btn/.maid-btn-primary/.maid-btn-danger— buttons matching the platform palette.maid-input/.maid-textarea/.maid-select— form fields with theme-aware focus rings.maid-card— styled container with header/help slots.maid-badge/.maid-pill— status indicators with semantic colors (ok / warn / error / info / muted).maid-table— theme-aware data table.maid-stack/.maid-grid/.maid-row— layout helpers.maid-empty— empty-state container
JS helpers (namespaced under window.YourBotUI):
YourBotUI.toast("Saved!", "ok");
YourBotUI.confirm("Delete this record?").then((yes) => { if (yes) /* ... */ });
YourBotUI.modal({ title: "Details", body: bodyElement, actions: [/* ... */] });
YourBotUI.tabs(container); // wire [data-yourbot-tab] tabs
YourBotUI.table(target, { columns, rows }); // themed, click-to-sort table
YourBotUI.copy("text to clipboard");
// Form saves: call your @plugin.on_dashboard("save_settings") handler.
// It receives the object as params["values"].
const values = YourBotUI.serializeForm(myFormElement);
await YourBotSDK.rpc("save_settings", { values });
YourBotUI.fetchJSON(url, init) is bridge-aware: inside a sandboxed iframe it routes /p/{plugin_id}/dashboard/rpc/{method} URLs through YourBotSDK.rpc (load yourbot-sdk.js first) and resolves to the endpoint's {ok, data} body, while on main-origin pages it fetches directly and adds the page's CSRF token to non-GET requests.