API Reference

Lua exports, REST API, and Socket.IO events for integrating with Tommy's Radio.

Lua ExportsREST APISocket.IO

Server Exports

Channel Information

ExportParametersReturns
getSpeakersInChannelfrequency: string|numbertable — server IDs connected as speakers
getListenersInChannelfrequency: string|numbertable — server IDs scanning (listen-only)
getAllUsersInChannelfrequency: string|numbertable — speakers + listeners combined
getActiveTalkersInChannelfrequency: string|numbertable — server IDs with PTT held right now
getActiveChannelsstring[] — frequencies with at least one active user
getChannelInfofrequency: string|number{ speakers, listeners, activeTalkers } or nil
getAllChannelstable[] — full channel list from config (zones, names, types, etc.)
local speakers = exports['tRadio']:getSpeakersInChannel("154.755")
local info     = exports['tRadio']:getChannelInfo("154.755")
-- info = { speakers = {...}, listeners = {...}, activeTalkers = {...} }

Alerts & Panic

ExportParametersReturns
getChannelAlertfrequencyalert config object or nil
setAlertOnChannelfrequency, enabled: boolean|nil, alertIndexOrName: number|string|nil
getChannelPanicfrequency{ [serverId] = true, ... }
setChannelPanicfrequency, serverId, enabled: boolean
getUserPanicStateserverId, frequencyboolean

setAlertOnChannel resolution rules:

  • alertIndexOrName omitted or nil → first alert in config
  • number → 1-based index into the alerts array
  • string → case-insensitive name match
  • enabled = nil → toggle 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 second alert
exports['tRadio']:setChannelPanic("154.755", 5, true)

User Management

ExportParametersReturns
setUserChannelserverId: number, frequency: string|numberboolean
disconnectUserserverId: numberboolean
isUserTalkingserverId: number, frequency: string|numberboolean

setUserChannel handles trunked channel routing automatically.

local ok = exports['tRadio']:setUserChannel(5, "460.125")
exports['tRadio']:disconnectUser(5)

User Information

ExportParametersReturns
getPlayerNameserverIdstring
getPlayerNacIdserverIdstring or nil
getUserInfoserverId{ name, nacId }
hasRadioAccessserverIdboolean
setUserNacIdserverId, nacId: stringboolean — overrides until next refreshNacId
refreshNacIdserverIdboolean — re-fetches NAC ID from source; call after job changes
refreshPlayerInfonumber — count of players refreshed
getAcePermissionsserverId{ connect, scan, gps, zones, dispatch }
local perms = exports['tRadio']:getAcePermissions(5)
-- { connect = true, scan = true, gps = false, zones = true, dispatch = false }

Codeplug / Subscriber

ExportParametersReturns
getSubscriberInfoserverIdsubscriber record or nil
getSubscriberZonesserverIdtable[] of zone objects or nil
refreshSubscriberserverIdboolean — rebuilds full subscriber record; more thorough than refreshNacId

getSubscriberInfo returns:

{
    codeplugId         = "leo",
    unitId             = "141",
    nac                = 0x293,
    radioModel         = "APX8000",
    features           = { ... },
    tones              = { ... },
    emergency          = { ... },
    announcementVolume = 0.8,
}

Callsigns

Requires useCallsignSystem = true in the admin panel.

ExportParametersReturns
setCallsignserverId, callsign: string|nil — pass "" or nil to clear
getCallsignserverIdstring or nil
exports['tRadio']:setCallsign(5, "L-141")
local cs = exports['tRadio']:getCallsign(5)  -- "L-141"

Audio

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

Client Exports

All volume exports use 0.0–1.0. The admin panel and config.lua use 0–100 — divide by 100 before passing those values to any volume export.

Radio Control

ExportParametersReturns
openRadiofocus: boolean (default true)
closeRadio
connectToFrequencyfrequency: string|numberboolean
getCurrentFrequencyfrequency string/number, or -1 if not connected
getCurrentChannelchannel object
addListeningChannelchannel
removeListeningChannelchannel
getListeningChannelsstring[] — active scan frequencies
exports['tRadio']:openRadio()          -- open with NUI focus
exports['tRadio']:openRadio(false)     -- open without focus
local ok = exports['tRadio']:connectToFrequency("154.755")

Transmission

ExportParametersReturns
startTransmitting
stopTransmitting
isTransmittingboolean
getActiveTalker{ serverId, name, frequency } or nil
setTalkingtalking: boolean— — UI feedback only, does not affect voice
exports['tRadio']:startTransmitting()
local talker = exports['tRadio']:getActiveTalker()
-- { serverId = 5, name = "John Smith", frequency = "154.755" }

Radio State

ExportParametersReturns
isConnectedboolean
isPowerOnboolean
setPowerstate: boolean|nil (omit to toggle)
isRadioOpenboolean
isRadioFocusedboolean

Volume & Audio

ExportParametersReturns
setVolumevolume: number (0.0–1.0)
getVolumenumber (0.0–1.0)
setToneVolumevolume: number (0.0–1.0)
getToneVolumenumber (0.0–1.0)
set3DVolumevolume: number (0.0–1.0)
get3DVolumenumber (0.0–1.0)
playTonetone: string— — plays locally
NotifySirenChanged— — forces immediate re-poll of bgModeCheck; call after siren state changes in custom ELS
exports['tRadio']:setVolume(0.75)
exports['tRadio']:playTone("ALERT")
exports['tRadio']:NotifySirenChanged()

Appearance & Settings

ExportParametersReturns
setRadioLayoutlayout: string
getRadioLayoutstring
setRadioThemetheme: string
getRadioThemestring
setAnimationIdanimId: string
getAnimationIdstring
setEarbudsEnabledenabled: boolean
getEarbudsEnabledboolean
setGPSEnabledenabled: boolean
getGPSEnabledboolean

Alerts & Panic (Client)

ExportParametersReturns
triggerAlertOnChannelfrequency, enabled: boolean
panicButtonfrequency, enabled: boolean

User Information (Client)

ExportParametersReturns
getCurrentNamestring
getCurrentNacIdstring
refreshMyNacId— — re-fetches from server
getMyCallsignstring or nil
setMyCallsigncallsign: string — pass "" to clear
getZonestring — current zone name

System

ExportParametersReturns
getBatteryLevelnumber (0–100)
getSignalStrengthsignal strength value
getConnectionDiagnosticsdiagnostic snapshot table

Web API

Authentication

All protected endpoints require a per-session authToken obtained at runtime. This is not Config.authToken from config.lua.

Step 1 — create a session:

curl -X POST http://192.0.2.100:7777/radio/dispatch/auth \
  -H "Content-Type: application/json" \
  -d '{"nacId": "141", "callsign": "Dispatcher-01"}'

Response:

{
  "success": true,
  "sessionId": "dispatch_abc123",
  "authToken": "550e8400-e29b-41d4-a716-446655440000"
}
  • nacId must match dispatchNacId in config.lua
  • Sessions expire after 24 hours
  • To re-issue a session after a server restart: POST /radio/dispatch/reauth with Authorization header only (no x-session-id)

Step 2 — authenticate all protected requests:

Authorization: Bearer 550e8400-e29b-41d4-a716-446655440000
x-session-id: dispatch_abc123

Endpoint Prefix Guide

PrefixPurpose
/api/General server 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 all external integrations — it is a superset and actively maintained.

Public Endpoints

MethodPathDescription
GET/api/healthServer status, version, auth method
GET/radio/dispatch/installerWindows desktop app installer (.exe)
GET/radio/dispatch/update.jsonAuto-updater manifest

Protected Endpoints

Status & Config

MethodPathBodyDescription
GET/radio/dispatch/statusUsers, channels, panic, alerts, patches (preferred)
GET/api/statusSubset of /radio/dispatch/status
GET/radio/dispatch/configZones, alerts, volumes, FX settings, installed themes
GET/radio/dispatch/tonesAvailable tone list

Broadcast & Tones

MethodPathBodyDescription
POST/radio/dispatch/broadcast{ message, frequency?, type?, tone? }Send broadcast to channel or all
POST/api/trigger-broadcast{ message, frequency?, type? }Broadcast (no tone field)
POST/radio/dispatch/tone{ frequency, tone }Play tone on a channel
POST/api/play-tone{ frequency, tone }Play tone (alias)

Alerts

MethodPathBodyDescription
POST/radio/dispatch/alert/trigger{ frequency, alertType, alertConfig }Activate a named alert
POST/radio/dispatch/alert/clear{ frequency }Clear active alert
POST/radio/dispatch/alert/oneshot{ frequency, alertConfig }Fire alert once without persisting

Channel & User Control

MethodPathBodyDescription
POST/radio/dispatch/switchChannel{ serverId, frequency, oldFrequency }Move in-game player to a channel
POST/dispatch/user/alert{ userId, message, frequency }Send alert message to a player
POST/dispatch/user/disconnect{ userId }Disconnect a player from the radio
POST/dispatch/user/set-player-callsign{ userId, callsign }Set in-game player callsign (requires useCallsignSystem)
POST/dispatch/user/update-callsign{ callsign, userId }Update callsign for a dispatch user (userId must be negative)

Patches

MethodPathBodyDescription
GET/api/patchesList active patches
POST/api/patch/create{ label, frequencies: ["154.755", "460.125"] }Create a cross-channel patch
POST/api/patch/remove{ id }Remove a patch by ID

Session

MethodPathHeadersDescription
POST/radio/dispatch/reauthAuthorization onlyRe-issue session after server restart
SESSION_TOKEN="550e8400-e29b-41d4-a716-446655440000"
SESSION_ID="dispatch_abc123"

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

# Create patch
curl -X POST http://192.0.2.100: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.125"]}'

# Play tone
curl -X POST http://192.0.2.100: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"}'

Socket.IO

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

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

const socket = io('http://192.0.2.100: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 → server)

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

Listen (server → client)

EventPayloadDescription
voice{ serverId, frequency, data, encoding?, receiveType, patchId? }Relayed voice packet
talkingState{ serverId, frequency, state: boolean }PTT start/stop
speakerJoined{ serverId, frequency, name?, nacId? }Speaker joined channel
speakerLeft{ serverId, frequency, name?, nacId? }Speaker left channel
listenerJoined{ serverId, frequency }Listener joined channel
listenerLeft{ serverId, frequency }Listener left channel
channelState{ frequency, speakers, listeners, activeTalkers, empty }Full channel state snapshot
patchStatus{ patchId, active: boolean, label, frequencies }Patch created or removed
serverTone{ tone: string, frequency: string }Tone played on channel
dispatchNotification{ type: string, ...payload }Cross-dispatcher notification
playerInfoUpdate{ serverId, name?, nacId?, unitId? }Player identity changed
configUpdate{ ... }Slim config re-broadcast — no zones/alerts inline
configRefresh{}Re-fetch /radio/dispatch/config
ptt:denied{ frequency, reason: string, currentTalker? }PTT denied — channel busy (blockTransmission = true)

Dispatch Theme JavaScript API

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.getState() returns a plain-object snapshot of the current store. Does not set up reactive subscriptions.

const s = dispatch.getState();
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" | "digital"
    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–1
  sfxVolume: number,     // 0–1
  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.0625')
dispatch.leaveChannel()
dispatch.addScan('154.755')
dispatch.removeScan('154.755')

PTT:

dispatch.startPTT()                    // global PTT — requires microphone + active channel
dispatch.stopPTT()
dispatch.startChannelPTT('460.0625')   // 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.0625', 'SIGNAL 100')

dispatch.sendBroadcastAlert({
  frequency: '460.0625',
  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:

const patchId = await dispatch.createPatch(['460.0625', '154.755'])
await dispatch.removePatch(patchId)

Settings & user management:

dispatch.updateSettings({ voiceVolume: 0.8 })
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.000', '460.000')  // (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.

Security Model

WhatHow it's enforced
CSS injection@import and external url() calls are stripped at install time. Relative URLs and data: URIs are permitted.
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 badgeMarketplace listing cards display a visible JS badge on themes that include JavaScript.
Auto-cleanupCSS vars, classes, canvas contents, and hideDefaultUI state are all reversed automatically on theme change or panel close.

Full Panel Replacement Example

theme.hideDefaultUI();
const root = theme.getContainer();
root.style.cssText = 'flex:1;overflow:auto;display:flex;flex-direction:column';

function renderChannel(c, activeFreq) {
  const panicking = c.users.filter(u => u.panic).length;
  const active = c.frequency === activeFreq;
  return `
    <div data-freq="${c.frequency}" style="
      padding:10px 14px; border-bottom:1px solid var(--border); cursor:pointer;
      background:${active ? 'var(--my-dispatch-bg)' : 'transparent'};
      border-left:3px solid ${active ? 'var(--accent-raw)' : 'transparent'}">
      <div style="display:flex;align-items:center;gap:8px">
        <span style="font-weight:600;color:var(--text-primary)">${c.name}</span>
        <span style="font-size:11px;color:var(--text-secondary)">${c.frequency}</span>
        ${panicking ? `<span style="color:var(--danger);font-size:11px;font-weight:700">⚠ PANIC</span>` : ''}
        ${c.users.some(u=>u.transmitting) ? `<span style="color:var(--accent-raw);font-size:11px">● TX</span>` : ''}
      </div>
      <div style="font-size:11px;color:var(--text-secondary);margin-top:2px">
        ${c.users.map(u =>
          `<span style="color:${u.transmitting ? 'var(--accent-raw)' : u.panic ? 'var(--danger)' : 'var(--text-secondary)'}">${u.name}</span>`
        ).join(', ') || 'No users'}
      </div>
    </div>`;
}

function render() {
  const s = dispatch.getState();
  const channels = s.zones.flatMap(z => z.channels);
  root.innerHTML = `
    <div style="padding:10px 14px;border-bottom:1px solid var(--border);
                display:flex;align-items:center;gap:12px;background:var(--bg-secondary)">
      <span style="font-weight:700;color:var(--text-primary)">${s.serverName || 'Dispatch'}</span>
      <span style="font-size:11px;color:${s.connected ? 'var(--success)' : 'var(--danger)'}">● ${s.health}</span>
      <span style="font-size:11px;color:var(--text-secondary);margin-left:auto">${s.callsign}</span>
    </div>
    <div style="flex:1;overflow-y:auto">
      ${channels.map(c => renderChannel(c, s.channel)).join('')}
    </div>
    <div style="padding:10px 14px;border-top:1px solid var(--border);
                display:flex;gap:8px;background:var(--bg-secondary)">
      <button id="ptt-btn" style="flex:1;padding:8px;border-radius:6px;border:none;cursor:pointer;
        font-weight:700;color:var(--text-primary);
        background:${s.pttActive ? 'var(--accent-raw)' : 'var(--bg-hover)'}">
        ${s.pttActive ? '● TRANSMITTING' : 'PTT'}
      </button>
      <button onclick="dispatch.openSettings()" style="padding:8px 12px;border-radius:6px;
        border:1px solid var(--border);background:transparent;color:var(--text-secondary);cursor:pointer">⚙</button>
    </div>`;

  root.querySelectorAll('[data-freq]').forEach(el => {
    el.onclick = () => dispatch.joinChannel(el.dataset.freq);
  });

  const pttBtn = root.querySelector('#ptt-btn');
  if (pttBtn) {
    pttBtn.onmousedown = () => dispatch.startPTT();
    pttBtn.onmouseup   = () => dispatch.stopPTT();
    pttBtn.onmouseleave = () => dispatch.stopPTT();
  }
}

dispatch.on('dispatch:zones', render);
dispatch.on('dispatch:channel', render);
dispatch.on('dispatch:ptt', render);
dispatch.on('dispatch:connection', render);
dispatch.on('dispatch:callsign', render);

Error Responses

All error responses use Content-Type: application/json.

StatusBodyCause
400{ "error": "..." }Missing or invalid parameters
401{ "error": "Authentication required" }Missing or invalid auth headers
404{ "error": "Channel not found" }Invalid frequency
500{ "error": "..." }Server error

Type notes:

  • Frequencies accept strings ("154.755") or numbers (154.755) on all endpoints and exports.
  • Dispatcher serverId values must be negative; in-game player IDs are positive.
  • CORS is enabled for all origins.
  • Volume: exports use 0.0–1.0; admin panel and config.lua use 0–100 — divide by 100 when passing config values to exports.

On this page

Need help?

Ask on Discord