Configuration
Reference for every config file shipped with oxide-dispatch.
Reference for every config file shipped with oxide-dispatch. All config files live under shared/ and are listed in escrow_ignore so they remain editable.
shared/config.lua
Global runtime settings — broadcast policy, popup behavior, auto-expiry, and the heatmap cache.
| Key | Type | Default | Description |
|---|---|---|---|
Config.AutoExpire.enabled | boolean | true | Toggles the background expiry sweeper |
Config.AutoExpire.defaultMinutes | number | 15 | TTL applied to new alerts (in minutes) |
Config.AutoExpire.sweepIntervalSec | number | 30 | How often the sweeper runs (in seconds) |
Config.Broadcast.onDutyOnly | boolean | true | Only deliver alerts to on-duty responders. Defers to oxide-police for LEO duty when installed |
Config.Broadcast.maxJobsPerAlert | number | 12 | Hard cap on the number of resolved jobs per alert (extras are trimmed) |
Config.Broadcast.rateLimitMs | number | 5000 | Minimum gap between client-originated alerts per player |
Config.DispatchHub.RadialEnabled | boolean | true | Master switch for the Dispatch Hub entry inside the Dispatch radial menu. Disable on servers where another resource (e.g. oxide-police MDT) already provides a dispatch view |
Config.Popup.durationMs | number | 20000 | How long each popup stays on screen (ms) |
Config.Popup.maxStacked | number | 3 | Maximum number of stacked popups visible at once |
Config.Popup.mapZoom | number | 3 | Zoom level used by the popup mini-map |
Config.Popup.soundEffect | string | 'Lose_1st' | Default sound effect played on alert receive |
Config.Popup.soundSet | string | 'GTAO_FM_Events_Soundset' | Sound set the effect is loaded from |
Config.Heatmap.enabled | boolean | true | Enables the heatmap data feed in the Dispatch Hub |
Config.Heatmap.defaultWindow | string | '30d' | Default time window. One of '24h', '7d', '30d', 'all' |
Config.Heatmap.cacheSeconds | number | 60 | TTL of the in-memory heatmap cache (per window) |
Config.Heatmap.maxPoints | number | 5000 | Max points returned per query (downsampled if exceeded) |
Config.AutoExpire = {
enabled = true,
defaultMinutes = 15,
sweepIntervalSec = 30,
}
Config.Broadcast = {
onDutyOnly = true,
maxJobsPerAlert = 12,
rateLimitMs = 5000,
}
Config.DispatchHub = {
RadialEnabled = true,
}
Config.Popup = {
durationMs = 20000,
maxStacked = 3,
mapZoom = 3,
soundEffect = 'Lose_1st',
soundSet = 'GTAO_FM_Events_Soundset',
}
Config.Heatmap = {
enabled = true,
defaultWindow = '30d',
cacheSeconds = 60,
maxPoints = 5000,
}shared/config/alerts.lua
The dispatch code catalog. Producers supply a code field on every alert; this catalog supplies the defaults (title, priority, icon, blip) for any field the producer does not explicitly populate. Per-alert sound overrides are also supported via data.soundEffect / data.soundSet.
Config.Codes
A mapping of code → { title, priority, icon, blip }. Each entry's blip is { sprite, color, scale }. Priority 1 is highest.
| Code | Title | Priority | Icon | Category |
|---|---|---|---|---|
10-11 | Traffic Stop | 3 | fas fa-car | Patrol / routine |
10-14 | Prowler | 3 | fas fa-user-secret | Patrol / routine |
10-66 | Suspicious Activity | 3 | fas fa-magnifying-glass | Patrol / routine |
10-30 | Grand Theft Auto | 2 | fas fa-key | Crime in progress |
10-31 | Crime in Progress | 2 | fas fa-handcuffs | Crime in progress |
10-34 | Assault in Progress | 2 | fas fa-hand-fist | Crime in progress |
10-91 | Store Robbery | 2 | fas fa-sack-dollar | Crime in progress |
10-99 | Narcotics Activity | 2 | fas fa-cannabis | Crime in progress |
10-50 | Vehicle Accident | 2 | fas fa-car-burst | Vehicle |
10-55 | Intoxicated Driver | 2 | fas fa-wine-bottle | Vehicle |
10-57 | Hit and Run | 2 | fas fa-car-side | Vehicle |
10-32 | Person With a Gun | 1 | fas fa-gun | High priority |
10-71 | Shots Fired | 1 | fas fa-gun | High priority |
10-72 | Drive-By Shooting | 1 | fas fa-gun | High priority |
10-77 | Explosion | 1 | fas fa-bomb | High priority |
10-80 | Pursuit | 1 | fas fa-car-side | High priority |
10-90 | Silent Alarm | 1 | fas fa-building-columns | High priority |
10-52 | Medical Emergency | 2 | fas fa-truck-medical | EMS |
10-53 | Person Down | 1 | fas fa-person-falling | EMS |
10-54 | Cardiac Arrest | 1 | fas fa-heart-pulse | EMS |
10-56 | Trauma / Injury | 2 | fas fa-user-injured | EMS |
10-58 | Overdose | 1 | fas fa-syringe | EMS |
10-70 | Structure Fire | 1 | fas fa-fire | Fire / hazmat |
10-73 | Vehicle Fire | 1 | fas fa-car-burst | Fire / hazmat |
10-74 | Brush / Wildfire | 1 | fas fa-fire-flame-curved | Fire / hazmat |
10-75 | Hazmat Incident | 1 | fas fa-radiation | Fire / hazmat |
10-76 | Rescue / Extrication | 1 | fas fa-hands-holding | Fire / hazmat |
10-13 | Officer Needs Backup | 1 | fas fa-shield-halved | Officer safety |
panic | Panic Button | 1 | fas fa-bell | Officer safety |
911 | 911 Call | 2 | fas fa-phone-volume | Citizen-initiated |
311 | 311 Tip | 4 | fas fa-comment-dots | Citizen-initiated |
Config.GetCodeDefaults(code)
Helper used by the alert pipeline. Returns the catalog entry for the code, or a generic fallback (title = code, priority = 3, icon = 'fas fa-triangle-exclamation', generic blip) when the code is not in the catalog.
local defaults = Config.GetCodeDefaults('10-71')
-- { title = 'Shots Fired', priority = 1, icon = 'fas fa-gun', blip = {...} }To add a new code, drop a new entry into Config.Codes:
Config.Codes['10-100'] = {
title = 'Burglary in Progress',
priority = 2,
icon = 'fas fa-house-lock',
blip = { sprite = 161, color = 1, scale = 1.1 },
-- optional per-code sound override:
-- soundEffect = '...', soundSet = '...',
}shared/config/jobs.lua
Maps responder group keys (police, ems, fire) to framework job names plus the UI metadata used by the Dispatch radial menu and panic title resolver.
Config.ResponderGroups
Each entry defines:
| Field | Type | Description |
|---|---|---|
jobs | string[] | Framework job names that count as members of this group |
label | string | Human-readable label used in dispatch UI and panic titles |
icon | string | Font Awesome icon name (no fas fa- prefix) |
Config.ResponderGroups = {
police = {
jobs = { 'police', 'lspd', 'bcso', 'sasp', 'sheriff', 'statetrooper',
'LSPD', 'BCSO', 'SASP' },
label = 'Officer',
icon = 'shield-halved',
},
ems = {
jobs = { 'ambulance', 'ems', 'EMS' },
label = 'Paramedic',
icon = 'truck-medical',
},
fire = {
jobs = { 'fire', 'FIRE' },
label = 'Firefighter',
icon = 'fire',
},
}A
radialIdfield from older configs is ignored — all groups now share the single Dispatch radial menu. You can safely delete the field.
Job-name matching is case-sensitive. Include every casing your framework actually uses.
Only jobs is required per group — label and icon are optional and are auto-filled from the group key when omitted. The older config shape where a group was just a plain list of job names (police = { 'police', 'lspd' }) is auto-migrated at load, so configs edited before the metadata fields existed keep working.
Config.ResolveJobsDefault
Fallback used when a producer omits jobs. nil (the default) means an alert with no jobs reaches no recipients and a one-time warning is logged. Set to a group key (e.g. 'police') to opt back into the legacy silent-fallback behavior.
Config.ResolveJobsDefault = nil -- or 'police' to silently defaultConfig.ResolveJobs(input)
Normalizes producer inputs into a flat job-name array.
| Input | Returns |
|---|---|
nil | Config.ResolveJobsDefault (or empty + warning) |
'police' (group key) | The group's jobs array |
'sheriff' (raw job) | { 'sheriff' } |
{ 'police', 'sasp' } | Deduped expansion of all values |
Config.GetGroupForJob(jobName)
Reverse lookup. Returns the group key ('police', 'ems', 'fire') that owns a given raw job name, or nil if it isn't grouped. Used by the broadcast layer, panic subsystem, and per-group status broadcasts.
Adding a new responder group
To add a new responder group (for example coastguard):
- Add the group key + jobs to
Config.ResponderGroupshere. - Add a status list under the same key in
Config.ResponderStatuses(or rely on_default). - (Optional) Add a
titles/messagesentry for the group inConfig.Panicso panic alerts read naturally.
Full recipes (add a job, rename a job, build a new group) live in the Jobs & Responder Groups guide.
shared/config/statuses.lua
Per-session responder status states. Each responder group has its own status list so police-coded labels (Code-4, 10-7) don't leak into EMS/Fire UIs. Status changes broadcast to colleagues in the SAME group.
Config.ResponderStatuses
Each entry is a list of { id, label, color, icon } rows.
| Group | Statuses (id → label) |
|---|---|
police | available → Available, busy → Busy / On Call, code4 → Code-4, 10-7 → 10-7 (Out of Service) |
ems | available, oncall, enroute, onscene, transport, 10-7 |
fire | available, enroute, onscene, returning, 10-7 |
_default | available, busy, 10-7 (used when a custom group has no specific list) |
Config.ResponderStatusDefault
| Key | Type | Default | Description |
|---|---|---|---|
Config.ResponderStatusDefault | string | 'available' | Status id new responders are initialized with. Must exist in every group's status list (and in _default) |
Helpers
| Function | Returns | Description |
|---|---|---|
Config.GetResponderStatusList(group) | table[] | The status list for a group (falls back to _default) |
Config.GetResponderStatus(group, id) | table|nil | Resolves a status entry by group + id, with fallback to _default |
Config.ResponderCallsign
| Key | Type | Default | Description |
|---|---|---|---|
Config.ResponderCallsign.maxLength | number | 16 | Maximum callsign length (chars). Enforced server-side and on the input dialog |
Config.ResponderCallsign.pattern | string | '^[%w%-]+$' | Lua string pattern (anchored) callsigns must match. Extend the character class to allow dots, slashes, etc. |
Config.ResponderCallsign = {
maxLength = 16,
pattern = '^[%w%-]+$', -- alphanumerics + hyphen
-- pattern = '^[%w%-%.]+$', -- alphanumerics + hyphen + dot ('A-12.5')
-- pattern = '^[%w%-/%.]+$', -- alphanumerics + hyphen + slash + dot ('EMS/01')
}shared/config/detection.lua
Toggles the automatic crime detection module. Each detector funnels into the standard alert pipeline (rate limiting + duty filter + persistence apply normally).
Config.Detection
Every detector accepts a jobs field for recipient routing. It takes the same input as Config.ResolveJobs: a responder-group key ('police'), a raw job name ('lspd'), or an array of either.
| Key | Type | Default | Description |
|---|---|---|---|
Config.Detection.rateLimitPerPlayerMs | number | 8000 | Fallback per-player cooldown (ms), used only for detectors that have no entry in cooldownsMs |
Config.Detection.cooldownsMs.shooting | number | 12000 | Per-player cooldown (ms) between shots-fired alerts |
Config.Detection.cooldownsMs.driveby | number | 15000 | Per-player cooldown (ms) between drive-by alerts |
Config.Detection.cooldownsMs.melee | number | 60000 | Per-player cooldown (ms) between melee alerts |
Config.Detection.cooldownsMs.explosion | number | 15000 | Per-player cooldown (ms) between explosion alerts |
Config.Detection.cooldownsMs.vehicletheft | number | 30000 | Per-player cooldown (ms) between vehicle-theft alerts |
Config.Detection.ServerDedup.enabled | boolean | true | Server-side duplicate suppression — drops repeated auto-detect alerts of the same code from the same offender, even if a modified client skips its own cooldown |
Config.Detection.ServerDedup.windowMs | number | 60000 | How long (ms) repeats of the same alert from the same offender are suppressed |
Config.Detection.Shooting.enabled | boolean | true | Detect shots fired on foot |
Config.Detection.Shooting.code | string | '10-71' | Code used for the alert |
Config.Detection.Shooting.jobs | string|table | 'police' | Recipient group(s) |
Config.Detection.DriveBy.enabled | boolean | true | Detect shots fired from a vehicle |
Config.Detection.DriveBy.code | string | '10-72' | Code used for the alert |
Config.Detection.DriveBy.jobs | string|table | 'police' | Recipient group(s) |
Config.Detection.Melee.enabled | boolean | true | Detect melee combat (witness-based) |
Config.Detection.Melee.code | string | '10-34' | Code used for the alert |
Config.Detection.Melee.jobs | string|table | 'police' | Recipient group(s) |
Config.Detection.Explosion.enabled | boolean | true | Detect explosions |
Config.Detection.Explosion.code | string | '10-77' | Code used for the alert |
Config.Detection.Explosion.jobs | string|table | {'police','ems','fire'} | Recipient group(s) |
Config.Detection.Death.enabled | boolean | true | Auto-dispatch on player death |
Config.Detection.Death.code | string | '10-52' | Code used for the alert |
Config.Detection.Death.jobs | string|table | 'ems' | Recipient group(s) |
Config.Detection.VehicleTheft.enabled | boolean | true | Detect car-alarm trip + carjacking + oxide-vehicles lockpick/hotwire |
Config.Detection.VehicleTheft.code | string | '10-30' | Code used for the alert |
Config.Detection.VehicleTheft.jobs | string|table | 'police' | Recipient group(s) |
Every detector also accepts chance (number, default 1.0) — the 0.0–1.0 probability that a qualifying event is reported (0.0 disables it, like enabled = false). For the cooldown-gated detectors the roll happens once per cooldown window; Death rolls once per death.
Config.Detection.WeaponBlacklist
Weapons that should never trigger an automatic alert (less-lethal, training weapons, fists). Each key is a weapon, each value is true. Write the weapon name inside backticks (e.g. `WEAPON_PISTOL`) — the game converts the backticked name into the weapon hash it uses internally.
Config.Detection.WeaponBlacklist = {
[`WEAPON_UNARMED`] = true,
[`WEAPON_STUNGUN`] = true,
[`WEAPON_FLAREGUN`] = true,
}Config.Detection.ZoneBlacklist
Geographic exclusion zones — shooting/explosion in these areas won't alert. Each entry: { coords = vector3, radius = number, label = string }.
Config.Detection.ZoneBlacklist = {
-- { coords = vector3(450.0, -980.0, 30.0), radius = 60.0, label = 'Mission Row PD' },
-- { coords = vector3(842.0, -1290.0, 28.0), radius = 80.0, label = 'PD Range' },
}Config.Detection.JobBlacklist
Jobs whose holders should never trip an auto-detect alert. Keys are framework job names and must exactly match the job name as your framework spells it (case-sensitive); values true. Use this to stop on-duty responders from dispatching themselves whenever they fire their service weapon, commandeer a vehicle in pursuit, or take a melee hit on the job. Manual paths (911, panic, MDT-issued alerts) ignore this list.
Config.Detection.JobBlacklist = {
-- ['police'] = true,
-- ['lspd'] = true,
-- ['bcso'] = true,
-- ['sasp'] = true,
-- ['sheriff'] = true,
-- ['statetrooper'] = true,
-- ['ambulance'] = true,
-- ['ems'] = true,
-- ['fire'] = true,
}shared/config/panic.lua
Two distinct subsystems live in this file: the panic / responder-needs-backup alert (separate from the panic dispatch code) and the civilian 911/311 category catalog.
Config.Panic
| Key | Type | Default | Description |
|---|---|---|---|
Config.Panic.cooldownSec | number | 60 | Per-responder cooldown (seconds) on the panic keybind / /panic |
Config.Panic.broadcastOffDuty | boolean | true | When true, panic alerts ignore Config.Broadcast.onDutyOnly |
Config.Panic.autoWaypoint | boolean | true | Auto-set the recipient's GPS waypoint to the panicker |
Config.Panic.soundEffect | string|nil | nil | Distinct alert sound for panic. Falls back to Config.Popup.soundEffect when nil |
Config.Panic.soundSet | string|nil | nil | Sound set for the panic alert. Falls back to Config.Popup.soundSet when nil |
Config.Panic.titles[group] | string | per group | Panic alert title per responder group. _default is the fallback |
Config.Panic.messages[group] | string | per group | Panic alert message per responder group. _default is the fallback |
Config.Panic = {
cooldownSec = 60,
broadcastOffDuty = true,
autoWaypoint = true,
soundEffect = nil,
soundSet = nil,
titles = {
police = 'OFFICER NEEDS BACKUP',
ems = 'MEDIC NEEDS BACKUP',
fire = 'FIREFIGHTER NEEDS BACKUP',
_default = 'RESPONDER NEEDS BACKUP',
},
messages = {
police = 'Officer panic alert',
ems = 'Medic panic alert',
fire = 'Firefighter panic alert',
_default = 'Responder panic alert',
},
}Config.Civilian
Configures the citizen 911 / 311 menu (radial + chat commands).
| Key | Type | Default | Description |
|---|---|---|---|
Config.Civilian.anonymousDefault | boolean | true | Whether 911/311 calls strip caller identity from the broadcast by default |
Config.Civilian.maxMessageLength | number | 240 | Hard cap on civilian call message + responder reply length (chars). Truncated server-side |
Config.Civilian.Categories911 | table[] | see file | Category list for /911 and the radial Call 911 submenu |
Config.Civilian.Categories311 | table[] | see file | Category list for /311 and the radial Call 311 submenu |
Each category entry is { id, label, code, jobs, icon? }. jobs accepts the same input Config.ResolveJobs does (group key, raw job, or array).
Config.Civilian = {
anonymousDefault = true,
maxMessageLength = 240,
Categories911 = {
{ id = 'crime', label = 'Crime in Progress', code = '10-31', jobs = 'police' },
{ id = 'medical', label = 'Medical Emergency', code = '10-52', jobs = 'ems' },
{ id = 'injury', label = 'Person Injured', code = '10-56', jobs = 'ems' },
{ id = 'overdose', label = 'Overdose', code = '10-58', jobs = 'ems' },
{ id = 'fire', label = 'Structure Fire', code = '10-70', jobs = 'fire' },
{ id = 'vfire', label = 'Vehicle Fire', code = '10-73', jobs = 'fire' },
{ id = 'hazmat', label = 'Hazmat / Spill', code = '10-75', jobs = 'fire' },
{ id = 'shooting', label = 'Shots Fired', code = '10-71', jobs = 'police' },
{ id = 'accident', label = 'Vehicle Accident', code = '10-50', jobs = { 'police', 'ems' } },
{ id = 'other', label = 'Other Emergency', code = '911', jobs = { 'police', 'ems', 'fire' } },
},
Categories311 = {
{ id = 'tip', label = 'Anonymous Tip', code = '311', jobs = 'police' },
{ id = 'noise', label = 'Noise Complaint', code = '311', jobs = 'police' },
{ id = 'suspicious', label = 'Suspicious Person', code = '10-66', jobs = 'police' },
{ id = 'medical_q', label = 'Medical Question', code = '311', jobs = 'ems' },
{ id = 'fire_safety', label = 'Fire / Safety Issue', code = '311', jobs = 'fire' },
{ id = 'other', label = 'Other Non-Emergency', code = '311', jobs = 'police' },
},
}shared/config/compat.lua
Per-framework toggles for the legacy police-alert shims. The shims forward old QB/ESX events into the modern Alerts.Create pipeline so existing scripts keep working without edits.
Top-level toggles
| Key | Type | Default | Description |
|---|---|---|---|
Config.Compatibility.enabled | boolean | true | Master toggle for all shims |
Config.Compatibility['qb-core']
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Enable the QBCore shim |
serverPoliceAlert | boolean | true | Listen for police:server:policeAlert from server scripts |
clientPoliceAlert | boolean | false | Listen for the QB client-side police-alert relay |
ambulanceAlert | boolean | true | Listen for hospital:server:ambulanceAlert (qb-ambulancejob / qbx_ambulancejob downed alerts, /911e, third-party) |
emergencyAlert | boolean | true | Listen for hospital:server:emergencyAlert (QBX radial menu fast path) |
clientAmbulanceAlert | boolean | false | Listen for the QB client-side ambulance-alert relay |
emsJobs | string|table | 'ems' | Group / job(s) the converted ambulance alert is addressed to |
emsCode | string | '10-52' | Code used for converted ambulance alerts |
jobs | string|table | 'police' | Group / job(s) the converted police alert is addressed to |
defaultCode | string | '10-31' | Code used when no keyword matches the police alert message |
rateLimitMs | number | 5000 | Per-source rate limit for the shim (shared across police + EMS paths) |
mirrorQbPhone | boolean | false | When true and qb-phone is started, mirror the police alert into qb-phone:client:addPoliceAlert |
keywordCodes | table | see file | Ordered keyword → code rules for the police shim (case-insensitive substring match) |
When
Config.Detection.Death.enabledANDambulanceAlertare both on, the sharedrateLimitMsabsorbs the duplicate alert that fires when a player dies. If you setrateLimitMs = 0, disable one of the two to avoid duplicates.
Config.Compatibility['qb-core'] = {
enabled = true,
serverPoliceAlert = true,
clientPoliceAlert = false,
ambulanceAlert = true,
emergencyAlert = true,
clientAmbulanceAlert = false,
emsJobs = 'ems',
emsCode = '10-52',
jobs = 'police',
defaultCode = '10-31',
rateLimitMs = 5000,
mirrorQbPhone = false,
keywordCodes = {
{ pattern = 'drug', code = '10-99' },
{ pattern = 'narcotic', code = '10-99' },
{ pattern = 'store', code = '10-91' },
{ pattern = 'bank', code = '10-90' },
{ pattern = 'jewel', code = '10-90' },
{ pattern = 'suspicious', code = '10-66' },
{ pattern = 'suspicous', code = '10-66' }, -- common misspelling
{ pattern = 'vehicle', code = '10-80' },
{ pattern = 'robbery', code = '10-31' },
},
}Config.Compatibility['es_extended']
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Enable the ESX shim |
serverPoliceAlert | boolean | true | Listen for esx_policejob:alert / esx_policejob:policeAlert |
clientPoliceAlert | boolean | false | Listen for the ESX client-side police-alert relay |
esxPhoneSend | boolean | true | Convert police-contact esx_phone:send calls to alerts |
outlawAlert | boolean | true | Listen for esx_outlawalert:policeNotify / outlawNotify |
distressAlert | boolean | true | Listen for esx_ambulancejob:onPlayerDistress (downed-player keybind) |
esxPhoneEms | boolean | true | Convert ambulance-contact esx_phone:send calls to alerts. Requires esxPhoneSend to also be enabled — both the police and ambulance contacts are handled by the single esx_phone:send listener, which is only registered when esxPhoneSend is on. Setting esxPhoneSend = false disables the ambulance path too, regardless of this value |
emsJobs | string|table | 'ems' | Group / job(s) the converted ambulance alert is addressed to |
emsCode | string | '10-52' | Code used for converted ambulance alerts |
jobs | string|table | 'police' | Group / job(s) the converted police alert is addressed to |
defaultCode | string | '10-31' | Code used when no keyword matches the police alert message |
rateLimitMs | number | 5000 | Per-source rate limit for the shim (shared across police + EMS paths) |
keywordCodes | table | see file | Ordered keyword → code rules for the police shim |
Config.Compatibility['es_extended'] = {
enabled = true,
serverPoliceAlert = true,
clientPoliceAlert = false,
esxPhoneSend = true,
outlawAlert = true,
distressAlert = true,
esxPhoneEms = true,
emsJobs = 'ems',
emsCode = '10-52',
jobs = 'police',
defaultCode = '10-31',
rateLimitMs = 5000,
keywordCodes = {
{ pattern = 'drug', code = '10-99' },
{ pattern = 'narcotic', code = '10-99' },
{ pattern = 'store', code = '10-91' },
{ pattern = 'bank', code = '10-90' },
{ pattern = 'jewel', code = '10-90' },
{ pattern = 'suspicious', code = '10-66' },
{ pattern = 'suspicous', code = '10-66' },
{ pattern = 'vehicle', code = '10-80' },
{ pattern = 'robbery', code = '10-31' },
{ pattern = 'shot', code = '10-71' },
{ pattern = 'gun', code = '10-32' },
},
}QBX
QBX uses the same police:server:policeAlert event names QBCore does (verified across qbx_bankrobbery, qbx_truckrobbery, qbx_houserobbery, qbx_jewelery, qbx_storerobbery, qbx_vehiclekeys, and the legacy qbx_police). The qb-core shim above handles QBX traffic — there is no separate qbx_core block. See the QBX Setup page for QBX-specific notes.
shared/config/ai_dispatch.lua
Configuration for the optional AI Radio Dispatcher feature — provider selection, radio channels (one per department job), idle timeout, identifier whitelist, integration toggles, model and voice choices.
| Key | Type | Default | Description |
|---|---|---|---|
Config.AIDispatch.Enabled | boolean | true | Master switch for the AI feature |
Config.AIDispatch.Provider | string | 'chained' | Global default provider ('realtime' or 'chained'); each channel can override |
Config.AIDispatch.Channels | table | see below | Map of radio channel number → channel entry. If left empty, a warning is logged at startup and the AI dispatch feature turns itself off — the server still starts normally |
Config.AIDispatch.IdleTimeoutMin | number | 10 | Auto-close idle realtime sessions after N minutes (chained mode skips) |
Config.AIDispatch.RateLimit.alerts | number | 3 | Per-officer alert cap per window |
Config.AIDispatch.RateLimit.windowSec | number | 60 | Rate-limit window in seconds |
Config.AIDispatch.Access.IdentifierWhitelist | string[] | {} | Global FiveM identifier allowlist fallback. Per-channel whitelist takes precedence |
Config.AIDispatch.Integrations.Mdt | table[] | { {name='oxide-police',...}, {name='ps-mdt',...}, {name='wasabi_mdt',...}, {name='lb-tablet',...} } | Priority list of MDT backends powering the 8 MDT-flavored AI tools (plate/person/profile/BOLO/custody/on-duty). First entry with enabled ~= false whose named resource is started wins. Reorder to change priority. Write your own bridge per the MDT Bridge Development guide. |
Config.AIDispatch.Realtime.Provider | string | 'openai' | Who runs realtime mode: 'openai', or 'custom' with a BaseUrl. See Choosing a provider |
Config.AIDispatch.Realtime.BaseUrl | string | unset | Address of a non-OpenAI realtime gateway. Must start with https:// — the game's browser blocks insecure addresses, and a plain http:// value is refused at startup with realtime_base_url_not_https |
Config.AIDispatch.Realtime.ApiKey | string | unset | Key set directly instead of via convar. Normally leave unset |
Config.AIDispatch.Realtime.Model | string | 'gpt-realtime' | Realtime model id. 'gpt-realtime-mini' costs roughly a third as much |
Config.AIDispatch.Realtime.TranscriptionModel | string|false | 'whisper-1' | Model used to transcribe the officer's own speech for the radio transcript. Set to false to omit it for gateways that don't host one |
Config.AIDispatch.Realtime.Voice | string | 'alloy' | Default Realtime voice if a channel doesn't set its own |
Config.AIDispatch.Realtime.Instructions | string | '' | Populated automatically from ai_dispatch_prompt.lua — leave empty |
Config.AIDispatch.Chained.STT.Provider / .Model | string | 'openai' / 'whisper-1' | Who does speech-to-text (turns the officer's voice into text), and which model. Cheaper: 'groq' / 'whisper-large-v3-turbo'. Free on your own machine: 'speaches' |
Config.AIDispatch.Chained.STT.Language | string | 'en' | Two-letter language code (ISO-639-1) the speech-to-text stage listens for — 'en' English, 'es' Spanish, 'fr' French, 'de' German, 'pt' Portuguese, and about 50 more. To run dispatch in another language, set this and translate the system prompt in ai_dispatch_prompt.lua so the AI replies in that language (the voice speaks whatever language the reply is written in — nothing to set on the TTS stage) |
Config.AIDispatch.Chained.LLM.Provider / .Model | string | 'openai' / 'gpt-4o-mini' | Who runs the AI language model (decides what the dispatcher says and does), and which model. Whatever you choose must support tool calling, or the dispatcher will talk but never create alerts or run plates |
Config.AIDispatch.Chained.LLM.MaxHistoryTurns | number | 20 | How many back-and-forth exchanges the dispatcher remembers per session |
Config.AIDispatch.Chained.LLM.MaxToolCascade | number | 4 | Cap on chained tool-call loops per transmission |
Config.AIDispatch.Chained.LLM.Temperature | number | 0.4 | How much the wording varies between replies. Comment the line out to leave it unset — some models on other providers reject it |
Config.AIDispatch.Chained.LLM.MaxTokens | number | 400 | Length cap on a single reply. Comment out to leave unset |
Config.AIDispatch.Chained.TTS.Provider / .Model | string | 'openai' / 'tts-1' | Who does text-to-speech (turns the dispatcher's reply into spoken audio), and which model. Free on your own machine: 'kokoro' / 'kokoro' |
Config.AIDispatch.Chained.TTS.Voice | string | 'onyx' | Default text-to-speech voice if a channel doesn't set its own. Voice names are provider-specific — OpenAI uses onyx/ash/shimmer, Kokoro uses af_sky/af_bella |
Config.AIDispatch.Chained.TTS.Format | string | 'mp3' | Audio format requested from the provider: mp3, opus, aac, flac or wav. mp3 is the safest; pcm is not supported |
Each of the four blocks above (Realtime, Chained.STT, Chained.LLM, Chained.TTS) also accepts a BaseUrl (the address of the service, needed only for 'custom' or a non-default port) and an ApiKey (used instead of the provider's convar). Both are unset by default.
Channel entry fields (Config.AIDispatch.Channels[N])
| Field | Type | Required | Description |
|---|---|---|---|
job | string | yes | Framework job name bound to this channel (e.g. 'lspd', 'bcso', 'sasp', 'ambulance', 'fire'). Police-MDT tools appear on this channel when the job is listed in Config.ResponderGroups.police.jobs |
label | string | no | UI label for the channel |
voice | string | no | Override the default voice for this channel |
chainedVoice | string | no | Voice used when this channel runs chained mode. Only needed when your chained and realtime providers use different voice names (e.g. Kokoro af_sky vs OpenAI ash) |
realtimeVoice | string | no | Voice used when this channel runs realtime mode. Same purpose as above |
provider | string | no | Override the global Provider for this channel ('realtime' or 'chained') |
whitelist | string[] | no | Per-channel identifier allowlist; takes precedence over Access.IdentifierWhitelist when non-empty |
Three channels ship enabled by default: 911 → job 'police' (voice ash), 921 → 'ambulance' (voice shimmer), and 931 → 'fire' (voice sage). Channels 912 (BCSO) and 913 (SASP) are included as commented-out examples.
Important: each channel's
jobmust match a job name that actually exists on your server, spelled exactly as your framework has it (usually lowercase). If your police job isn't calledpolice(e.g.lspd), change channel911'sjobvalue — otherwise no player will pass the channel's job check.
Config.AIDispatch.RadioFX
The radio-static filter applied to the AI dispatcher's voice — band-limiting, light distortion, and a faint background hiss make it sound like a real comms radio instead of a clean synthesized stream.
| Key | Type | Default | Description |
|---|---|---|---|
Enabled | boolean | true | Master toggle for the radio effect |
BandpassLow | number | 400 | Lowest voice frequency let through (Hz). Higher = thinner sound |
BandpassHigh | number | 3400 | Highest voice frequency let through (Hz) |
Distortion | number | 0.04 | Distortion amount, 0–1 (0 = clean, 0.3 = crunchy) |
NoiseFloor | number | 0.008 | Volume of the constant background hiss, 0–1 (0 = off, 0.04 = very loud) |
NoiseLow | number | 1000 | Lowest frequency of the hiss (Hz) |
NoiseHigh | number | 4500 | Highest frequency of the hiss (Hz) |
ChainedGain | number | 1.85 | Output volume of the voice in chained mode (1.0 = unchanged) |
RealtimeGain | number | 1.85 | Output volume of the voice in realtime mode (1.0 = unchanged) |
API key
The OpenAI API key is not stored in this file. Set it as a server convar (a server-side variable, set in server.cfg or the live console):
set oxide:ai-dispatch:openai_key sk-...your-key-here...Use
set, notsetr—setrreplicates the value to every connected player and would leak your key.
Each provider reads its own line, named after the provider — so a step set to Provider = 'groq' looks for oxide:ai-dispatch:groq_key:
set oxide:ai-dispatch:groq_key gsk-...
set oxide:ai-dispatch:openrouter_key sk-or-...
set oxide:ai-dispatch:lemonfox_key ...
set oxide:ai-dispatch:custom_key ...Providers that run on your own machine (ollama, lmstudio, kokoro, speaches) need no key — leave the line out entirely. On startup the server log lists every step, the provider it resolved to, and the address it will use, so you can confirm what's actually configured.
Full feature documentation, cost notes, voice options, identifier-whitelist usage, and privacy disclosure templates live in the AI Radio Dispatcher guide.
shared/config/ai_dispatch_acks.lua
Spoken acknowledgements for the AI dispatcher. When the dispatcher decides to look something up (a plate, a person's records, a BOLO), the chained pipeline can take a few seconds to answer. To avoid dead silence on the radio while that happens, a short pre-rendered acknowledgement plays the moment the lookup starts — "Running that plate.", "Pulling those records.", and so on.
Config.AIDispatch.Acks
| Key | Type | Default | Description |
|---|---|---|---|
Enabled | boolean | true | Master toggle. When false, no clips are rendered at startup and the pipeline behaves as before (silence during lookups) |
Phrases | table | see file | One list of phrases per tool name. A phrase from the matching list is picked at random each time so it doesn't feel scripted — add as many variants as you like |
Default | string[] | { "Copy, stand by.", "Stand by." } | Fallback phrases used when a tool has no Phrases entry (e.g. a new tool added later, or a custom one) |
The audio clips are generated automatically on first server startup using each channel's text-to-speech voice and cached on disk under <resource>/cache/acks/<voice>/. You can add or edit phrases freely — changed phrases are re-rendered on the next boot while unchanged ones are reused from the cache.
Shipped phrase lists cover the core dispatch tools (create_dispatch_alert, cancel_dispatch_alert, attach_responder, detach_responder, set_responder_status, set_officer_status, trigger_panic, list_active_alerts, update_dispatch_alert) and the police MDT tools (lookup_plate, lookup_person, get_person_profile, create_bolo, list_active_bolos, clear_bolo, list_on_duty_officers, check_custody).
shared/config/ai_dispatch_prompt.lua
The dispatcher's system prompt — the behavior contract the AI follows. Built per session by Config.BuildAIDispatchPrompt(job, hasPoliceTools) and used by both the realtime and chained providers.
Structure:
JOB_BLOCKS— per-job lookup of{ name, flavor }.nameis how the dispatcher introduces itself ("LSPD", "BCSO", "EMS", etc.);flavorselects which routing-rules block applies ('police','ems','fire', ornilfor generic). Add a key for any custom job you want to tune.FLAVOR_ROUTING— three blocks of natural-language routing guidance keyed by flavor.BASE_PROMPT,CORE_TOOL_RULES,POLICE_TOOL_RULES,RADIO_AWARENESS— structural sections spliced together byBuildAIDispatchPrompt. Police MDT guidance is only included when the channel's job qualifies for those tools.
Jobs that aren't in JOB_BLOCKS get a generic uppercase fallback (e.g. 'amr_paramedic' → "AMR_PARAMEDIC dispatch"). If you customize the prompt, keep the structural sections intact — the model relies on them to use the tools correctly.