Sal's Kewl Korner Documentation / Sal's Kewl Dispatch Discord

Sal's Kewl Dispatch — owner's manual

A trunked two-way radio for FiveM with a fire/EMS tone-out pager, 911 call-taking, CAD integration, and a hosted browser dispatch console with two-way voice. This manual covers installation, configuration, daily use and troubleshooting for server owners.

documents resource version 3.8.0 ↓ Download as PDF

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).

The radios are modeled on real public-safety hardware: an all-band handheld and two hi-viz full-keypad portables on foot, two control heads for the vehicle, and a fire/EMS voice pager, which is its own device rather than a radio you pick. Every maker and model name on them is our own invention, and no real brand name, product number or manufacturer's logo appears anywhere in the resource. If you would rather badge them yourself, Config.Models.Marks puts your own print on any chassis.

3.0.0 replaced the catalog. The radios used to be drawn in CSS. A drawn faceplate never quite reads as a photograph of a radio, and the two beside each other in one picker made both look worse, so the drawn ones were retired in one go rather than added to. And you are not waiting on us for radios either — you can install your own without restarting the server, and build one with an image editor and a text editor.

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):

BackendConfig valueNotes
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

  1. Drop the folder into your resources/ directory as sals_kewldispatch.
  2. Add ensure sals_kewldispatch to server.cfg.
  3. Grant yourself the admin panel (below).
  4. Start the server once. It creates its data/ folder. Then open /radioadmin in game: the codeplug, and under Settings every setting the resource has. Nothing needs a file. defaults.lua beside it is documentation, replaced by every release and never edited (see Configuration).

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. This is one of the few settings read at startup rather than from the panel, so it goes in config.local.lua (never defaults.lua, which every upgrade replaces):

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.

Configuration: the panel and the files

You don't edit files. Every setting the resource has is on the Settings branch of /radioadmin in game and the Radio settings page of your customer portal, and what you change is saved under data/, where an upgrade never touches it.

WhereWhat it is
/radioadmin and the portal Where you change things. 252 settings in 24 sections under five headings, with a search box, a ? on every one, and ↺ default on anything you've changed. Saved to data/overrides.json.
defaults.lua Ours. Every setting at its default, with a plain-language comment explaining it. The file you read to find out what a setting is called. Replaced by every release, never edited. It was called config.lua before 3.8.0.
config.local.lua For the few things a screen can't hold, because the client registers them from its own copy of the defaults when it starts: command names, default key bindings, whether 911 and the pager exist at all, and the identifiers that open the panel. One line per setting, same path as in defaults.lua. Most servers never write one.

RESTART, and what it means

A few settings are read once when the resource starts: which CAD driver, the fire and phone adapters, chat capture on a 911 call, the radio item name, the voice service switches. They used to be kept off the panel because a save that changes nothing until a restart looks broken. Now they're saved like any other, tagged RESTART beside the control, and named again on the status line after the save so you know what waits for the next restart sals_kewldispatch.

Export and import

EXPORT SETTINGS hands you the whole configuration as one file: the codeplug, every setting, the tower sites, the console layouts and the radio AI's settings. In game it's copied to your clipboard and written under data/exports/; in the portal it downloads. Keep it in version control, carry it to a second server, or attach it to a support request. IMPORT SETTINGS takes it back through every check a save gets, tells you where it came from before it applies, and says which settings wait for a restart. From the server console the same two are radioexport [name] and radioimport <name>.

The overlay, if you ever need it

For the boot-time names only, copy the line out of defaults.lua into config.local.lua with your value on it, whole path, spelled exactly:

Config.Commands.Radio = 'dispatch'
Config.Keys.Ptt       = 'CAPITAL'

The file is created for you from config-local-EXAMPLE.lua the first time the resource starts. Write only the lines you're changing; a whole table replaces what was there. The server prints what the file changed at every start, names a misspelled path rather than ignoring it, and says when a panel save is winning over a line in the file.

Coming from a release before 3.8.0 with edits in config.lua? That file is no longer loaded, and the server warns at every start until it's gone. Your settings go in the panel now, where every one of them is; the boot-time names go in config.local.lua. Send us your config.lua and we'll send back the lines that differ from what we shipped. Blank out any key first.

Upgrading

Copy the new folder over the old one. That is all.

  1. Copy the new sals_kewldispatch folder over your existing one, letting it overwrite. Do not delete the old folder first.
  2. refresh, then restart sals_kewldispatch.
  3. Run /radiodiag as an admin. The first line names the running version, which is how you know the copy took.

There is nothing to merge and no diff to read, because everything you configured is saved under data/, and the download contains no data folder. Press EXPORT SETTINGS in /radioadmin before you start and keep the file; it is the one backup that holds everything.

Never delete the old folder. The whole data folder, and config.local.lua if you wrote one, live only there and are not in the download. Deleting the folder loses the codeplug and settings your admins saved and their backups, radio aliases, per-player radio state, tower sites, postals, Radio AI settings, console layouts and prefs, your exports, and every chassis package you installed. Nothing can get them back.

What is yours

If your hosting panel can only replace a folder, copy these out first and put them back before restarting. The same list is what belongs in any backup of the server.

File or folderWhat it holds
data/overrides.json, data/overrides.bak*.jsonThe codeplug and every setting saved from /radioadmin or the portal, and their backups
data/exports/Anything you exported
data/aliases.json, data/players.jsonRadio aliases and per-player radio state
data/rfsites.json, data/postals.jsonTower sites and the postal grid
data/airadio.json, data/layouts.json, data/consoleprefs.jsonRadio AI settings, owner console layouts, dispatcher screen prefs
data/chassis/Every chassis package installed from /radioadmin
config.local.lua, data/config.local.backup.luaYour overlay, if you wrote one, and the server's own copy of it
integrations/phones/yours.luaAny phone integration you wrote or renamed

The short version: the whole data folder, config.local.lua if you have one, and any file you added yourself.

Where a setting can come from

Three layers, and later beats earlier:

LayerWhat it is
defaults.luaOur defaults. Ours to improve, yours to read, never edited
config.local.luaA hand-written overlay for the boot-time names. Beats defaults.lua
data/overrides.jsonWhat /radioadmin and your portal save. Where your settings are. Beats both

So if you put something in the overlay and it does not take effect, the usual reason is that the same thing was saved from the panel. The server console says which setting, by name, at every start, and the panel shows the value it is actually running with.

Concepts

TalkgroupA logical channel: a stable id, a display label (what the radio screen shows), a color, and flags for encryption, emergency eligibility and dispatch monitoring.
ZoneAn ordered bank of talkgroups — one position set of the channel knob.
PersonalityThe 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 IDThe 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 monitorScan 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.MaxSeconds is a repeater-style time-out timer, so a stuck mic cannot hold a talkgroup hostage.
  • Config.Tx.QueueRequests auto-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:

KeyAction
Numpad *Push to talk
F3Show / hide the radio
F4Grab / release the mouse
. (hold)Emergency
Page Up / Page DownChannel up / down
HomeNext zone

Commands

/radioToggle the radio (the radio item's own name works too)
/radioguideLists every command that player can actually use, under the names you gave them, with their own key bindings — and offers the interactive tour that spotlights and explains every control on the chassis actually on screen. Nothing pressed while the guide is open transmits.
/radiorostershowShow or hide the panel listing who else is on the player's channel (see below). reset puts it back where it started
/radiorosterfocus <channel>Point that panel at one channel the player can hear. No channel puts it back on the one they have selected
/radiokeysKey setup panel (see below)
/radioalias <suffix>Set your personal alias suffix (the agency prefix stays fixed)
/radiomicRe-open FiveM's microphone permission prompt
/radiomovetop-left | top-right | bottom-left | bottom-right | center | reset — reposition the radio from chat if it is ever stranded
/radiounfocusEscape hatch that releases NUI focus if anything strands it
/pagerToggle the pager (its item name works too, e.g. /pspager)
/911, /911textCall — or text — 911 (see 911 call-taking)
/radiocheckChecks your own radio and tells you what is wrong with it and what to press. Any player, no permission (see below)
/radioid, /radioadmin, /radioreload, /radiodiag, /cadtest, /firetestAdmin: identifiers, codeplug programmer, re-issue codeplugs, diagnostics, CAD test, fire integration test

Who else is on this channel

The dispatch console has always been able to see who is listening to a talkgroup. Since 3.5.0 the people carrying the radios can too. /radiorostershow opens a small panel beside the radio listing everyone who would hear that player if they keyed up right now.

Each name is tagged with how they are hearing it — SEL sitting on the channel, SCAN sweeping it, MON monitoring it — and anyone not sitting on it also shows which channel they are on. That last column is the one that answers “why didn't my partner come back to me”: a unit on scan is working something else and may not answer. A unit with an emergency on sorts to the top.

/radiorosterfocus <channel> points the panel at one channel the player can hear rather than the one they have selected, so a unit can keep an eye on a talkgroup they are scanning while they work another. Partial names work, so tac finds TAC-2, and a name that matches nothing lists the channels in their current zone. With no channel at all it goes back to following the selected one.

It is off for everyone until a player turns it on, and each player's choice is remembered. There are tick boxes for it in /radio and in /radioguide as well, so it can be found without knowing the command exists.

It behaves like a HUD panel, because it is one. Whenever a player has the mouse — the cursor key, the same one that works the radio itself — they can drag it by its header, pull the bottom-right corner to set its width and how many names are visible, and click the chevron to fold it to a single line that still carries the channel and the count. All of it is remembered per player. The list scrolls past whatever height they chose, so the count in the header is always the truth. /radio has a slider for the number of names and a Reset button, and /radiorostershow reset is the rescue if a panel ever ends up somewhere the mouse cannot reach. The rest of the time it is click-through, so it never eats a click meant for the game.

A player can only read a channel they can hear. The server answers with the roster of one talkgroup, and only after checking that the asking radio actually receives it — the same test that decides whether they would hear the traffic. A unit sitting on a channel the reader's own personality does not carry shows as elsewhere rather than being named, so the panel can never hand somebody a codeplug entry they were never issued.

Config.RosterWhat it does
EnabledMay players see a roster at all. false removes both commands too
DefaultDoes the panel start visible for a player who has never touched it. Ships false
Scopehearing (anything they receive) or selected (only the channel they are on, and the focus command says so rather than being ignored)
ShowViaThe SEL / SCAN / MON tags
ShowChannelThe “they are actually on X” column
ShowConsolesInclude dispatch consoles in the list
RefreshSeconds between refreshes while it is open. Nothing is sent while it is closed. Floored at 2
RowsHow many names are visible at once before the list scrolls. Each player can change their own
MaxRowsThe most names the server will ever send, and the ceiling on Rows. Beyond it they are counted
DraggableMay a player drag the panel somewhere else
ResizableMay a player resize it by the corner grip
CollapsibleMay a player fold it to its header
HudWhere it sits by default, in the same terms as Config.Calls.Hud. A player's own position wins

Checking your own radio

“I can't talk on the radio, sometimes” is the hardest report to act on, because every one of its causes feels identical from the player's chair — an unbound key, a radio switched off, someone else holding the channel, hang time, no tower, a blocked microphone, a leftover pma-voice binding.

/radiocheck tells them apart, and any player can run it. It checks the radio they were issued (or why they were refused), power, selected channel, the push-to-talk binding, a clashing pma-voice “Talk over Radio” key, the voice backend, the microphone grant, RF coverage, whether the artwork loaded, and why their last transmission was refused — and says what to press about it. The full list goes to F8 so it can be screenshotted; the first problem and its fix also go to chat, because a player who does not know what F8 is still needs the one sentence that matters.

It needs no permission because every fact in it is one that player's own client already holds about itself, which is also why it can say nothing about anybody else. Staff read the same rows for a specific player with /radiodiag <id>, in their own console, without talking anybody through anything.

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.

The radio talks

Since 3.7.0, turn the knob and the radio tells you where you landed, the way a real APX or XL does: "Zone alpha." "L S P D one." It reads the channel on a channel change, the zone and then the channel on a zone change, and the radioset at power-up. Only the person holding the radio hears it. It never goes out on the air, never takes a transmit grant, and it waits rather than talking over live traffic. Players can switch it off in the radio's own SETTINGS window, separately from the tones.

It costs nothing to switch on. With no setup at all it uses the game browser's own voice where that works and stays silent where it doesn't, with the name still on the display. If you want your own voice, a voice pack is a folder of your recordings with a name, handed to the personalities that should use it, so fire reads your fire codeplug and PD reads PD off the same codeplug. A pack with no mapping is searched by the channel's own name (CHANNEL_LSPD1.ogg), so you name the files after your channels and nothing else needs writing. We ship no clips, deliberately: a voice is a licensing question. audio/README.md in the download is the whole procedure, and a hosted voice (Config.Annunciator.Tts, key in server.cfg) renders your closed list of channel names once and never pays for them again.

Config.Annunciator = {
    Voices = {
        ['pd'] = { Dir = 'audio/bank/pd' },
        ['fd'] = { Dir = 'audio/bank/fd' },
    },
    ByPersonality = { police = { Voice = 'pd' }, fire = { Voice = 'fd' } },
    Say = { ['LSPD1'] = 'L S P D one' },
}

Say is the setting to look at. Your labels were written to be read on a display, not spoken, so LSPD1 would come out "lispid one". The radio already splits a trailing number off, spells out short initialisms and leaves real words like FIRE and EMS alone; Say is where you correct the rest, and /radiodiag prints what every label will be said as so you can see which ones need it.

Your badge on the display

Config.Display puts a splash screen over the display for a couple of seconds when the radio is switched on, and a faded wallpaper behind the channel text the whole time it's on. They're independent and both are off until you point them at an image. Drop a file into html/splash/ and name it as 'splash/yours.png', or use a data: URI; a URL on another host, an absolute path and a .. hop are all refused server side, and the console says which image it wouldn't load and why. Keep the file small, because every player who joins downloads it, and the wallpaper is capped at 0.6 opacity because the display is where somebody reads their channel in a hurry. ByModel gives one chassis different artwork from the rest.

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).

Codeplugs from ACE groups

A personality is normally picked from the player's job, which needs a framework to report one. vMenu isn't a framework, and neither is running nothing at all, so on those servers every player used to come back as one civilian job. Since 3.7.0 the add_principal lines already in your server.cfg decide who gets which radio:

Config.Access.ByAce = {
    { ace = 'group.police', personality = 'police' },
    { ace = 'group.fire',   personality = 'fire' },
}

It's a list and the order is the priority, because people are usually in several groups at once: the first row that matches wins, so put the most specific group first. A row naming a personality that doesn't exist is skipped and named at startup rather than taking anybody's radio away. It sits below a pin and a CAD duty profile and above the job, so it's useful on a qb or ESX server too, for somebody who dispatches but isn't employed as one. /radiodiag prints the mapping and which group each player online matched.

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

  1. Dispatch opens the console's PAGER TONE-OUT window, checks one or more tone sets (frequency pairs — Config.Pager.ToneSets to start with, and edited live from the Pager tab of /radioadmin or your customer portal, one catalog for every pager and every console — or custom pairs when AllowCustomTones is on), checks the target talkgroups, types an announcement, and sends.
  2. The tones sound over the air — every radio and console on those talkgroups hears them.
  3. 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 for AlertAudioSeconds so the voice announcement that follows is heard. REPLAY on the pager replays it afterward (requires Config.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' },
},

RF coverage & tower sites

By default your radios work everywhere on the map, because nothing in the resource models where the towers are. The four signal bars are decoration. Turn on Config.RF.Enabled and they stop being decoration.

This arrives switched off, and off means nothing changes. No extra work per player, no new failure mode, and the bars stay exactly as they were. Turn it on when you want them to mean something.

What changes when it is on

Each radio measures the tower sites you have placed, roams to the strongest one it can hear, and shows what it actually has. The menu grows an RF STATUS page with the site name, the signal in dBm and whether you can transmit, the way a real radio's service display does.

Six sites ship on real landmarks: the Chiliad antenna farm, the Vinewood tower, downtown, the port, Sandy Shores and Paleto. They are tuned so normal play has full bars and the holes are interiors, tunnels and the far water. Move them, repower them, add your own, or delete the lot and start again. If you clear them, do it with RF switched off or place your first site before you save: RF on with zero sites means nobody has signal.

Coverage never decides who is allowed on a channel. Personalities, talkgroup grants and item checks are all resolved first and are untouched by any of this. RF only answers whether the radio can reach a tower. A unit in a dead spot has exactly the permissions it always had, it just cannot reach anybody with them.

The three thresholds

SettingDefaultWhat happens below it
Limits.RxUsableDbm−118Out of range. The radio goes quiet and the screen says NO SERVICE.
Limits.TxUsableDbm−110You can still hear, but keying up gets a bonk and NO SITE.
Limits.MarginalDbm−110Usable but poor. Audio starts breaking up.

There is a band where you can hear dispatch and cannot answer them. That is deliberate — a tower transmits with hundreds of watts and a portable answers with five, so the uplink is the weaker half of a real radio link. It is the most interesting failure in the model, and it is adjustable. The refusal is enforced on the server against its own idea of where you are standing, so a player editing their game can make their own bars lie and still not transmit.

A weak signal sounds weak

Digital radio at the edge of a cell loses syllables. It does not politely fade, and none of this is a volume change. Two paths degrade, driven by the same number — Receive.MaxDropChance, the fraction lost at the weakest usable signal, ramping to none at the marginal threshold:

  • Audio that arrives through the radio — console traffic, pager tones — is thinned frame by frame, because the interface is holding each frame and can decline to play it.
  • Player voice belongs to your voice backend (pma-voice, Mumble, SaltyChat), and the only lever we have on it is the listen set, which is on or off. So the same loss is spent as short windows instead: the radio drops the channel for about a quarter of a second at a time. Receive.VoiceDropouts, Receive.DropWindowMs.

A short codec artifact fires with each loss, so a lost word reads as a weak signal rather than as somebody's microphone cutting out. The display does not flicker with it: a dropout is not NO SERVICE, and a screen strobing between the two several times a second reads as a broken radio rather than a weak one.

Sites fail

At a rate you choose. Commercial power drops one outright. Air conditioning is the interesting one: cooling stops, the shelter heats over real minutes, and the site loses transmit power before it dies — so everyone in that cell watches their bars sag before anything goes off the air. Repairs happen on their own unless you set RequireManualRepair, which is the switch to flip the day somebody is actually being paid to drive out there.

An optional shelter mode gives every site a rack of individual parts — amplifier, feedline, rectifier, batteries, generator, HVAC, backhaul, even the tower lights — that fail, wear out and cascade into each other. If every site is down at once, Config.RF.Failsoft decides what happens: 'degraded' keeps the talkgroups working with FAILSOFT on the display, which is what a real system does.

An optional weather adapter is detected at runtime like every other integration, so storms raise the odds of a power failure and hot weather pushes the shelters closer to the edge. Without one, a configured daily temperature curve does the same job.

Your dispatch console is never affected. Consoles, 911 and the radio AI sit on the system itself rather than on a tower, so no amount of RF trouble can make dispatch unreachable. That is not configurable on purpose. Pager tone-outs ignore coverage too, unless you turn on Config.RF.AffectsPager.

Channel capacity: the busy signal and the queue

A real trunked site has a finite number of voice channels. Since 3.8.0 yours can too: set channels on a site, and when they're all in use a key-up gets the bonk and SYS BUSY on the display. Turn the queue on and the key-up waits in line instead and is granted the moment a channel frees, in request order.

Config.Tx.Capacity = {
    Enabled       = true,
    Channels      = 0,        -- per site unless the site sets its own; 0 = unlimited
    CountWireline = true,     -- dispatch traffic occupies a channel at the sites carrying it
    Queue = { Enabled = true, WindowMs = 6000, MaxWaiting = 10 },
}
-- and per site:
{ id = 'chiliad', label = 'MT CHILIAD', channels = 4, ... }

A channel is counted per site and per talkgroup, the way a real controller assigns one, so two units talking on the same channel under the same tower are one call. Only the transmitter's own site can refuse them. BUSY and OUT OF RANGE stay different faults: /radiocheck names which one it was and /radiodiag has a trunking section with the channel count, what's in use, what's waiting and which sites are full. Dispatch consoles, 911 and the radio AI are never refused a channel. An emergency never queues: it takes a channel at a full site when Config.Tx.EmergencyPreempt is on. With RF coverage off it's one system-wide pool, so you get the behavior without planning towers first.

It ships unlimited, and the queue ships off. Switching capacity on without setting a number changes nothing, on purpose. A queued transmission can start a second or two after the button went down, which is right for a trunked radio and surprising for anyone used to an ordinary FiveM radio. For scale: a small real P25 site runs three to five voice channels. Pick for how many units you actually have on the air; two channels is tense, ten is invisible.

Checking it

/rfdebug prints every site's signal from where you are standing, which one you are affiliated with, whether you can transmit, and how much voice you are losing right now — so a coverage complaint can be checked rather than argued about. /radiodiag carries an rf coverage block listing each site, whether it is up, how much power it has lost and how hot it is.

The setting worth understanding is the path-loss exponent (Config.RF.Model.Exponent). It is the single number that decides how far every radio reaches — raise it and the map gets bigger, lower it and everything closes in. Get that right before you start moving sites around.

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; red at rest, pulsing ON AIR while keyed), 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).

Simulcast. Shift+click SEL on a module to add it to the simulcast; the tile shows a SIMUL flag and the PTT shows +N. Every key-up on SELECT then goes out on SELECT and every leg at once, each leg arbitrated on its own: a busy leg is skipped and reported, a preempted one drops off while the rest carry on. XMIT on another module stays a single-channel key. CLEAR SIMUL in the header empties the set, and it clears at sign-off on purpose.

Layout that follows you. Press ARRANGE to move and resize the modules freely and, since 3.3.2, the sidebar too: drag a panel by its header to reorder it, its bottom edge for height, the seam beside the modules for the column width. Everything on the surface, faders and theme included, is saved against your account on your own game server (data/consoleprefs.json), so the same console comes up from any browser or workstation you sign on from.

Tabs. While arranging, + TAB makes a tab and modules drag onto it; anything no tab names sits on an automatic OTHER tab. A tab you are not looking at lights green with a count while anybody talks on it and flashes red for an emergency, so grouping never costs awareness; what you hear does not change with the tab. Sidebar panels can dock beside a tab's modules or stay pinned on every tab. Ctrl+1…9 switch tabs. The header carries a clock, server time by default.

Owner-designed layouts. Your portal's Console layouts tab opens the console in design mode: pick a personality, arrange exactly the modules it lays out, save under a name. Assign layouts per personality with a policy — Free (dispatchers may start from yours and arrange their own; also the default), Choose (they pick one of yours, no arranging) or Locked (always the one marked DEFAULT). A layout is filtered on your own server against the personality's talkgroups, so it can only ever hide a module.

You can do the assigning in game instead: /radioadmin carries a Console layouts branch listing every personality, where you tick the layouts it may use, pick the rule, and mark the default. Drawing a layout stays a browser job — the designer is the console itself — but assigning does not need one. Apply writes on its own: assignments live in data/layouts.json rather than in the codeplug, so you never press WRITE CODEPLUG for them and pressing it does not undo them.

Live map. A MAP panel shows where units are, from your server's own measurement, only on talkgroups the console can reach and only while a radio has position sharing on (/radiogps). Dots are colored by talkgroup and pulse red in an emergency. The imagery is an open-source San Andreas map the platform hosts; your portal's Live map tab takes a zip of tiles for Roxwood or any other map you run.

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):

DirectionNotes
Console → gamePlayed through each radio's own speaker. Works on every voice backend, including 'none'.
Game → consoleThe player's client bridges their mic alongside game voice. Needs the mic permission (/radiomic re-asks).
Console → consoleSame 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.

Your own name on it

A community running a real dispatch center can have the console, the browser tab and the in-game programmer carry its name, accent color and logo instead of ours. White-labeling is part of a hosted arrangement rather than a switch you set, so ask us and we will turn it on for your server. Nothing to re-download and no restart. The maker's mark printed on the handheld is a separate thing, and has always been yours to set.

The radio never dims. With no link configured, the handhelds, mobiles and everything in-game work forever, entirely on your own server.

Keys and control surfaces

Since 3.8.0 every gesture a dispatcher repeats has a key, and the whole map is under SETTINGS → KEYS AND CONTROL SURFACE: click the key, press the one you want, Backspace to clear it, Esc to leave it alone. Two actions can't share a key; choosing one that's taken is refused with the name of the action holding it. The map saves against the dispatcher's account like their layout, so it follows them to the next desk.

DefaultAction
SpacePush to talk on SELECT
Shift+SpaceTransmit latch: press on, press off
↓ / ↑Select the next / previous talkgroup
1…8Select talkgroup 1 to 8 as laid out on screen
MMonitor the selected talkgroup
Shift+1 / 2 / 3ALERT 1 (steady), 2 (pulsed), 3 (hi-lo)
TOpen the pager tone-out
AAcknowledge: go to the emergency
Shift+CClear the oldest emergency
Shift+MMute everything

A Stream Deck needs no plugin

A Stream Deck is a box of buttons that presses keys for you, so the key map is the hardware integration: nothing for a dispatcher to install, no service in their tray, no port open on their machine or on your server, and nothing that breaks when either side updates. There's a ready-made profile for the MK.2 and the table on its own for anyone building the buttons by hand on the hardware control surfaces page. Loupedeck, X-keys, a gaming keyboard's macro keys and a USB foot switch all send keystrokes just the same.

Transmit from a button is the latch, not push to talk. A Stream Deck sends a keystroke as a tap, so the console can't tell how long the button was held. The latch is one press on, one press off, and it's released by every path that already releases a transmission. A foot switch on the space bar is still the best push-to-talk money can buy.

Your customer portal

Your community's account is yours to run, at /portal: your plan, your dispatcher accounts, who may sign in, your link key, your own model keys, your codeplug and radio AI settings, and an audit trail of what changed and who changed it. You do not open a ticket for any of it.

An owner is a rank in your own access table, so there is nothing extra to administer. Give somebody the owner rule in Discord and they have the portal; take the role away and they do not. A portal session is not a console session and does not use a dispatch seat, so a community sitting at its seat limit can still read its own billing.

Two things we will not let you do: save an access table that leaves nobody as owner, and write anything while the account is suspended. A suspended community can still read everything.

Signing in, and our Discord bot

There are two ways into the portal and they are not exclusive. Sign in with Discord if you hold a role mapped at rank owner, which is the usual way and needs nothing handed over. Or sign in with an email and password we issue you — for a community that would rather not tie portal access to a Discord role, or that has not mapped one yet. Ask us and we will create it; the password is shown to us once, so we send it to you separately from this address and you should change it.

Separately, we may send you a link to add our Discord bot to your server. It is worth knowing what that is and is not. It asks for no permissions at all, it reads your role names and nothing else, and it is optional — it changes nobody's access either way. Dispatcher sign-in reads a member's roles through Discord's own consent screen, bot or no bot. What the bot buys is setup: we can map your access by role name instead of you copying eighteen-digit role ids, and the connection test can tell you a role is not in the server you named.

Bringing your own model keys

If you would rather be billed directly by OpenAI and Anthropic, put your own keys in. It is both or neither. OpenAI covers voice and the radio's speech recognition and speech, Anthropic covers text and the radio's reasoning, and one key on its own would bill that vendor to you while leaving the rest on your plan's allowance. With both in, your usage stops counting against the allowance at all. Once they are saved we only ever show you the last four characters.

If your key is refused we do not quietly fall back to ours. The lane stands down, the failure is recorded, and both our panel and your portal say so. Falling back would turn your outage into our bill, and you would never find out the key was dead.

Serving the console from your own hostname

Point a hostname at us and your dispatchers reach the console there instead of on the shared address. A subdomain needs a CNAME onto our host; an apex needs matching A and AAAA records. There is no second verification record to add, so there is no state where your DNS is correct and we still refuse. The certificate is issued automatically once the name checks out.

Your original console address keeps working, so this is an alias rather than a migration, and a resolver hiccup cannot take a working console down. Your hostname serves the console and its sign-in and nothing else: the panel, this portal and your game server's link all stay on ours.

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, and you can teach it a new one

Automatic detection for lb-phone, qb-phone, npwd, Roadphone, gksphone, qs-smartphone and Y-Series. 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.

Your phone is not on that list? Add it yourself. Each of those phones is one short file in integrations/phones/, which ships outside the asset escrow — readable, copyable and yours to add to. Copy EXAMPLE.lua to any other name, fill in your phone's export names, restart. There is no list to add it to and nothing in defaults.lua to change.

Registering a phone we already ship replaces ours, and the boot log says so — which is how to correct one of our files in a way that survives an upgrade: copy it to a name of your own and change the copy, the same rule as config.local.lua. If you write one that works, send it to us and it ships for everybody. A phone can announce a call either by firing an event or by writing a state bag; Y-Series does the second, and its file is the worked example. Config alone still works too (Config.Calls.Phone.Custom), with no file at all.

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 colored 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 dialing 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.

The radio AI dispatcher

When a unit calls dispatch on the radio and no live dispatcher answers, the AI can. It listens on a talkgroup you nominate, answers in a voice you pick, and writes to your CAD through the same seam everything else uses. It needs the AI tier of a dispatch plan, and it is a separate switch from 911 call-taking, so you can run either, both or neither.

It never decides whether it was called

A plain-text wake word decides that, and it is ordinary matching rather than a judgment call. You set your dispatch term, any aliases, and which hail formats your server actually uses. No match means no model is called and nothing goes out on the air.

A human always outranks it. Set a grace and any dispatcher keying up on that talkgroup inside the window stands the AI down for that hail, for good. With nobody signed on it answers straight away. It transmits under a real transmit grant like every other position, so it waits its turn and has no priority path.

Start it in shadow mode

Shadow mode runs the whole thing and transmits nothing, writes nothing and changes nothing. It reports every decision to a live activity feed: what it heard, whether the wake word matched, what it would have said and what it would have written to your CAD. Read a shift of that before you let it talk. Two things usually turn up, a hail format nobody here uses and callsigns your roster did not resolve, and both are a two-minute fix in the portal.

How quickly it answers

Standard transcribes, thinks, then speaks. That is three services one after another and about three to four seconds before the unit hears anything. It is the default and the cheaper one. Fast is a single live session that is already listening while the unit is still talking and starts speaking as it works out what to say, so you are looking at roughly a second, and it sounds less stitched together.

Fast costs about three times as much, because it bills audio in both directions instead of text in the middle, and because it listens to the whole talkgroup rather than only the transmissions aimed at dispatch. Everything else is identical between the two: same wake word, same grace, same shadow mode. If Fast cannot run, it answers on Standard instead, so a bad day at the vendor costs you a couple of seconds rather than a silent channel.

All of it lives in your portal, including which of the ten voices it uses, and changes apply live.

Shift changes

One voice answering a channel for six hours straight sounds like a machine; a dispatch center is several people. So every ten minutes or so, jittered, a different voice takes the desk, only ever between exchanges and never on a unit the current dispatcher is still talking to. Pick who takes turns and how often in the portal's Radio AI tab; set it to 0 to keep the one voice.

Calls opened from the air

"Central, Bravo 2, start me a call, traffic stop at Legion Square" opens an incident in your CAD with the nature and location as said, through the same path a 911 call takes, so the CAD's number comes back and the record can be noted and closed like any other. The AI asks for a location if none was given rather than guessing one. And when a unit asks for a plate or a warrants check, it keys up with "stand by", releases, runs the check, and keys up again with the answer, the way a dispatcher does.

It covers the desk when nobody else is on it

With Config.AiRadio.AutoStaff on, the AI is the dispatcher only when none of yours is signed on. The desk goes unstaffed, a few minutes later the AI takes the channel and says so on the air, and the moment somebody signs back on it hands the desk straight back. An overnight channel is covered without anybody remembering to switch anything. A browser console counts as a dispatcher, and so does a player whose personality carries the dispatch feature; only positions whose codeplug carries a watched channel count toward staffing it.

Config.AiRadio.AutoStaff = {
    Enabled          = true,
    AfterMinutes     = 5,     -- unstaffed this long before the AI takes it; 0 = immediately
    StandDownSeconds = 30,
    Announce         = true,
}

Be honest with yourself about AfterMinutes. Until the AI takes the desk, nobody is answering it. A unit who calls dispatch two minutes after the last dispatcher signed off gets no answer if this is set to five.

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. Sal's Kewl CAD, CDE CAD, BackdraftCAD and Imperial CAD ship as drivers; anything else is a drop-in driver file, a config block, or one event handler in your own resource. Everything is off by default, capability by capability.

Drivers live in integrations/cad/, outside the asset escrow, so they are readable and yours to edit or add to. Copy the example, fill in your CAD's endpoints, point Config.CAD.Provider at it, restart. Imperial's own convars are the credentials, so a server already running their FiveM resource needs no extra setup. One thing worth knowing before you turn Imperial on: its only close-shaped endpoint deletes the incident outright, so a call ending here leaves the record in place unless you ask for otherwise.

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.

Running CDE? Every convar, every setting with its shipped default, duty profiles, station tone-outs and a symptom-by-symptom troubleshooting table are in the CDE CAD appendix.

Setting it up (Sal's Kewl CAD)

If you run our CAD, its bridge resource already holds the signed link to it, so the radio needs no key, no url and nothing in server.cfg. Start the bridge first, then:

ensure sals_kewlcadbridge      # before sals_kewldispatch

Config.CAD = {
    Provider = 'kewlcad',
    Push = { Calls = { Enabled = true }, Panic = { Enabled = true }, Radio = { Enabled = true } },
    Pull = { Dispatch = { Enabled = true, Talkgroup = 'LSPD1' },
             Duty = { Enabled = true }, Status = { Enabled = true } },
}

A 911 call arrives in the CAD as a worked call, prefilled with the caller, the location, what the call taker established and the text transcript if they texted. Notes land on it, and when the 911 line ends the CAD is told rather than closed, because the dispatcher working it there closes it when the units clear. The emergency button becomes a PANIC call, PTT and pages are logged, and a status a unit says on the radio moves their row. A call a unit opens on the air is created as that unit, attached. Coming back, the CAD's live board drives Pull.Dispatch and Pull.Duty, and a plate, name, VIN, warrant or firearm serial check asked for on the radio is run in the CAD as that unit and read back on the same talkgroup, warnings first. Needs bridge 2.1.0 or newer.

Setting it up (BackdraftCAD)

set backdraft_api_key  <your integration key>
set backdraft_api_url  https://dev.backdraftcad.com/api/v1

Config.CAD = {
    Provider = 'backdraftcad',
    Push = { Calls = { Enabled = true } },
    Pull = { Calls = { Enabled = true }, Dispatch = { Enabled = true, Talkgroup = 'LSPD1' },
             Duty = { Enabled = true }, Status = { Enabled = true } },
}

It's the same integration key your CAD already uses; BackdraftCAD works out which community you are from it. A 911 call becomes an incident filed under one of your community's own call types, notes and dispositions land on it, and it's closed rather than deleted when the call ends. A call a unit opens on the air arrives with that unit assigned, a status they call in lands on the incident they're working, and plate, person, vehicle, VIN and warrant checks are answered on the air. Your dispatcher's calls come back to page units and drop station tones, and the on-duty roster sets up each radio at the start of a shift through Pull.Duty.

Their integration API is on their development server for now, and the url above is that server. Calls you send land there, not in your CAD, and the radio says so at every startup. When they move it to production, the url is the only thing that changes.

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.

Fire script integration

If you run a fire script, the fires it starts can tone the fire department out by themselves: station tones on the fire talkgroup, the location in the announcement, and optionally a 911 call that rings the dispatch console. Everything lives under Config.Fire.

This arrives switched off. Set Config.Fire.Enabled = true when you want it. With it off, nothing about how fires get dispatched on your server changes.

Written against Smart Fires (London Studios), which is detected while the server runs and needs nothing configured beyond turning this on. It is never listed as a dependency, so the resource still starts on a server that does not run it. Any other fire script fits through Config.Fire.Custom, or by calling one export.

A fire is not an incident

This is the part that makes it usable rather than maddening. Fire scripts spread, so one working structure fire is dozens of separate fires arriving over several minutes. If each one paged the station, the crew already riding to that job would be toned out forty times — and a pager that does that gets switched to STANDBY, and then it misses the next call.

So fires are grouped by place and time into one incident that tones out once:

SettingDefaultWhat it does
Cluster.Radius60.0A new fire within this many meters of a live incident joins it and pages nobody.
Cluster.Seconds300How long an incident keeps swallowing fires. A fire in the same place after this is a new job — which is right: the first crew has gone.
Cluster.SettleMs1500Wait this long before dispatching, so everything arriving alongside the first fire is in before the tones go out.
MaxPerMinute6Hard ceiling on tone-outs per minute, on top of the grouping. Normal play never reaches it; it is what stops a misbehaving fire script turning every pager on the server into an alarm clock.
MinSize0Ignore fires smaller than this. Size is read at the end of the settle, so a spark that has grown into a working fire still counts.
Buckets{ 0 }Routing buckets that page the fire department. A fire inside a housing or MLO instance is somebody's scripted scene, not a job. false pages from every bucket.

What happens for each kind of fire

Config.Fire.Types is keyed by the fire type name your fire script uses; anything it does not name falls through to Config.Fire.Default.

ModeWhat happens
'tone'Station tones on the configured talkgroups, straight to the pagers subscribed to that tone set. Needs nothing but the pager.
'call'A 911 call at the fire's own coordinates. It rings the dispatch console, it can be answered, it reaches your CAD, and when nobody takes it Config.Calls.Fallback tones the units out anyway.
'both'The tones now, the call alongside them.
'none'Ignore this kind of fire entirely.
Config.Fire = {
    Enabled = true,
    Default = { Mode = 'tone', Tones = { 'ALLCALL' }, Talkgroups = { 'FIRE1' }, Nature = 'FIRE' },
    Types = {
        normal  = { Tones = { 'STATION1' }, Nature = 'STRUCTURE FIRE', Mode = 'both' },
        vehicle = { Tones = { 'STATION1' }, Nature = 'VEHICLE FIRE' },
        grass   = { Nature = 'BRUSH FIRE', MinSize = 3.0 },
        rubbish = { Mode = 'none' },
    },
}

The announcement is the fire's nature and where it is. The zone and postal come from the same table 911 calls use (Config.Calls.ALI), so filling that in once improves both. Where your fire script describes a fire itself — Smart Fires does, for the automatic ones — its description is used instead of Nature.

Any fire script, and your own

exports['sals_kewldispatch']:ReportFire({
    coords = vector3(x, y, z), size = 4.0, fireType = 'normal',
})

That is the whole integration for a script nobody has written an entry for — two lines, and it gets the same grouping, routing and ceilings as everything else. Every dispatched incident also fires sals_kewldispatch:fire:incident for your own CAD or callout script, before the tones go out.

Checking it

/firetest is the command to run first. It prints which fire script was found, the grouping settings, the routes, every counter and the last error — then fires one test incident at your feet and prints the announcement it produced. “The pagers never went off” has half a dozen possible causes and they look identical in game; this tells them apart in one line. /radiodiag carries the same summary.

Codeplug programmer & live config

/radioadmin opens the in-game codeplug programmer. It is built like the software a real radio is programmed with rather than like a settings dialog: the codeplug is a tree down the left, one editor sits beside it, the actions are in a command strip along the top, and the line along the bottom reports state and carries no buttons.

Under CODEPLUG: Talkgroups (add, edit, reorder, delete — deleting one tells you which personalities reference it and cleans them up), Personalities (each one its own row in the tree, carrying jobs, zones, channel order, scan lists, feature flags and the emergency talkgroup) and Pager. Under its own SETTINGS heading, open when the panel opens, the All settings branch holds every setting the resource has: 24 sections under five headings (Radio, Sound and display, Dispatch and 911, Integrations, System), with a search box at the top that finds a setting by its name, a word from its help or its config path and draws the hits as real controls. A setting that isn't at its shipped default is marked on its edge and offers ↺ default; the tree counts the changed ones per section; one read once at boot carries a RESTART tag and is named again after the save. Lists, maps and ordered rows have their own editors. EXPORT SETTINGS and IMPORT SETTINGS sit in the command strip (see Configuration). A personality with no talkgroups in any zone is flagged in the tree, because that is the one thing that cannot be written. Under SYSTEM, Chassis shows every radio this server can issue — shipped and installed alike, each with its faceplate, which two are your defaults, and which are hidden from players by Config.Models.Available — and Console layouts lists the layouts your account has saved.

+ New personality… asks three questions, one screen each, instead of handing you twenty empty fields: who carries it (name, display prefix, jobs or catch-all, on-duty only), what they hear (click the talkgroups — click order is dial order, and each shows the position it will hold), and what they may do (scan, emergency, page, private call, encryption, dispatch monitor). It creates an ordinary personality and drops you on its card, which is where everything else is edited.

A moon/sun button switches the panel between light and dark, remembered per machine. The tool takes its accent from the radio's own display backlight, so an amber radio gets an amber programmer and an ice-blue one gets an ice-blue programmer.

Saving writes data/overrides.json and re-issues every radio live — units keep their current zone/channel wherever the new codeplug still has it. defaults.lua is never rewritten. Everything the panel saves lives in overrides.json, and deleting that file throws all of it away: talkgroups, personalities, pager tone sets, RF sites, every panel setting. If a file setting is not taking effect because the panel saved the same thing, change it in the panel rather than deleting the file. If you really do want to start over, copy the file somewhere first. The last Config.Admin.BackupCount saves are kept, and Restore archive loads the most recent back in. Write codeplug commits; closing the window is the only cancel there is, and it asks before discarding. 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.

Adding a radio chassis

Several radio bodies ship with the resource, and players pick between them in the chassis picker. From 2.25.0 you can also install one on a server that is already running — no restart, no fxmanifest.lua edit, nothing to copy into html/models/.

A chassis arrives as a package: one file named something.chassis.json, carrying the model's name, the position of every control on it, and its faceplate picture. Open /radioadmin and pick Chassis under SYSTEM.

Install from URL Paste a web address. Your server downloads the package itself, so you need no file access at all. Addresses inside your own network are refused.
Install from file Upload the package into the resource's data/chassis/ folder, then type its filename here.
Re-read the folder Reloads everything already installed, for when you have edited a package file by hand.
Remove Takes a chassis away again. Anyone who had chosen it falls back to your default radio — nobody is left with a blank screen.

Either way the radio is live at once. It appears in the chassis picker beside the ones that ship with the resource, players can choose it straight away, and you can issue it per personality from Settings › Radio models like any other.

Installed chassis do not need a line in Config.Models.Available. That list is about the radios that ship with the resource; a package is offered by being installed and withdrawn by being removed.

Settings

Config.Models.Packages = {
    Enabled         = true,
    AllowUrlInstall = true,   -- false = only files you put in data/chassis/
    AllowedHosts    = nil,    -- e.g. { 'downloads.example.com' }; nil = any public host
    MaxFaceBytes    = 768 * 1024,
    MaxPackageBytes = 2 * 1024 * 1024,
}

Keep the faceplate small. The picture is sent to each player once and remembered for the rest of their session, so a large one is a cost everybody pays. Stay under 768 KB — the server refuses anything bigger and tells you the size it got.

data/chassis/ is where your installed radios live. Back it up along with the rest of data/, and never copy an old data/ folder over a live one.

A package can never replace one of the radios that ships with the resource, and it carries no code of any kind — a chassis is a picture and a list of buttons. Every value in one is checked by your server before it reaches any player, and anything it does not recognize is dropped.

Building one yourself takes an image editor and a text editor, and the whole format is in Appendix: designing your own chassis.

Audio

Voice coloring is a GTA audio submix applied to radio traffic. Presets in Config.Audio.Presets:

PresetCharacter
p25350–2700 Hz passband, zero analog fudge, and a light ring-mod blend. The default, tuned against a real IMBE round trip. A vocoder empties the space between harmonics and a ring modulator fills it back in, so the blend stays low on purpose.
p25_strictThe heaviest coloring. Same passband, a firmer edge.
p25_lightThe widest passband and the least processing.
analogClassic FM grit and distortion.
offUntouched voice.

You do not have to edit a file to change any of this. Config.Audio.Tuning overrides the passband and the edge on top of whichever preset you picked, it is editable from your portal and from the in-game codeplug programmer, and it applies to every radio live with no restart. Set the preset, get on the air, and move one knob at a time until it sounds right to you. Once you like it, copy the numbers into a preset so they survive as a named thing rather than an override nobody remembers setting.

Signaling tones — talk-permit beep, bonk, courtesy tone, squelch clicks, emergency warble, page alert, keypress clicks — are synthesized 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')

-- fire script integration
exports['sals_kewldispatch']:ReportFire({
    coords = vector3(x, y, z), size = 4.0, fireType = 'normal' })

-- 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
sals_kewldispatch:fire:incident     -- a fire was dispatched (see Fire script integration)

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

SymptomFix
A player says they cannot talk on the radio Have them run /radiocheck — it needs no permission and names the cause and the fix. /radiodiag <id> puts the same rows in your console.
Fires do not tone the station out /firetest. It distinguishes the causes that all look like silence: the feature off, the fire script not found, an event name that changed, a routing bucket filter, a MinSize nothing reaches, or a route with no talkgroup on it.
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.

Appendix: CDE CAD, end to end

The CAD integration section covers what the integration is and the one rule it follows. This appendix is the wiring detail: every convar, every setting with its shipped default, the exact endpoints, the order to turn things on in, and the failure each symptom maps to.

The CAD is an accessory to a call, never a step in one. Nothing in the 911 path or the radio path waits on the CAD, retries into it, or fails because of it. A CAD that is down, slow or misconfigured has exactly one symptom: the CAD does not have the data. The call still rings, the units are still paged, the dispatcher still works the position.

Everything here is off by default, capability by capability. Setting a provider and a key is not enough on its own, because sending your players' positions to a third-party service is not a decision we will make on your behalf.

Before you start

  • A CDE CAD account with admin access, so you can read Admin Panel → FiveM Settings.
  • Your Discord guild id. CDE uses it as a second authentication factor.
  • Your players linking Discord to FiveM. CDE keys every unit read by Discord id, so an unlinked player cannot be matched to a status or a duty profile. This is the most common reason a correct configuration appears to do nothing at all.
  • Your department names exactly as the CAD spells them. Case does not matter, the words do.
  • Config.Pager.Enabled on, if you want station tone-outs from CAD assignments.
  • A decision about who owns 911, made before you turn anything on.

Credentials

Credentials go in server.cfg, and they are the same convars CDE's own resource reads. If you already run it, there is nothing to configure here at all:

set CDE_CAD_API_URL       https://your-cad.example      # no trailing /api
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"                 # optional; defaults to sv_hostname
Convardefaults.lua equivalentNotes
CDE_CAD_API_URLAuth.BaseUrlOrigin only. It must not end in /api, because every endpoint path already includes it. The driver refuses to start and says why.
CDE_CAD_API_KEYAuth.ApiKeyStarts with fvm_. The boot log warns if yours does not.
CDE_CAD_COMMUNITY_IDAuth.ServerIdYour Discord guild id.
CDE_CAD_SERVER_NAMEAuth.ServerNameHow this server is labeled inside the CAD.

A value written in defaults.lua overrides the convar of the same meaning. Use that for the URL or the server name if you like, but not for the key: defaults.lua is the file you copy between servers and hand to developers, and it is the file an update replaces. The boot line and /cadtest both print where each credential came from, so a stale convar and a forgotten config value cannot quietly fight.

The shape of the traffic

CDE is polled, not pushed. Its live events serve its own dispatch front end rather than FiveM, so everything inbound arrives on a timer you set. Two capabilities share one request in each direction, and the faster interval wins.

CapabilityDirectionCDE endpoint
Push.CallsoutboundPOST /api/fivem/911, then PATCH /api/fivem/call/{ref} and POST /api/fivem/call/{ref}/close
Push.PanicoutboundPOST /api/fivem/panic
Push.Units, Push.LocationoutboundPOST /api/dispatch/location-update
Pull.Calls, Pull.Dispatchinbound (poll)GET /api/fivem/dispatch/calls, one shared request
Pull.Status, Pull.Dutyinbound (poll)GET /api/fivem/dispatch/units, one shared request
Push.Radiolocal onlyCDE publishes no radio-traffic endpoint. The driver declines the kind, the boot log says so once, and the event still fires locally for your own consumer.

Every request spends the same rate limit as your 911 calls. Units × 60 ÷ IntervalSeconds is requests per minute. 30 units every 20 seconds is 90 a minute. Set the live map too fast and you do not degrade the live map, you start losing calls. The boot log warns when your configured worst case goes over RateBudget.

Turning it on, one capability at a time

This order exists so that when something does not work, only one thing changed. Run /cadtest after each step.

Step 1, prove the credentials. Nothing but 911 calls, outbound:

Config.CAD = {
    Provider = 'cdecad',
    Push = { Calls = { Enabled = true, Include = 'determined', Notes = true, Close = false } },
}

/cadtest does a complete create, correlate and close round trip for this one, so it proves the part that matters (their incident id comes back and we can act on it) and leaves nothing behind in your CAD.

Step 2, add Push.Panic and Push.Units. Step 3, add Push.Location once you have done the arithmetic above. Step 4, add Pull.Dispatch and watch a CAD assignment page the radio. Step 5, add Pull.Duty last, and read the on-duty match count in /cadtest before concluding that nobody's radio changed.

Every setting, with its shipped default

SettingDefaultWhat it does
Provider'none''none', 'cdecad', 'event' (emit locally, send nothing), or 'custom' (your own endpoints).
Auth.TimeoutMs4000Ceiling on how long one message may hold a worker. Not a promise of delivery.
Auth.Retries1Only for rate limits and server errors, only for calls and panics. Retry loops are how API keys get flagged.
Auth.QueueSize200Messages held while the CAD is slow. Over this, the least important are dropped first.
Auth.MaxInFlight2Concurrent requests. Keep it small.
Auth.XPayloadfalseRepeat the body in an x-payload header. CDE's documented workaround for Cloudflare configurations that strip FiveM request bodies. Turn it on only if /cadtest shows requests arriving empty.
Push.Calls.Include'determined''determined' sends calls somebody established the nature of, plus calls that went unanswered and fell back to the radio. 'all' sends everything, creating an uncharacterized record when the call closes.
Push.Calls.NotestrueNotes, disposition and radio pages go up as updates.
Push.Calls.ClosefalseClose the CAD's record when the call ends. Leave it off if your dispatchers close incidents in the CAD themselves.
Push.Units.Jobspolice, ambulance, fireWhich jobs are reported coming on and off the radio. An empty list means everyone holding a radio, which on most servers includes civilians.
Push.Location.IntervalSeconds20How often positions go up.
Push.Location.MaxUnits30Ceiling on units reported per pass.
Push.Location.RateBudget100Requests per minute you are willing to spend. Used to warn you at boot, nothing else.
Push.Radio.Includetx = falseLeave tx off. Every key-up on a busy server is several messages a second, which is not an integration, it is a denial of service paid for with your own API key. CDE has no endpoint for this anyway.
Push.Panic.EnabledfalseThe radio's emergency button as a CAD panic. The one radio event every CAD has somewhere to put.
Pull.Calls.Ownership'we-own'Decides whether one incident becomes two records. See below.
Pull.Calls.IntervalSeconds10How often the CAD's call feed is read.
Pull.Calls.ServicenilWhich of Config.Calls.Services a CAD call arrives on. Defaults to Config.Calls.DefaultService.
Pull.Dispatch.TalkgroupnilWhere an assignment pages. Falls back to Config.Calls.Fallback.Talkgroup.
Pull.Dispatch.Alias'CAD'What the page appears to come from on the radio display.
Pull.Dispatch.ByDepartment{}Per-department talkgroup routing, checked before Talkgroup.
Pull.Dispatch.ToneOuts{}Departments that get real station tones instead of a text page.
Pull.Statusoff, 15 sEmits an event when a unit's status changes in the CAD. Acts on nothing by design: what "10-8" means belongs to your job scripts.
Pull.Dutyoff, 15 sThe CAD roster decides each unit's radio.

Duty profiles: the CAD decides the radio

Normally the radio works out a player's codeplug from their job name. With Pull.Duty on, the CAD's own on-duty roster decides instead. When a unit goes on duty in the CAD, their radio here is set up from the profile their department maps to: the codeplug (personality), the chassis they were issued, and their CAD callsign as the radio's PTT alias suffix. Off duty, the profile is released and resolution goes back to the job.

Config.CAD.Pull.Duty = {
    Enabled = true,
    ByDepartment = {   -- the department string exactly as the CAD reports it
        ['Police Department'] = { personality = 'police', portable = 'cx8000', mobile = 'nc700m' },
        ['Sheriff']           = { personality = 'sheriff' },
        ['Fire Department']   = { personality = 'fire' },
        ['EMS']               = { personality = 'ems' },
    },
    Default     = nil,        -- profile for unmapped departments; {} = callsign only
    UseCallSign = true,       -- "1-A-12" in the CAD becomes the alias suffix
    OffDuty     = 'release',  -- or 'keep': leave the last profile standing
    IntervalSeconds = 15,     -- shares one request with Pull.Status; faster wins
    Notify      = true,       -- tell the player when their radio is set up or released
}

personality must be a key of Config.Personalities. portable and mobile are chassis ids from Config.Models, and you can leave them out to keep whatever chassis the personality normally issues.

  • Matching is by Discord id. That is how CDE keys its unit reads, so a player who has not linked Discord cannot be matched. Same limitation as Pull.Status.
  • The agency prefix still comes from the personality. The CAD names the unit, never the agency, which is the same rule /radioalias and the hosted console follow. Nobody gets on the air as LSPD by editing a callsign in a CAD.
  • Precedence is pin, then duty, then job. A personality pinned with the SetPlayerPersonality export still wins, the duty profile beats job resolution, and the job is the fallback.
  • Item gates still apply. A duty personality that requires a radio item the player is not carrying refuses the radio exactly as a job-resolved one would.
  • A bad personality name is refused; a bad chassis id is not. An unknown personality fails the whole profile, loudly, because a typo in a mapping must not leave a unit quietly on the wrong codeplug. An unknown chassis id is dropped with a console warning and the rest of the profile still applies, because the codeplug is the load-bearing half and should not be lost to a cosmetic typo.
  • A department that matches nothing changes nothing, and the console says so once, so a misspelling cannot hide. Default = {} is useful on its own: no codeplug change, but the CAD callsign still lands on the radio.
  • The pager is separate. It resolves from Config.Pager.Personalities and is not touched by a duty profile. Use SetPlayerPagerPersonality if your CAD should drive that too.

A CAD that runs its own resource on your server, or any duty script of yours, can drive the same machinery directly with no Config.CAD at all:

exports['sals_kewldispatch']:SetPlayerDutyProfile(src, {
    personality = 'police',    -- codeplug for the shift
    portable    = 'cx8000',   -- chassis issued for the shift   (optional)
    mobile      = 'nc700m',
    suffix      = '1-A-12',    -- PTT alias suffix                (optional)
})
exports['sals_kewldispatch']:SetPlayerDutyProfile(src, nil)   -- off duty; releases
exports['sals_kewldispatch']:GetPlayerDutyProfile(src)        -- what is applied, or nil

Every apply and release also fires sals_kewldispatch:cad:duty with { src, onDuty, unit, callSign, department, profile }, so your own scripts can follow along.

Pages and station tone-outs

When the CAD assigns units to a call, Pull.Dispatch pages the mapped talkgroup so units hear the assignment on the radio instead of only seeing it on a screen. A department listed in ToneOuts gets the real thing: a two-tone station tone-out, identical to a dispatcher pressing TONES on the console. Every pager subscribed to that tone set decodes and alerts, and the call (type, location, postal) is the announcement text on every alerted pager and radio.

Config.CAD.Pull.Dispatch = {
    Enabled   = true,
    Talkgroup = 'LSPD1',                       -- plain pages for everyone else
    Alias     = 'CAD',
    ByDepartment = { ['Sheriff'] = 'BCSO1', ['EMS'] = 'EMS1' },
    ToneOuts  = {
        ['Fire Department'] = { Tones = { 'STATION1' }, Talkgroups = { 'FIRE1' } },
        ['EMS']             = { Tones = { 'MEDIC1' } },  -- Talkgroups defaults to the page talkgroup
    },
}
  • Any service with station tones can be mapped, not just fire and EMS. Wreckers, lifeguard, search and rescue.
  • Tone ids come from the tone-set catalog (Config.Pager.ToneSets, or what you last saved from the Pager tab), and raw { a = Hz, b = Hz } pairs work too, up to four. Needs Config.Pager.Enabled.
  • This runs whether or not a dispatcher is signed on. The CAD assigning units is what drops the tones, so a fully CAD-run department never needs a live console position for its stations to turn out.
  • If the tone-out cannot fire (pager off, or a tone set that does not exist), the plain text page goes out instead. Never silence, and the console warns once naming the reason.
  • For calls nobody answers at all, Config.Calls.Fallback.ToneOut drops one fixed tone set, typically an all-call. One fixed set on purpose: an unanswered call cannot be triaged.

Who owns 911

CDE ships its own LB Phone 911 bridge, so a server can end up running two systems that each believe they own 911. The symptom is duplicate incidents, or none, depending on start order. We warn at boot when we see one of those resources running. Config.CAD.Pull.Calls.Ownership settles it: 'we-own' (recommended) means this resource takes the call and creates the CAD record, and calls the CAD created are not mirrored back. 'cad-owns' means the CAD's own bridge takes the call, we create nothing, its calls ring the 911 queue here, and we only ever add notes and close.

cad-owns with Pull.Calls off is half an arrangement. The CAD takes the calls and they never come back to a dispatcher here. The boot log calls this one out by name.

Events and exports

EventFires when
sals_kewldispatch:cad:dutyA duty profile is applied to or released from a player.
sals_kewldispatch:cad:unitStatusA unit's status changed in the CAD (Pull.Status). An event, not an action.
sals_kewldispatch:cad:call, :call_update, :call_close, :unit, :location, :radio, :panicEvery outbound payload, mirrored locally. These fire in every provider mode, so you can watch exactly what the driver is handed before you send anything anywhere.
ExportWhat it is for
SetPlayerDutyProfile(src, profile)Apply or release a duty profile. nil releases.
GetPlayerDutyProfile(src)The profile currently applied, or nil.
CadPush(kind, payload)Push a fact at the CAD through the port's queue, deadline and capability gating. Use this rather than your own PerformHttpRequest.
CadReference(callId)The CAD's own id for one of our calls. nil until the create round trip finishes, which is normal for the first second or two of a call.
CadUnitStatus(payload)A deliberate unit status change, correlated to the assigned incident, with an honest answer about whether anything was sent.
CadStatus()The posture and counters /cadtest prints, as a table.
SendToneOut(tgIds, tones, text, alias), SendPagerText(src, text, alias)Drop tones or a pager text directly, through the same validated path the console's TONES button uses.

Reading /cadtest

Run it from an admin account. It exists because the failure this integration actually has is silence, and silence looks identical whether the cause is a disabled capability, a missing key, a driver with no endpoint for that kind, a full queue, or a CAD that is quietly returning 401. One command separates all five. It prints the provider and base URL, where each credential came from, which capabilities are enabled, how many on-duty units currently match a duty mapping, and then one payload of each enabled kind with what was sent and what came back. /radiodiag carries the same posture plus running counters, and it is what to attach to a support ticket.

Troubleshooting

SymptomWhere to look
Nothing arrives in the CAD/cadtest first, always. It distinguishes the five causes that all look like silence.
The driver refuses to startA BaseUrl ending in /api. Every endpoint path already includes it, and the console says so by name.
401 or 403 in /cadtestThe key or the community id is wrong, or not authorized. Re-copy the fvm_ key from Admin Panel → FiveM Settings.
429, rate limitedLower your push rates, usually Push.Location. Units × 60 ÷ interval is requests per minute, spent from the same limit as your 911 calls.
Requests arrive with an empty bodySome Cloudflare configurations strip FiveM request bodies. Set Config.CAD.Auth.XPayload = true, which is CDE's documented workaround.
Nobody's radio changes with Pull.Duty onThe match count in /cadtest. Zero means the departments in ByDepartment do not match what the CAD reports, or those players have no linked Discord.
A profile applied, but the chassis did not changeAn unknown chassis id is dropped with a console warning while the rest of the profile applies. Check the id against Config.Models.
Assignments page, but tones never dropThe department name in ToneOuts has to match what the CAD reports, Config.Pager.Enabled has to be on, and the tone ids have to exist in Config.Pager.ToneSets. A failed tone-out warns once and sends a text page instead.
Duplicate 911 incidents, or noneTwo systems both think they own 911. See above, and the boot warning that names the other resource.
Calls exist only in the CADOwnership = 'cad-owns' with Pull.Calls off.

A CAD outage is never a game outage. Outbound messages queue (bounded, least important dropped first), every request owns its own deadline, and failures are summarized every five minutes rather than flooding your console.

Any other CAD

Two supported ways in, neither of which needs us to have written a driver. Provider = 'event' emits sals_kewldispatch:cad:* and sends nothing, which is both a first-class integration route and the way to see exactly what your endpoint will receive. Provider = 'custom' posts those same payloads, as JSON and unchanged, to endpoints you describe in Config.CAD.Custom. Run 'event' first and look at what comes out, then describe your CAD. A kind with no endpoint listed simply is not supported, and the boot log says so.

Appendix: designing your own chassis

A chassis is a picture and a list of rectangles. That's the whole idea, and it's why you can build one with nothing but an image editor and a text editor. This appendix is the entire format.

What you're actually making

The picture is the radio. The resource draws nothing on top of it except the live display and a set of invisible buttons, each sitting exactly over the control it stands for. So a chassis is a front-on picture of a control head with the background removed, plus a list saying this rectangle is the display, this one is the push-to-talk, these three are the soft keys.

Nothing is drawn for you and nothing is guessed. If a rectangle sits two pixels left of the key it represents, the button works and looks two pixels off. If you forget a rectangle, that button simply isn't there. Both are easy to fix and neither breaks anything else.

The picture

Front on, flatA three-quarter view can't have rectangles laid over it honestly — the buttons and the picture will disagree more the further from center they sit.
Transparent backgroundPNG with an alpha channel. Whatever isn't transparent is the radio, including any drop shadow you leave behind, so knock it all out.
About 900 px wide or moreFor a portable. Below that it looks soft in game. JPEG and WebP work too, but a photograph with a knocked-out background wants PNG.
Under 768 KBThe ceiling the server enforces. It travels to every player who equips the radio, so smaller is genuinely better.
Yours to useA photograph of a real radio belongs to whoever took it, and a manufacturer's product shot belongs to the manufacturer. Use renders you made, photographs you took, or artwork you licensed.

Enlarging a small picture doesn't add detail. A 300 px photo scaled to 900 has 300 px of information in it however it's resampled, and it will look exactly that soft on the radio. The answer to a blurry chassis is a bigger original, not a bigger file.

Coordinates

Every number is in image pixels, measured on the picture you're shipping, with 0,0 at its top-left corner. A box is x, y (its top-left corner) and w, h (its size).

To read them off, use any editor whose rectangular selection reports position and size: in GIMP, the Rectangle Select tool's Tool Options shows Position and Size as you drag; in Photoshop it's the Info panel; in Paint.NET it's the status bar. Drag a box round each control, write down four numbers, move on. A control head is usually fifteen to twenty of them and takes about twenty minutes the first time.

imageScale is image pixels per on-screen pixel, and it's how you size the radio in game without touching the picture. 1 draws it at its own size; 2 draws a 2× picture at half, which is how you use a large source crisply; 0.8 draws a small picture a little larger. It scales the boxes with the picture, so you never re-measure.

The file

One JSON file, named <id>.chassis.json. Here is a complete, minimal control head:

{
  "format": "sals_kewldispatch.chassis/1",
  "id": "mk700m",
  "kind": "mobile",
  "brand": "Marlowe",
  "label": "MK-700M",
  "note": "Single-DIN control head. Three soft keys, no keypad.",
  "version": "1.0.0",
  "author": "Your name",

  "width": 446, "height": 164,
  "imageScale": 0.8,
  "softKeys": 3,
  "keypad": false,

  "screen": { "x": 118, "y": 57, "w": 127, "h": 61, "bg": "#e6eae2" },

  "hotspots": [
    { "act": "ptt",       "x": 55,  "y": 109, "w": 44, "h": 44, "shape": "circle",
                          "title": "Push to talk" },
    { "act": "emerg",     "x": 388, "y": 51,  "w": 22, "h": 22, "shape": "circle" },
    { "act": "knob-chan", "x": 372, "y": 102, "w": 68, "h": 62, "shape": "circle",
                          "title": "Scroll to change channel" },
    { "act": "knob-vol",  "x": 292, "y": 55,  "w": 70, "h": 100 },
    { "act": "menu",      "x": 264, "y": 134, "w": 21, "h": 22 },
    { "act": "back",      "x": 220, "y": 135, "w": 32, "h": 20 },
    { "act": "up",        "x": 264, "y": 54,  "w": 21, "h": 20 },
    { "act": "down",      "x": 264, "y": 78,  "w": 21, "h": 20 },
    { "led": true,        "x": 69,  "y": 77,  "w": 19, "h": 9 },
    { "mark": true,       "x": 150, "y": 41,  "w": 68, "h": 14 },
    { "labels": true,     "x": 120, "y": 100, "w": 123, "h": 14 },
    { "soft": 0,          "x": 113, "y": 135, "w": 33, "h": 20 },
    { "soft": 1,          "x": 150, "y": 135, "w": 32, "h": 20 },
    { "soft": 2,          "x": 185, "y": 135, "w": 32, "h": 20 }
  ],

  "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg…"
}

image is the picture itself, base64-encoded, with that data:image/png;base64, prefix in front of it. Most languages will do it in one line; on Windows, certutil -encode face.png out.txt writes the base64 and you strip the header and newlines. It's the last field on purpose — it's hundreds of kilobytes of one line, and everything you'll actually edit is above it.

FieldWhat it is
formatExactly sals_kewldispatch.chassis/1. It's how the server knows what it's been handed.
idLowercase letters and digits, starting with a letter. It's how the chassis is stored and referred to forever, so pick one you won't want to change. It can't be the id of a chassis that ships with the resource.
kindportable (carried on foot) or mobile (a control head in a vehicle). A chassis is only ever offered for its own slot.
brand, labelMaker and model designation, as shown in the picker and printed on the chassis.
noteOne line in the picker, under the name.
version, authorShown on the Chassis page. Bump the version when you re-issue a corrected package; that's what tells players' radios to fetch the new picture.
width, heightThe picture's real size in pixels. Get these wrong and every box lands in the wrong place.
imageScaleImage pixels per on-screen pixel. See Coordinates above.
softKeysHow many soft keys the display has. The radio adapts its menus to this, so it must match the number of soft boxes.
keypadtrue if the radio has a DTMF keypad. Drives unit call and directed page.
screenThe display. Required — without it the chassis is a dead lump of plastic. bg is the panel color as #rrggbb; the resource works out readable text colors from it, and a light panel gets light-LCD treatment automatically.
hotspotsEverything else, up to 64 entries. Each is a box plus exactly one role from the role table below.

Any box may also carry "shape": "circle" (round button, round press highlight) and "title", which is the tooltip a player sees when the mouse is over it. Use titles for anything that isn't obvious — especially a control with no button of its own, like a mic socket that transmits or a speaker grille you scroll for volume.

The roles

A box says which of the radio's existing controls it is. It can never say what a control does — that's fixed, and it's why installing a chassis is safe.

RoleThe control
"act": "ptt"Push to talk. Every chassis needs one, and the server refuses a package without it. On a control head, put it over the microphone socket or the hand mic.
"act": "emerg"Emergency. Held, not clicked; the hold ring draws itself around whatever shape you gave the box.
"act": "up" / "down"Channel up and down — and menu movement while a menu is open.
"act": "zone-prev" / "zone-next"Zone down and up; menu back and select while a menu is open.
"act": "menu"Open and close the main menu.
"act": "back", "sel"Menu back, menu select.
"act": "scan"Toggle scan.
"act": "power"Toggle power.
"act": "keypad"Expand and collapse the DTMF keypad, on a chassis that draws one as a separate panel.
"act": "knob-chan"Scroll the wheel over this to step channels. Put it over the channel dial.
"act": "knob-vol"Scroll to change volume. Over the volume knob — or over the speaker grille, on a head that has no volume control of its own.
"soft": 0…A soft key, numbered left to right from zero, with no gaps. One box per physical key.
"key": "1"One keypad key. 0–9, * and #.
"led": trueThe transmit/receive lamp. By default it paints a dark lens over the picture and lights from there, because product shots are taken with the radio powered up and a lit lamp would otherwise look permanently on. Add "unlit": "none" if your picture's lamp is genuinely dark.
"mark": trueWhere the maker's mark is printed. Your picture already shows it, so nothing is drawn here — until a server uses Config.Models.Marks to hide it or replace it with their own badge, and then this is the patch that covers it.
"labels": trueNot a control: the strip on the display where the soft-key labels are printed. Mark it and the live labels land exactly there, each one centered over its own key. Leave it out and they go along the bottom of the screen, which usually looks fine and occasionally doesn't. It must sit inside the screen box.

Things that will bite

No PTTRefused outright. It's the one control a radio can't be without, and it's the failure nobody notices until somebody can't talk.
Soft keys numbered with a gap0, 1, 3 leaves a hole. Number them left to right from zero, and make softKeys match how many there are.
A labels box outside the screenThe label strip is part of the display. Outside it, the labels are drawn over the case.
Colors that aren't #rrggbbSilently dropped — six hex digits with a leading hash, nothing else. red and #fff don't count.
Boxes far off the pictureA control may overhang the faceplate by up to a quarter of its size, because real ones do. Further out, it's clamped back.
Re-using an idInstalling a package whose id is already installed replaces it, which is how you ship a correction. Bump version when you do, so players' radios know to fetch the new picture.

Trying it

Install it the same way you'd install anybody else's: /radioadmin › Chassis, then either paste a URL or drop the file in data/chassis/ and type its name. It's live immediately, so the loop is fast — fix a number, re-install, look again. Reinstalling replaces what was there.

Then pick it with /radiomodel and take the tour — MAIN MENU › RADIO GUIDE on the radio itself, or the first button in /radioguide. It reads the chassis actually on screen and walks you through every control it finds, naming each one, which is the quickest way to discover you've labeled the volume knob as the channel knob. After that, press everything.

When the server refuses it

Refusals are deliberately specific, and they all appear on the status line of the Chassis page.

What it saysWhat to do
bad_formatThe format field is missing or isn't sals_kewldispatch.chassis/1.
bad_idThe id isn't lowercase letters and digits starting with a letter.
id_is_shipped, id_is_legacyThat id belongs to a chassis that ships with the resource. Pick another.
no_screenNo screen box. The radio needs somewhere to paint.
bad_dimensionswidth or height is missing, or under 40 px.
no_pttNothing is marked "act": "ptt".
face_not_data_uriimage doesn't start with data:image/png;base64,.
face_bad_typePNG, JPEG and WebP only. An SVG is a document, not a picture, and is never accepted.
face_too_largeOver MaxFaceBytes. The message gives both numbers.
not_jsonThe file isn't valid JSON — usually a trailing comma or a smart quote from a word processor. Use a text editor.
refused_private_hostThe URL points inside your own network. Put the package somewhere publicly reachable, or install it from data/chassis/ instead.
fetch_failedThe download didn't return 200. Check the link in a browser — a lot of file hosts serve a preview page rather than the file.
file_not_foundNo such file in data/chassis/. Check the spelling and that the upload finished.
packages_disabled, url_install_disabledSwitched off in Config.Models.Packages.

Anything the server doesn't recognize is dropped, not refused. A box with a role from a newer version of the resource, or a field we haven't invented yet, is quietly ignored and the rest of the chassis installs. So if a control is missing rather than wrong, check its role against the table above first.

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.