Jobs & Responder Groups
How oxide-dispatch resolves jobs to alert recipients, with recipes for adding jobs, renaming jobs, and adding new responder groups.
This guide explains how oxide-dispatch resolves jobs to alert recipients, how to add or rename jobs, and how to add a brand-new responder group (e.g. coastguard).
Mental model
Every alert ultimately resolves to a flat list of framework job names ('police', 'lspd', 'ambulance', etc.) — those are the strings the bridge compares against olink.job.Get(src).name to decide who receives the popup, panic, civilian call, or detection alert.
Producers (civilian 911 menu, detection module, panic, your own exports) don't pass job lists directly. They pass either:
- a responder-group key (
'police','ems','fire'), or - a raw job name (
'sheriff'), or - an array of any combination.
Those inputs are normalized by Config.ResolveJobs(input) (defined in shared/config/jobs.lua). The normalized array is what the broadcast layer fans out. The keystone of the whole system is therefore Config.ResponderGroups — every other config refers back to it.
Config.GetGroupForJob(jobName) is the reverse lookup: it tells the client which group the local player belongs to so the responder entries in the Dispatch radial menu, the status list, and the panic title can be rendered correctly. If a job is not listed in any group's jobs array, the player sees only the civilian 911/311 entries in the Dispatch menu, cannot panic, and cannot receive same-group status toasts.
Case sensitivity
Job-name matching is case-sensitive. Frameworks store the job name verbatim — 'police' and 'LSPD' are different jobs. If your departments use mixed casing (common on QBCore servers with custom departments), list every casing your server actually uses:
jobs = { 'police', 'lspd', 'LSPD', 'bcso', 'BCSO', 'sasp', 'SASP' },If you're not sure what casing your framework uses, log in on a test character with the job assigned and run /job (QBCore/QBX), or check the job name in your admin panel or database — copy the exact string.
Config.ResolveJobs input contract
| Input | Returns |
|---|---|
nil | Config.ResolveJobsDefault (defaults to nil → empty list + one-time warning per call site) |
'police' (group key) | Full Config.ResponderGroups.police.jobs array |
'sheriff' (raw job) | { 'sheriff' } |
{ 'police', 'sasp' } | Deduped expansion: every group key expanded, raw jobs passed through |
If you want missing-jobs producers to default silently to police (legacy behavior), set:
Config.ResolveJobsDefault = 'police'Where jobs must be configured
All files live in shared/config/. Every one of these is in escrow_ignore, so they are safe to edit on a live server.
| File | Purpose | Job fields you edit |
|---|---|---|
jobs.lua | Single source of truth for responder groups | Config.ResponderGroups.<group>.jobs |
statuses.lua | Per-group status lists (shown in the Dispatch radial's Set Status submenu) | Config.ResponderStatuses.<group> (keyed by group, not job) |
panic.lua | Panic titles + civilian 911/311 menus | Config.Panic.titles/messages.<group>, Config.Civilian.Categories911[*].jobs, Config.Civilian.Categories311[*].jobs |
detection.lua | Auto-detect crime routing | Config.Detection.<Detector>.jobs |
compat.lua | Legacy QB/QBX/ESX alert shim | Config.Compatibility['qb-core'].jobs, .emsJobs, Config.Compatibility.es_extended.jobs, .emsJobs |
ai_dispatch.lua | AI radio channels (optional feature) | Config.AIDispatch.Channels[<num>].job |
A few details that aren't obvious from skimming the files:
compat.lua.jobs/.emsJobsaccept the sameResolveJobsinput — pass a group key, raw job, or array. Used bypolice:server:policeAlert,hospital:server:ambulanceAlert, ESXesx_policejob:alert, etc.ai_dispatch.lua.jobmust be a single framework job string, not an array — one channel = one department. The MDT-flavored AI tools (plate / person / BOLO) only appear on channels whosejobis also listed inConfig.ResponderGroups.police.jobs.statuses.lua_defaultfallback list is used by any group that doesn't define its own statuses. Custom groups can rely on it instead of duplicating the police/EMS/fire status arrays.
Recipe: add a job to an existing group
Example: your server adds a Park Ranger department whose framework job name is 'parkranger', and you want them treated as police.
Append the job to Config.ResponderGroups in shared/config/jobs.lua:
Config.ResponderGroups = {
police = {
jobs = { 'police', 'lspd', 'bcso', 'sasp', 'sheriff', 'statetrooper',
'LSPD', 'BCSO', 'SASP', 'parkranger' }, -- added
label = 'Officer',
...
},
...
}Mirror the change in compat.lua if you rely on the legacy QB/ESX police alert events (police:server:policeAlert etc.) — those events go through the compat shim, which has its own jobs field:
Config.Compatibility['qb-core'].jobs = { 'police', 'lspd', 'bcso', 'sasp', 'parkranger' }Or, more cleanly, set the shim's jobs to the group key so it auto-tracks ResponderGroups:
Config.Compatibility['qb-core'].jobs = 'police'Restart oxide-dispatch (or use /refresh if you have it; otherwise restart oxide-dispatch in the txAdmin console).
That's it. The new job will receive police-flavored alerts, panic broadcasts, civilian 911 calls routed to police, and the responder entries in the Dispatch radial menu.
QBX note: all top-level keys in
qbx_core/shared/jobs.luamust be lowercase. qbx_core only warns about capitalized keys, it doesn't reject them — butAddPlayerToJoblowercases the name before writing toplayer_groups, so the stored record never matches an entry like'ParkRanger'inConfig.ResponderGroups. Register['parkranger'], not['ParkRanger']. QBCore and ESX tolerate mixed casing.
oxide-policeusers — important: if you haveoxide-policerunning, the police group'sjobsarray is overwritten every 60 seconds (and on every player job change) by the live list fromoxide-police'spolice_departmentstable. EditingConfig.ResponderGroups.police.jobsdirectly will be wiped on the next refresh. See Running alongsideoxide-policebelow.
Recipe: rename a job
The same procedure as adding — replace the old string. Make sure to also update any file that referenced the old name explicitly:
jobs.lua—Config.ResponderGroups.<group>.jobscompat.lua—jobs/emsJobsif you used explicit job names thereai_dispatch.lua—Channels[*].jobif you have an AI radio channel for that department
Restart oxide-dispatch after the change. Online players keep their old job string in memory until the next olink:server:jobChanged event for them.
Recipe: add a new responder group
Example: you want a Coast Guard responder group with framework job 'coastguard'. Police-style statuses, custom panic title, and the responder entries in the Dispatch radial menu.
Add the group to Config.ResponderGroups in shared/config/jobs.lua:
Config.ResponderGroups = {
-- ... existing police / ems / fire ...
coastguard = {
jobs = { 'coastguard' },
label = 'Coast Guard',
icon = 'anchor', -- Font Awesome name, no 'fas fa-' prefix
},
}(Optional) Add a status list in shared/config/statuses.lua. If you skip this step, the group falls back to Config.ResponderStatuses._default (available / busy / 10-7), which is usually fine.
Config.ResponderStatuses.coastguard = {
{ id = 'available', label = 'Available', color = '#22c55e', icon = 'check' },
{ id = 'enroute', label = 'En Route', color = '#3b82f6', icon = 'route' },
{ id = 'onwater', label = 'On the Water', color = '#a855f7', icon = 'water' },
{ id = '10-7', label = '10-7', color = '#6b7280', icon = 'moon' },
}The default-status id (Config.ResponderStatusDefault, default 'available') must exist in every group's status list, including _default. If you use ids other than available make sure to add it.
(Optional) Add panic titles + messages in shared/config/panic.lua so panic alerts read naturally. Without this, panic falls back to the _default line ("RESPONDER NEEDS BACKUP"):
Config.Panic.titles.coastguard = 'COAST GUARD NEEDS BACKUP'
Config.Panic.messages.coastguard = 'Coast guard panic alert'(Optional) Add civilian 911/311 categories routed to the new group in shared/config/panic.lua:
Config.Civilian.Categories911 = {
-- ... existing entries ...
{ id = 'boatdistress', label = 'Boat in Distress', code = '911', jobs = 'coastguard' },
}(Optional) Add detection routing in shared/config/detection.lua if you want, e.g., explosions over water to alert coast guard. Just include the group key in the jobs array:
Config.Detection.Explosion = { enabled = true, code = '10-77', jobs = { 'police', 'ems', 'fire', 'coastguard' } }Restart oxide-dispatch. A character with coastguard as their job will now:
- See the responder entries in the Dispatch radial menu — their Coast Guard status list under Set Status, plus Set Callsign and Panic / Backup.
- Receive any alert whose
jobsincludes'coastguard'(raw) or'coastguard'(as a group key). - Trigger a "COAST GUARD NEEDS BACKUP" alert when pressing
F4//panic. - See their group statebag:
Player(src).state['oxide-dispatch:responderGroup'] == 'coastguard'.
There is no SQL migration, no client UI rebuild, and no oxide-police change required. Responder groups are entirely shared-config driven.
Running alongside oxide-police
When oxide-police is installed and started, server/modules/police_lookup.lua takes over the police group's job list:
- On dispatch boot, when
oxide-police(re)starts, and every 60 seconds, the module queriesSELECT name FROM police_departments WHERE is_enabled = 1from oxide-police's table. (A player changing job only refreshes that one player's per-player flags — it does not re-run the full department query.) - The result replaces
Config.ResponderGroups.police.jobsin memory. - If the query fails (or
oxide-policeisn't installed), it falls back to the static seed defined injobs.lua.
Practical consequences:
| You want to... | Do this |
|---|---|
| Add an LEO department | Add the department in oxide-police's admin panel (not in jobs.lua). It'll show up on the next refresh — at most 60 seconds. |
| Remove an LEO department | Disable it in oxide-police (is_enabled = 0) or delete the row. The job will be dropped on the next refresh. |
| Add EMS / fire / custom jobs | Edit jobs.lua normally — oxide-police only manages the police group. |
| Force an immediate refresh | Restart oxide-police, or just wait — the list refreshes automatically within 60 seconds. |
Editing Config.ResponderGroups.police.jobs directly while oxide-police is running is not harmful — it's just pointless. The next refresh wipes it.
Other side effects when oxide-police is running:
- Duty checks (
Config.Broadcast.onDutyOnly) defer toexports['oxide-police']:IsPlayerOnDuty(src)for LEOs. - AI radio police-MDT tools (lookup_plate, lookup_person, BOLOs, etc.) auto-attach to channels whose
jobends up in the live police job list.
The per-player statebags oxide-dispatch:isLEO (boolean) and oxide-dispatch:responderGroup (string|nil) are published for every player whether or not oxide-police is running — without it, they're computed from the static lists in jobs.lua instead of the live department table.
Verification
After making changes:
Restart oxide-dispatch. If oxide-police is involved, restart it too (or wait up to 60s for the next refresh).
Test with /testdispatch — an admin command shipped with the resource:
/testdispatch 10-71 police
/testdispatch 10-52 ems
/testdispatch 10-70 fire
/testdispatch 911 coastguard # your new groupConfirm an on-duty member of the targeted group receives the popup.
Check the radial. With a job assigned, open the radial — the single Dispatch menu should include the responder entries, with your group's statuses (Officer / Paramedic / Firefighter / Coast Guard / ...) under Set Status.
Trigger panic (F4 keybind or /panic). Colleagues in the same group should receive a panic alert with the configured title.
Check the statebag in your code or via the developer console:
print(Player(source).state['oxide-dispatch:responderGroup'])If an on-duty player isn't receiving alerts, the Troubleshooting doc walks through the duty filter, rate limiter, and oxide-police integration checks.