Creating Plugins
Developer guide for building OpenAVC plugins. Plugins are Python packages that extend the platform with system-wide integrations, control surfaces, and services.
For installing and using plugins as an end user, see Plugins.
Plugin Extensions
Plugins can contribute content to several places in the Programmer IDE and the touch panel.
Plugin Views
Plugins can add their own views to the sidebar (below the standard sections). These might show a monitoring dashboard, a routing matrix, or a live state table. Plugin views appear automatically when the plugin is running.
Status Cards
Plugins can add status cards to the Dashboard. These show key metrics (connection status, message counts, device counts) as compact cards alongside the standard system status.
Device Panels
Plugins can add panels to the device detail page. These appear below the standard device sections and only show when relevant (e.g., a Dante plugin adds audio channel info to Dante-compatible devices).
Context Actions
Plugins can add action buttons to toolbars. These might appear globally (e.g., “Scan Dante Network”) or on specific device pages (e.g., “Identify on Dante”). Clicking a context action triggers the plugin to perform the action.
Panel Elements
Plugins can provide custom panel elements that appear on the touch panel alongside built-in elements like buttons, labels, and faders. Panel elements are rendered in sandboxed iframes, giving the plugin full control over its visual presentation.
Use cases include video previews (NDI/IP camera), custom routing matrices, live audio meters, floor plan overlays, or any domain-specific widget the built-in elements don’t cover.
Plugin panel elements appear in the Element Palette under a Plugins category. Drag them onto the canvas like any other element. In the Properties panel, you can configure the plugin-specific settings (e.g., source name, display mode) that the plugin author defined.
Panel Element Development
Plugin panel elements use an iframe-based renderer. The plugin provides an HTML page that runs inside a sandboxed iframe on the touch panel. The panel and the iframe communicate through a postMessage API. The panel sends state updates and configuration to the iframe, and the iframe can send commands and state changes back.
This architecture keeps plugins fully isolated from the panel DOM. A plugin cannot break the panel layout or interfere with other elements.
Defining Panel Elements
Panel elements are declared in the plugin’s EXTENSIONS dictionary under the panel_elements key:
EXTENSIONS = { "panel_elements": [ { "type": "status_display", "label": "Status Display", "renderer": "iframe", "default_size": {"w": 25, "h": 25}, "config_schema": [ { "key": "title", "label": "Title", "type": "string", "default": "System Status" }, { "key": "state_key", "label": "State Key", "type": "state_key", "default": "" }, { "key": "style", "label": "Display Style", "type": "select", "options": ["compact", "detailed", "minimal"], "default": "compact" } ] } ]}| Field | Required | Description |
|---|---|---|
type | Yes | Unique element type name within this plugin. This also names the renderer file — the panel loads panel/<type>.html (see note below). |
label | Yes | Human-readable name shown in the Element Palette |
renderer | Yes | Always "iframe" |
default_size | Yes | How big the element is when dropped, as a percentage of the page it lands on: {"w": N, "h": N}. The UI Builder uses this on drop and on click-to-add; if you omit it the element falls back to roughly a third of the page wide by a bit over a third tall. |
config_schema | No | Array of configuration fields shown in the UI Builder Properties panel. Field types match plugin config (see “Config field types” below): string, text, integer, float, boolean, select, state_key, device_ref, macro_ref. See “Select options” below for static vs. dynamic dropdowns. |
sandbox_permissions | No | Extra iframe.sandbox tokens beyond the default allow-scripts. See “Iframe Permissions” below for the whitelist. |
allow_features | No | Permissions-Policy tokens applied via the iframe’s allow attribute. See “Iframe Permissions” below for the whitelist. |
Renderer file: the panel loads panel/<type>.html for each element — an element with type: "status_display" loads panel/status_display.html from your plugin directory. Name the HTML file to match the element’s type. (There is no separate URL field; the file name is the contract.)
Config field types: the Properties panel renders each config_schema field by its type, the same way the plugin CONFIG_SCHEMA form does:
| Type | Control |
|---|---|
string | Single-line text input |
text | Multi-line textarea |
integer / float | Number input |
boolean | Checkbox |
select | Dropdown (see “Select options” below) |
state_key | State-key picker |
device_ref | Device picker |
macro_ref | Macro picker |
Select Options
A select field can populate its dropdown from a static list or from runtime state.
Static options — fixed list set at declaration time:
{"key": "fit", "label": "Fit", "type": "select", "options": ["contain", "cover"], "default": "contain"}Dynamic options from state — list driven by a plugin state key. State values must be flat primitives, so the plugin publishes the option list as a JSON-encoded string of [{"value": ..., "label": ...}, ...]:
{"key": "stream_id", "label": "Stream", "type": "select", "options_source": "plugin.my_plugin.stream_ids"}And in the plugin code, when the list changes:
self.api.set_state( "plugin.my_plugin.stream_ids", json.dumps([{"value": s.id, "label": s.name} for s in streams]),)The Properties panel re-renders the dropdown whenever the source state changes. If the currently-selected value isn’t in the published list (e.g. the plugin hasn’t started yet, or the user renamed the option), the stale value still shows as a selectable option so it doesn’t silently switch. This mirrors the convention used by plugin macro action params.
Iframe Permissions
By default, plugin iframes get only sandbox="allow-scripts" and no allow attribute. That’s enough for most widgets — they can run JavaScript and call postMessage back to the panel — but it blocks anything that needs a same-origin context, autoplay-eligible media, or other delegated features. Two optional fields opt into extra permissions; the server filters both against whitelists, so anything outside these tables is silently dropped.
sandbox_permissions — extra iframe sandbox tokens:
| Token | What it enables |
|---|---|
allow-same-origin | Iframe shares your origin; required for fetch against the OpenAVC server with session cookies, or for localStorage |
allow-forms | Form submission |
allow-modals | alert(), confirm(), prompt() |
allow-popups | window.open |
Tokens that would let an iframe escape the sandbox (allow-popups-to-escape-sandbox, allow-top-navigation, allow-pointer-lock) are deliberately excluded.
allow-same-origin is a trust grant, not a routine flag. Combined with the always-present allow-scripts, it gives your element’s UI the same origin as the panel page itself — the panel’s storage, its cookies, credentialed fetches against the server — which effectively removes the iframe sandbox boundary in the browser. That is an accepted posture: a plugin is already arbitrary Python running on the server, so the iframe sandbox is defense-in-depth, not the trust boundary. It does mean the grant is part of the trust a user extends when installing your plugin: the Programmer IDE shows each plugin’s sandbox permissions in its details alongside capabilities, and reviewers will expect a plugin declaring allow-same-origin to clearly need it (same-origin fetches or storage). If your element works without it, don’t declare it.
allow_features — Permissions-Policy tokens:
| Token | What it enables |
|---|---|
autoplay | <video autoplay muted> works without a user gesture |
encrypted-media | EME (DRM playback) |
fullscreen | element.requestFullscreen() |
picture-in-picture | PiP API |
camera, microphone, geolocation, and similar sensor-access tokens are deliberately excluded — no v1 use case needs them, and they have non-obvious privacy implications.
Example: a video-streaming plugin needing same-origin fetches (to a WHEP signaling endpoint hosted on the same OpenAVC server) plus autoplay would declare:
"sandbox_permissions": ["allow-same-origin"],"allow_features": ["autoplay"]Stay minimal — only request what you need. Unknown tokens log a warning in the system log so plugin authors can diagnose typos.
File Structure
Plugin panel files (HTML, CSS, JavaScript, images) are served from:
/api/plugins/{plugin_id}/panel/{file_path}Place your panel files in a panel/ directory inside your plugin folder:
plugin_repo/└── my_plugin/ ├── __init__.py # Plugin class └── panel/ ├── index.html # Main renderer page ├── style.css # Styles └── app.js # LogicIf your plugin’s Python code spans several files, import the extra modules with a relative import (from . import helpers or from .helpers import Thing). Each plugin loads in its own isolated namespace, so a plain import helpers won’t find a sibling file.
postMessage API
The panel and the plugin iframe communicate through window.postMessage. All messages are JSON objects with a type field.
Panel to iframe (incoming messages):
| Message Type | When Sent | Payload |
|---|---|---|
openavc:init | When the iframe loads, and again whenever the iframe asks via openavc:request-init | {config, theme, state, elementId, grant, ext_token}: the element’s plugin_config values, the active theme’s CSS variables, the state this element may see, this element’s ID, what it was granted, and for plugins that declare ext_auth a token for calling the plugin’s own /ext/* routes |
openavc:state | When a key this element may see changes | {key, value}: the changed key and its new value |
The state an element may see is its own plugin’s namespace (plugin.<plugin_id>.*), always, plus whatever the person who placed the element granted it. The init snapshot and the openavc:state updates use the same rule, so a key missing from the snapshot never arrives later.
iframe to panel (outgoing messages):
Outgoing messages use type: "openavc:action" for both device commands and state writes, with an action field selecting the operation. Page navigation uses its own message type.
| Message Type | action | Purpose | Payload |
|---|---|---|---|
openavc:action | device.command | Send a device command | {device, command, params} |
openavc:action | state.set | Write a state key | {key, value} |
openavc:action | macro.run | Run a macro | {macro} |
openavc:navigate | — | Navigate to a page | {page} |
openavc:request-init | — | Ask the panel to re-send openavc:init with a freshly-minted ext_token — send it when an /ext/* call starts returning 401 mid-session (the token expired; panels often outlive the token lifetime) | — |
What these can reach is set on the element, not by your plugin. Whoever places it on a page ticks the devices and variables it may touch and the switches for running a macro and changing pages, in Can reach in the UI Builder properties panel. Your plugin’s capabilities gate the Python API on the server; they do not gate this bridge.
device.commandreaches a device on that list, and nothing else.state.setwrites your ownplugin.<plugin_id>.*keys always, and avar.<name>key only when that variable is on the list.device.*,system.*,isc.*,ui.*and other plugins’ keys are always dropped.macro.runandopenavc:navigateneed their switches.
An element can be placed with nothing ticked, so read grant from openavc:init and hide what you cannot use. A dropped action writes a [panel] warning to the browser console.
Example: Custom Status Display
A minimal panel element that shows a state value with a colored background:
panel/index.html:
<!DOCTYPE html><html><head> <style> body { margin: 0; display: flex; align-items: center; justify-content: center; font-family: Inter, sans-serif; color: #fff; height: 100vh; } .status { text-align: center; } .title { font-size: 12px; opacity: 0.7; } .value { font-size: 24px; font-weight: bold; } </style></head><body> <div class="status"> <div class="title" id="title">Status</div> <div class="value" id="value">--</div> </div> <script> let config = {};
window.addEventListener('message', (event) => { const msg = event.data; switch (msg.type) { case 'openavc:init': config = msg.config || {}; document.getElementById('title').textContent = config.title || 'Status'; document.body.style.backgroundColor = msg.theme?.['--panel-bg'] || '#1a1a2e'; break;
case 'openavc:state': if (msg.key === config.state_key) { document.getElementById('value').textContent = msg.value; } break; } }); </script></body></html>Security
Plugin iframes are sandboxed with allow-scripts. This gives the iframe an opaque origin, which means:
- The iframe can run JavaScript
- The iframe cannot access the parent panel DOM
- The iframe cannot read or write cookies, localStorage, or any same-origin resources
- The iframe cannot navigate the parent page
- The iframe cannot open popups
- Network requests are subject to CORS like any cross-origin request
All interaction with the panel goes through the postMessage API. This prevents a plugin from accidentally or intentionally breaking the panel interface.
Contributing Plugins
For the full guide on plugin structure, manifest format, testing, and submitting to the community repository, see the Contributing Plugins guide.
See Also
- Plugins. Installing and configuring plugins as an end user
- Creating Drivers. Building device drivers
- Scripting API Reference. The
openavcmodule reference