API examples
Complete recipes you can drop into your own resource. Each one assumes koja-dispatcher is started before your resource (see the overview).
| Recipe | Uses |
|---|---|
| Phone app 911 | PhoneCall, CreateCall, Call911 |
| Store robbery alert with auto-assign | CreateIncident, CreateCall, GetDispatcherCount |
| ALPR camera script | ReportPlate |
| Priority 1 for Fleeca robberies | incidentCreate hook |
| Block shifts for suspended players | shiftStart hook |
| Discord or log on incidentEnded | koja-dispatcher:incidentEnded, DiscordLog |
| Field unit status from a job script | RunFieldCode, SetUnitStatus, GetPlayerUnit |
| Units on a custom map | GetUnits, unitAdded / unitRemoved |
| Create a BOLO from another script | CreateBolo, CloseBolo, GetBolos |
| Escalation in a custom scenario | shared/scenarios/*.lua, events, stages |
| Register your own admin setting | RegisterSetting, GetSetting, settingChanged |
Phone app 911
Your phone's 911 app sends the text to its own server event, which hands it to the dispatcher with the player as the caller.
-- PhoneCall works with any phone: it uses the /911 logic, so follow-up texts
-- within calls.playerReplyWindow join the open call. Needs phones.enabled.
RegisterNetEvent('myphone:server:call911', function(text)
local src = source
if type(text) ~= 'string' then return end
local id, route = exports['koja-dispatcher']:PhoneCall(src, text)
if not id then
if route == 'rate' then
TriggerClientEvent('myphone:client:notify', src, 'Please wait before calling again.')
end
-- 'no_dispatchers': the player already got a "nobody is available" notification
return
end
if route == 'incident' then
TriggerClientEvent('myphone:client:notify', src, 'Units have been sent to your location.')
elseif route == 'call' then
TriggerClientEvent('myphone:client:notify', src, 'Connecting you to the dispatcher...')
end
end)-- CreateCall gives you full control (caller name, category) but every message
-- is a new call. Works even with phones.enabled off.
local lastCall = {}
RegisterNetEvent('myphone:server:call911', function(text)
local src = source
if type(text) ~= 'string' then return end
text = text:gsub('%c', ' '):sub(1, 250)
if #text < 3 then return end
local now = os.time()
if lastCall[src] and now - lastCall[src] < 30 then return end
lastCall[src] = now
local id, route = exports['koja-dispatcher']:CreateCall({
source = src, -- position, notifications and replies go to this player
caller = GetPlayerName(src),
message = text,
})
if not id then
TriggerClientEvent('myphone:client:notify', src, route == 'no_dispatchers'
and 'Nobody is answering 911 right now.' or 'The call could not be connected.')
end
end)
AddEventHandler('playerDropped', function() lastCall[source] = nil end)-- Call911 is the /911 command, called from your phone UI
RegisterNUICallback('call911', function(data, cb)
local ok, route = exports['koja-dispatcher']:Call911(tostring(data.text or ''))
cb({ ok = ok, route = route })
end)Store robbery alert with auto-assign
The robbery script tells its server side that the alarm went off. The server checks the player is really at the store, then creates the incident and sends the nearest free units.
local stores = {
ltd_grove = { label = 'LTD Gasoline, Grove Street', coords = vector3(-47.5, -1757.6, 29.4) },
twentyfour_innocence = { label = '24/7, Innocence Blvd', coords = vector3(25.7, -1347.3, 29.5) },
}
local lastAlarm = {}
RegisterNetEvent('myrobbery:server:alarm', function(storeId)
local src = source
local store = stores[storeId]
if not store then return end
-- never trust the client: the robber must stand in the store
if #(GetEntityCoords(GetPlayerPed(src)) - store.coords) > 30.0 then return end
-- one alarm per store every 10 minutes
local now = os.time()
if lastAlarm[storeId] and now - lastAlarm[storeId] < 600 then return end
lastAlarm[storeId] = now
local incidentId, err = exports['koja-dispatcher']:CreateIncident({
category = 'robbery',
priority = 1,
coords = store.coords,
label = store.label,
need = { police = 2 },
note = 'Silent alarm, armed robbery in progress',
autoAssign = true, -- nearest free units for { police = 2 }
})
if not incidentId then
print(('[myrobbery] alarm failed: %s'):format(err))
end
end)-- With a dispatcher on shift the alarm rings at the console and the dispatcher
-- decides; otherwise the incident is created and auto-assigned.
local function storeAlarm(store)
local dispatch = exports['koja-dispatcher']
if dispatch:GetDispatcherCount() > 0 then
return dispatch:CreateCall({
coords = store.coords,
label = store.label,
caller = 'Alarm company',
message = ('Silent alarm at %s'):format(store.label),
category = 'robbery',
priority = 1,
need = { police = 2 },
})
end
return dispatch:CreateIncident({
category = 'robbery', priority = 1, coords = store.coords, label = store.label,
need = { police = 2 }, note = 'Silent alarm', autoAssign = true,
})
end-- in your robbery script, when the register is opened
TriggerServerEvent('myrobbery:server:alarm', 'ltd_grove')ALPR camera script
Fixed cameras on the server read every vehicle that passes and report the plate. ReportPlate only alerts the dispatchers when the plate has an active BOLO, so you can report every read.
local cameras = {
{ label = 'Camera 14, Del Perro Fwy', coords = vector3(-1236.0, -722.0, 21.0), radius = 25.0 },
{ label = 'Camera 3, Route 68', coords = vector3(1172.0, 2696.0, 37.9), radius = 25.0 },
}
local lastRead = {}
CreateThread(function()
while true do
Wait(1000)
local now = os.time()
for _, vehicle in ipairs(GetAllVehicles()) do
local pos = GetEntityCoords(vehicle)
for index, camera in ipairs(cameras) do
if #(pos - camera.coords) <= camera.radius then
local plate = GetVehicleNumberPlateText(vehicle)
local key = index .. ':' .. tostring(plate)
if plate and (not lastRead[key] or now - lastRead[key] > 60) then
lastRead[key] = now
local hits = exports['koja-dispatcher']:ReportPlate(plate, pos, camera.label)
if #hits > 0 then
print(('[alpr] %s hit BOLO %s (%s)'):format(plate, hits[1].id, hits[1].title))
end
end
end
end
end
end
end)A hit puts an ALPR alert and a radio line in every dispatcher's CAD and moves the BOLO's last-seen point to the camera. It needs mdt_tools.alpr on.
Priority 1 for Fleeca robberies
An incidentCreate hook upgrades every robbery near a Fleeca branch, whether it came from a dispatcher, a robbery script, a phone or a dispatch-script alert.
local fleeca = {
vector3(149.9, -1040.7, 29.4), -- Legion Square
vector3(314.2, -278.6, 54.2), -- Hawick Ave
vector3(-351.5, -49.5, 49.0), -- Burton
vector3(-1212.9, -330.8, 37.8), -- Rockford Hills
vector3(-2962.6, 482.6, 15.7), -- Great Ocean Hwy
vector3(1175.0, 2706.6, 38.1), -- Route 68
}
local function nearFleeca(coords)
if not coords then return false end
local point = vector3(coords.x, coords.y, coords.z or 0.0)
for _, branch in ipairs(fleeca) do
if #(point.xy - branch.xy) < 60.0 then return true end
end
return false
end
exports['koja-dispatcher']:RegisterHook('incidentCreate', function(data)
if data.training or data.category ~= 'robbery' then return end
local byLabel = type(data.label) == 'string' and data.label:lower():find('fleeca') ~= nil
if byLabel or nearFleeca(data.real or data.coords) then
return {
priority = 1,
need = { police = 3 },
known = { police = 3 },
note = 'Fleeca bank robbery: priority 1, three units minimum',
}
end
end)data.real holds the true position when the dispatcher dispatched a call before learning the exact location.
Block shifts for suspended players
A shiftStart hook refuses the shift with a reason. The CAD shows the locale text errors.<reason>, so add a suspended entry under game.errors in the locale files (or return an existing key such as not_dispatcher). Suspensions are kept in the resource KVP so they survive restarts.
local function licenseOf(src)
return GetPlayerIdentifierByType(src, 'license')
end
local function suspendedUntil(license)
return GetResourceKvpInt(('suspended:%s'):format(license))
end
exports['koja-dispatcher']:RegisterHook('shiftStart', function(data)
local license = licenseOf(data.source)
if license and suspendedUntil(license) > os.time() then
return false, 'suspended'
end
end)
-- /dispatchsuspend <id> <hours>, restricted with: add_ace group.admin command.dispatchsuspend allow
RegisterCommand('dispatchsuspend', function(src, args)
local target, hours = tonumber(args[1]), tonumber(args[2])
local license = target and licenseOf(target)
if not license or not hours then return end
SetResourceKvpInt(('suspended:%s'):format(license), os.time() + math.floor(hours * 3600))
print(('suspended %s from dispatch for %sh'):format(GetPlayerName(target), hours))
end, true)"suspended": "You are suspended from dispatch duty."The hook only stops new shifts. To remove a dispatcher who is already on shift, end it in your own code (for example by kicking them from the job in your framework).
Discord or log on incidentEnded
-- uses the dispatcher's own webhooks (convar koja_dispatcher_webhook_incidents)
AddEventHandler('koja-dispatcher:incidentEnded', function(incident)
if incident.status ~= 'resolved' or incident.priority ~= 1 then return end
exports['koja-dispatcher']:DiscordLog('incidents',
('Priority 1 #%d closed'):format(incident.id),
incident.note,
{
{ name = 'Category', value = incident.category },
{ name = 'Outcome', value = incident.outcome or 'resolved' },
{ name = 'Location', value = incident.label or ('%.0f, %.0f'):format(incident.x, incident.y), inline = false },
{ name = 'Duration', value = ('%ds'):format((incident.endedAt or os.time()) - incident.createdAt) },
{ name = 'Dispatchers on shift', value = tostring(exports['koja-dispatcher']:GetDispatcherCount()) },
},
0x57F287)
end)local webhook = GetConvar('my_incident_webhook', '')
AddEventHandler('koja-dispatcher:incidentEnded', function(incident)
if webhook == '' then return end
local body = json.encode({
username = 'Incident log',
embeds = { {
title = ('Incident #%d %s'):format(incident.id, incident.status),
description = ('%s, priority %d, outcome %s'):format(incident.category, incident.priority, tostring(incident.outcome)),
color = incident.status == 'resolved' and 5763719 or 15548997,
} },
})
PerformHttpRequest(webhook, function() end, 'POST', body, { ['Content-Type'] = 'application/json' })
end)AddEventHandler('koja-dispatcher:incidentEnded', function(incident)
MySQL.insert('INSERT INTO my_incident_log (incident, category, status, outcome, origin, ended_at) VALUES (?, ?, ?, ?, ?, ?)', {
incident.id, incident.category, incident.status, incident.outcome, incident.origin, incident.endedAt,
})
end)The dispatcher already stores every closed incident in koja_dispatch_history when stats.enabled is on; use your own table only for extra data.
Field unit status from a job script
A booking or evidence script marks the officer busy while they work and available afterwards. RunFieldCode behaves like the officer's own key (radio line, HUD chip, koja-mdt sync); SetUnitStatus is the plain status change.
-- server.lua of your booking script
RegisterNetEvent('mybooking:server:start', function()
local src = source
local ok, err = exports['koja-dispatcher']:RunFieldCode(src, '10-6') -- busy
if not ok and err ~= 'not_field_unit' then print('status failed: ' .. err) end
end)
RegisterNetEvent('mybooking:server:finish', function()
exports['koja-dispatcher']:RunFieldCode(source, '10-8') -- available
end)RegisterNetEvent('mybooking:server:start', function()
local src = source
local unit = exports['koja-dispatcher']:GetPlayerUnit(src)
if not unit then return end -- not a field unit right now
if unit.incident then return end -- keep officers that are on a call
exports['koja-dispatcher']:SetUnitStatus(src, 'busy')
end)
RegisterNetEvent('mybooking:server:finish', function()
exports['koja-dispatcher']:SetUnitStatus(source, 'available')
end)Units on a custom map
The server sends a compact unit list every 2 seconds to the players who use your map (here: an ACE permission), and the client draws blips.
local viewers = {}
RegisterNetEvent('mymap:server:open', function()
local src = source
if IsPlayerAceAllowed(src, 'mymap.view') then viewers[src] = true end
end)
RegisterNetEvent('mymap:server:close', function() viewers[source] = nil end)
AddEventHandler('playerDropped', function() viewers[source] = nil end)
CreateThread(function()
while true do
Wait(2000)
if next(viewers) then
local list = {}
for _, unit in ipairs(exports['koja-dispatcher']:GetUnits()) do
if unit.status ~= 'offline' then
list[#list + 1] = {
id = unit.id, callsign = unit.callsign, dept = unit.dept,
status = unit.status, x = unit.x, y = unit.y, h = unit.h,
incident = unit.incident,
}
end
end
for src in pairs(viewers) do TriggerClientEvent('mymap:client:units', src, list) end
end
end
end)local blips = {}
local colours = { police = 3, ems = 1, fire = 17 }
RegisterNetEvent('mymap:client:units', function(list)
local seen = {}
for _, unit in ipairs(list) do
seen[unit.id] = true
local blip = blips[unit.id]
if not blip then
blip = AddBlipForCoord(unit.x, unit.y, 0.0)
SetBlipSprite(blip, unit.dept == 'police' and 56 or 61)
SetBlipColour(blip, colours[unit.dept] or 0)
SetBlipScale(blip, 0.8)
BeginTextCommandSetBlipName('STRING')
AddTextComponentSubstringPlayerName(unit.callsign)
EndTextCommandSetBlipName(blip)
blips[unit.id] = blip
end
SetBlipCoords(blip, unit.x, unit.y, 0.0)
SetBlipRotation(blip, math.floor(unit.h or 0))
SetBlipFlashes(blip, unit.status == 'enroute')
end
for id, blip in pairs(blips) do
if not seen[id] then RemoveBlip(blip); blips[id] = nil end
end
end)For a log of who is on duty, listen to koja-dispatcher:unitAdded and koja-dispatcher:unitRemoved instead of polling.
Create a BOLO from another script
CreateBolo creates the BOLO where the CAD keeps them (mdt_tools.boloSource): in koja-mdt while it runs, otherwise in the dispatcher's own list. Every dispatcher sees it at once, and with koja-mdt officers see it in the MDT too.
-- a bank robbery script flags the getaway car
RegisterNetEvent('mybank:server:getaway', function(netId)
local src = source
local vehicle = NetworkGetEntityFromNetworkId(netId)
if vehicle == 0 or #(GetEntityCoords(vehicle) - GetEntityCoords(GetPlayerPed(src))) > 15.0 then return end
local pos = GetEntityCoords(vehicle)
local boloId, err = exports['koja-dispatcher']:CreateBolo({
type = 'vehicle',
title = 'Getaway car, armed occupants',
plate = GetVehicleNumberPlateText(vehicle),
description = 'Fled the Pacific Standard robbery',
priority = 1,
point = { x = pos.x, y = pos.y },
label = 'Pacific Standard Bank',
}, 'Pacific Standard security')
if not boloId then
print('[mybank] BOLO failed: ' .. err) -- e.g. plate_required, mdt_offline
return
end
-- close it automatically after 30 minutes
SetTimeout(30 * 60 * 1000, function()
exports['koja-dispatcher']:CloseBolo(boloId)
end)
end)| Result | Meaning |
|---|---|
'm12' | Created in koja-mdt as BOLO 12 (also visible in the MDT). |
'i7' | Created in the dispatcher's own list; on-duty police units get a notification (mdt_tools.notifyUnits) and koja-dispatcher:boloCreated fires. |
nil, 'mdt_offline' | mdt_tools.boloSource = 'mdt' and koja-mdt is not running. |
GetBolos() returns the list as the CAD sees it about 0.3 s after the change. All fields and errors are on the server exports page.
Escalation in a custom scenario
Generated calls come from Scenarios.List. Add your own in a new file in shared/scenarios/ (loaded by the manifest, not escrowed). This one starts as a burglary, escalates while the caller is on the line, and continues on scene in stages.
local list = {
jewelry_after_hours = {
category = 'burglary',
priority = 2,
weight = 2, -- how often it is picked compared to others
tags = { 'jewelry', 'store' }, -- location tags from shared/locations.lua
distress = 0.5,
time = { 'night' }, -- more likely at night
opening = {
"I'm the night guard at the jewelry store, the back door alarm just went off.",
"Someone is inside the jewelry store, I can see flashlights through the window!",
},
answers = {
what = 'Back door alarm, then I saw two flashlights moving behind the counters.',
suspects = { chance = 0.6, text = 'At least three of them, masks on.', reveal = { police = 1 }, alt = 'I only saw one.' },
weapons = { chance = 0.4, text = 'One of them has a shotgun!', reveal = { police = 1 } },
},
base = { police = 1 },
instruction = 'hide',
respond = 150,
fail = 360,
work = 60,
points = 140,
scene = 'crowd',
escalate = { after = 180, add = { police = 1 }, text = 'Suspects are loading a van out back!' },
events = {
-- 20 s after the dispatcher answered: the situation changes on the line
{
at = 20,
line = "They smashed the display cases, they're grabbing everything!",
radio = 'Caller reports the suspects are smashing display cases.',
distress = 0.2,
priority = 1,
known = { police = 1 },
alert = 'escalation',
},
-- only when the dispatcher asks about weapons, with a 2 s delay
{
on = 'ask:weapons',
chance = 0.5,
delay = 2,
line = 'Oh no, one of them just fired at the ceiling!',
category = 'shooting', -- coding the call as shooting also counts as correct
need = { ems = 1 },
alert = 'shots',
},
-- 45 s after dispatch: a callback reaches the incident
{
after = 45,
when = 'incident',
line = 'A neighbour called back: the van is leaving north on Palomino!',
radio = 'Callback: suspect van leaving north on Palomino Ave.',
need = { police = 1 },
priority = 1,
},
},
stages = {
{ name = 'Burglary in progress', note = 'Suspects inside the store.' },
{
name = 'Injured guard',
category = 'medical',
need = { ems = 1, police = 1 },
work = 40,
fail = 240,
transport = true,
points = 40,
radio = 'Store secured. The night guard was assaulted and needs EMS.',
note = 'Guard with a head wound behind the counter.',
},
},
},
}
for key, scenario in pairs(list) do
if Scenarios.List[key] == nil then Scenarios.List[key] = scenario end
end| Event field | Meaning |
|---|---|
at | Seconds after the call was answered. If the call was dispatched already, it fires on the incident. |
on | ask:<question>, ask, instruct:<option>, instruct, instruct:good, instruct:bad. Only while the caller is on the line. |
after | Seconds after dispatch, on the incident only. |
when | 'call' or 'incident' limits where it may fire. |
chance | Rolled once per call. |
delay | Seconds between the trigger and the event (default Config.Dynamics.eventDelay, 2). |
line | Caller line (string or list, {location} works). |
radio, alert, ai, note | Radio text, CAD alert kind, extra context for AI callers, incident note. |
distress, priority, category | Added distress, raised priority, category suggestion. |
need / known | Hidden extra units / extra units the dispatcher learns about. |
silent | The caller stops answering. |
| Stage field | Meaning |
|---|---|
name | Stage name in the CAD. Stage 1 is the incident as created. |
need / add / hidden | Replace the need / add to it / add units revealed only on scene. |
work, fail, points, priority, category, transport, escalate, note, radio | Values for the new stage. |
Each next stage starts when progress reaches 100 %; units stay on scene and missing units are requested over the radio. dynamics.events and dynamics.stages must be on.
Test it right away:
dispatchtest jewelry_after_hoursexports['koja-dispatcher']:CreateCall({
scenario = 'jewelry_after_hours',
coords = vector3(-630.4, -236.7, 38.1),
label = 'Vangelico, Rockford Hills',
})Scenarios can also be made in the in-game scenario editor in /dispatchadmin, which stores them in the database.
Register your own admin setting
RegisterSetting adds your own keys to the dispatcher's admin menu (Modules page). Admins change them in /dispatchadmin, the values are saved with the other settings and survive restarts, and every resource can read them with GetSetting.
-
Register the keys
The group is the part before the first dot (your-resource/server.lualocal dispatch = exports['koja-dispatcher'] local function registerSettings() dispatch:RegisterSetting('myserver.robberyAlerts', 'bool', true) dispatch:RegisterSetting('myserver.robberyUnits', 'int', 2, { min = 1, max = 8, onChange = function(value, previous) print(('robbery units %s -> %s'):format(previous, value)) end, }) dispatch:RegisterSetting('myserver.robberyRoute', 'enum', 'incident', { values = { 'call', 'incident' } }) dispatch:RegisterSetting('myserver.cooldown', 'number', 10, { min = 1, max = 60, step = 0.5 }) -- client = true also sends the value to every client dispatch:RegisterSetting('myserver.showHint', 'bool', false, { client = true }) end -- on your resource start (also after a restart: re-registering returns true -- and attaches the new onChange handler) registerSettings() -- and again when koja-dispatcher restarts after your resource AddEventHandler('onResourceStart', function(name) if name == 'koja-dispatcher' then registerSettings() end end)myserver) unless you passgroup. Saved values are restored even though your resource registers the keys after koja-dispatcher loaded its settings. On a re-register onlyonChangechanges; type, range, default and group stay as first registered until koja-dispatcher restarts. -
Add the labels
Add the group title and the labels tokoja-dispatcher/locales/en.json(and your other locales) underui.admin:Merge these keys into the existing objects; do not replace the file.locales/en.json{ "ui": { "admin": { "module": { "myserver": "My server" }, "set": { "myserver": { "robberyAlerts": "Robbery alerts", "robberyAlerts_hint": "Store robberies alert the dispatcher.", "robberyUnits": "Robbery units", "robberyUnits_hint": "Police units sent to a store robbery.", "robberyRoute": "Robbery alerts become", "robberyRoute_hint": "call rings at the console, incident goes straight to the map.", "cooldown": "Robbery cooldown (min)", "cooldown_hint": "Minutes between robberies of the same store.", "showHint": "Show robbery hint", "showHint_hint": "Shows a hint to robbers." } } } } } -
Read the values
your-resource/server.lualocal function robberyAlert(coords) if not dispatch:GetSetting('myserver.robberyAlerts') then return end local units = dispatch:GetSetting('myserver.robberyUnits') if dispatch:GetSetting('myserver.robberyRoute') == 'incident' then dispatch:CreateIncident({ category = 'robbery', coords = coords, need = { police = units }, autoAssign = true }) else dispatch:CreateCall({ coords = coords, category = 'robbery', need = { police = units }, message = 'Store robbery' }) end end -- other resources can react to the same key with the event AddEventHandler('koja-dispatcher:settingChanged', function(change) if change.key == 'myserver.robberyAlerts' then print('robbery alerts: ' .. tostring(change.value)) end end)