# Server exports

All exports on this page are called from **server** scripts:

```lua title="server.lua"
local dispatch = exports['koja-dispatcher']
```

Read the [overview](/new/dispatcher/api/overview) first: it explains the call router, coordinate and need formats, unit references and the `id, route` / `nil, errorCode` convention.

**Domains on this page:** Calls · Incidents · Units · Dispatchers · Modes and settings · Radio · Hooks · Voice · Phones and dispatch-script compatibility · Discord · BOLO and field codes · Career

| Export | Domain | Returns |
|---|---|---|
| `CreateCall(data)` / `AddCall` | Calls | `id, route` or `nil, error` |
| `GetCall(id)` | Calls | call or `nil` |
| `GetCalls()` | Calls | `call[]` |
| `CreateIncident(data)` / `AddIncident` | Incidents | `id` or `nil, error` |
| `GetIncident(id)` | Incidents | incident or `nil` |
| `GetIncidents()` | Incidents | `incident[]` |
| `UpdateIncident(id, patch)` | Incidents | `boolean` |
| `ResolveIncident(id, outcome?)` | Incidents | `boolean` |
| `CancelIncident(id)` | Incidents | `boolean` |
| `GetUnits()` | Units | `unit[]` |
| `GetUnit(ref)` | Units | unit or `nil` |
| `GetPlayerUnit(src)` | Units | unit or `nil` |
| `AssignUnits(id, refs)` | Units | `number` |
| `AutoAssign(id)` | Units | `number` |
| `ReleaseUnit(ref)` | Units | `boolean` |
| `SetUnitStatus(ref, status)` | Units | `boolean` |
| `SetCallsign(ref, callsign)` | Units | `boolean` |
| `IsDispatcher(src)` | Dispatchers | `boolean` |
| `GetDispatchers()` | Dispatchers | `dispatcher[]` |
| `GetDispatcherCount()` | Dispatchers | `number` |
| `GetMode()` | Settings | `{ calls, units, noDispatcher }` |
| `GetSetting(key)` | Settings | value or `nil` |
| `SetSetting(key, value)` | Settings | `true` or `false, error` |
| `RegisterSetting(key, kind, default, options?)` | Settings | `boolean` |
| `SendRadio(text, kind?)` | Radio | `boolean` |
| `RegisterHook(name, fn)` | Hooks | hook id or `nil` |
| `RemoveHook(id)` | Hooks | `boolean` |
| `ConnectVoice(callId)` | Voice | `true, channel` or `false, error` |
| `DisconnectVoice(callId)` | Voice | `boolean` |
| `GetVoiceSession(callId)` | Voice | session or `nil` |
| `PhoneCall(src, text)` | Phones | `id, route` or `nil, error` |
| `CompatAlert(name, data)` | Compatibility | `id, route` or `nil, error` |
| `DiscordLog(channel, title, ...)` | Discord | `boolean` |
| `CreateBolo(data, author?)` | BOLO | BOLO id or `nil, error` |
| `CloseBolo(id, author?)` | BOLO | `true` or `false, error` |
| `ReportPlate(plate, coords?, label?)` | BOLO | `bolo[]` |
| `GetBolos()` | BOLO | `bolo[]` |
| `RunFieldCode(src, code)` | Field codes | `true` or `false, error` |
| `HasCareerPerk(src, perk)` | Career | `boolean` |
| `GetCareerRank(src)` | Career | rank key or `nil` |

---

## Calls

Public call shape (returned by `GetCall`, `GetCalls` and the `callCreated` hook):

```lua
{
    id = 7, kind = 'player',            -- 'player' or 'script'
    state = 'ringing',                  -- 'ringing', 'talking' or 'done'
    caller = 'Store alarm', callerAvatar = nil,
    handler = nil, handlerName = nil,   -- dispatcher server id and name once answered
    ringingSince = 1730000000, patience = 45,
    transcript = { { who = 'caller', text = 'Silent alarm at the 24/7' } },
    asked = {}, known = { police = 1 },
    located = true, x = 24.5, y = -1347.3, radius = 0, label = 'Innocence Blvd',
    instruction = nil, instructionGood = nil,
    incident = nil,                     -- incident id after dispatch
    voice = nil, distress = nil, ai = nil, training = nil, dynamics = nil,
    risk = nil,                         -- career perk needed to answer ('pursuits', 'hostage', ...)
}
```

Generated calls that are not located yet return an approximate `x`, `y` and a `radius` (300 m) instead of the real position.

### `CreateCall(data)`

Rings a 911 call at the dispatcher console, or routes it through the fallback when nobody is on shift (see [the call router](/new/dispatcher/api/overview)). `AddCall` is an alias.

| Field | Type | Required | Description |
|---|---|---|---|
| `coords` | vector3 / table | yes, unless `source` | Where the call comes from. |
| `source` | number | no | Player server id. Used for the position when `coords` is missing, and as the caller: they get notifications (answered, questions, dispatcher replies) and an `auto` fallback incident follows them. |
| `caller` | string | no | Caller name shown in the CAD (max 64). Default `Anonymous`. |
| `message` | string | no | Opening line of the transcript. Control characters become spaces, max 300. |
| `label` | string | no | Place or street label shown in the CAD (max 96). |
| `category` | string | no | Preselected category for the dispatcher. Must be a [known category](/new/dispatcher/api/overview), otherwise ignored. |
| `priority` | number | no | Preselected priority `1` to `3`. |
| `need` | table | no | Units the dispatcher already knows are needed (`{ police = 2 }`). In the `auto` fallback it becomes the incident need (default `{ police = 1 }`). |
| `scenario` | string | no | Key of a scenario in `Scenarios.List`. Rings a **generated** call with the full scripted conversation at `coords`. All other fields except `coords`, `source` and `label` are ignored. |

**Returns** `id, route`:

| `route` | `id` is |
|---|---|
| `'call'` | the call id (rings at the console) |
| `'incident'` | the incident id (`mode.noDispatcher = 'auto'`) |
| `'mdt'` | the koja-mdt dispatch call id (`mode.noDispatcher = 'mdt'`) |

With `scenario` it returns only the call id, or `nil` (without a reason) when a hook blocked it.

**Errors** `invalid` (no table, no usable coordinates, unknown scenario), `no_dispatchers`, `cancelled` or the reason a `callCreate` hook returned. When an `incidentCreate` hook blocks the `auto` fallback, the error is `cancelled`.

::::codegroup
```lua title="Alarm call"
local id, route = exports['koja-dispatcher']:CreateCall({
    coords = vector3(-47.3, -1757.5, 29.4),
    caller = 'Fleeca alarm company',
    message = 'Silent alarm triggered at Fleeca Bank, Hawick Ave',
    label = 'Fleeca Bank, Hawick Ave',
    category = 'robbery',
    priority = 1,
    need = { police = 2 },
})
if not id then print('call failed: ' .. route) end
```

```lua title="From a player"
-- uses the player's position, the player gets the dispatcher's replies
local id, route = exports['koja-dispatcher']:CreateCall({
    source = source,
    caller = GetPlayerName(source),
    message = 'Someone is breaking into my car',
})
```

```lua title="Generated scenario"
-- full conversation (questions, answers, instructions) at your coordinates
local callId = exports['koja-dispatcher']:CreateCall({
    scenario = 'store_robbery',
    coords = vector3(25.7, -1347.3, 29.5),
    label = '24/7 Innocence Blvd',
})
```
::::

**Notes**

- The call's `origin` is the name of your resource. It shows up in `incidentCreated` / `incidentEnded` payloads when the call is dispatched.
- `scenario` calls skip the router: they ring even when nobody is on shift or `mode.calls = 'real'`, and count as missed after `calls.patience`.
- A ringing call fires the `callCreate` (before) and `callCreated` (after) hooks.

### `GetCall(id)`

| Param | Type | Required | Description |
|---|---|---|---|
| `id` | number | yes | Call id. |

**Returns** the public call or `nil`. Calls are removed 4 s after they end.

```lua title="server.lua"
local call = exports['koja-dispatcher']:GetCall(id)
if call and call.state == 'talking' then print(call.handlerName .. ' is on the line') end
```

### `GetCalls()`

**Returns** every call that is not `done`, as an array of public calls. Private training calls are not included.

```lua title="server.lua"
local ringing = 0
for _, call in ipairs(exports['koja-dispatcher']:GetCalls()) do
    if call.state == 'ringing' then ringing = ringing + 1 end
end
```

---

## Incidents

Public incident shape (returned by `GetIncident`, `GetIncidents` and the incident hooks):

```lua
{
    id = 1004, category = 'robbery', priority = 1,
    x = -47.3, y = -1757.5, label = 'Fleeca Bank, Hawick Ave', located = true,
    status = 'active',                  -- 'active', 'resolved', 'failed' or 'cancelled'
    outcome = nil,                      -- set when it ends ('resolved', 'failed', 'cancelled', 'prank', 'code4', 'mdt', 'lost' or your own)
    createdAt = 1730000000, endedAt = nil,
    known = { police = 2 },             -- need known to the dispatcher
    units = { 'ai:1', 'p:12' },         -- assigned unit ids
    onScene = { ['ai:1'] = true },
    progress = 40,                      -- 0..100
    danger = 15,                        -- 0..100, fails at 100
    escalated = false,
    owner = 'Jane Doe',                 -- name of the dispatcher who created it, if any
    note = 'Silent alarm',
    source = 'external',                -- 'call', 'player', 'external', 'manual' or 'mdt'
    origin = 'my-robberies',            -- resource or system that created it
    mdt = 55,                           -- koja-mdt call id when linked
    transport = false, training = nil,
    stage = nil,                        -- { index, total, name } for multi-stage scenarios
    mci = nil,                          -- mass casualty board when it is an MCI
}
```

### `CreateIncident(data)`

Skips the call and creates an incident on the map straight away, whether or not a dispatcher is on shift. `AddIncident` is an alias.

| Field | Type | Required | Description |
|---|---|---|---|
| `coords` | vector3 / table | yes, unless `source` | Scene position. |
| `source` | number | no | Player server id whose position is used when `coords` is missing. |
| `category` | string | no | One of the categories. Unknown or missing becomes `suspicious`. |
| `priority` | number | no | `1` to `3`, default `2`. |
| `need` | table | no | Units needed per department, default `{ police = 1 }`. Also used as the known need. |
| `label` | string | no | Place label (max 96). |
| `note` | string | no | Incident note shown in the CAD and to field units (max 200). `message` is accepted as an alias. |
| `autoAssign` | boolean | no | `true` assigns the nearest free units for the need at once. |
| `follow` | number | no | Player server id: the incident moves with this player (when they are more than 60 m away) until the first unit is on scene. |
| `work` | number | no | Seconds of fully covered work on scene to resolve it. Default `60`. |
| `fail` | number | no | Seconds of uncovered exposure until it fails. Default `480`. |
| `respond` | number | no | Expected response time in seconds, stored on the incident (default `180`). Not used by the simulation at the moment. |

**Returns** the incident id. **Errors** `invalid` (no table or no usable coordinates), `cancelled` or the reason an `incidentCreate` hook returned.

::::codegroup
```lua title="Robbery with auto-assign"
local incidentId, err = exports['koja-dispatcher']:CreateIncident({
    category = 'shooting',
    coords = vector3(127.4, -1298.6, 29.2),
    priority = 1,
    need = { police = 2, ems = 1 },
    label = 'Vanilla Unicorn',
    note = 'Shots fired inside the club',
    autoAssign = true,
})
if not incidentId then print('incident failed: ' .. err) end
```

```lua title="Follow a player"
-- the scene follows the victim until the first unit arrives
exports['koja-dispatcher']:CreateIncident({
    source = victimId,
    follow = victimId,
    category = 'assault',
    need = { police = 1, ems = 1 },
    note = 'Victim is running from the attacker',
})
```

```lua title="Long job"
-- a slow hazmat job: 4 minutes of work, 15 minutes before it fails
exports['koja-dispatcher']:CreateIncident({
    category = 'hazmat', coords = coords, priority = 1,
    need = { fire = 2, police = 1 }, work = 240, fail = 900,
})
```
::::

**Notes**

- `source` of the incident is `'external'` and `origin` is your resource name. With koja-mdt and `mdt.pushIncidents = 'real'` or `'all'` it is pushed to the MDT call list.
- Fires `incidentCreate` (before, any field can be changed) and `incidentCreated` (after).
- Multi-stage scenarios, MCI and pursuits start from the category (`pursuit` starts a live chase when `pursuit.enabled` is on and a slot is free).

### `GetIncident(id)`

| Param | Type | Required | Description |
|---|---|---|---|
| `id` | number | yes | Incident id. |

**Returns** the public incident or `nil`. Ended incidents stay readable for 20 s.

```lua title="server.lua"
local incident = exports['koja-dispatcher']:GetIncident(incidentId)
if incident and incident.status == 'active' then
    print(('#%d %d%% done, %d unit(s)'):format(incident.id, incident.progress, #incident.units))
end
```

### `GetIncidents()`

**Returns** every incident still in memory (active ones and those that ended less than 20 s ago). Training incidents are not included.

```lua title="server.lua"
for _, incident in ipairs(exports['koja-dispatcher']:GetIncidents()) do
    if incident.status == 'active' and incident.priority == 1 then print('P1: #' .. incident.id) end
end
```

### `UpdateIncident(id, patch)`

Changes an active incident and pushes it to the CAD. Fires `incidentUpdated`.

| Field of `patch` | Type | Description |
|---|---|---|
| `priority` | number | `1` to `3` (clamped, floored). |
| `note` | string | Max 200. |
| `label` | string | Max 96. |
| `category` | string | Must be a known category, otherwise ignored. |
| `need` | table | Replaces both the need and the known need. An empty need is ignored. |
| `coords` | vector3 / table | Moves the incident. It becomes `located`, and assigned units are re-routed (player units get a new waypoint). |

**Returns** `true`, or `false` when the incident does not exist, is not active or `patch` is not a table.

```lua title="server.lua"
exports['koja-dispatcher']:UpdateIncident(incidentId, {
    priority = 1,
    category = 'hostage',
    need = { police = 4, ems = 1 },
    note = 'Suspects took two hostages inside the vault',
})
```

### `ResolveIncident(id, outcome?)`

Ends an active incident as `resolved`. Units are released (AI units drive home; EMS units on scene of a `transport` incident drive to a hospital).

| Param | Type | Required | Description |
|---|---|---|---|
| `id` | number | yes | Incident id. |
| `outcome` | string | no | Outcome label, max 24 chars. Default `'resolved'`. |

**Returns** `true`, or `false` when it is not active.

```lua title="server.lua"
exports['koja-dispatcher']:ResolveIncident(incidentId, 'arrested')
```

:::hint{type="info" title="Multi-stage scenarios"}
`ResolveIncident` always ends the incident, even when its scenario has more stages. Stages only advance when progress reaches 100 % on its own.
:::

### `CancelIncident(id)`

Ends an active incident as `cancelled` (outcome `'cancelled'`, or `'prank'` for a prank scenario). **Returns** `true`, or `false` when it is not active.

```lua title="server.lua"
exports['koja-dispatcher']:CancelIncident(incidentId)
```

---

## Units

Public unit shape (returned by `GetUnits`, `GetUnit`, `GetPlayerUnit` and the `unitAdded` hook):

```lua
{
    id = 'ai:1', kind = 'ai',                -- 'ai' or 'player'
    dept = 'police', type = 'patrol',        -- type is 'player' for player units
    callsign = '1-ADAM-12', name = 'Patrol', -- AI: unit type label, player: character name
    status = 'enroute',
    x = 441.0, y = -982.0, h = 90.0,
    incident = 1004,                         -- assigned incident id or nil
    target = { x = -47.3, y = -1757.5 },     -- where it is driving to
    purpose = 'incident',                    -- 'incident', 'move', 'home', 'hospital', 'handover' or 'patrol'
    home = 'Mission Row PD',                 -- station label (AI)
    avatar = nil,                            -- avatar key (players)
    route = { { 441.0, -982.0 }, ... },      -- remaining road route (AI)
    -- with fleet.enabled, AI units also carry:
    fatigue = 35, damage = nil, fleetUntil = nil, fleetPending = nil,
}
```

### `GetUnits()`

**Returns** every unit (AI and player, including `offline` AI units).

```lua title="server.lua"
local free = {}
for _, unit in ipairs(exports['koja-dispatcher']:GetUnits()) do
    if unit.dept == 'ems' and unit.status == 'available' then free[#free + 1] = unit.callsign end
end
```

### `GetUnit(ref)`

| Param | Type | Required | Description |
|---|---|---|---|
| `ref` | string / number | yes | Unit id (`'ai:3'`, `'p:12'`) or a player server id. |

**Returns** the public unit or `nil`.

```lua title="server.lua"
local unit = exports['koja-dispatcher']:GetUnit('ai:5')
```

### `GetPlayerUnit(src)`

| Param | Type | Required | Description |
|---|---|---|---|
| `src` | number | yes | Player server id. |

**Returns** the player's unit, or `nil` when the player is not a field unit (wrong job, off duty in koja-mdt, on shift as a dispatcher, or `mode.units = 'ai'`).

```lua title="server.lua"
local unit = exports['koja-dispatcher']:GetPlayerUnit(source)
if unit and unit.incident then print(unit.callsign .. ' is on incident #' .. unit.incident) end
```

### `AssignUnits(id, refs)`

Assigns units to an active incident. Each unit leaves its previous incident, gets `enroute`, and player units get the assignment with a waypoint.

| Param | Type | Required | Description |
|---|---|---|---|
| `id` | number | yes | Incident id. |
| `refs` | table / string / number | yes | A list of unit references (`{ 'ai:1', 12, 'p:15' }`) or a single one. |

**Returns** the number of units assigned. `0` when the incident is not active. Offline units, unknown references and assignments blocked by a `unitAssign` hook (for example a resting unit with `fleet.enabled`) are skipped; the hook's reason is not returned by this export.

```lua title="server.lua"
local count = exports['koja-dispatcher']:AssignUnits(incidentId, { 'ai:1', source })
```

### `AutoAssign(id)`

Assigns the nearest free units until the incident's known need is covered (unit power counts, AI distance is weighted by speed). Units blocked by a `unitAssign` hook are skipped and the next nearest unit is tried.

| Param | Type | Required | Description |
|---|---|---|---|
| `id` | number | yes | Incident id. |

**Returns** the number of units assigned (`0` when none are free or the incident is not active).

```lua title="server.lua"
if exports['koja-dispatcher']:AutoAssign(incidentId) == 0 then
    print('no free units')
end
```

### `ReleaseUnit(ref)`

Detaches a unit from its incident. AI units drive back to their station (`returning`); player units become `available` and their waypoint is cleared.

| Param | Type | Required | Description |
|---|---|---|---|
| `ref` | string / number | yes | Unit reference. |

**Returns** `true`, or `false` when the unit does not exist.

```lua title="server.lua"
exports['koja-dispatcher']:ReleaseUnit(source)
```

### `SetUnitStatus(ref, status)`

| Param | Type | Required | Description |
|---|---|---|---|
| `ref` | string / number | yes | Unit reference. |
| `status` | string | yes | `'available'`, `'busy'` or `'unavailable'`. |

`available` works like `ReleaseUnit`. For player units the change is announced on the radio and synced to koja-mdt (`mdt.syncStatus`). Fires `unitStatus`.

**Returns** `true`, or `false` for an unknown unit or another status.

```lua title="server.lua"
exports['koja-dispatcher']:SetUnitStatus(source, 'busy')        -- processing a suspect
exports['koja-dispatcher']:SetUnitStatus(source, 'available')   -- back in service
```

:::hint{type="warning" title="AI units"}
An AI unit set to `busy` or `unavailable` cannot be assigned or auto-assigned until you set it `available` again.
:::

### `SetCallsign(ref, callsign)`

| Param | Type | Required | Description |
|---|---|---|---|
| `ref` | string / number | yes | Unit reference. |
| `callsign` | string | yes | Upper-cased; only letters, digits and `-` are kept; max 16, min 2 characters. |

For player units the callsign is saved in the database by character identifier and reused on the next duty (unless `mdt.useCallsigns` takes the MDT callsign). **Returns** `true`, or `false` for an unknown unit or a callsign shorter than 2 characters.

```lua title="server.lua"
exports['koja-dispatcher']:SetCallsign(source, '1-ADAM-12')
```

---

## Dispatchers

### `IsDispatcher(src)`

| Param | Type | Required | Description |
|---|---|---|---|
| `src` | number | yes | Player server id. |

**Returns** `true` when the player is on shift as a dispatcher.

```lua title="server.lua"
if exports['koja-dispatcher']:IsDispatcher(source) then return end -- dispatchers cannot rob stores
```

### `GetDispatchers()`

**Returns** the dispatchers on shift: `{ src, name, console, since, avatar }[]`. `console` is the station label (or the remote console label), `since` is the shift start (`os.time()`).

```lua title="server.lua"
for _, d in ipairs(exports['koja-dispatcher']:GetDispatchers()) do
    print(('%s at %s since %s'):format(d.name, d.console, os.date('%H:%M', d.since)))
end
```

### `GetDispatcherCount()`

**Returns** the number of dispatchers on shift.

```lua title="server.lua"
local staffed = exports['koja-dispatcher']:GetDispatcherCount() > 0
```

---

## Modes and settings

### `GetMode()`

**Returns** `{ calls, units, noDispatcher }`, the current values of `mode.calls`, `mode.units` and `mode.noDispatcher`.

```lua title="server.lua"
local mode = exports['koja-dispatcher']:GetMode()
if mode.noDispatcher == 'reject' and exports['koja-dispatcher']:GetDispatcherCount() == 0 then
    -- 911 calls will not go anywhere right now
end
```

### `GetSetting(key)`

| Param | Type | Required | Description |
|---|---|---|---|
| `key` | string | yes | A settings key, see [Settings](/new/dispatcher/api/settings). |

**Returns** the current value (saved value or default), or `nil` for an unknown key.

```lua title="server.lua"
local maxRinging = exports['koja-dispatcher']:GetSetting('calls.maxRinging')
```

### `SetSetting(key, value)`

Changes a setting exactly like the admin menu: the value is validated, applied, saved in `koja_dispatch_settings` and announced with `settingChanged`.

| Param | Type | Required | Description |
|---|---|---|---|
| `key` | string | yes | A settings key. |
| `value` | boolean / number / string | yes | Booleans must be real booleans. Numbers are clamped to the key's range (ints are rounded). Enums must match one of the values exactly. |

**Returns** `true` (also when the value did not change), or `false, 'invalid'` for an unknown key or a value of the wrong type.

```lua title="server.lua"
-- no dispatchers at night: dispatch AI units automatically
local ok, err = exports['koja-dispatcher']:SetSetting('mode.noDispatcher', 'auto')
```

**Notes** `SetSetting` writes the database (yields). Changing `mode.units` or `units.disableWhenPlayers` re-applies unit membership at once; `mdt.*` keys refresh the koja-mdt link; client-side keys are pushed to all players.

### `RegisterSetting(key, kind, default, options?)`

Adds your own key to the dispatcher settings. It shows up on the Modules page of `/dispatchadmin` and works with `GetSetting`, `SetSetting` and `settingChanged` like a built-in key. Values admins save are kept across restarts.

| Param | Type | Required | Description |
|---|---|---|---|
| `key` | string | yes | `group.name` format: letters, digits and `_`, at least one dot (`'myserver.robberyUnits'`). |
| `kind` | string | yes | `'bool'`, `'int'`, `'number'` or `'enum'`. |
| `default` | any | yes | Default value. For `bool` anything but `true` means `false`; for `int` / `number` a non-number becomes `min`; for `enum` a value not in `values` becomes `values[1]`. |
| `options` | table | no | Fields below. |

| Option | Type | Description |
|---|---|---|
| `group` | string | Admin menu group. Default: the part of the key before the first dot. |
| `min`, `max` | number | Range for `int` and `number`. Defaults `0` and `100`. |
| `step` | number | Slider step for `number`. Default `0.1`. |
| `values` | string[] | Allowed values. **Required** (non-empty) for `enum`. |
| `client` | boolean | `true` sends the value to every client with the module settings (`koja-dispatcher:client:sync`). |
| `onChange` | function | Called with `(value, previous)` when the value changes. |

**Returns** `true` when registered, or when the key was already registered through `RegisterSetting` (then only `onChange` is replaced). `false` when the key is badly formed, `kind` is unknown, an `enum` has no `values`, or the key belongs to a built-in setting or one registered inside koja-dispatcher.

```lua title="server.lua"
local dispatch = exports['koja-dispatcher']
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' } })
```

**Notes**

- **Restarts:** saved values of keys that are not registered yet are kept when koja-dispatcher loads its settings and applied the moment you register the key, so registration order does not matter. When **your** resource restarts, call `RegisterSetting` again: it returns `true` and attaches your new `onChange`. Type, range, default and group stay as first registered until koja-dispatcher restarts.
- Labels come from the locale files: `ui.admin.module.<group>`, `ui.admin.set.<group>.<name>` and `<name>_hint`.

---

## Radio

### `SendRadio(text, kind?)`

Adds a line to the radio log in every dispatcher's CAD.

| Param | Type | Required | Description |
|---|---|---|---|
| `text` | string | yes | Control characters become spaces, max 160. |
| `kind` | string | no | Colour and style: `'dispatch'` (default), `'unit'`, `'alert'`, `'ok'`, `'incident'`, `'call'`, `'ems'`. Anything else becomes `'dispatch'`. |

**Returns** `true`, or `false` for an empty text. The log keeps the last 40 lines (`Config.Incidents.maxLog`).

```lua title="server.lua"
exports['koja-dispatcher']:SendRadio('AIR-1 is grounded for maintenance', 'alert')
```

---

## Hooks

### `RegisterHook(name, fn)`

Registers a hook function. See [Hooks](/new/dispatcher/api/hooks) for every name and payload.

| Param | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | A hook name (`callCreate`, `incidentCreated`, ...). |
| `fn` | function | yes | Called with the payload. Before hooks return `false` (optionally with a reason) to block; `incidentCreate` hooks may return a table to change fields. |

**Returns** the hook id (number), or `nil` for an unknown name or a non-function.

```lua title="server.lua"
local hookId = exports['koja-dispatcher']:RegisterHook('incidentCreated', function(incident)
    print(('new incident #%d (%s)'):format(incident.id, incident.category))
end)
```

Hooks are removed automatically when the resource that registered them stops.

### `RemoveHook(id)`

| Param | Type | Required | Description |
|---|---|---|---|
| `id` | number | yes | The id returned by `RegisterHook`. |

**Returns** `true` when a hook was removed.

```lua title="server.lua"
exports['koja-dispatcher']:RemoveHook(hookId)
```

---

## Voice

When a dispatcher answers a **player** call, the caller and the dispatcher are put on a private call channel (pma-voice `setPlayerCall`, or mumble-voip through the client). Channels are `voice.channelBase` + 0..899 (`Config.Voice.channelRange`). Without a voice resource, or with `voice.enabled` off, calls stay text.

### `ConnectVoice(callId)`

Opens the voice channel for a player call that is in `talking` state. Normally this happens automatically on answer.

| Param | Type | Required | Description |
|---|---|---|---|
| `callId` | number | yes | Call id. |

**Returns** `true, channel` (also when the session already exists), or `false, error`.

**Errors** `invalid` (unknown call, not a player call, not answered, caller is the dispatcher), `offline` (caller or dispatcher left), `busy` (one of them is already in a voice call), `unavailable` (no voice resource or `voice.enabled` off), `no_channel` (all 900 channels used).

```lua title="server.lua"
local ok, channelOrError = exports['koja-dispatcher']:ConnectVoice(callId)
```

### `DisconnectVoice(callId)`

Takes both players off the channel. Fires `voiceEnded` with `reason = 'api'`. **Returns** `true`, or `false` when there is no session.

```lua title="server.lua"
exports['koja-dispatcher']:DisconnectVoice(callId)
```

### `GetVoiceSession(callId)`

**Returns** the session or `nil`:

```lua
{
    call = 7, channel = 91103, provider = 'pma-voice',
    caller = 'John Doe',          -- caller name
    callerSrc = 12,               -- caller server id
    handler = 3,                  -- dispatcher (or unit after a transfer) server id
    handlerKind = 'dispatcher',   -- 'dispatcher' or 'unit'
    unit = nil,                   -- unit id after a transfer
    startedAt = 1730000000, muted = false, held = false, detached = false, talking = false,
}
```

```lua title="server.lua"
local session = exports['koja-dispatcher']:GetVoiceSession(callId)
if session and session.handlerKind == 'unit' then print('transferred to ' .. session.unit) end
```

---

## Phones and dispatch-script compatibility

### `PhoneCall(src, text)`

Sends a 911 call or text for a player from any phone resource. It uses the `/911` logic: a second message within `calls.playerReplyWindow` is added to the open call. When a supported phone (`lb-phone`, `qs-smartphone-pro`, `npwd`, ...) is running, dispatcher replies come back as phone messages from the service number; otherwise they arrive as normal notifications.

| Param | Type | Required | Description |
|---|---|---|---|
| `src` | number | yes | Player server id. |
| `text` | string | yes | Message, control characters removed, max 250. |

**Returns** `id, route` (`route` is `nil` when the text was added to an open call or to the incident that follows the player), or `nil, error`. Called from the server console (no invoking resource) it returns `false`.

**Errors** `invalid` (player offline, empty text), `disabled` (`phones.enabled` off), `rate` (same player within 2 s), `no_dispatchers` (the player also gets a "nobody is available" notification or phone message), `cancelled` or a hook reason.

```lua title="server.lua"
local id, route = exports['koja-dispatcher']:PhoneCall(source, 'There is a fire in my kitchen')
```

### `CompatAlert(name, data)`

Feeds an alert in the format of another dispatch script. This is what the shim resources in `compat/` call.

| Param | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | `'ps-dispatch'`, `'cd_dispatch'`, `'rcore_dispatch'`, `'qs-dispatch'`, `'core_dispatch'` or `'linden_outlawalert'`. |
| `data` | table | yes | The alert in that script's format (see below). |

| `name` | Fields read |
|---|---|
| `ps-dispatch` | `message`, `information`, `code`, `codeName`, `priority`, `coords`, `jobs`, `street` |
| `cd_dispatch` | `title`, `message`, `coords`, `job_table`, `blip.text` |
| `rcore_dispatch` | `text`, `code`, `type`, `default_priority`, `coords`, `job` |
| `qs-dispatch` | `message`, `callLocation` or `coords`, `job`, `callCode.code`, `callCode.snippet` |
| `core_dispatch` | `title`, `message`, `extraInfo`, `coords`, `job`, `code`, `priority` |
| `linden_outlawalert` | `title`, `message`, `coords` or `pos`, `recipientList`, `code` |

The category comes from the 10-code (`10-90` robbery, `10-71` shooting, `10-80` pursuit, `10-99` officer down, ...) or keywords in the text; default `suspicious`. Priority comes from the alert or the category default. Jobs containing police/sheriff, ambulance/ems or fire pick the departments; otherwise the category default need is used.

**Returns** `id, route`. With `compat.mode = 'call'` the alert goes through the call router; with `'incident'` it becomes an incident (`route = 'incident'`, auto-assigned when `compat.autoAssign` is on).

**Errors** `invalid` (unknown `name`, bad data, no coordinates), `disabled` (`compat.enabled` off), `duplicate` (same alert within 20 s), `no_dispatchers`, `cancelled` (in `call` mode a `callCreate` hook's own reason is passed on).

```lua title="server.lua"
local id, route = exports['koja-dispatcher']:CompatAlert('ps-dispatch', {
    message = 'Suspicious vehicle', code = '10-66', priority = 2,
    coords = vector3(215.8, -810.1, 30.7), jobs = { 'police' },
})
```

---

## Discord

### `DiscordLog(channel, title, description?, fields?, color?)`

Queues an embed on one of the dispatcher's webhooks. Webhook URLs come only from the server convars `koja_dispatcher_webhook_<channel>` (falling back to `koja_dispatcher_webhook_default`).

| Param | Type | Required | Description |
|---|---|---|---|
| `channel` | string | yes | `'shifts'`, `'incidents'` or `'admin'`. Anything else becomes `'incidents'`. |
| `title` | string | yes | Embed title (max 256). |
| `description` | string | no | Embed text (max 2000). |
| `fields` | table | no | Up to 10 `{ name, value, inline }` entries (name max 256, value max 1024). |
| `color` | number | no | Embed colour, for example `0x5865F2`. |

**Returns** `true` when queued, `false` when `title` is not a string or `discord.enabled` is off. Embeds are sent in batches of up to 10 every 2 s per webhook; a channel without a URL is dropped silently.

```lua title="server.lua"
exports['koja-dispatcher']:DiscordLog('incidents', 'Bank job finished', 'Pacific Standard was cleared.', {
    { name = 'Crew', value = '4' },
    { name = 'Loot', value = '$1,250,000' },
}, 0x5865F2)
```

---

## BOLO and field codes

BOLO shape (`GetBolos`, `ReportPlate`, `koja-dispatcher:boloCreated`):

```lua
{
    id = 'i12',                        -- 'i<id>' internal, 'm<id>' koja-mdt
    source = 'internal',               -- 'internal' or 'mdt'
    type = 'vehicle',                  -- 'person', 'vehicle' or 'general'
    title = 'Black Sultan, armed', description = '...',
    plate = 'ABC123', model = 'Sultan', colour = 'Black',
    priority = 1, image = nil,
    x = 215.8, y = -810.1, label = 'Legion Square', incident = 1004,
    createdBy = 'Jane Doe', createdAt = 1730000000,
    department = nil,                  -- MDT BOLOs only
}
```

Where BOLOs are stored depends on `mdt_tools.boloSource`: `auto` uses koja-mdt while it runs and the dispatcher's own list (`koja_dispatch_bolos`) otherwise; `mdt` only uses koja-mdt; `internal` only the dispatcher's list.

### `CreateBolo(data, author?)`

Creates a BOLO exactly like the CAD's MDT workspace: in koja-mdt (with location, colour and linked incident kept in `koja_dispatch_bolos`) or in the internal list, depending on `mdt_tools.boloSource`. Every dispatcher sees it at once.

| Field | Type | Required | Description |
|---|---|---|---|
| `type` | string | yes | `'person'`, `'vehicle'` or `'general'`. |
| `title` | string | yes | 3 to 96 characters. |
| `plate` | string | for `vehicle` | 2 to 8 characters; upper-cased, only letters, digits and spaces. |
| `description` | string | no | Max 1000 characters. |
| `model`, `colour` | string | no | Max 48 each. |
| `priority` | number | no | `1` to `3`, default `2`. |
| `image` | string | no | `http(s)` URL, max 512. |
| `incident` | number | no | Links an incident; its position and label are used. |
| `point` | table | no | `{ x, y }` last-seen position when no `incident` is given. |
| `label` | string | no | Place label for `point` (max 128). |

| Param | Type | Required | Description |
|---|---|---|---|
| `author` | string | no | Name stored as the creator (max 128). Default: your resource name. |

**Returns** the BOLO id: `'m<id>'` when it was created in koja-mdt, `'i<id>'` for the internal list. Or `nil, error`.

**Errors** `invalid` (not a table, unknown type), `title_required`, `plate_required`, `invalid_plate`, `invalid_url`, `mdt_offline` (`boloSource = 'mdt'` while koja-mdt is down), `mdt_failed` or a koja-mdt error code, `db` (internal list without a database).

```lua title="server.lua"
local boloId, err = exports['koja-dispatcher']:CreateBolo({
    type = 'vehicle',
    title = 'Black Sultan, armed occupants',
    plate = 'ABC123',
    model = 'Sultan', colour = 'Black',
    description = 'Fled the Pacific Standard robbery northbound',
    priority = 1,
    point = { x = 235.0, y = 216.0 },
    label = 'Pacific Standard Bank',
}, 'Pacific Standard security')
if not boloId then print('BOLO failed: ' .. err) end
```

**Notes** A radio alert announces the BOLO. Internal BOLOs also fire `koja-dispatcher:boloCreated` and notify on-duty police field units when `mdt_tools.notifyUnits` is on. Writes the database (yields).

### `CloseBolo(id, author?)`

| Param | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | BOLO id from `CreateBolo` or `GetBolos` (`'i12'`, `'m5'`). |
| `author` | string | no | Name passed to koja-mdt as the closer. Default: your resource name. |

**Returns** `true`, or `false, error`. **Errors** `invalid` (bad id format), `not_found` (unknown internal BOLO), `mdt_offline`, `mdt_failed` or a koja-mdt error code.

```lua title="server.lua"
exports['koja-dispatcher']:CloseBolo(boloId)
```

### `ReportPlate(plate, coords?, label?)`

For third-party ALPR and camera scripts. Checks a plate against the active BOLO list; a match alerts every dispatcher like an ALPR hit (radio line and CAD alert) and moves the BOLO's last-seen position to `coords`.

| Param | Type | Required | Description |
|---|---|---|---|
| `plate` | string | yes | Upper-cased, letters, digits and spaces kept, 2 to 8 characters. Spaces are ignored when matching. |
| `coords` | table / vector3 | no | Where the plate was read (`x`, `y`). |
| `label` | string | no | Camera or street label (max 128). |

**Returns** the matching BOLOs (an empty table when none match, the plate is invalid or `mdt_tools.alpr` is off).

```lua title="server.lua"
local hits = exports['koja-dispatcher']:ReportPlate('ABC 123', GetEntityCoords(GetPlayerPed(src)), 'Camera 14, Del Perro Fwy')
if #hits > 0 then print('BOLO hit: ' .. hits[1].title) end
```

### `GetBolos()`

**Returns** the active BOLO list as the CAD sees it, sorted by priority then newest first. With koja-mdt (`mdt_tools.boloSource` `auto` or `mdt`) MDT BOLOs are included. The list refreshes about 0.3 s after a change.

```lua title="server.lua"
for _, bolo in ipairs(exports['koja-dispatcher']:GetBolos()) do
    if bolo.type == 'vehicle' then print(bolo.plate, bolo.title) end
end
```

### `RunFieldCode(src, code)`

Runs a field status code for a player as if they pressed the key or typed `/tencode`.

| Param | Type | Required | Description |
|---|---|---|---|
| `src` | number | yes | Player server id (online). |
| `code` | string | yes | A code id from `Config.MdtStatus.codes`. Default codes below. |

| Code | Action |
|---|---|
| `'10-8'` | available |
| `'10-6'` | busy |
| `'10-97'` | en route |
| `'10-23'` | on scene (counts as arrival when the unit drives to an incident) |
| `'10-7'` | unavailable (MDT `break`) |
| `'10-15'` | transport (MDT `busy`) |
| `'backup'` | priority 1 `officer_down` incident at the officer, assigns them plus `mdt_status.backupUnits` nearest units; with koja-mdt also a `10-78` MDT call |
| `'10-99'` | koja-mdt `SendPanic`; without koja-mdt a local priority 1 incident (`mdt_status.panicFallback`) |

**Returns** `true`, or `false, error`. **Errors** `invalid` (offline player, unknown code), `disabled` (`mdt_status.enabled` off, or panic without MDT and `panicFallback` off), `not_field_unit` (not a unit or MDT officer, or a dispatcher), `cooldown` (backup or panic within `mdt_status.cooldown`), `cancelled`.

```lua title="server.lua"
local ok, err = exports['koja-dispatcher']:RunFieldCode(source, '10-6')
```

---

## Career

Dispatcher ranks, XP and unlocks are configured in `shared/config/career.lua`.

### `HasCareerPerk(src, perk)`

| Param | Type | Required | Description |
|---|---|---|---|
| `src` | number | yes | Player server id. |
| `perk` | string | yes | Perk name. Default perks: `pursuits`, `hostage`, `mci`, `supervise`. |

**Returns** `true` when the player's rank (or a lower one) unlocks the perk. Returns `true` for everybody when `career.enabled` is off. May read the database the first time it is asked about a player (yields).

```lua title="server.lua"
if not exports['koja-dispatcher']:HasCareerPerk(source, 'supervise') then
    return print('supervisors only')
end
```

### `GetCareerRank(src)`

**Returns** the rank key (`'trainee'`, `'dispatcher'`, `'senior'`, `'supervisor'`, `'chief'` by default), or `nil` when `career.enabled` is off.

```lua title="server.lua"
local rank = exports['koja-dispatcher']:GetCareerRank(source)
```
