Overview
Sal's Kewl Dispatch is two products that form one communications system:
- The in-game radio (
sals_kewldispatch) — a trunked radio resource for your FiveM server. Talkgroups with codeplug-style labels, job-driven personalities, server-enforced single-transmitter arbitration, PTT IDs on every display, a full-keypad portable on foot and a control head in vehicles, a fire/EMS tone-out pager, 911 call-taking, and an in-game admin configuration window. A one-time purchase that runs entirely on your server, forever. - The hosted dispatch console — a browser-based, multi-channel dispatch position with two-way voice, delivered as a hosted service on a subscription. Dispatchers work from any browser; no copy of the game is required.
The two are sold separately — all pricing is on the main site. The radio never depends on the console: with no dispatch subscription, everything in-game works forever on your own server, including 911 (see 911 call-taking).
Eight chassis ship, drawn entirely in CSS from four invented manufacturers — Valcon, Corvex, Nordvik and Bulwark — plus an unbranded compact. Nothing is streamed, and no photograph, texture, logo or model file from any real manufacturer is included or referenced.
Requirements & voice
Works with qb-core, qbx_core, es_extended, or standalone (everyone gets the catch-all personality). Detection is automatic; there is nothing to configure. Optional integrations (inventories, phones, CAD) are detected at runtime — none of them are hard dependencies.
Voice backends
The radio does not ship its own voice transport. Pick one in
Config.Voice.Backend (the default 'auto' detects a
running backend):
| Backend | Config value | Notes |
|---|---|---|
| pma-voice | 'pma' | Most common. Carries one talkgroup at a time; scan stops on the active talkgroup like a real single-receiver scanner and returns to your selected talkgroup after a configurable hang time (Config.Voice.ScanFollow). Selected traffic always wins, and keying up always transmits on the selected talkgroup. |
| Native Mumble | 'native' | Uses FiveM's Mumble natives directly. Hears every scanned talkgroup simultaneously. Do not run alongside pma-voice. |
| SaltyChat | 'salty' | Routing only; the SaltyChat plugin keeps its own PTT. |
| None | 'none' | State, UI and arbitration with no player audio. Browser consoles are still audible. |
pma-voice users: this resource owns the push-to-talk key
and drives pma-voice's +radiotalk / -radiotalk
on grant and release. Have players unbind pma-voice's own radio key
(Settings → Key Bindings → FiveM) or they will key up twice and
bypass arbitration. The radio warns any player whose pma key is still set —
once on spawn and again if they press it.
Installation
- Drop the folder into your
resources/directory assals_kewldispatch. - Add
ensure sals_kewldispatchtoserver.cfg. - Grant yourself the admin panel (below).
- Open
config.luaand set your talkgroups and personalities — or start the server and configure everything live with/radioadmin.
Admin access
Two routes; either one is enough.
Identifier list (simplest). Join the server and run
/radioid — it prints your identifiers to the server
console and tells you which line to paste:
Config.Admin = {
Identifiers = {
'license:1a2b3c4d5e6f7890...',
},
}
Matching is case-insensitive and the license: prefix is
optional. Restart the resource after editing.
ACE (better once you have an admin team). In
server.cfg:
add_ace group.admin sals_kewldispatch.admin allow
add_principal identifier.license:1a2b3c4d5e6f7890... group.admin
If /radioadmin does nothing, check the server console: a denial
prints the player's actual identifiers and the exact add_ace line
to run.
Concepts
| Talkgroup | A logical channel: a stable id, a display label (what the radio screen shows), a colour, and flags for encryption, emergency eligibility and dispatch monitoring. |
|---|---|
| Zone | An ordered bank of talkgroups — one position set of the channel knob. |
| Personality | The codeplug a player is handed, chosen by job. It decides which zones exist, what may be scanned, where emergency goes, and which features are unlocked. |
| Alias / PTT ID | The unit ID other radios display when you key up. The agency prefix always comes from the personality, so a civilian cannot transmit as LSPD even with a custom suffix. |
| Scan vs monitor | Scan is one radio hunting for activity across a permitted list. Monitor is a set of talkgroups heard permanently and simultaneously — what a dispatch console sits on. Both are validated server-side against the codeplug. |
Transmit arbitration
One unit holds a talkgroup at a time, enforced in the audio path, not just on the display: the server grants the channel and only the granted unit's voice is routed to it.
- A denied key-up gets the bonk and
TG BUSY <holder>on the display. Config.Tx.MaxSecondsis a repeater-style time-out timer, so a stuck mic cannot hold a talkgroup hostage.Config.Tx.QueueRequestsauto-grants to the next waiting unit on release.- A unit in emergency preempts whoever is holding the talkgroup.
Emergency
Hold the orange button (or the emergency key) for
Config.Emergency.HoldMs. The radio forces itself to the
personality's emergency talkgroup, powers on if it was off, alarms on
everyone's display, and hands the emergency unit a hot mic. It re-announces
until cleared and auto-clears as a safety net. Dispatch consoles can clear it
remotely.
Player guide
Keybinds
All rebindable in Settings → Key Bindings. Defaults:
| Key | Action |
|---|---|
| Numpad * | Push to talk |
| F3 | Show / hide the radio |
| F4 | Grab / release the mouse |
| . (hold) | Emergency |
| Page Up / Page Down | Channel up / down |
| Home | Next zone |
Commands
/radio | Toggle the radio (the radio item's own name works too) |
/radioguide | Interactive tour that spotlights and explains every control on the chassis actually on screen. Nothing pressed while the guide is open transmits. |
/radiokeys | Key setup panel (see below) |
/radioalias <suffix> | Set your personal alias suffix (the agency prefix stays fixed) |
/radiomic | Re-open FiveM's microphone permission prompt |
/radiomove | top-left | top-right | bottom-left | bottom-right | center | reset — reposition the radio from chat if it is ever stranded |
/radiounfocus | Escape hatch that releases NUI focus if anything strands it |
/pager | Toggle the pager (its item name works too, e.g. /pspager) |
/911, /911text | Call — or text — 911 (see 911 call-taking) |
/radioid, /radioadmin, /radioreload, /radiodiag, /cadtest | Admin: identifiers, config window, re-issue codeplugs, diagnostics, CAD test |
Two states, deliberately separate
F3 shows the radio as a plain HUD — click-through, holding no focus, so the player keeps complete control of their ped. F4 grabs the mouse for clicking, dragging and resizing; the radio picks up a rim light so the state is obvious, and Esc releases it. Position, scale and the keypad's expanded state persist per player, separately for the portable and the mobile head.
The keypad
The portable's keypad is functional: digits enter a unit ID, #
sends a directed page to the matching unit, * backspaces.
Matching is on the digits inside an alias, so 402 finds
LSPD 402-ADAM. The KEYPAD bar collapses it to
the short limited-keypad radio, remembered per player.
Instant recall
MENU → RECALL lists the last few transmissions on the
selected talkgroup and replays them through the radio speaker. Live traffic
outranks the recorder, exactly like the recall button on a real portable.
Recordings live in server memory only — nothing is written to disk, and a
radio can only replay the talkgroup it is currently tuned to. Configure with
Config.Recall; requires the voice bridge.
Key setup modes
Config.Keys.Mode decides who owns the bindings:
'default' | Classic FiveM key mappings, changed in the pause menu — which accepts mouse side buttons. /radiokeys is a read-only view (FiveM's binding store cannot be written from script). |
|---|---|
'managed' | The radio owns the bindings and captures them in /radiokeys: click, press the key, done. Layout-independent, persists across sessions, works on production-mode servers. Keyboard only. |
For joystick buttons — or mouse buttons in managed mode — map the button to F13–F24 in your peripheral software (G HUB, Synapse, JoyToKey, reWASD, Stream Deck…) and bind that.
Radio & pager items
Config.Access.RequireItem gates the radio behind an inventory
item (default on); a personality can override with its own
requireItem — false for issued kit, or a specific
item or list. Picking the item up hands you a radio without relogging
(re-checked every Config.Access.RecheckSeconds, default 15;
/radio re-checks immediately), and
Config.Access.RevokeOnLoss decides whether losing it takes the
radio back.
Using the item opens the radio, and the item's name works
as a command. When somebody cannot use the radio they are told why — the
missing item by name, or that their job has no radio assigned. Set
Config.Access.ExplainDenials = false for a flat refusal (the
right call if the item is contraband).
ox_inventory routes item use through the item definition:
point the item's client.event at
sals_kewldispatch:client:radioUse (the pager's is
sals_kewldispatch:client:pagerUse).
Fire/EMS pager
A two-tone voice pager beside the radio — the device a volunteer
firefighter wears off shift. Everything lives under Config.Pager;
set Config.Pager.Enabled = false and nothing else changes.
How a page happens
- Dispatch opens the console's PAGER TONE-OUT window,
checks one or more tone sets (frequency pairs from
Config.Pager.ToneSets, or custom pairs whenAllowCustomTonesis on), checks the target talkgroups, types an announcement, and sends. - The tones sound over the air — every radio and console on those talkgroups hears them.
- Every on-duty pager subscribed to a matching tone set (within
MatchToleranceHz, like a real reed decoder) alerts, shows the announcement, and opens its speaker on the paged talkgroup forAlertAudioSecondsso the voice announcement that follows is heard. REPLAY on the pager replays it afterwards (requiresConfig.Recall.Enabled).
Who gets a pager is Config.Pager.Personalities — resolved from
the same job names as the radio but a separate table on purpose, so the fire
job's radio and pager are configured independently. A pager never transmits.
The pager's DUTY switch is pager-local — it silences that
pager and never touches the framework's duty flag.
The pspager item
Item definitions belong to your inventory system — add one there (optional;
the item gate is off by default). A ready-made 128×128 icon
(pspager.png) ships beside fxmanifest.lua — copy it
into your inventory's images folder.
qb-core — qb-core/shared/items.lua:
['pspager'] = {
name = 'pspager', label = 'Public Safety Pager', weight = 220,
type = 'item', image = 'pspager.png', unique = true, useable = true,
shouldClose = true, combinable = nil,
description = 'Two-tone fire/EMS voice pager',
},
ox_inventory — ox_inventory/data/items.lua
(the client.event line is what makes using it open the pager;
if you run ox alongside qb-core, define the item here only):
['pspager'] = {
label = 'Public Safety Pager',
weight = 220,
stack = false,
close = true,
description = 'Two-tone fire/EMS voice pager',
client = { event = 'sals_kewldispatch:client:pagerUse' },
},
Hosted dispatch console
The browser console is a real position on the trunked system — it registers with the same engine a handheld does, competes in the same arbitration, and shows up in everyone's PTT ID. It is delivered as a hosted service: your game server connects out to hosted dispatch, dispatchers open their community's console page over HTTPS in any browser, and each signs in with their own named account (Discord or password) — their own PTT ID, their own audit trail, revocable on their own.
Nothing inbound ever touches your game port — no reverse proxy, no port forward, no certificate to manage. How many positions may be signed on at once comes from your dispatch plan.
Linking your server
Your tenant id and key come with your dispatch account. Put them in
server.cfg — pasting a key there turns the link on by itself:
set sals_dispatch_tenant "ten_..."
set sals_dispatch_key "your-key"
Use set, never setr — a replicated convar is
sent to every connecting client, and your key must not be. Treat the key
like your rcon password. The resource audits its own posture loudly at
startup and refuses the link on a published or sample key.
The position
The console is a grid of talkgroup modules, all live at once, and two speakers: SELECT (the module your PTT goes to, at full level) and UNSELECT (everything else you monitor, mixed under it). Each module has SEL (talk on it), MON (hear it), XMIT (instant transmit without moving your SELECT), its own fader, mute, live PTT ID, listener count and level meter. Click a module's listener count to see exactly who would hear traffic on it right now, tagged with why (SEL / SCAN / MON).
Also on the position: the emergency queue with remote clear, unit roster, text paging, the pager tone-out window, a traffic log, and instant recall with WAV export. Which modules a position lays out comes from its personality; an account's agency can only ever narrow that list, never grant a module the codeplug does not carry.
Voice
The console carries voice in both directions — G.711 at 8 kHz riding the same transport as everything else (about 10 kB/s per talking unit):
| Direction | Notes |
|---|---|
| Console → game | Played through each radio's own speaker. Works on every voice backend, including 'none'. |
| Game → console | The player's client bridges their mic alongside game voice. Needs the mic permission (/radiomic re-asks). |
| Console → console | Same relay. |
The relay refuses any frame from a unit that does not hold the talkgroup grant — the single-transmitter rule governs bridged audio exactly as it governs game audio. Sign-on attempts are throttled per address, sessions are pinned to the address that opened them, and the account credential is re-checked on every request.
The radio never dims. With no link configured, the handhelds, mobiles and everything in-game work forever, entirely on your own server.
911 call-taking
A player dials 911 in game and reaches a dispatcher at the browser console. It behaves like a telephone, not like a radio: two parties, full duplex, no keying, created when somebody dials. A call is never in anybody's codeplug and cannot be reached by tuning to it — the two systems meet in exactly one place, PATCH, where a dispatcher deliberately puts the caller's voice onto a talkgroup.
911 ships off. Point Config.Calls.Services at
the personalities that should answer, set
Config.Calls.Fallback.Talkgroup, then either set
Config.Calls.Enabled = true or put
set sals_dispatch_calls "true" in server.cfg.
It works with your phone
Automatic detection for lb-phone, qb-phone, npwd, Roadphone,
gksphone, qs-smartphone and yseries. Anything else is described in
config — export and event names, no Lua edit
(Config.Calls.Phone.Custom). Export names are tried as candidate
lists and the one that answered is printed at boot, so a phone that renames an
export between versions keeps working. With no phone resource at all,
/911 works and numbers are synthesized stably per character.
ANI and ALI
ANI is who is calling — number, mobile or fixed line,
subscriber name when the phone can resolve one, and a repeat-caller flag.
ALI is where they are: a fixed line
(Config.Calls.Landlines) reports its registered address exactly;
a mobile draws a position with an uncertainty radius that tightens
over the first seconds of the call, the street name withheld until
the fix is tight enough, and REBID asks for a fresh fix.
Config.Calls.ALI.Phase is 'one', 'two'
(default) or 'exact'.
At the console
Three sidebar panels: CALL QUEUE (oldest first, ring timer), ACTIVE CALL (ANI, ALI, hold, transfer, conference, patch, rebid, notes, disposition) and CALL HISTORY, including abandoned calls. The telephone leg has its own fader and is deliberately not coloured like radio traffic; keying the radio PTT mutes the phone leg — one mouth.
Text-to-911
/911text opens a call that carries text instead of audio, with
the same queue, location and disposition — for players with no microphone,
players who refused the permission prompt, and players who cannot speak right
now.
Without a dispatch subscription
911 still works. The call is logged,
sals_kewldispatch:call:fallback fires for your own CAD, and
Config.Calls.Fallback pages or tones the emergency talkgroup. A
player dialling 911 cannot tell the difference from the game side.
A call opens the caller's microphone for the duration of the call — the
only place this resource does that — and releases it when the call ends. A
caller who refused the permission prompt shows as CALLER HAS NO
MICROPHONE on the call card and is told so on their own screen;
/radiomic is the way back.
CAD integration
Send 911 calls, radio emergencies and unit positions to the CAD your server already runs, and let the CAD send calls and assignments back. CDE CAD ships as a driver; anything else is either a config block or one event handler in your own resource. Everything is off by default, capability by capability.
The rule the integration obeys: the CAD is an accessory to a call, never a step in one. Nothing in the 911 or radio path waits on your CAD — a CAD that is down has exactly one symptom: the CAD does not have the data.
Setting it up (CDE)
Credentials go in server.cfg — the same convars CDE's own
resource uses, so if you already run it there is nothing to do here:
set CDE_CAD_API_URL https://your-cad.example
set CDE_CAD_API_KEY fvm_... # Admin Panel > FiveM Settings
set CDE_CAD_COMMUNITY_ID 000000000000000000 # your Discord guild id
set CDE_CAD_SERVER_NAME "Your Server"
Then turn on the parts you want in Config.CAD
(Push.Calls, Push.Panic, Push.Units,
Push.Location, Pull.Dispatch…).
/cadtest is the command to run first — it prints
the posture, fires one payload of each enabled kind, and the 911 test is a
full round trip that cleans up after itself.
If your CAD ships its own phone bridge,
Config.CAD.Pull.Calls.Ownership settles who owns 911:
'we-own' (recommended) or 'cad-owns'. The resource
warns at boot when it sees a second 911 owner running.
For a CAD nobody has written a driver for: Provider = 'event'
emits everything as events for your own resource, and
Provider = 'custom' posts the same payloads as JSON to endpoints
you name.
Admin panel & live config
/radioadmin opens the in-game configuration window. Three
tabs: Talkgroups (add, edit, reorder, delete — deleting one
tells you which personalities reference it and cleans them up),
Personalities (jobs, zones, channel order, scan lists,
feature flags, emergency talkgroup) and Settings (nine
sections down a rail — arbitration, emergency, audio, voice routing,
interface, alias format, access, logging). A moon/sun button switches the
panel between light and dark, remembered per machine.
Saving writes data/overrides.json and re-issues every radio
live — units keep their current zone/channel wherever the new codeplug still
has it. config.lua is never rewritten; delete
overrides.json to fall back to it. The last
Config.Admin.BackupCount saves are kept, and Roll Back
restores the most recent. Everything from the panel is re-validated server
side.
Talkgroup order matters. The array index determines the voice channel number. On a live server, append new talkgroups at the end rather than inserting in the middle, or you will re-map voice channels for everyone connected.
Audio
Voice coloring is a GTA audio submix applied to radio
traffic. Presets in Config.Audio.Presets:
| Preset | Character |
|---|---|
p25 | 300–3000 Hz passband, zero analog fudge, a light ring-mod blend for the vocoded edge. |
p25_light | Same idea, dialled back. |
analog | Classic FM grit and distortion. |
off | Untouched voice. |
Signaling tones — talk-permit beep, bonk, courtesy tone,
squelch clicks, emergency warble, page alert, keypress clicks — are
synthesised in the UI; no audio files ship with the resource. Frequencies and
lengths are in Config.Audio.Tones, and
Config.Audio.DigitalArtifact is the short quantised-noise burst
when a transmission opens.
Exports & events
The commonly used server exports:
exports['sals_kewldispatch']:GetTalkgroupRoster('LSPD1') -- who is listening
exports['sals_kewldispatch']:IsTalkgroupBusy('LSPD1')
exports['sals_kewldispatch']:SendPageToTalkgroup('LSPD1', 'CAD', 'Signal 100')
-- 911
exports['sals_kewldispatch']:StartCall({ src = source, number = '911' })
exports['sals_kewldispatch']:EndCall(callId, 'reason')
-- pager
exports['sals_kewldispatch']:SendToneOut(
{ 'FIRE1', 'EMS1' },
{ 'STATION1', { a = 607.0, b = 1032.0 } },
'Structure fire, Vinewood Blvd', 'CAD')
exports['sals_kewldispatch']:SendPagerText(src, 'Respond to station', 'CHIEF')
-- jobs (standalone servers, or to override the framework)
exports['sals_kewldispatch']:SetPlayerJob(src, 'police', 4, true)
exports['sals_kewldispatch']:RefreshPlayer(src)
Three events fire for every 911 call, whatever else is configured:
sals_kewldispatch:call:determined -- somebody established what the call is
sals_kewldispatch:call:fallback -- nobody took it; the units are being paged
sals_kewldispatch:call:closed -- it ended, with disposition and notes
StartCall accepts nature, priority,
location, a landline block for a known place, and
meta — a callout script that already knows what the call is
should say so. On qb/qbx/esx the framework outranks
SetPlayerJob: the next real job or duty event clears the
override. The full export list is in the README that ships with the
resource.
Troubleshooting
| Symptom | Fix |
|---|---|
| Anything at all | /radiodiag first. It prints the running version, which
framework / inventory / phone / CAD adapter resolved and by which export,
the voice backend, the link posture and the pma-voice key check. |
| A fix you just uploaded changes nothing | FXServer caches the manifest. After copying files, run
refresh, restart the resource, and confirm the version line
/radiodiag prints matches the CHANGELOG before testing. |
| Players key up twice / bypass arbitration | pma-voice's own radio key is still bound — have them unbind it in Settings → Key Bindings → FiveM. The radio warns them in chat, naming the key. |
| A caller or unit is silent on the console | They refused FiveM's microphone prompt. /radiomic
re-asks. The call card says CALLER HAS NO MICROPHONE when
this is the reason. |
/radioadmin does nothing |
Check the server console — a denial prints the player's identifiers
and the exact add_ace line to run. |
| The radio is stuck somewhere the mouse can't reach | /radiomove reset. Saved positions are clamped to the
screen on load, so a resolution change cannot strand it permanently. |
| The game stops taking input | /radiounfocus releases NUI focus if anything stranded
it. |
| A player says they "have no radio" | With Config.Access.ExplainDenials on (the default) the
refusal names the missing item or the job problem. Check their job is in a
personality and they hold the required item. |
Support
Support, release news and coupon codes are on
Discord.
Purchases are handled by Tebex, who own the checkout, billing support and
refunds. The resource ships with its own README and CHANGELOG — the CHANGELOG
calls out breaking config changes explicitly, and /radiodiag
prints the version you are actually running.
In-game radio and dispatch console are separate products — all pricing is on the main site.