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
| Export | Parameters | Returns |
|---|---|---|
getSpeakersInChannel | frequency: string|number | table — server IDs connected as speakers |
getListenersInChannel | frequency | table — server IDs scanning (listen only) |
getAllUsersInChannel | frequency | table — speakers and listeners combined |
getActiveTalkersInChannel | frequency | table — server IDs holding PTT right now |
getActiveChannels | — | string[] — frequencies with at least one active user |
getChannelInfo | frequency | { speakers, listeners, activeTalkers } or nil |
getAllChannels | — | table[] — 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
| Export | Parameters | Returns |
|---|---|---|
getChannelAlert | frequency | The active alert config, or nil |
setAlertOnChannel | frequency, enabled: boolean|nil, alertIndexOrName: number|string|nil | — |
setChannelSignal | frequency, enabled: boolean | true — fires the first configured alert. The export equivalent of the in-game SGN button |
getChannelPanic | frequency | { [serverId] = serverId, ... } |
setChannelPanic | frequency, serverId, enabled: boolean | boolean |
getUserPanicState | serverId, frequency | boolean |
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
| Export | Parameters | Returns |
|---|---|---|
setUserChannel | serverId: number, frequency | boolean — handles trunked routing automatically |
disconnectUser | serverId: number | boolean |
isUserTalking | serverId: number, frequency | boolean |
User information
| Export | Parameters | Returns |
|---|---|---|
getPlayerName | serverId | string |
getPlayerNacId | serverId | string or nil |
getUserInfo | serverId | { name, nacId } |
hasRadioAccess | serverId | boolean |
setUserNacId | serverId, nacId: string | boolean — display override until the next refreshNacId |
refreshNacId | serverId | boolean — re-resolves access and pushes it to the client. Call after a job change |
refreshPlayerInfo | — | number — count of players whose NAC ID and name were refreshed |
getAcePermissions | serverId | { 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
| Export | Parameters | Returns |
|---|---|---|
getSubscriberInfo | serverId | The subscriber record, or nil |
getSubscriberZones | serverId | table[] of zone objects, or nil |
refreshSubscriber | serverId | boolean — 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.
| Export | Parameters | Returns |
|---|---|---|
setCallsign | serverId, callsign: string|nil — "" or nil clears it | — |
getCallsign | serverId | string or nil |
Audio
| Export | Parameters | Returns |
|---|---|---|
playToneOnChannel | frequency, tone: string | — |
playToneOnSource | serverId, 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
| Export | Parameters | Returns |
|---|---|---|
openRadio | focus: boolean (default true) | — |
closeRadio | — | — |
connectToFrequency | frequency | boolean |
setCurrentChannel | frequency | — |
getCurrentFrequency | — | frequency, or -1 when not connected |
getCurrentChannel | — | channel object |
addListeningChannel | frequency | — |
removeListeningChannel | frequency | — |
getListeningChannels | — | string[] — 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
| Export | Parameters | Returns |
|---|---|---|
startTransmitting | — | — |
stopTransmitting | — | — |
setTalking | talking: boolean | — — same as start/stop |
isTransmitting | — | boolean |
getActiveTalker | — | { serverId, name, frequency } or nil |
activateProximityVoice | — | — — triggers in-game proximity voice |
Radio state
| Export | Returns |
|---|---|
isConnected | boolean |
isPowerOn | boolean |
isRadioOpen | boolean |
isRadioFocused | boolean |
setPower(state) | — — omit the argument to toggle |
Volume and audio
| Export | Parameters | Returns |
|---|---|---|
setVolume / getVolume | 0.0–1.0 | — / number |
setToneVolume / getToneVolume | 0.0–1.0 | — / number |
set3DVolume / get3DVolume | 0.0–1.0 | — / number |
playTone | tone: string | — — plays locally only |
NotifySirenChanged | — | — — forces an immediate re-poll of bgModeCheck |
Appearance and settings
| Export | Parameters | Returns |
|---|---|---|
setRadioLayout / getRadioLayout | model: string | — / string |
setRadioTheme / getRadioTheme | "Auto"|"Light"|"Dark" | — / string |
setAnimationId / getAnimationId | id: string | — / string |
setEarbudsEnabled / getEarbudsEnabled | boolean | — / boolean |
setGPSEnabled / getGPSEnabled | boolean | — / boolean |
Alerts and panic
| Export | Parameters |
|---|---|
triggerAlertOnChannel | frequency, enabled: boolean, alertConfig? |
panicButton | frequency, enabled: boolean |
Player and system information
| Export | Returns |
|---|---|
getCurrentName | string |
getCurrentNacId | string |
refreshMyNacId | — — re-fetches from the server |
getMyCallsign / setMyCallsign | string|nil / — ("" clears) |
getZone | string — current zone name |
getBatteryLevel | number (0–100) |
getSignalStrength | number (0–5) |
getConnectionDiagnostics | diagnostic 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
nacIdmust matchdispatchNacIdinconfig.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
202and aredirectUriinstead - After a server restart, re-issue a session with
POST /radio/dispatch/reauthsending only theAuthorizationheader
Endpoint prefixes
| Prefix | Purpose |
|---|---|
/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
| Method | Path | Description |
|---|---|---|
GET | /api/health | { status, service, serviceName, authMethod }. Used by the startup health check |
GET | /api/locale | The string table from config/locales.json |
GET | /radio/dispatch/installer | Windows desktop app installer. Rate-limited to 5/min |
GET | /radio/dispatch/update.json | Desktop app auto-updater manifest |
Status and config
| Method | Path | Description |
|---|---|---|
GET | /radio/dispatch/status | Users, channels, panic, alerts, patches — preferred |
GET | /api/status | A subset of the above |
GET | /radio/dispatch/config | Zones, alerts, volumes, FX settings, installed themes |
GET | /radio/dispatch/tones | Available tone list |
Broadcasts and tones
| Method | Path | Body | Description |
|---|---|---|---|
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
| Method | Path | Body |
|---|---|---|
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
| Method | Path | Body | Description |
|---|---|---|---|
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
| Method | Path | Body |
|---|---|---|
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.
| Status | Body | Cause |
|---|---|---|
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
| Event | Payload | Description |
|---|---|---|
setDispatchSession | sessionId: string | Link the socket to the auth session |
updateUserInfo | { name, nacId } | Set dispatcher identity |
setSpeakerChannel | frequency: string | Join a channel to speak |
addListeningChannel | frequency: string | Start scanning |
removeListeningChannel | frequency: string | Stop scanning |
listenToUser | targetServerId: number | Follow a user across any frequency |
stopListeningToUser | targetServerId: number | Stop 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 |
heartbeat | Date.now() | Keep-alive |
Listen — server to client
| Event | Payload | Fires 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.
| Tier | You write | Effort | Host keeps feature-gating, a11y, i18n, keyboard? | Auto-publishes? |
|---|---|---|---|---|
| 1 — Design tokens | cssVars | Lowest | Yes — stock panel | Yes |
| 2 — CSS | cssText | Low | Yes — stock panel | Yes |
| 3 — Slot override | slots | Medium | Yes — the host keeps every gate and guard; you swap only the markup you name | Yes |
| 4 — Full takeover | template and/or jsText | Highest | No — you own parity for everything you render | template-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):
| Token | Category | Default (dark) | Controls |
|---|---|---|---|
--bg-primary | Surfaces | #030303 | App background behind everything. |
--bg-secondary | Surfaces | #141414 | Header, bars, cards, panels. |
--bg-hover | Surfaces | #262626 | Hover/pressed state for interactive rows and buttons. |
--card | Surfaces | hsl(0 0% 8%) | Raised card/dialog surface (shadcn-derived components). |
--text-primary | Text | #fafafa | Default foreground text colour. |
--text-secondary | Text | #a3a3a3 | Muted labels, metadata, secondary rows. |
--foreground | Text | hsl(0 0% 98%) | Base foreground for shadcn-derived components. |
--accent-raw | Accent | #3b82f6 | Primary accent — active channel, PTT, selected states. |
--primary | Accent | hsl(204 77% 47%) | Primary colour for shadcn-derived buttons and rings. |
--ring | Accent | hsl(0 0% 83%) | Keyboard focus outline colour. |
--danger | Status | #ef4444 | Panic, destructive actions, error states. |
--success | Status | #22c55e | Connected/healthy indicators. |
--destructive | Status | hsl(0 84% 60%) | Destructive colour for shadcn-derived components; drives panic flash. |
--my-dispatch-bg | Dispatch | hsl(220 100% 13%) | Background tint for units controlled by this dispatcher. |
--my-dispatch-border | Dispatch | hsl(220 100% 28%) | Border for units controlled by this dispatcher. |
--other-dispatch-bg | Dispatch | hsl(270 100% 13%) | Background tint for units controlled by another dispatcher. |
--other-dispatch-border | Dispatch | hsl(270 100% 28%) | Border for units controlled by another dispatcher. |
--border | Structure | hsl(0 0% 15%) | Default border/divider colour. |
--input | Structure | hsl(0 0% 18%) | Form control border colour. |
--radius | Structure | 0.5rem | Base border-radius for cards and controls. |
--font-sans | Typography | "JetBrains Mono", monospace | UI font stack. Also settable via the theme font field. |
--font-size | Typography | 100% | 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.
| Slot | Scope var | Replaces | Host gates (owned by the panel) |
|---|---|---|---|
header | — (root) | Top bar: connection, callsign, counts, voice status, global PTT. | — |
announcement-bar | g | One announcement-group button. | announcementGroups.length |
zone | zone | A zone column, including its header and collapsed strip. | — |
channel | ch | A channel card: name, badges, roster, and its controls. | — |
unit | u | One unit row inside a channel roster. | — |
tx-entry | tx | One transmission-log row. | settings.transmissionLogEnabled |
call-card | c | One 911 call card. | call911Enabled |
patch-row | p | One active-patch row. | — |
speaker-row | sp | One 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
templateofdata-rd-*markup driven by the binder. No JavaScript, so it auto-publishes. This is the recommended way to build a whole custom panel. - Theme JavaScript —
jsText, 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:
| Global | Purpose |
|---|---|
dispatch | Read state, subscribe to events, call actions |
theme | CSS 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.
| Field | Type | Description |
|---|---|---|
channel | string | Currently monitored frequency |
scannedChannels | string[] | Additional scanned frequencies |
zones | Zone[] | Full zone → channel → user tree |
pttActive | boolean | Global PTT active |
channelPttActive | boolean | Per-channel PTT active |
channelPttFrequency | string | Frequency for active channel PTT |
voiceStatus | string | MUTED | LISTENING | TRANSMITTING | TX (IMBE) | TX (Opus) |
micReady | boolean | null | Microphone availability |
activeAlerts | Record<string, string> | { [frequency]: alertName } |
alertTypes | AlertType[] | Available alert configs |
tones | { id, name }[] | Available tones for broadcast alerts |
patches | Patch[] | Active frequency patches |
transmissions | TransmissionEntry[] | Last 50 transmission log entries |
announcementGroups | AnnouncementGroup[] | Group definitions |
activeAnnouncementGroup | string | null | Currently armed group ID |
settings | DispatchSettings | PTT bindings, volumes, callsign |
callsign | string | Dispatcher callsign |
serverName | string | Connected server name |
connected | boolean | Socket connection state |
health | string | connected | stale | disconnected |
authenticated | boolean | Auth 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));| Event | Payload | Fires 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); // disarmAlerts:
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 optionalPatches:
// 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 howcssTextis applied once installed. - For templates and JS, use
window.__tjDev— see Live authoring below. It exposes the exact samedispatch/themeobjects 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:
| Attribute | Example | Effect |
|---|---|---|
data-rd-text | data-rd-text="ch.name" | Sets textContent |
data-rd-html | data-rd-html="ch.note" | Sets innerHTML (values are trusted markup) |
data-rd-show | data-rd-show="ch.transmitting" | Shows when truthy (! to negate) |
data-rd-hide | data-rd-hide="ch.userCount" | Hides when truthy |
data-rd-attr | data-rd-attr="title:ch.name; data-freq:ch.frequency" | Sets/removes attributes |
data-rd-class | data-rd-class="active:ch.isCurrent; tx:ch.transmitting" | Toggles classes |
data-rd-style | data-rd-style="color:accent" | Sets inline style properties |
data-rd-repeat | data-rd-repeat="ch in zone.channels" | Repeats the element per list item |
data-rd-key | data-rd-key="ch.id" | Reconciliation key (defaults to index) |
data-rd-on | data-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:
| Form | Example |
|---|---|
| Negation | data-rd-show="!ch.isCurrent" |
| Comparison (strict, no coercion) | data-rd-show="settings.transmissionLogFilter == 'all'" |
| Ordering | data-rd-show="counts.users > 0" |
| And / or (short-circuit) | data-rd-show="ch.transmitting && !ch.hasPanic" |
| Ternary | data-rd-text="ch.isCurrent ? 'ACTIVE' : 'STANDBY'" |
| Grouping | data-rd-show="(a || b) && c" |
| Filters | data-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):
| Filter | Args | Result |
|---|---|---|
default | 1 | the argument when the value is null, undefined or "" |
elapsed | none | epoch ms → time since, "3:07" past a minute else "42s" (uses vm.now) |
join | 0–1 | array → string joined by the separator (default ", ") |
limit | 1 | array → at most N leading items |
lower | none | lowercase text |
pct | none | number 0–100 → "50%" |
plural | 2 | number → first argument when exactly 1, else the second |
round | 0–1 | number → rounded to N decimal places (default 0) |
sortBy | 1 | array → stably sorted ascending by the named field |
time | none | epoch ms → local wall clock "14:32:07" |
upper | none | uppercase text |
where | 1 | array → only items whose named field is truthy |
whereNot | 1 | array → 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:
| Action | Args |
|---|---|
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/openBroadcasttake a frequency,acceptCall911/endCall911a call id,removePatcha patch id (fromvm.patches[].id).removePatchremoves a patch, but creating one needs free text and so the JS API (dispatch.createPatch) — a template cannot.setLogFilteraccepts only'all','monitored'or'current';setVoiceVolume/setSfxVolumetake a number 0–100 (the grammar has no arithmetic, so render discrete steps).playTransmission/stopTransmissionreplay buffered audio — gate the button ontx.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
canvas — mountTemplate() 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 editor — tgstudios → 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.
| Section | What it takes |
|---|---|
| Name, Description, Resource, Screenshot | Listing metadata. Resource is locked once the theme exists |
| Colours & variables | Key/value rows with a colour swatch. A :root { … } block in Custom CSS feeds these rows and vice versa |
| Custom CSS | Free-form CSS. Applies to the stock panel and to any slot overrides |
| Component overrides | Pick a slot from the dropdown and write its data-rd-* fragment |
| Advanced | Hidden 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 contains | Result |
|---|---|
Only cssVars, cssText, slots, and/or template | Published immediately. No staff review |
Any jsText | Pending 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
| What | How it's enforced |
|---|---|
| CSS injection | In 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 & slots | data-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 execution | Runs directly in the dispatch panel — no iframe sandbox. Full DOM access by design. |
| Install review | Any theme containing jsText shows a mandatory source-review dialog. Cannot be bypassed. |
| JS badge | Theme cards in Admin Panel → Marketplace carry a visible JS badge when the theme includes JavaScript, before you open it. |
| Auto-cleanup | CSS vars, classes, canvas contents, and hideDefaultUI state are all reversed automatically on theme change or panel close. |
