Fantago Developer Guide — Custom Handlers and REST API

Extend Fantago with custom device plugins or integrate it with any external system via REST API.

Overview

Fantago is built to grow — anyone can add support for new devices or integrate it with external systems.

1The open plugin architecture

Beyond the 50+ built-in device handlers, Fantago exposes a full plugin API that lets developers integrate any hardware or software — no matter how niche or proprietary. Plugins are standard files placed in the userData/handlers/ directory: Fantago detects and loads them automatically at startup, making them available everywhere in the Dashboard, Panel Builder, and Stream Deck Builder just like native handlers.

There are two development paths, each suited to different complexity levels:

Dynamic Handlers (JSON) No coding required. Define commands, parameters, and response parsing rules directly inside Fantago's Handler Builder. Ideal for devices that speak simple TCP/UDP/HTTP command strings.
JavaScript Plugins (.js) Full Node.js module with access to all Fantago internals. For complex protocols, bidirectional state sync, async feedback parsing, and multi-instance logic. Placed in userData/handlers/.
💡Both types appear alongside built-in handlers in every part of Fantago — there is no distinction from the user's perspective.
2Share your plugin with the community

If you build a handler for a device not yet covered by Fantago, consider sharing it. Community-contributed handlers expand what every user can do with the platform — and your work could save dozens of hours for someone in the next studio.

To submit a handler, send us the .js file (or export of your JSON dynamic handler) via the contact page. We will review it for quality and security, and if it meets the standards it will be published on the Fantago website and made available for download to all users in a future release.

Terms for submitted plugins

  • You keep your copyright. Ownership of your code stays with you.
  • Open source (MIT). Accepted plugins are published under the MIT License so that any user can download, use, and build on them freely.
  • Full attribution. Your name is embedded in the plugin's author metadata field — visible in the Fantago UI to every user who selects your handler — and credited in the public listing on this website, for the entire duration of publication.
  • Review and discretion. Submission does not guarantee publication. We reserve the right to decline or later remove any plugin at our discretion.
  • No compensation. Contributions are voluntary. No payment or royalty is due for accepted submissions.
  • Author warranty. By submitting, you confirm that you are the original author, that the code does not infringe third-party rights, and that it contains no malicious code.

The complete terms are in EULA §5 — Community Plugin Contributions. By submitting a plugin you accept those terms.

Attribution guaranteed. The author field in your plugin's metadata is shown in the Fantago UI whenever a user selects your handler. Your contribution is always credited by name.
3Integration via External API

Beyond device plugins, Fantago exposes a REST API and extended TCP/UDP listener commands that allow any external system to interact with the server — read and write variables, trigger sequences and flows, or send commands to connected devices.

This is the integration layer for tools like Home Assistant, Node-RED, custom Python scripts, touch-screen controllers, or any hardware that can make an HTTP request or open a TCP connection.

REST API
TCP
UDP

Dynamic Handlers (JSON)

Configuration-driven handlers — no code required.

1What is a Dynamic Handler?

A Dynamic Handler is a structured JSON definition that describes how Fantago should communicate with a device: what commands to send, how parameters are composed into strings, and how to parse responses back into variables. The Dynamic Handler Engine interprets this definition at runtime — no JavaScript compilation, no server restart after edits.

Dynamic handlers are created and edited directly inside Fantago's Handler Builder (Dashboard → Handlers section). The Builder provides a visual interface to define actions, data sources, and response parsers without writing a single line of code.

💡Dynamic handlers are ideal for devices with a documented, text-based command protocol (PJLink, Extron SIS, Kramer Protocol 3000, any REST API, etc.).
2Command composition and variable placeholders

Each action in a dynamic handler maps to a command string that is sent to the device. The string can contain placeholder tokens in double-curly-brace syntax that are replaced with the values the user configures in the Panel Builder or Stream Deck Builder:

ROUTE {{input}} {{output}} SET VOLUME {{level}} /api/v1/source?input={{source_id}}

Placeholders can reference action parameters (configured per-button), system variables (live values from the variable store), or device config fields defined in extraConfigFields.

3Response parsing and variable feedback

Dynamic handlers can parse device responses using regular expressions and write the extracted values into system variables. Those variables then update Panel buttons and Stream Deck keys in real time via the standard variable injection mechanism ({{variable_name}} in button labels).

For each action you can define a response parser: a regex with named capture groups that map directly to variable names. Example: a router that replies SRC 03 DST 07 can be parsed with SRC\s+(\d+)\s+DST\s+(\d+) to populate router_source and router_destination automatically.

4Data sources — dynamic dropdowns

Dynamic handlers support data sources — named lists (e.g. inputs, outputs, presets) that can be populated at runtime by querying the device and used to fill dropdown menus in the Panel Builder. When a user configures a button that uses a "Set Input" action, they see a dropdown with the actual input labels fetched from the device, not a generic text field.

Data sources can be of type fixed (defined statically in the handler definition) or dynamic (populated by a sync action that queries the device). The Sync button shown on the device card in the Dashboard triggers the refresh.

5When to choose JSON vs JavaScript
Use Dynamic (JSON) when…
  • The device uses a simple text-based protocol
  • Commands are predictable strings with parameters
  • Responses follow a consistent, parseable pattern
  • No complex state machine or multi-step handshake is needed
Use JavaScript when…
  • The protocol is binary or highly stateful
  • You need async feedback on a persistent TCP connection
  • Multiple instances need shared state
  • Complex logic (retries, timeouts, computed values) is required

JavaScript Plugins

Full-power Node.js modules for complex device integrations.

1Getting started — file placement and loading

A Fantago plugin is a single .js file placed in userData/handlers/. Fantago scans this directory at startup and loads every .js file it finds. User-created plugins in userData/handlers/ take precedence over built-in handlers with the same id, making it easy to override and improve existing handlers.

userData/ handlers/ my_device.js ← your plugin goes here my_other_device.js
⚠️Plugins are loaded once at startup. After modifying a plugin file, restart the Fantago server to apply changes: Settings → System → Restart Application Server.
2Complete plugin template

Below is the full annotated structure of a plugin. Only the fields marked REQUIRED are mandatory — all others unlock additional features. See the handlerInfo Reference panel for a complete description of every field.

const logger = require('../../lib/logger'); const dbManager = require('../../lib/db_manager'); const deviceComm = require('../../lib/device_comm'); const websocket = require('../../lib/websocket'); const SYSTEM_PANEL_ID = '_system_variables'; module.exports = { handlerInfo: { id: "my_handler", // REQUIRED — unique snake_case id displayName: "My Device", // REQUIRED — shown in Dashboard UI version: "1.0.0", // REQUIRED — semver string author: "Your Name", // REQUIRED — shown in plugin info beta: true, // optional — shows BETA badge description: "Setup notes.", // optional — HTML, shown in Handler Builder connectionConfig: { // optional — pre-fills device form protocol: 'TCP', // TCP | UDP | HTTP | HTTPS | OSC port: 4010, terminator: '\n', persistent: true // keep connection open for async feedback }, extraConfigFields: [ // optional — extra fields in device dialog { name: "username", label: "Username", type: "text", default: "admin" }, { name: "channels", label: "Channels", type: "number", default: 16 } ], syncConfig: { // optional — adds Sync button on device row buttonLabel: "Sync Status", actionId: "refresh_all" // must match a key returned by getActions() } }, // Optional — enables dynamic data sources (populates dropdowns) definition: { dataSources: [ { name: "inputs", type: "dynamic" }, { name: "outputs", type: "dynamic" } ] }, // Returns { commandId: "Label" } — defines the action list async getActions(device) { return { "power_on": "Power On", "power_off": "Power Off", "set_input": "Set Input" }; }, // Returns parameter definitions for a given action async getActionOptions(device, actionId) { if (actionId === 'set_input') { return { input: { label: 'Input', choices: [{ id:'1', name:'Input 1' }], isPrimary: true }, level: { label: 'Level', type: 'number', default: 100 } }; } return {}; }, // Main execution — called when a button is pressed async execute(device, action, panelId, source = 'handler_execute', event = 'keyDown') { if (event === 'keyUp') return; // skip key-release events const cmd = action.command_string; const params = action.action_params || {}; // Build and send command via deviceComm await deviceComm.sendCommand(device, `POWER ${params.state}\n`, source); }, // Called for every incoming message on persistent TCP connections async processAsynchronousResponse(device, responseData) { const raw = String(responseData.args?.[0] || '').trim(); // Parse raw and write variables via dbManager.setPanelVariable() }, // Suggest feedback variable for a given action (optional) getFeedbackSuggestion(device, actionId, params) { return null; } };
3Accessing action parameters in execute()

When a user presses a button, Fantago calls execute(device, action, ...). The action object contains everything configured on the button:

async execute(device, action, panelId, source, event) { if (event === 'keyUp') return; const params = action.action_params || {}; // Access individual parameters by name const inputId = params.input; // e.g. "3" const level = parseInt(params.level || 100); // Access device config fields set during device creation const username = device.handler_params?.username || 'admin'; const channels = parseInt(device.handler_params?.channels || 16); // Build and send command const cmd = `SET INPUT ${inputId} LEVEL ${level}\r\n`; await deviceComm.sendCommand(device, cmd, source); }
4Real-time feedback — processAsynchronousResponse

When a device sends unsolicited data on a persistent TCP connection (state changes, tally updates, status pushes), Fantago calls processAsynchronousResponse automatically for every incoming message.

async processAsynchronousResponse(device, responseData) { const raw = String(responseData.args?.[0] || '').trim(); if (!raw) return; const devicePrefix = device.name.toLowerCase().replace(/[^a-z0-9_-]/g, '') + '_'; // Example: parse "INPUT 3 ACTIVE" and write a system variable const match = raw.match(/INPUT (\d+) (ACTIVE|INACTIVE)/); if (match) { await dbManager.setPanelVariable(SYSTEM_PANEL_ID, `${devicePrefix}input_${match[1]}_status`, match[2]); } }
⚠️Persistent connection required. This method is only called when Persistent Connection is enabled on the device (or via connectionConfig.persistent: true in handlerInfo).

getFeedbackSuggestion

When a user creates a button in the Panel or Stream Deck Builder, Fantago calls this method to suggest an automatic feedback rule — pre-filling the feedback configuration so the button highlights when the device state matches.

getFeedbackSuggestion(device, actionId, params) { const prefix = device.name.toLowerCase().replace(/[^a-z0-9_-]/g, '') + '_'; if (actionId === 'switch_input') { return { variable: `${prefix}input_${params.input}_status`, value: 'ACTIVE', style: 'feedback-highlight', // CSS class applied to button condition: 'VAR_EQUALS_VALUE' }; } return null; }

Available feedback styles

Style classVisual effect
feedback-highlightBlue border
feedback-successGreen border
feedback-warningYellow border
feedback-dangerRed border
feedback-overlay-blueBlue color overlay
feedback-overlay-greenGreen color overlay
feedback-overlay-yellowYellow color overlay
feedback-overlay-redRed color overlay
feedback-pulse-bluePulsing blue border
feedback-pulse-greenPulsing green border
5State management — in-memory and persistent

For data used frequently at runtime (label caches, connection flags, counters), use a module-level Map keyed on device.name. This avoids hitting the database on every call and is the recommended pattern for all non-trivial handlers.

// Module-level — shared across all calls, reset on server restart const handlerState = new Map(); function _getState(device) { if (!handlerState.has(device.name)) { handlerState.set(device.name, { labels: {}, isConnected: false, syncInProgress: false }); } return handlerState.get(device.name); } async execute(device, action, panelId, source, event) { if (event === 'keyUp') return; const state = _getState(device); if (!state.isConnected) { logger.log('WARN', 'MyPlugin', 'Device not ready.'); return; } // ... rest of execution }

Persistent state across server restarts

For data that must survive restarts (downloaded labels, router crosspoint maps), combine the in-memory Map with dbManager.setHandlerData:

async function _loadPersistentState(device) { const state = _getState(device); if (state.loaded) return; const saved = await dbManager.getHandlerData(device.name, 'labels'); if (saved) state.labels = saved; state.loaded = true; } // Call at the start of execute() and processAsynchronousResponse() await _loadPersistentState(device);

Debouncing database writes

When receiving many updates in quick succession (e.g. bulk label sync), debounce writes to avoid database overhead:

const saveTimers = new Map(); function _debounceSave(device, state) { if (saveTimers.has(device.name)) clearTimeout(saveTimers.get(device.name)); saveTimers.set(device.name, setTimeout(async () => { await dbManager.setHandlerData(device.name, 'labels', state.labels); saveTimers.delete(device.name); }, 500)); }

handlerInfo Reference

All fields of the handlerInfo metadata object.

1Core identity fields
FieldTypeStatusDescription
idstringREQUIREDUnique identifier in snake_case. Used as an internal key. Never change once published — it breaks existing device assignments.
displayNamestringREQUIREDHuman-readable name shown in the Dashboard and Handler Builder.
versionstringREQUIREDSemantic version string (e.g. "1.2.0"). Displayed in Plugin Information.
authorstringREQUIREDAuthor name shown in Plugin Information and handler list.
betabooleanoptionalIf true, a yellow BETA badge appears next to the handler name. Omit for stable releases.
descriptionstring (HTML)optionalSetup instructions rendered as HTML in the Plugin Information panel. Supports <b>, <code>, lists.
2Connection and configuration fields
FieldTypeStatusDescription
connectionConfigobjectoptionalPre-fills the device creation form. Sub-fields: protocol (TCP/UDP/HTTP/HTTPS/OSC), port, terminator, persistent (boolean — required for async feedback on persistent TCP).
extraConfigFieldsarrayoptionalAdditional fields shown during device creation. Each item: { name, label, type, default }. Types: "text", "number", "password". Values accessed at runtime via device.handler_params.fieldName.
syncConfigobjectoptionalAdds a Sync button to the device row in the Dashboard. Sub-fields: buttonLabel (string), actionId (must match a key returned by getActions()).
integrationGuidestring (HTML)optionalHTML content rendered in the "Command Integration Guide" tab in the Handler Builder. Document how to trigger commands from external systems (HTTP, TCP, UDP).
3getActionOptions() — parameter field types

Each key returned by getActionOptions() defines a UI control shown in the Panel Builder and Stream Deck Builder when a user configures a button.

DefinitionUI ControlNotes
{ label, type: 'text', default: '' }Text inputFree text entry.
{ label, type: 'number', default: 0 }Number inputNumeric entry.
{ label, choices: [{ id, name }] }Dropdownid is the value passed to execute(); name is shown to the user.
{ label, choices: [...], isPrimary: true }Dropdown (primary)Marks this as the main parameter — used for auto-fill of button text suggestions.

Essential Modules

Internal libraries available to every JavaScript plugin via require().

1deviceComm — sending commands

The primary tool for sending commands to devices. Handles protocol differences automatically — the same call works for TCP, UDP, HTTP, and OSC.

const deviceComm = require('../../lib/device_comm'); // TCP / UDP await deviceComm.sendCommand(device, 'SET FADER 1 ON\n', source); // HTTP GET await deviceComm.sendCommand(device, '/api/v1/status', source); // HTTP POST await deviceComm.sendCommand(device, '/api/v1/route', source, { httpMethod: 'POST', body: JSON.stringify({ src: 1, dst: 2 }), headers: { 'Content-Type': 'application/json' } }); // HTTP with Basic Auth await deviceComm.sendCommand(device, '/api/status', source, { httpMethod: 'GET', username: device.handler_params?.username, password: device.handler_params?.password });
💡sendCommand returns the response body for HTTP requests. For persistent TCP connections, incoming messages arrive asynchronously via processAsynchronousResponse().
2dbManager — variables and persistent data

Use to store device state as system variables (visible to all panels and buttons in real time) and to persist data across server restarts.

const dbManager = require('../../lib/db_manager'); const SYSTEM_PANEL_ID = '_system_variables'; // Write a system variable — updates all panels and Stream Deck buttons live await dbManager.setPanelVariable(SYSTEM_PANEL_ID, 'mydevice_input_status', 'ON'); // Read a system variable const val = await dbManager.getPanelVariable(SYSTEM_PANEL_ID, 'mydevice_input_status'); // Persist device-specific data (survives server restart) await dbManager.setHandlerData(device.name, 'data_sources', { inputs: [...] }); const data = await dbManager.getHandlerData(device.name, 'data_sources');
💡Naming convention: always prefix variable names with a sanitized version of the device name to avoid collisions when multiple devices of the same type are connected. Pattern: const prefix = device.name.toLowerCase().replace(/[^a-z0-9_-]/g,'') + '_';
3logger — structured logging

All log levels are routed to the Fantago Dashboard log viewer and to the application log file. Use structured logging for easier debugging.

const logger = require('../../lib/logger'); logger.log('INFO', 'MyPlugin', 'Device connected.'); logger.log('WARN', 'MyPlugin', 'Unexpected response received.'); logger.log('ERROR', 'MyPlugin', `Failed to parse: ${e.message}`); logger.log('DEBUG', 'MyPlugin', 'Raw data: ' + raw); logger.log('SENT', 'MyPlugin', 'Sent: SET INPUT 1'); logger.log('RECEIVED', 'MyPlugin', 'Got: OK');
4websocket — real-time UI updates

Push live progress updates or notifications to connected browsers during long sync operations. You can target a specific browser session or broadcast to all connected clients.

const websocket = require('../../lib/websocket'); // Progress update to the browser that triggered the action websocket.sendToSocket(source, 'handlerProgressUpdate', { deviceName: device.name, progress: 75 // 0–100 }); // Broadcast an event to all connected browsers websocket.broadcast('config_update', { type: 'device_updated', name: device.name });

External REST API

HTTP endpoints for integrating Fantago with any external system.

1Base URL and authentication
http://<fantago-host>:3001/api/ext

If Require API Key is enabled in Settings, include the key in every request via header or query parameter:

X-API-Key: your_api_key_here # or ?api_key=your_api_key_here

API Keys are created and managed in Variables & Logic → API Keys. Each key is shown only once at creation — copy it immediately.

2System & Variables endpoints
GET/api/ext/status

Returns server status and current timestamp.

{ "status": "ok", "server": "Fantago", "timestamp": "2026-03-23T10:00:00.000Z" }
GET/api/ext/variables

Returns all system variables as a key-value object.

{ "camera_1_pgm": "true", "slide_index": "3", "timer_running": "false" }
GET/api/ext/variables/:name

Returns the value of a single named variable.

{ "name": "slide_index", "value": "3" }
POST/api/ext/variables/set

Sets a variable value. Triggers any Logic Flows watching that variable.

Body: { "name": "slide_index", "value": "5" } Response: { "success": true, "name": "slide_index", "value": "5" }
POST/api/ext/variables/toggle

Toggles a boolean variable between true and false. Triggers flows.

Body: { "name": "camera_1_pgm" } Response: { "success": true, "name": "camera_1_pgm", "value": "true" }
DELETE/api/ext/variables/:name

Deletes a system variable permanently.

3Devices endpoint
GET/api/ext/devices

Returns the list of all configured devices.

GET/api/ext/devices/:name

Returns info for a single device.

POST/api/ext/devices/:name/command

Sends a command to a device. Execution is asynchronous (202 Accepted). For handler-based devices, command is the action ID defined in the handler.

Body: { "command": "set_input", "params": { "input": "3" } } Response: { "success": true, "message": "Command sent." }
4Sequences & Logic Flows endpoints
GET/api/ext/sequences

Returns all sequences with their current status.

POST/api/ext/sequences/:id/start

Starts a sequence. Asynchronous (202 Accepted).

POST/api/ext/sequences/:id/stop

Requests a stop of a running sequence.

GET/api/ext/flows

Returns all logic flows with enabled/disabled status.

POST/api/ext/flows/:id/trigger

Manually triggers a flow, bypassing trigger conditions. The flow must be enabled.

Response: { "success": true, "flowId": 3, "name": "Lights On", "chainsStarted": 1 }
POST/api/ext/flows/:id/enable

Enables a logic flow.

POST/api/ext/flows/:id/disable

Disables a logic flow.

TCP/UDP Commands

Plain-text commands over the existing listener ports — no HTTP stack required.

1How it works

In addition to the standard DEVICE:COMMAND format, the TCP and UDP listeners support an extended set of plain-text commands for variables, sequences, and flows. This makes integration trivial for any hardware or software that can open a socket — no HTTP library needed.

Default listener ports: 3002 (TCP) and 3003 (UDP). Both are configurable in Settings → External Access & Security.

ℹ️GET is only supported over TCP — it requires an active connection to receive the reply. UDP is fire-and-forget and cannot return a value.
2Variable, Sequence & Flow commands

Variable commands

CommandDescriptionExample
SET varname valueSets a system variable. Triggers flows watching it.SET slide_index 5
TOGGLE varnameToggles a boolean variable (true ↔ false). Triggers flows.TOGGLE camera_1_pgm
GET varnameReturns the variable value (TCP only).GET slide_index5

Sequence commands

CommandDescriptionExample
SEQ name STARTStarts the named sequence.SEQ Morning Show START
SEQ name STOPStops the named sequence.SEQ Morning Show STOP

Flow commands

CommandDescriptionExample
FLOW name ENABLEEnables a logic flow.FLOW Lights On ENABLE
FLOW name DISABLEDisables a logic flow.FLOW Lights On DISABLE
FLOW name TRIGGERManually triggers a flow (bypasses conditions).FLOW Lights On TRIGGER
⚠️The sequence or flow name must match exactly (case-insensitive). Spaces in names are supported — the action keyword (START/STOP/ENABLE/DISABLE/TRIGGER) is always the last word.
3Quick-start example (netcat)
# Set a variable echo "SET slide_index 5" | nc fantago-host 3002 # Start a sequence echo "SEQ Morning Show START" | nc fantago-host 3002 # Manually trigger a flow echo "FLOW Lights On TRIGGER" | nc fantago-host 3002 # Read a variable (TCP only) echo "GET slide_index" | nc fantago-host 3002

The same commands work from Python, Node.js, shell scripts, touch panels, or any hardware automation system with network output capability.

Security

Configure access control for both the REST API and the TCP/UDP listeners.

1Available security settings

All security settings are in Settings → External Access & Security.

SettingDescription
TCP / UDP Allowed IPseveryone = no restriction (trusted LAN). Otherwise, a comma-separated list of authorized IPs: 192.168.1.10,192.168.1.20. Connections from unauthorized IPs are rejected before any command is processed.
Allow variable commands (TCP/UDP)If disabled, only the original DEVICE:COMMAND format is accepted. SET / TOGGLE / GET / SEQ / FLOW commands are ignored.
Require API Key (REST)If enabled, all /api/ext requests must include a valid X-API-Key header. Manage keys in Variables & Logic → API Keys.
2Recommended configurations by environment
EnvironmentSuggested Setup
Fully trusted LAN (isolated production network)IP Allowlist = everyone, API Key not required, variable commands enabled.
Mixed / semi-public LANIP Allowlist = specific IPs only, API Key required for REST.
Exposed to internet or VPN with external clientsIP Allowlist = authorized IPs only, API Key required, variable commands limited to known sources.
⚠️Fantago is designed for use on trusted production networks. If you expose the server to the internet, always enable API Key authentication and restrict TCP/UDP access to specific IP addresses.

Testing & Debugging

Development workflow, log reading, common errors, and a complete production example.

1Development workflow
  1. Place your .js file in userData/handlers/.
  2. Open the Handler Builder (Handlers in the top navigation).
  3. Click the Advanced (JS) tab, then click Reload Plugins to load your file without restarting the server.
  4. Your plugin should now appear in the Advanced Plugins list.
  5. Add a device in the Dashboard using your new handler.
  6. Use the Execute button on the device row to trigger a test command.
💡Use Reload Plugins during development to avoid full server restarts. Only a full restart is needed for changes to module-level code (e.g. Map initialization, top-level requires).
2Reading logs and monitoring variables

All logger.log() calls appear in the Dashboard log panel (bottom section). Filter by your plugin name using the search box. Log levels useful during development:

LevelUse for
DEBUGVerbose trace messages — disabled in production by default
INFONormal operational messages (connection events, sync completed)
SENT / RECEIVEDRaw command traffic to/from device — essential for protocol debugging
WARNUnexpected but recoverable conditions
ERRORExceptions and failures

To monitor variables written by your plugin, open Logic Builder → Variable Monitor. Filter by your device name prefix to watch variables update in real time as feedback arrives from the device.

3Common errors
SymptomLikely cause
Plugin not visible in device type listhandlerInfo.id or handlerInfo.displayName missing, or the file has a syntax error. Check the server log for load errors.
processAsynchronousResponse never calledPersistent Connection not enabled on the device, or connectionConfig.persistent not set to true.
Variables written but buttons not updatingVariable name mismatch between what you write in setPanelVariable and what the button feedback rule monitors. Check exact spelling in Variable Monitor.
Cannot find module '../../lib/...'Wrong relative path. From userData/handlers/, all Fantago lib modules are at ../../lib/module_name.
Button works once then stopsUnhandled exception inside execute(). Wrap your code in try/catch and log the error to identify the failure.
Data sources not populating dropdownsdataSources not declared in definition, or sync action not writing to dbManager.setHandlerData(device.name, 'data_sources', ...).
4Case study: Evertz Quartz Router

A complete walkthrough of the Evertz Quartz handler — a production-grade plugin managing a professional video matrix router via TCP, with dynamic label sync, crosspoint status tracking, real-time updates, and progress feedback. Every advanced pattern in Fantago plugin development is demonstrated here in a single file.

1 — Imports and initial setup

handlerState is a module-level Map that caches labels and internal flags per device, avoiding constant database hits during high-frequency feedback processing.

const logger = require('../../lib/logger'); const dbManager = require('../../lib/db_manager'); const deviceComm = require('../../lib/device_comm'); const websocket = require('../../lib/websocket'); const SYSTEM_PANEL_ID = '_system_variables'; const handlerState = new Map(); // Per-device in-memory cache const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); module.exports = { handlerInfo: { id: "evertz_quartz", displayName: "Evertz Quartz Router", version: "3.2.0", author: "Fantago Bridge", description: "Advanced handler for Evertz Quartz series routers via TCP.", connectionConfig: { protocol: 'TCP', port: 4010, persistent: true }, // Extra fields shown during device creation extraConfigFields: [ { name: "max_sources", label: "Max Sources to Scan", type: "number", default: 32 }, { name: "max_destinations", label: "Max Destinations to Scan", type: "number", default: 32 } ], // Adds a Sync button to the device row in the Dashboard syncConfig: { buttonLabel: "Sync Labels & Status", actionId: "refresh_all_data" } },

2 — Definition and UI

dataSources tells Fantago that this device provides "sources" and "destinations" lists, used to populate dropdowns in the Panel Builder and Stream Deck Builder. onLoadActions runs a route-status check every time a panel opens.

definition: { dataSources: [ { name: "sources", type: "dynamic" }, { name: "destinations", type: "dynamic" } ], ui: { actions: [], onLoadActions: [{ commandId: "check_all_routes", action_params: {} }] } }, async getActions(device) { return { "route": "Route Source to Destination", "select_destination": "Select Destination (X-Y)", "route_to_selected": "Route Source to Selected Destination", "lock_destination": "Lock/Unlock Destination", "refresh_all_data": "Sync Labels & Status", "check_all_routes": "Check All Routes Status" }; },

3 — Dynamic dropdowns with fallback

The "route" command builds dropdowns from the cached labels. If labels have not been synced yet, it falls back to numbered placeholders so the button remains configurable immediately after device creation.

async getActionOptions(device, actionId) { const state = await this._getInternalState(device); const getSafeOptions = (list, type, countParam) => { const count = parseInt(device.handler_params?.[countParam] || 32); if (list && list.length > 0) return [...list].sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { numeric: true })); return Array.from({ length: count }, (_, i) => ({ id: String(i+1), name: `${type} ${i+1}` })); }; const levels = [ { id: 'V', name: 'Video Only' }, { id: 'A', name: 'Audio Only' }, { id: 'AV', name: 'Audio & Video' } ]; switch (actionId) { case "route": return { dest: { label: 'Destination', choices: getSafeOptions(state.dataSources.destinations, 'Destination', 'max_destinations') }, srce: { label: 'Source', choices: getSafeOptions(state.dataSources.sources, 'Source', 'max_sources'), isPrimary: true }, level: { label: 'Level', choices: levels, default: 'AV' } }; // ... other cases } return {}; },

4 — Execution logic

Each action is translated into the Quartz protocol. The X-Y routing pattern stores the selected destination in a system variable so route_to_selected can retrieve it later from any panel.

async execute(device, action, panelId, source = 'handler_execute', event = 'keyDown') { if (event === 'keyUp') return; const actionId = action.command_string; const params = action.action_params || {}; const devicePrefix = device.name.toLowerCase().replace(/[^a-z0-9-_]/g, '') + '_'; const targetPanel = panelId || SYSTEM_PANEL_ID; const sendCmd = async (cmd) => await deviceComm.sendCommand(device, cmd, source); switch (actionId) { // Evertz protocol: .S[Level][Dest],[Src]$ case "route": return sendCmd(`.S${params.level}${params.dest},${params.srce}$`); case "select_destination": // Store selected destination for later X-Y routing await dbManager.setPanelVariable(targetPanel, `${devicePrefix}selected_destination_id`, params.dest); return sendCmd(`.IV${params.dest}`); case "route_to_selected": const selDest = await dbManager.getPanelVariable(targetPanel, `${devicePrefix}selected_destination_id`); if (!selDest) throw new Error("No destination selected."); return sendCmd(`.S${params.level || 'AV'}${selDest},${params.srce}$`); case "refresh_all_data": return this._refreshAll(device, await this._getInternalState(device), source); } },

5 — Asynchronous response parsing

Incoming data from the matrix is parsed line by line using RegExp. Route changes and label updates are written to system variables, which immediately trigger button feedback updates on all connected panels.

async processAsynchronousResponse(device, responseData) { const incomingRaw = String(responseData.args?.[0] || '').trim(); if (!incomingRaw) return; const devicePrefix = device.name.toLowerCase().replace(/[^a-z0-9-_]/g, '') + '_'; const state = await this._getInternalState(device); const lines = incomingRaw.split(/[\r\n]+/).map(l => l.trim()); let hasNewLabels = false; for (const line of lines) { // Route change: .A[Level][Dest],[Src] → dest 01 now shows src 05 const routeMatch = line.match(/\.([UA])(\w+?)?(\d+),(\d+)/); if (routeMatch) { await dbManager.setPanelVariable(SYSTEM_PANEL_ID, `${devicePrefix}current_source_for_dest_${routeMatch[3]}`, routeMatch[4]); continue; } // Label: .RAS01,CAMERA 1 or .RAD02,MONITOR 2 const labelMatch = line.match(/\.(RAS|RAD)(\d+),(.*)/); if (labelMatch) { const type = labelMatch[1] === 'RAS' ? 'sources' : 'destinations'; const id = labelMatch[2]; const label = labelMatch[3].trim(); await this._updateLabel(state, type, id, label); await dbManager.setPanelVariable(SYSTEM_PANEL_ID, `${devicePrefix}${type === 'sources' ? 'source' : 'dest'}_name_${id}`, label); hasNewLabels = true; } } if (hasNewLabels) this._debounceSave(device, state); },

6 — Feedback suggestion

When a "Route" button is configured in the builder, Fantago calls this to pre-fill the feedback rule. The suggestion monitors the destination's current source variable and highlights the button when the routed source matches the one configured on the button.

getFeedbackSuggestion(device, actionId, params) { const devicePrefix = device.name.toLowerCase().replace(/[^a-z0-9-_]/g, '') + '_'; if (actionId === "route") { return { variable: `${devicePrefix}current_source_for_dest_${params.dest}`, value: String(params.srce), style: 'feedback-highlight', condition: 'VAR_EQUALS_VALUE' }; } return null; },

7 — Synchronization with progress updates

_refreshAll iterates through all sources and destinations, requesting their labels one by one. A 40 ms delay between requests prevents flooding the matrix TCP buffer. Progress is sent to the browser every 5 steps so the Dashboard can show a live progress bar.

async _refreshAll(device, state, socketId) { state.isRefreshing = true; const maxS = parseInt(device.handler_params?.max_sources || 32); websocket.sendToSocket(socketId, 'handlerProgressUpdate', { deviceName: device.name, progress: 0 }); for (let i = 1; i <= maxS; i++) { await deviceComm.sendCommand(device, `.RS${i}`, 'handler_sync'); await delay(40); if (i % 5 === 0) { websocket.sendToSocket(socketId, 'handlerProgressUpdate', { deviceName: device.name, progress: Math.round((i / maxS) * 100) }); } } state.isRefreshing = false; return "Sync Complete"; } };
This pattern — persistent TCP + async line parsing + feedback suggestion + progress updates — is the foundation of every advanced handler in Fantago, from ATEM to Evertz to NewTek TriCaster. Use this as your starting template for any stateful device integration.