Skip to content

API Reference

Lua exports, REST API, Socket.IO events, and the dispatch panel theming SDK for Tommy's Radio.

Server exports

Call these from any server-side script as exports['tRadio']:name(...). The provide 'tRadio' alias means the folder name does not matter.

Channel information

ExportParametersReturns
getSpeakersInChannelfrequency: string|numbertable — server IDs connected as speakers
getListenersInChannelfrequencytable — server IDs scanning (listen only)
getAllUsersInChannelfrequencytable — speakers and listeners combined
getActiveTalkersInChannelfrequencytable — server IDs holding PTT right now
getActiveChannelsstring[] — frequencies with at least one active user
getChannelInfofrequency{ speakers, listeners, activeTalkers } or nil
getAllChannelstable[] — the full configured channel list with zones, names, and types
local speakers = exports['tRadio']:getSpeakersInChannel("154.755")
local info     = exports['tRadio']:getChannelInfo("154.755")
-- info = { speakers = {...}, listeners = {...}, activeTalkers = {...} }

Alerts and panic

ExportParametersReturns
getChannelAlertfrequencyThe active alert config, or nil
setAlertOnChannelfrequency, enabled: boolean|nil, alertIndexOrName: number|string|nil
setChannelSignalfrequency, enabled: booleantrue — fires the first configured alert. The export equivalent of the in-game SGN button
getChannelPanicfrequency{ [serverId] = serverId, ... }
setChannelPanicfrequency, serverId, enabled: booleanboolean
getUserPanicStateserverId, frequencyboolean

setAlertOnChannel resolves its third argument like this:

  • omitted or nil → the first alert in the list
  • a number → 1-based index into the alerts array
  • a string → case-insensitive name match
  • enabled = nil → toggle the current state
exports['tRadio']:setAlertOnChannel("154.755", true)                -- first alert, activate
exports['tRadio']:setAlertOnChannel("154.755", true, "SIGNAL 100")  -- by name
exports['tRadio']:setAlertOnChannel("154.755", nil, 2)              -- toggle the second alert
exports['tRadio']:setChannelPanic("154.755", 5, true)

User management

ExportParametersReturns
setUserChannelserverId: number, frequencyboolean — handles trunked routing automatically
disconnectUserserverId: numberboolean
isUserTalkingserverId: number, frequencyboolean

User information

ExportParametersReturns
getPlayerNameserverIdstring
getPlayerNacIdserverIdstring or nil
getUserInfoserverId{ name, nacId }
hasRadioAccessserverIdboolean
setUserNacIdserverId, nacId: stringboolean — display override until the next refreshNacId
refreshNacIdserverIdboolean — re-resolves access and pushes it to the client. Call after a job change
refreshPlayerInfonumber — count of players whose NAC ID and name were refreshed
getAcePermissionsserverId{ connect, scan, gps, zones, dispatch }
local perms = exports['tRadio']:getAcePermissions(5)
-- connect/scan/gps are frequency arrays, zones is a 1-based index array,
-- dispatch is a boolean (supervisor privileges)

Codeplugs and subscribers

ExportParametersReturns
getSubscriberInfoserverIdThe subscriber record, or nil
getSubscriberZonesserverIdtable[] of zone objects, or nil
refreshSubscriberserverIdboolean — rebuilds the whole subscriber record. More thorough than refreshNacId

getSubscriberInfo returns:

{
    codeplugId         = "leo",
    unitId             = "1041",
    nac                = "141",
    allowedModels      = { "ATX-8000", "AFX-1500" },
    defaultLayouts     = { Handheld = "ATX-8000", Vehicle = "AFX-1500" },
    features           = { gps = true, scan = true, emergencyButton = true, manDown = false },
    tones              = { ... },
    emergency          = { emergencyChannelId = nil, autoTransmitDuration = 5 },
    announcementVolume = 0.8,
}

Each subscriber gets a P25 unit ID derived from their codeplug ID and license hash, so the same player on the same codeplug keeps the same unit ID across sessions and restarts.

Callsigns

Requires Enable Callsigns in the admin panel.

ExportParametersReturns
setCallsignserverId, callsign: string|nil"" or nil clears it
getCallsignserverIdstring or nil

Audio

ExportParametersReturns
playToneOnChannelfrequency, tone: string
playToneOnSourceserverId, tone: string
exports['tRadio']:playToneOnChannel("154.755", "ALERT_A")
exports['tRadio']:playToneOnSource(5, "BEEP")

Tone keys come from the tone catalog.


Client exports

All volume exports use 0.0–1.0. The admin and dispatch panels use 0–100 — divide by 100 before passing a panel value to an export.

Radio control

ExportParametersReturns
openRadiofocus: boolean (default true)
closeRadio
connectToFrequencyfrequencyboolean
setCurrentChannelfrequency
getCurrentFrequencyfrequency, or -1 when not connected
getCurrentChannelchannel object
addListeningChannelfrequency
removeListeningChannelfrequency
getListeningChannelsstring[] — active scan frequencies
exports['tRadio']:openRadio()       -- open with NUI focus
exports['tRadio']:openRadio(false)  -- open without focus
exports['tRadio']:connectToFrequency("154.755")

radio:client:openRadio is also fired as a client event when a player uses the radio item from their inventory — handle it yourself only if Config.inventory.system = 'custom'.

Transmission

ExportParametersReturns
startTransmitting
stopTransmitting
setTalkingtalking: boolean— — same as start/stop
isTransmittingboolean
getActiveTalker{ serverId, name, frequency } or nil
activateProximityVoice— — triggers in-game proximity voice

Radio state

ExportReturns
isConnectedboolean
isPowerOnboolean
isRadioOpenboolean
isRadioFocusedboolean
setPower(state)— — omit the argument to toggle

Volume and audio

ExportParametersReturns
setVolume / getVolume0.0–1.0— / number
setToneVolume / getToneVolume0.0–1.0— / number
set3DVolume / get3DVolume0.0–1.0— / number
playTonetone: string— — plays locally only
NotifySirenChanged— — forces an immediate re-poll of bgModeCheck

Appearance and settings

ExportParametersReturns
setRadioLayout / getRadioLayoutmodel: string— / string
setRadioTheme / getRadioTheme"Auto"|"Light"|"Dark"— / string
setAnimationId / getAnimationIdid: string— / string
setEarbudsEnabled / getEarbudsEnabledboolean— / boolean
setGPSEnabled / getGPSEnabledboolean— / boolean

Alerts and panic

ExportParameters
triggerAlertOnChannelfrequency, enabled: boolean, alertConfig?
panicButtonfrequency, enabled: boolean

Player and system information

ExportReturns
getCurrentNamestring
getCurrentNacIdstring
refreshMyNacId— — re-fetches from the server
getMyCallsign / setMyCallsignstring|nil / — ("" clears)
getZonestring — current zone name
getBatteryLevelnumber (0–100)
getSignalStrengthnumber (0–5)
getConnectionDiagnosticsdiagnostic snapshot table

Web API

Authenticating

Protected endpoints need a per-session token obtained at runtime. This is not Config.authToken.

curl -X POST http://203.0.113.10:7777/radio/dispatch/auth \
  -H "Content-Type: application/json" \
  -d '{"nacId": "4417", "callsign": "Dispatcher-01"}'
{
  "success": true,
  "sessionId": "dispatch_abc123",
  "authToken": "550e8400-e29b-41d4-a716-446655440000"
}

Then send both headers on every protected request:

Authorization: Bearer 550e8400-e29b-41d4-a716-446655440000
x-session-id: dispatch_abc123
  • nacId must match dispatchNacId in config.lua
  • Sessions last 24 hours; at most 100 exist at once, oldest evicted first
  • Authentication is rate-limited to 15 attempts per 15 minutes per IP
  • With Discord auth enabled the same endpoint returns 202 and a redirectUri instead
  • After a server restart, re-issue a session with POST /radio/dispatch/reauth sending only the Authorization header

Endpoint prefixes

PrefixPurpose
/api/General integrations — bots, CAD, external monitoring
/radio/dispatch/Full dispatcher presence and real-time state
/dispatch/Unit management — controlling individual in-game players

Prefer GET /radio/dispatch/status over GET /api/status for integrations. It is a superset and the actively maintained one.

Public endpoints

MethodPathDescription
GET/api/health{ status, service, serviceName, authMethod }. Used by the startup health check
GET/api/localeThe string table from config/locales.json
GET/radio/dispatch/installerWindows desktop app installer. Rate-limited to 5/min
GET/radio/dispatch/update.jsonDesktop app auto-updater manifest

Status and config

MethodPathDescription
GET/radio/dispatch/statusUsers, channels, panic, alerts, patches — preferred
GET/api/statusA subset of the above
GET/radio/dispatch/configZones, alerts, volumes, FX settings, installed themes
GET/radio/dispatch/tonesAvailable tone list

Broadcasts and tones

MethodPathBodyDescription
POST/radio/dispatch/broadcast{ message, frequency?, type?, tone? }Broadcast to a channel or all
POST/api/trigger-broadcast{ message, frequency?, type? }Broadcast, no tone field
POST/radio/dispatch/tone{ frequency, tone }Play a tone on a channel
POST/api/play-tone{ frequency, tone }Alias of the above
POST/radio/dispatch/speaker-tone{ targetId, tone }Play a tone at a world speaker, or every speaker in a group

Alerts

MethodPathBody
POST/radio/dispatch/alert/trigger{ frequency, alertType, alertConfig }
POST/radio/dispatch/alert/clear{ frequency }
POST/radio/dispatch/alert/oneshot{ frequency, alertConfig } — fire once without persisting

Channels and units

MethodPathBodyDescription
POST/radio/dispatch/switchChannel{ serverId, frequency, oldFrequency }Move an in-game player
POST/dispatch/user/alert{ userId, message, frequency }Send an alert to one player
POST/dispatch/user/disconnect{ userId }Disconnect a player from the radio
POST/dispatch/user/set-player-callsign{ userId, callsign }Set an in-game player's callsign
POST/dispatch/user/update-callsign{ callsign, userId }Update a dispatcher's callsign — userId must be negative

Patches

MethodPathBody
GET/api/patches
POST/api/patch/create{ label, frequencies: ["154.755", "460.25"] }
POST/api/patch/remove{ id }
SESSION_TOKEN="550e8400-e29b-41d4-a716-446655440000"
SESSION_ID="dispatch_abc123"

curl http://203.0.113.10:7777/radio/dispatch/status \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "x-session-id: $SESSION_ID"

curl -X POST http://203.0.113.10:7777/api/patch/create \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "x-session-id: $SESSION_ID" \
  -H "Content-Type: application/json" \
  -d '{"label": "TAC1-FIRE", "frequencies": ["154.755", "460.25"]}'

curl -X POST http://203.0.113.10:7777/radio/dispatch/tone \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "x-session-id: $SESSION_ID" \
  -H "Content-Type: application/json" \
  -d '{"frequency": "154.755", "tone": "ALERT_A"}'

Error responses

All errors are application/json.

StatusBodyCause
400{ "error": "..." }Missing or invalid parameters
401{ "error": "Authentication required" }Missing or invalid auth headers
404{ "error": "Channel not found" }Unknown frequency
429{ "error": "Too many..." }Rate limited
500{ "error": "..." }Server error

Type notes. Frequencies accept strings ("154.755") or numbers (154.755) everywhere. Dispatcher serverId values must be negative; in-game player IDs are positive. CORS is enabled for all origins.


Socket.IO

Connect to appear as a dispatcher and receive real-time events.

const res = await fetch("http://203.0.113.10:7777/radio/dispatch/auth", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ nacId: "4417", callsign: "Bot-01" }),
}).then((r) => r.json());

const socket = io("http://203.0.113.10:7777", {
  auth: {
    authToken: res.authToken, // per-session UUID — not Config.authToken
    sessionId: res.sessionId,
    serverId: -(1000 + Math.floor(Math.random() * 10000)), // must be negative
  },
});

Emit — client to server

EventPayloadDescription
setDispatchSessionsessionId: stringLink the socket to the auth session
updateUserInfo{ name, nacId }Set dispatcher identity
setSpeakerChannelfrequency: stringJoin a channel to speak
addListeningChannelfrequency: stringStart scanning
removeListeningChannelfrequency: stringStop scanning
listenToUsertargetServerId: numberFollow a user across any frequency
stopListeningToUsertargetServerId: numberStop following
setTalking{ state, frequency?, override? }PTT. override: true bypasses half-duplex blocking and is audit-logged
voice{ data, encoding?, frequency? }Base64 Opus packet; encoding: "imbe" for P25
heartbeatDate.now()Keep-alive

Listen — server to client

EventPayloadFires when
voice{ serverId, frequency, data, encoding?, receiveType, patchId? }A voice packet is relayed
talkingState{ serverId, frequency, state }PTT starts or stops
speakerJoined / speakerLeft{ serverId, frequency, name?, nacId? }A speaker joins or leaves
listenerJoined / listenerLeft{ serverId, frequency }A listener joins or leaves
channelState{ frequency, speakers, listeners, activeTalkers, empty }Full channel snapshot
patchStatus{ patchId, active, label, frequencies }A patch is created or removed
serverTone{ tone, frequency }A tone plays on a channel
dispatchNotification{ type, ...payload }Cross-dispatcher notification
playerInfoUpdate{ serverId, name?, nacId?, unitId? }Player identity changes
configUpdate{ ... }Slim config re-broadcast, no zones or alerts inline
configRefresh{}Re-fetch /radio/dispatch/config
ptt:denied{ frequency, reason, currentTalker? }PTT denied — channel busy

Dispatch panel theming

The dispatch panel is themeable in four tiers, from a colour swap to a fully custom console. Each tier is more capable than the last and carries more of the work — pick the lowest one that meets your goal.

TierYou writeEffortHost keeps feature-gating, a11y, i18n, keyboard?Auto-publishes?
1 — Design tokenscssVarsLowestYes — stock panelYes
2 — CSScssTextLowYes — stock panelYes
3 — Slot overrideslotsMediumYes — the host keeps every gate and guard; you swap only the markup you nameYes
4 — Full takeovertemplate and/or jsTextHighestNo — you own parity for everything you rendertemplate-only: yes · jsText: no

Layers combine — cssVars + a couple of slots, or cssVars + cssText + a full template. The rule that governs publishing is simple: a theme with no jsText is declarative and installs without the JavaScript source-review gate; adding jsText reintroduces it (see Security Model).

Themes are installed from Admin Panel → Marketplace → Themes. Building and publishing one is covered in Publishing a theme below.

Which tier? Recolouring the stock panel → Tier 1 / 2. Restyling one kind of component (channel cards, unit rows, 911 cards) while everything else stays stock → Tier 3. A genuinely different console layout → Tier 4. Reach for Tier 4 last: it is the only tier where a new panel feature does not appear automatically, because you have replaced the component that would have rendered it.

Tier 1 — Design tokens

The "change some colours" surface. Every dispatch component is styled with CSS custom properties; set them via a theme's cssVars, a :root { … } block in cssText, or theme.setVar() at runtime — no markup, no JavaScript. The table below is the complete, authoritative registry, generated from server/web/src/theme-sdk/tokens.ts (the same source the panel and the admin Theme Studio read):

TokenCategoryDefault (dark)Controls
--bg-primarySurfaces#030303App background behind everything.
--bg-secondarySurfaces#141414Header, bars, cards, panels.
--bg-hoverSurfaces#262626Hover/pressed state for interactive rows and buttons.
--cardSurfaceshsl(0 0% 8%)Raised card/dialog surface (shadcn-derived components).
--text-primaryText#fafafaDefault foreground text colour.
--text-secondaryText#a3a3a3Muted labels, metadata, secondary rows.
--foregroundTexthsl(0 0% 98%)Base foreground for shadcn-derived components.
--accent-rawAccent#3b82f6Primary accent — active channel, PTT, selected states.
--primaryAccenthsl(204 77% 47%)Primary colour for shadcn-derived buttons and rings.
--ringAccenthsl(0 0% 83%)Keyboard focus outline colour.
--dangerStatus#ef4444Panic, destructive actions, error states.
--successStatus#22c55eConnected/healthy indicators.
--destructiveStatushsl(0 84% 60%)Destructive colour for shadcn-derived components; drives panic flash.
--my-dispatch-bgDispatchhsl(220 100% 13%)Background tint for units controlled by this dispatcher.
--my-dispatch-borderDispatchhsl(220 100% 28%)Border for units controlled by this dispatcher.
--other-dispatch-bgDispatchhsl(270 100% 13%)Background tint for units controlled by another dispatcher.
--other-dispatch-borderDispatchhsl(270 100% 28%)Border for units controlled by another dispatcher.
--borderStructurehsl(0 0% 15%)Default border/divider colour.
--inputStructurehsl(0 0% 18%)Form control border colour.
--radiusStructure0.5remBase border-radius for cards and controls.
--font-sansTypography"JetBrains Mono", monospaceUI font stack. Also settable via the theme font field.
--font-sizeTypography100%Root font size as a percentage (100% = 16px). Also settable via the theme fontSize field.

--font-sans and --font-size are also settable through a theme's dedicated font / fontSize fields.

The table above is the dispatch-panel token contract (tokens.ts). The shared UI kit — every dialog, button, input and select — is styled by a second set of tokens on the shadcn/UnoCSS convention: --background, --foreground, --card / --card-foreground, --primary / --primary-foreground, --secondary / --secondary-foreground, --muted / --muted-foreground, --accent / --accent-foreground, --destructive / --destructive-foreground, --border, --input, --ring, --radius. These are consumed through utility classes (bg-background compiles to var(--background) via uno.config.ts), so a grep 'var(--background)' over source will not find them even though they are very much in use. A full-panel or takeover theme that only sets the dispatch tokens will leave the modal dialogs on the default palette — set the UI-kit tokens too so Settings, Broadcast, Patches and the confirm dialogs match. Note --accent (UI-kit hover) is a different token from --accent-raw (dispatch).

Tier 2 — CSS

cssText is injected as a <style> block, so it can restyle any part of the stock panel, not merely recolour it — target the panel's own class names or the Tier 1 tokens. It is sanitized at install (see Security Model): @import, expression(…) and every url(…) — including relative paths and data: URIs — are removed, so use gradients or unicode glyphs for imagery. cssVars values are applied as custom properties and are not sanitized as rules, though {, } and ; are stripped from each value.

Set extends to a built-in theme ID to inherit its tokens and override only what you need. Built-in IDs: dark-minimal (default), dark, dark-blue, basic, basic-dark — all five are also directly selectable in the panel's own Settings → Theme picker, no install required.

Tier 3 — Slot overrides

A slot replaces one kind of component — a channel card, a unit row, a 911 call card — while the host keeps the shell around it: the feature gates, keyboard handling, ARIA, localisation and every guard the stock component applies. Anything you do not override stays stock and keeps improving. This is the right tier when you want console-style channel tiles but not a from-scratch panel; it is a rounding error in size next to a full takeover, and it cannot get the gating wrong because it never sees those decisions.

A slot fragment is ordinary data-rd-* markup (the same binding language as declarative templates), bound by the same binder and the same action allow-list. The only difference is scope: an item slot's fragment is evaluated with the item bound to a scope variable in addition to the root view model, so both ch.name and counts.panics resolve inside a channel fragment. Ship them on a theme's slots map:

{
  "id": "mcc5500-slots",
  "name": "Console (slots)",
  "cssText": "/* .mx-tile { … } */",
  "slots": {
    "channel": "<div class=\"mx-tile\" data-rd-class=\"cur:ch.isCurrent\">…</div>",
    "unit": "<div class=\"mx-u\" data-rd-text=\"u.identityLine\"></div>"
  }
}

A slot-only theme (no jsText) auto-publishes, exactly like a template theme, and each fragment is sanitized identically to template.

SlotScope varReplacesHost gates (owned by the panel)
header— (root)Top bar: connection, callsign, counts, voice status, global PTT.
announcement-bargOne announcement-group button.announcementGroups.length
zonezoneA zone column, including its header and collapsed strip.
channelchA channel card: name, badges, roster, and its controls.
unituOne unit row inside a channel roster.
tx-entrytxOne transmission-log row.settings.transmissionLogEnabled
call-cardcOne 911 call card.call911Enabled
patch-rowpOne active-patch row.
speaker-rowspOne speaker or speaker-group row on the tone board.counts.speakers

The host gates column lists the view-model conditions the panel checks before it renders the slot at all. A theme never sees them — an overridden call-card cannot appear on a server with 911 disabled, because the host never reaches the slot. Slots with no gates (and header) always render. A worked, annotated example ships in the repo as mcc5500-slots-demo.js: three overridden slots (channel, unit, call-card) and nothing else, inheriting the entire stock shell.

Compatibility — requires

The view-model shape and the slot registry are versioned independently (viewModelVersion, slotRegistryVersion), and an older panel may not provide what a theme targets. A theme declares its floor:

{ "requires": { "viewModel": 2, "slots": 1 } }

Both fields are optional floors, not pins: the contract is additive, so a theme that needs view model v2 runs on v2 and every later version and fails only downward. A theme with no requires is treated as requiring v1 — the contract before the field existed — so nothing already published is affected. On a mismatch the failure is legible rather than silent: install is rejected with a message like "needs view model v2, but this panel provides v1 — update tRadio", and an incompatible theme that is somehow already stored fails safe at mount — the panel falls back rather than binding a fragment against data it does not have.

Tier 4 — Full takeover

The highest tier hides the stock panel and renders your own, two ways that can be combined:

  • Declarative templates — a template of data-rd-* markup driven by the binder. No JavaScript, so it auto-publishes. This is the recommended way to build a whole custom panel.
  • Theme JavaScriptjsText, imperative code with full DOM access. Maximum control; trips the source-review gate and is never auto-published.

Either way you take on the full parity burden: every feature gate, list, guard, keyboard and a11y affordance the stock panel had is now yours to reproduce. Two worked references live in the repo root: mcc5500-slots-demo.js (the slot tier above, for contrast) and mcc5500-theme.js — a template that rebuilds a full dispatch console from scratch and delegates the handful of things a template cannot express (free-text dialogs, PTT key capture, drag-and-drop) to a small jsText layer.

Theme JavaScript

JS themes run directly in the dispatch panel with full DOM access. Dispatchers are shown the complete source code and must explicitly accept a warning before installation.

Theme JavaScript receives three injected globals:

GlobalPurpose
dispatchRead state, subscribe to events, call actions
themeCSS helpers, DOM control
onUnmount(fn)Register cleanup for when the theme changes or unmounts

Reading state

dispatch.getViewModel() returns the typed view model — the same versioned contract the declarative templates bind to, and a superset of getState(). It adds 911 calls and precomputes the booleans/counts a panel renders (channel.hasPanic, channel.isCurrent, zone.userCount, counts.panics, …). Prefer it for new themes. Grab dispatch-theme.d.ts for full autocomplete while authoring.

const vm = dispatch.getViewModel();
vm.version; // contract version (currently 2)
vm.channels; // every channel, flattened across zones
vm.counts.panics; // active panics across the board
vm.calls911; // 911 queue (not in getState())

dispatch.getState() returns the original flat snapshot of the store. Neither call sets up reactive subscriptions.

FieldTypeDescription
channelstringCurrently monitored frequency
scannedChannelsstring[]Additional scanned frequencies
zonesZone[]Full zone → channel → user tree
pttActivebooleanGlobal PTT active
channelPttActivebooleanPer-channel PTT active
channelPttFrequencystringFrequency for active channel PTT
voiceStatusstringMUTED | LISTENING | TRANSMITTING | TX (IMBE) | TX (Opus)
micReadyboolean | nullMicrophone availability
activeAlertsRecord<string, string>{ [frequency]: alertName }
alertTypesAlertType[]Available alert configs
tones{ id, name }[]Available tones for broadcast alerts
patchesPatch[]Active frequency patches
transmissionsTransmissionEntry[]Last 50 transmission log entries
announcementGroupsAnnouncementGroup[]Group definitions
activeAnnouncementGroupstring | nullCurrently armed group ID
settingsDispatchSettingsPTT bindings, volumes, callsign
callsignstringDispatcher callsign
serverNamestringConnected server name
connectedbooleanSocket connection state
healthstringconnected | stale | disconnected
authenticatedbooleanAuth state

Zone shape:

{
  id: string, name: string,
  channels: [{
    id: string, frequency: string, name: string,
    type: string,           // "conventional" | "trunked"
    canTransmit: boolean,
    listeners: number,
    users: [{
      id: string, name: string,
      nacId: string | null, unitId: string | null,
      transmitting: boolean, panic: boolean
    }]
  }]
}

DispatchSettings shape:

{
  callsign: string,
  voiceVolume: number,   // 0–100
  sfxVolume: number,     // 0–100
  globalPTT: { pttKey, pttType, pttMouseButton },
  channelPTT: { [frequency]: PTTSettings },
  announcementGroupPTT: { [groupId]: PTTSettings }
}

Events

Subscribe with dispatch.on(event, callback). All events fire immediately on subscribe with the current state — no separate initialisation call needed.

dispatch.on("dispatch:zones", ({ zones }) => render(zones));

// Unsubscribe
const handler = ({ active }) => {
  /* ... */
};
dispatch.on("dispatch:ptt", handler);
onUnmount(() => dispatch.off("dispatch:ptt", handler));
EventPayloadFires when
dispatch:zones{ zones }Any channel, user, panic, or listener-count change
dispatch:ptt{ active }Global PTT pressed or released
dispatch:channel{ freq }Main monitored channel changes
dispatch:channel-ptt{ active, freq }Per-channel PTT starts or stops
dispatch:voice-status{ status }Voice status changes
dispatch:connection{ connected, health }Connection state changes
dispatch:alerts{ alerts: [{id, name}] }Active channel alerts change
dispatch:alert-types{ alertTypes }Alert config reloads
dispatch:scans{ scanned: string[] }Scanned channel list changes
dispatch:patches{ patches }Patches created or removed
dispatch:transmissions{ transmissions }TX log entry added or completed
dispatch:callsign{ callsign }Dispatcher callsign changes
dispatch:announcement-group{ groupId: string | null }Active announcement group changes
dispatch:settings{ settings }Any PTT binding or volume setting changes

Actions

Channel:

dispatch.joinChannel("460.25");
dispatch.leaveChannel();
dispatch.addScan("154.755");
dispatch.removeScan("154.755");

PTT:

dispatch.startPTT(); // global PTT — requires microphone + active channel
dispatch.stopPTT();
dispatch.startChannelPTT("460.25"); // per-channel PTT
dispatch.stopChannelPTT();

PTT silently no-ops if the dispatcher has no active channel, the channel has canTransmit: false, or another PTT is already active.

Announcement groups:

dispatch.activateAnnouncementGroup("group-id"); // arm
dispatch.activateAnnouncementGroup(null); // disarm

Alerts:

dispatch.triggerAlert("460.25", "SIGNAL 100");

dispatch.sendBroadcastAlert({
  frequency: "460.25",
  type: "Priority Alert", // "General Alert" | "Information Alert" | "Priority Alert" | "Emergency Alert"
  message: "All units respond to 123 Main St",
  tone: "PRIORITY",
});

dispatch.acknowledgePanic(serverId, unitId); // unitId optional

Patches:

// createPatch(label, frequencies) — frequencies are numbers. Resolves to { ok, error? }.
await dispatch.createPatch("Tac 1", [460.25, 154.755]);

// removePatch takes a patch id from the view model, not the create result.
const patch = dispatch.getViewModel().patches[0];
if (patch) await dispatch.removePatch(patch.id);

Settings and unit management:

dispatch.updateSettings({ voiceVolume: 80 });
dispatch.updateSettings({ callsign: "DISP-1" });
dispatch.openSettings();
dispatch.closeSettings();

// All return Promise<boolean>
dispatch.kickUser(serverId);
dispatch.alertUser(serverId, "Message text", frequency);
dispatch.setUserCallsign(serverId, "UNIT-5");
dispatch.switchUserChannel(serverId, "462.45", "460.25"); // (id, newFreq, oldFreq)

DOM control

All CSS changes through the theme API are automatically reversed when the theme changes or the panel unmounts.

theme.setVar("--accent-raw", "#ff6600"); // set a CSS variable on <html>
theme.removeVar("--accent-raw");

// Adds "tj-ptt-active" to <html> (auto-prefixed)
theme.addClass("ptt-active");
theme.removeClass("ptt-active");

theme.hideDefaultUI(); // hides Header, ZoneList, and announcement bar
theme.showDefaultUI(); // restores them

// Persistent <div> inside .dispatch-app — mount custom HTML here
const root = theme.getContainer();

Variable names: --[a-zA-Z][a-zA-Z0-9-]* (max 60 chars). Class names: [a-zA-Z][a-zA-Z0-9-_]* (max 40 chars, without the tj- prefix). hideDefaultUI() does not affect Settings, broadcast, or patch modals — they render in overlay portals.

Declarative templates

The recommended way to build a custom panel. Instead of hand-writing DOM and wiring event listeners, you write HTML annotated with data-rd-* attributes and hand it to theme.mountTemplate(). The binder pushes the view model in, keeps it in sync, and reconciles lists by key — a single unit's status changing updates one node rather than rebuilding the panel. No arbitrary JavaScript runs, so template-only themes are safe by construction.

theme.hideDefaultUI();
theme.mountTemplate(`
  <div class="panel">
    <header data-rd-text="serverName"></header>
    <div data-rd-repeat="ch in channels" data-rd-key="ch.id"
         data-rd-class="active:ch.isCurrent"
         data-rd-on="click:join(ch.frequency)">
      <span data-rd-text="ch.name"></span>
      <span data-rd-show="ch.hasPanic">⚠ PANIC</span>
    </div>
  </div>
`);

mountTemplate returns a binder and handles updates and teardown for you — no render loop, no manual dispatch.on(...). A complete, styled starting point lives at reference-panel.html.

No-JS template themes

A theme can ship a template directly, with no JavaScript at all, via a template field on the theme definition:

{
  "id": "my-cad-panel",
  "name": "CAD Panel",
  "cssVars": { "--accent-raw": "#22d3ee" },
  "template": "<div class=\"p\">…data-rd-* markup…</div>"
}

When the theme is active, the panel hides its default UI and auto-mounts the template — you never write mountTemplate yourself. Because the markup is declarative and sanitized at install (<script>, inline on* handlers, and javascript: URIs are stripped), a template theme installs without the JavaScript source-review gate that jsText themes require. Use data-rd-on for behaviour instead of inline handlers. For logic beyond the template's reach, add a jsText — but that reintroduces the review gate.

Building a theme — test against the real panel, not a mock. Open your own dispatch panel while logged in, open your browser's devtools (or any extension you like), and:

  • Inject a <style> tag for CSS — this already works with zero cooperation from tRadio, and is exactly how cssText is applied once installed.
  • For templates and JS, use window.__tjDev — see Live authoring below. It exposes the exact same dispatch/theme objects an installed theme runs against, bound to your panel's real live data, so nothing changes between testing and shipping.

Once it looks right, install it straight to this server (no marketplace, no editing data.json) by POSTing the finished definition from the same devtools console:

fetch("/admin/api/themes", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  credentials: "include",
  body: JSON.stringify({
    name: "My Theme",
    cssVars: { "--accent-raw": "#22d3ee" },
    cssText: "",
    jsText: "",
    template: "",
  }),
});

Requires an active admin session (you're already logged into /admin). It appears in the dispatch panel's Settings → Theme picker immediately — pass the same id on a later call to edit it in place instead of creating a duplicate.

Live authoring

Any dispatch panel exposes window.__tjDev = { dispatch, theme } while you're logged in — the same dispatch/theme objects a jsText theme receives as function arguments, just reachable from your own devtools/extension console instead. There's no separate opt-in flag: authentication into the panel is already the only gate that matters, and any installed jsText theme already has this exact same unsandboxed access once published, so exposing it to you while you're logged into your own panel doesn't cross a new trust boundary.

// In your browser's devtools console, on your own authenticated dispatch panel:
window.__tjDev.theme.hideDefaultUI();
window.__tjDev.theme.mountTemplate(`
  <div data-rd-repeat="zone in zones" data-rd-key="zone.id">…</div>
`);

// Read real live state:
window.__tjDev.dispatch.getViewModel();

A good iteration loop for the template string specifically: keep it in your browser's DevTools Snippets panel (Chrome: Sources → Snippets; Firefox has an equivalent) rather than retyping it into the console each time — a real multi-line, syntax-highlighted editor that persists across reloads, with a keyboard shortcut (Ctrl+Enter) to re-run window.__tjDev.theme.mountTemplate(...) after each edit. Editing the live-rendered DOM directly in the Elements panel does not work for anything the binder controls — it re-asserts state from its compiled template on every reactive update and will silently overwrite manual DOM edits.

Binding attributes:

AttributeExampleEffect
data-rd-textdata-rd-text="ch.name"Sets textContent
data-rd-htmldata-rd-html="ch.note"Sets innerHTML (values are trusted markup)
data-rd-showdata-rd-show="ch.transmitting"Shows when truthy (! to negate)
data-rd-hidedata-rd-hide="ch.userCount"Hides when truthy
data-rd-attrdata-rd-attr="title:ch.name; data-freq:ch.frequency"Sets/removes attributes
data-rd-classdata-rd-class="active:ch.isCurrent; tx:ch.transmitting"Toggles classes
data-rd-styledata-rd-style="color:accent"Sets inline style properties
data-rd-repeatdata-rd-repeat="ch in zone.channels"Repeats the element per list item
data-rd-keydata-rd-key="ch.id"Reconciliation key (defaults to index)
data-rd-ondata-rd-on="click:join(ch.frequency)"Binds events to actions

Expressions are dotted paths (zone.channels, ch.isCurrent) and literals ('text', 42, true, null), combined with a small, fixed set of operators:

FormExample
Negationdata-rd-show="!ch.isCurrent"
Comparison (strict, no coercion)data-rd-show="settings.transmissionLogFilter == 'all'"
Orderingdata-rd-show="counts.users > 0"
And / or (short-circuit)data-rd-show="ch.transmitting && !ch.hasPanic"
Ternarydata-rd-text="ch.isCurrent ? 'ACTIVE' : 'STANDBY'"
Groupingdata-rd-show="(a || b) && c"
Filtersdata-rd-text="tx.startTime | elapsed"

Repeat variables shadow the view model, and nested repeats see their parent's scope (data-rd-repeat="ch in zone.channels" can reference zone). Use data-rd-repeat="ch, i in channels" to bind an index variable.

Filters transform a value with expression | name or expression | name:arg, and chain left to right (transmissions | where:'hasAudio' | limit:5). The list is fixed — a theme cannot define its own — and an unknown name simply passes the value through. The Args column is the number of arguments each filter takes (elapsed, upper, … take none; round/join take an optional one):

FilterArgsResult
default1the argument when the value is null, undefined or ""
elapsednoneepoch ms → time since, "3:07" past a minute else "42s" (uses vm.now)
join0–1array → string joined by the separator (default ", ")
limit1array → at most N leading items
lowernonelowercase text
pctnonenumber 0–100 → "50%"
plural2number → first argument when exactly 1, else the second
round0–1number → rounded to N decimal places (default 0)
sortBy1array → stably sorted ascending by the named field
timenoneepoch ms → local wall clock "14:32:07"
uppernoneuppercase text
where1array → only items whose named field is truthy
whereNot1array → only items whose named field is falsy

Filter arguments are themselves expressions, so field names must be quoted (where:'hasAudio', not where:hasAudio). A | inside an argument ends that argument — wrap it in parentheses to filter within one, e.g. default:(a | upper).

Template actions (data-rd-on) — a fixed, safe allow-list. This is the same capability registry the JS dispatch API exposes, so every action below is also callable as dispatch.<name>(…) from a jsText theme. The Args column is the number of positional arguments each takes:

ActionArgs
acceptCall911()1
acknowledgePanic()2
activateGroup()1
addScan()1
checkUpdate()none
clearLog()none
closeBroadcast()none
closePatchPanel()none
closeSettings()none
endCall911()1
expandSidebar()none
focusCall911()1
join()1
kickUser()1
leave()none
logout()none
openBroadcast()1
openPatchPanel()none
openSettings()none
playTransmission()1
removePatch()1
removeScan()1
setAutoCollapse()1
setLogFilter()1
setSfxVolume()1
setTransmissionLogEnabled()1
setVoiceVolume()1
speakerTone()2
startChannelPtt()1
startPtt()none
stopChannelPtt()none
stopPtt()none
stopTransmission()1
toggleCallQueue()none
toggleChannel()2
toggleTxLog()none
toggleZone()1
triggerAlert()2
unfocusCall911()none

A few semantics worth stating, since the table only lists arity:

  • activateGroup(null) disarms the armed announcement group; focusCall911 / openBroadcast take a frequency, acceptCall911 / endCall911 a call id, removePatch a patch id (from vm.patches[].id).
  • removePatch removes a patch, but creating one needs free text and so the JS API (dispatch.createPatch) — a template cannot.
  • setLogFilter accepts only 'all', 'monitored' or 'current'; setVoiceVolume / setSfxVolume take a number 0–100 (the grammar has no arithmetic, so render discrete steps).
  • playTransmission / stopTransmission replay buffered audio — gate the button on tx.hasAudio. acknowledgePanic's second argument (unitId) is optional at runtime even though its signature lists two.

Reaching what a template cannot express. The binder passes only paths and literals to an action, so it can never read a value out of an <input>. Anything needing free text (creating a patch, composing a broadcast, changing a unit's callsign), live key capture (PTT rebinding), drag-and-drop or a popout window has to be delegated. Two ways to do that:

1. Open a built-in modal. SettingsModal, BroadcastModal, SpeakerBoard and PatchPanel render outside .dispatch-default-ui, so they stay fully usable after hideDefaultUI(). Open them with openPatchPanel() / openBroadcast(freq) / openSettings() rather than rebuilding them.

2. Go hybrid — a jsText theme that mounts the same template. theme.mountTemplate() is available inside jsText, so you keep declarative rendering for the 95% that is lists and state binding, and write imperative code only for the rest. Mark intent in the template with a plain data-* attribute (the binder ignores unknown ones) and handle it with a delegated listener:

theme.hideDefaultUI();
theme.mountTemplate(TEMPLATE);
const root = theme.getContainer();

//   <button data-rd-attr="data-alert-user:u.id">Alert…</button>
root.addEventListener(
  "click",
  (e) => {
    const el = e.target.closest?.("[data-alert-user]");
    if (el) myDialog((msg) => dispatch.alertUser(el.dataset.alertUser, msg, freq));
  },
  true,
);

Two things will bite you here. Listen in the capture phase (the true above): the binder calls stopPropagation() on every click/mouse* it handles, so a bubbling listener never fires for a button nested under an element that has its own data-rd-on. And append your own dialogs to document.body, not the theme canvasmountTemplate() assigns container.innerHTML, and repeat reconciliation replaces nodes, so anything parked inside is destroyed on the next update. A <style> block inside the template is document-global once parsed, so body-level nodes still pick up its CSS.

Note the trade-off: a jsText theme runs unsandboxed on the panel's origin, is recorded in the audit log, and on the marketplace requires review before publishing, whereas a template-only theme publishes automatically.

Two authoring traps worth knowing before you write a template.

1. Give every data-rd-repeat its own parent element. Bindings are addressed by a childNodes index path captured when the template compiles, but a repeat inserts its items before its anchor at runtime — shifting the index of every later sibling in the same parent. A binding placed after an un-wrapped repeat will resolve to the wrong node and throw Cannot set properties of undefined (setting 'display'). The usual victim is an empty-state message following a list:

<!-- WRONG — the empty state's index shifts as items render -->
<div class="list">
  <div data-rd-repeat="p in patches" data-rd-key="p.id">…</div>
  <div data-rd-hide="patches.length">No patches.</div>
</div>

<!-- RIGHT — the repeat gets its own container (display:contents keeps layout identical) -->
<div class="list">
  <div style="display:contents">
    <div data-rd-repeat="p in patches" data-rd-key="p.id">…</div>
  </div>
  <div data-rd-hide="patches.length">No patches.</div>
</div>

2. data-rd-text sets textContent, which erases child elements. An element cannot both carry data-rd-text and contain markup (an icon, a coloured dot). Put the text in its own child span.

Publishing a theme

Themes are authored and submitted in the theme editortgstudios → Marketplace → Themes → Create Theme. Build against your own live panel first (see Live authoring), then paste the result in.

The editor is one form, not a set of modes. Every layer composes at runtime, so a single theme can carry colours and a slot override and JavaScript. A "This theme:" strip at the top shows which layers you currently have: Colours & CSS, N slot overrides, Full template, JavaScript.

SectionWhat it takes
Name, Description, Resource, ScreenshotListing metadata. Resource is locked once the theme exists
Colours & variablesKey/value rows with a colour swatch. A :root { … } block in Custom CSS feeds these rows and vice versa
Custom CSSFree-form CSS. Applies to the stock panel and to any slot overrides
Component overridesPick a slot from the dropdown and write its data-rd-* fragment
AdvancedHidden behind a disclosure, and auto-opened when you edit a theme that already uses it. Holds the Full-panel template and the JavaScript box

A live preview sits above the editors: a representative mock panel styled by your colours and CSS, plus a real binder view running your template or slots against mock dispatch data as soon as either exists. JavaScript cannot be previewed — build it against your live panel. Validation errors block submission; warnings do not.

A full template disables your slot overrides

A template replaces the whole panel, so any slots you have defined are ignored while it is set. The editor warns you when both are present. Clear the template to go back to slots.

What happens on submit

Your theme containsResult
Only cssVars, cssText, slots, and/or templatePublished immediately. No staff review
Any jsTextPending staff review. A staff member reads the source before it goes live

Editing an existing theme follows the same rule: a CSS-, slot- or template-only edit republishes straight away, while an edit that introduces JavaScript drops the theme back to pending — even if it was already approved. That is deliberate, and it is why the gate cannot be worked around by getting a benign version approved and then swapping the JavaScript in afterwards.

Once live, updates reach servers that installed the theme on their next dispatch-panel refresh.

A theme is capped at 10 MB for the whole definition and 256 CSS variables.

Security model

WhatHow it's enforced
CSS injectionIn cssText, @import, expression(…) and every url(…) — including relative paths and data: URIs — are stripped at install time. Use gradients or unicode glyphs instead. A <style> block inside a theme's template is sanitized as HTML rather than CSS, so url() survives there.
Declarative templates & slotsdata-rd-* bindings resolve only dotted paths, literals, operators over them and the fixed filter list; events dispatch only to the fixed action allow-list. No eval, no indexing, no theme-defined functions — no arbitrary code runs. Slot fragments are sanitized identically to template (<script>, inline on* handlers and javascript: URIs stripped). A template/slot theme with no jsText is safe by construction and auto-publishes.
JS executionRuns directly in the dispatch panel — no iframe sandbox. Full DOM access by design.
Install reviewAny theme containing jsText shows a mandatory source-review dialog. Cannot be bypassed.
JS badgeTheme cards in Admin Panel → Marketplace carry a visible JS badge when the theme includes JavaScript, before you open it.
Auto-cleanupCSS vars, classes, canvas contents, and hideDefaultUI state are all reversed automatically on theme change or panel close.