A lightweight SDK and collection of HTML plugin templates for building signage
display plugins. Plugins run as iframes inside a host signage application and
communicate via window.postMessage using the signage-plugin/v1 protocol.
There is no build step, no bundler, no package manager, and no framework. Everything is vanilla JavaScript (ES5) and self-contained HTML files.
- Copy an existing plugin template (e.g.
youtube.html) and rename it. - Include the SDK:
<script src="plugin.js"></script> - Call
SignagePlugin.create(...)with your plugin metadata, config schema, and handlers. - Validate your plugin using the built-in validator tool (see Validating Plugins).
plugin.js Core SDK - exposes the global SignagePlugin object
youtube.html YouTube player plugin template
instagram.html Instagram embed plugin template
validator.html Development tool - protocol compliance validator
.prettierrc Prettier config (single quotes, 4-space indent)
Plugins follow a strict message-based lifecycle between the host application and the plugin iframe:
Host Plugin (iframe)
| |
| (host loads plugin in iframe) |
| | SignagePlugin.create() called
|<----------- loaded --------------| plugin sends metadata + capabilities
| |
|------------ config ------------>| host sends configuration
| | onConfig callback fires
| | (plugin prepares content)
|<----------- ready ---------------| plugin.ready() called
| |
|------------ play -------------->| host triggers playback
| | onPlay callback fires
| | (plugin plays content)
| |
|<-------- interaction ------------| plugin.interaction(...) (optional)
| |
|<----------- finished ------------| plugin.finished() (if can_finish=true)
| |
|<----------- error --------------| plugin.error({...}) at any point
- Loaded -- The plugin initializes and immediately announces itself to the host with its name, version, capabilities, and config schema.
- Config -- The host sends configuration data (instance ID, config object, optional content and timing info). The plugin processes this and prepares.
- Ready -- The plugin signals it is ready for playback.
- Play -- The host tells the plugin to start. The plugin begins playback.
- Interaction -- Optional. During playback, the plugin can notify the host
about a user interaction and optionally include a suggested
new_duration. - Finished -- The plugin signals playback is complete (only for plugins that
declare
can_finish: true). - Error -- The plugin can report errors at any point in the lifecycle.
Creates and returns a plugin instance. This is the only public method on the
SignagePlugin global.
var plugin = SignagePlugin.create({
plugin: {
name: 'my-plugin',
version: '1.0.0'
},
capabilities: {
requires_play_signal: true,
can_finish: true,
static_media: false
},
config_schema: { /* ... */ },
allowed_origin: null,
onConfig: function (data) { /* ... */ },
onPlay: function () { /* ... */ }
});| Property | Type | Required | Default | Description |
|---|---|---|---|---|
plugin.name |
string | Yes | -- | Plugin name (e.g. 'youtube-player') |
plugin.version |
string | Yes | -- | Semantic version (e.g. '1.0.0') |
capabilities.requires_play_signal |
boolean | No | true |
Plugin waits for the host play message before starting |
capabilities.can_finish |
boolean | No | true |
Plugin will call finished() when done |
capabilities.static_media |
boolean | No | false |
Content does not change over time |
config_schema |
object | No | {} |
JSON-Schema-like descriptor for host UI generation |
allowed_origin |
string | No | null |
Restrict accepted messages to this origin |
onConfig |
function | No | null |
Called when the host sends configuration |
onPlay |
function | No | null |
Called when the host triggers playback |
Throws an Error if plugin.name or plugin.version is missing.
On creation, the SDK immediately sends a loaded message to the host and begins
listening for incoming config and play messages.
Called when the host sends a config message. Receives a single argument:
onConfig: function (data) {
data.instance_id // string - unique instance identifier
data.config // object - plugin-specific config matching your schema
data.content // object|null - optional content descriptor
// { kind, source, url, mime_type }
data.timing // object|null - optional timing info
// { scheduled_duration_ms }
}Use this callback to validate configuration, load external resources, and
prepare the plugin. Call plugin.ready() when preparation is complete.
Called when the host sends a play message. Takes no arguments. Start playback
here.
Signals to the host that the plugin is prepared and ready for playback. Call this after processing configuration (e.g. after loading an external API, rendering content, etc.).
Signals that playback is complete. Only relevant for plugins with
can_finish: true. Guarded against duplicate calls -- only the first call sends
a message.
Reports an error to the host. Can be called at any point in the lifecycle.
plugin.error({
code: 'LOAD_FAILED', // UPPER_SNAKE_CASE error code (default: 'UNKNOWN_ERROR')
message: 'Failed to load', // Human-readable message (default: 'An unknown error occurred')
fatal: true, // Unrecoverable error? (default: false)
details: { status: 404 } // Additional context (default: {})
});Reports an interaction event to the host. Pass a numeric duration to include a
new_duration value in the payload. If omitted, the SDK sends 0.
plugin.interaction();
plugin.interaction(30);Returns a snapshot of the plugin's internal state:
{
configured: boolean, // true after config received
playing: boolean, // true after play received
finished: boolean, // true after finished() called
instanceId: string, // instance_id from config
config: object, // config from host
content: object, // content from host
timing: object // timing from host
}Replaces a handler after initialization. event must be 'config' or
'play'.
plugin.on('config', function (data) { /* new handler */ });
plugin.on('play', function () { /* new handler */ });The config_schema describes what configuration your plugin accepts. The host
application uses this to auto-generate a configuration UI. It follows a
JSON-Schema-like structure:
config_schema: {
type: 'object',
properties: {
video_id: {
type: 'string',
title: 'Video ID',
description: 'The YouTube video ID to play'
},
mute: {
type: 'boolean',
title: 'Mute',
description: 'Whether to mute audio',
default: true
},
quality: {
type: 'string',
title: 'Quality',
description: 'Playback quality',
default: 'auto',
enum: ['auto', '720p', '1080p']
},
advanced: {
type: 'object',
title: 'Advanced Settings',
properties: {
timeout: {
type: 'number',
title: 'Timeout',
default: 30
}
}
}
}
}| Attribute | Type | Description |
|---|---|---|
type |
string | 'string', 'number', 'boolean', or 'object' |
title |
string | Short label for the host UI |
description |
string | Longer help text |
default |
any | Default value |
enum |
array | Constrained set of allowed values (for dropdowns) |
properties |
object | Nested properties (when type is 'object') |
All messages conform to this envelope:
{
api: 'signage-plugin/v1', // protocol identifier (always this value)
type: string, // message type
request_id: string, // optional correlation ID
payload: object // optional, type-dependent
}| Type | Payload | Description |
|---|---|---|
loaded |
{ plugin, capabilities, config_schema } |
Plugin initialized and ready for config |
ready |
none | Plugin prepared and ready for playback |
interaction |
{ new_duration } |
Optional interaction event during playback |
finished |
none | Playback complete |
error |
{ code, message, fatal, details } |
Error report |
| Type | Payload | Description |
|---|---|---|
config |
{ instance_id, config, content?, timing? } |
Configuration delivery |
play |
none | Start playback |
A video player plugin that uses the YouTube IFrame API.
Capabilities: Requires play signal, can finish (non-loop mode), dynamic content.
Configuration:
| Property | Type | Default | Description |
|---|---|---|---|
video_id |
string | -- | YouTube video ID (e.g. dQw4w9WgXcQ) |
api_key |
string | -- | YouTube Data API v3 key (optional) |
mute |
boolean | true |
Mute audio |
loop |
boolean | true |
Loop video playback |
start |
number | -- | Start time in seconds |
end |
number | -- | End time in seconds |
controls |
boolean | false |
Show player controls |
The video ID can also be provided via content.url using standard YouTube URL
formats (youtu.be/ID, youtube.com/watch?v=ID, youtube.com/embed/ID).
Error codes: MISSING_VIDEO_ID, YT_API_LOAD_FAILED, YT_PLAYER_ERROR
An embed plugin for Instagram posts and reels using Instagram's embed.js API.
Capabilities: Requires play signal, cannot finish (static content), static media.
Configuration:
| Property | Type | Default | Description |
|---|---|---|---|
url |
string | -- | Full Instagram post or reel URL |
shortcode |
string | -- | Instagram shortcode (alternative to full URL) |
content_type |
string | 'post' |
'post' or 'reel' (used with shortcode) |
show_caption |
boolean | false |
Display the post caption |
max_width |
number | 540 |
Embed max width in pixels (326-540) |
The URL can also be provided via content.url.
Error codes: MISSING_URL, INVALID_URL, MISSING_CONTAINER,
EMBED_SCRIPT_LOAD_FAILED, EMBED_RENDER_TIMEOUT, EMBED_API_UNAVAILABLE
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Plugin</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
#error-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
color: #ff4444;
font-family: monospace;
font-size: 16px;
justify-content: center;
align-items: center;
text-align: center;
padding: 20px;
z-index: 9999;
}
</style>
</head>
<body>
<!-- Your plugin content here -->
<div id="content"></div>
<div id="error-overlay"></div>
<script src="plugin.js"></script>
<script>
(function () {
'use strict';
// ---------------------------------------------------------------
// Config schema -- describes what the host UI should show
// ---------------------------------------------------------------
var CONFIG_SCHEMA = {
type: 'object',
properties: {
message: {
type: 'string',
title: 'Message',
description: 'Text to display',
default: 'Hello, World!'
},
duration_ms: {
type: 'number',
title: 'Duration (ms)',
description: 'How long to display before finishing',
default: 5000
}
}
};
// ---------------------------------------------------------------
// Error overlay
// ---------------------------------------------------------------
function showError(msg) {
var overlay = document.getElementById('error-overlay');
if (overlay) {
overlay.textContent = msg;
overlay.style.display = 'flex';
}
}
// ---------------------------------------------------------------
// Plugin state
// ---------------------------------------------------------------
var config = null;
var pendingPlay = false;
// ---------------------------------------------------------------
// Create plugin instance
// ---------------------------------------------------------------
var plugin = SignagePlugin.create({
plugin: { name: 'my-plugin', version: '1.0.0' },
capabilities: {
requires_play_signal: true,
can_finish: true,
static_media: false
},
config_schema: CONFIG_SCHEMA,
onConfig: function (data) {
config = data.config || {};
// Validate required fields
var message = config.message || 'Hello, World!';
document.getElementById('content').textContent = message;
plugin.ready();
if (pendingPlay) {
pendingPlay = false;
startPlayback();
}
},
onPlay: function () {
if (!config) {
pendingPlay = true;
return;
}
startPlayback();
}
});
function startPlayback() {
var duration = config.duration_ms !== undefined
? config.duration_ms
: 5000;
setTimeout(function () {
plugin.finished();
}, duration);
}
})();
</script>
</body>
</html>When building a new plugin, ensure you:
- Single HTML file -- The plugin must be entirely self-contained.
- Include the SDK -- Add
<script src="plugin.js"></script>before your inline script. - Full-viewport layout -- Use
width: 100%; height: 100%; overflow: hiddenwith abackground: #000. - IIFE with strict mode -- Wrap all code in
(function () { 'use strict'; ... })(); - Define a CONFIG_SCHEMA -- Describe your configuration for the host UI.
- Call
SignagePlugin.create()-- Providepluginmetadata,capabilities,config_schema,onConfig, andonPlayhandlers. - Call
plugin.ready()-- After processing config and preparing content. - Call
plugin.finished()-- When playback ends (ifcan_finish: true). - Call
plugin.error()-- On any failure, with a descriptive UPPER_SNAKE_CASEcode, a human-readablemessage, andfatal: true/false. - Add an error overlay -- Include a
#error-overlayelement and ashowError(msg)helper function for visual error display. - Handle race conditions -- Use a
pendingPlayflag if your plugin loads external APIs asynchronously (the host may sendplaybefore your API is ready). - Clean up on re-config -- If the host sends a new config, destroy existing resources before re-initializing.
- ES5 only -- Use
var, notlet/const. No arrow functions, template literals, destructuring, or classes. - IIFE module pattern -- No ES modules, CommonJS, or AMD.
- Naming: camelCase for JS variables/functions, UPPER_SNAKE_CASE for constants and error codes, snake_case for protocol/data properties, kebab-case for HTML IDs.
- Formatting: Single quotes, 4-space indentation, semicolons required
(configured via
.prettierrc).
The repository includes a built-in protocol compliance validator
(validator.html) that simulates a host application and checks your plugin
against the signage-plugin/v1 protocol.
-
Start a local server from the repository root:
python3 -m http.server 8080
-
Open
http://localhost:8080/validator.htmlin your browser. -
Enter your plugin URL in the input field (e.g.
my-plugin.html). -
Walk through the lifecycle:
- Load -- Click to load your plugin in a sandboxed iframe. The validator
waits up to 5 seconds for a
loadedmessage. - Send Config -- Opens a JSON editor pre-populated with defaults from
your
config_schema. Edit the values and click Send. The validator waits up to 30 seconds for areadymessage. - Send Play -- Sends a
playmessage to trigger playback. - Reset -- Removes the iframe and clears state for a fresh test.
- Load -- Click to load your plugin in a sandboxed iframe. The validator
waits up to 5 seconds for a
The validation log uses color-coded entries:
| Tag | Meaning |
|---|---|
[PASS] |
Check passed (green) |
[FAIL] |
Check failed (red) |
[WARN] |
Warning or unusual behavior (orange) |
[INFO] |
Informational (blue) |
[RECV] |
Message received from plugin (purple) |
[SEND] |
Message sent to plugin (cyan) |
Protocol envelope checks:
apifield equals'signage-plugin/v1'typeis a valid plugin message type (loaded,ready,finished,error)
The SDK also supports an interaction plugin message. The current validator does
not yet recognize it, so interactive plugins may log a validator failure if they
emit interaction during testing.
Loaded message checks:
- Payload is present
plugin.nameandplugin.versionare non-empty stringscapabilitiesobject is present with boolean values forrequires_play_signal,can_finish, andstatic_mediaconfig_schemais present
Lifecycle checks:
readyarrives afterconfigwas sent (warns if before)finishedis not sent by plugins that declaredcan_finish: falsefinishedis not sent more than onceplayis not sent beforereadywas received
Error message checks:
codeis a non-empty UPPER_SNAKE_CASE stringmessageis a non-empty stringfatalis a booleandetailsis an object (if present)
Timeout checks:
- Plugin must send
loadedwithin 5 seconds of iframe creation - Plugin must send
readywithin 30 seconds of receiving config
A summary bar at the bottom of the log shows the total count of passed, failed, and warning checks.
Prettier is configured via .prettierrc (single quotes, 4-space indent). Format
files with:
prettier --write . # format all files
prettier --check . # check formatting (CI-friendly)
prettier --write my-plugin.html # format a single fileSee the LICENSE file for details.