# Hooks

Hooks let another server resource block, rewrite or react to dispatcher actions, wherever they come from: the CAD, `/911`, phones, koja-mdt, dispatch-script alerts or exports.

| Kind | Runs | Can do |
|---|---|---|
| **before** | Before the action happens. | Return `false` (optionally with a reason) to cancel it. `incidentCreate` hooks can also return a table to change fields. |
| **after** | After the action happened. | React. Return values are ignored. Every after hook is also fired as the server event `koja-dispatcher:<name>`. |

## Register and remove

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

-- later
exports['koja-dispatcher']:RemoveHook(hookId)
```

| Rule | Detail |
|---|---|
| Return value of `RegisterHook` | The hook id, or `nil` when the name is unknown or `fn` is not a function. |
| Order | Hooks run in registration order. koja-dispatcher registers its own internal hooks at start, so they run before yours. The first before hook that returns `false` stops the chain. |
| Errors | Each hook runs in `pcall`. An error is printed (`hook <name> from <resource> failed: ...`) and the next hook still runs; a failing before hook does **not** cancel the action. |
| Cleanup | All hooks of a resource are removed when that resource stops. You only need `RemoveHook` to stop listening while your resource keeps running. |
| Copies | A hook from another resource receives a serialized **copy** of the payload. Changing the table in place does nothing; return a table instead (`incidentCreate`). |
| Speed | Hooks run synchronously inside the dispatcher's logic. Do not wait or query slow APIs in a before hook. |

### Cancelling

Return `false` from a before hook to cancel. Return a short code as the second value to tell the caller why: `return false, 'suspended'`. Without a reason the caller gets `cancelled`.

| Hook | What the caller gets when cancelled |
|---|---|
| `callCreate` | `CreateCall`, `PhoneCall`, `CompatAlert` (call mode), client `SendAlert` and `Call911` return `nil` / `false` and your reason. `CreateCall({ scenario })` returns only `nil`. Blocked generated calls are skipped. |
| `incidentCreate` | `CreateIncident` returns `nil` and your reason. The `auto` fallback, dispatch-script alerts in incident mode and a dispatcher's dispatch report `cancelled`. |
| `unitAssign` | The unit is not assigned (internally `false, reason`). `AssignUnits` and `AutoAssign` do not count it; auto-assign moves on to the next unit. |
| `shiftStart` | The shift does not start. The CAD shows the locale text `errors.<reason>` (for example `errors.cancelled`); add your own reason to the locale files (`game.errors`) or reuse an existing key. |

### Changing fields

Only **`incidentCreate`** applies returned changes: every key of the table you return is copied into the creation data before the incident is built. The other before hooks get a fresh payload table, so returned tables have no effect there.

```lua title="server.lua"
exports['koja-dispatcher']:RegisterHook('incidentCreate', function(data)
    if data.category == 'shooting' then
        return { priority = 1, need = { police = 3, ems = 2 } }
    end
end)
```

---

## Before hooks

### `callCreate`

Fires when any call is about to ring: generated calls, player and API calls through the router, koja-mdt calls pulled into the CAD and private training calls.

| Field | Type | Description |
|---|---|---|
| `kind` | string | `'script'` (generated) or `'player'`. |
| `scenario` | string? | Scenario key for generated calls. |
| `caller` | string | Caller name. |
| `source` | number? | Player server id of the caller (`/911`, phones, `CreateCall({ source })`). `nil` for generated calls, `SendAlert`, compat alerts and koja-mdt calls. |
| `coords` | vector3 | Real position of the call. |
| `label` | string? | Place label. |
| `message` | string | First line: the opening line of a generated call or the player's message. |
| `origin` | string? | Where it came from: your resource name, `'api'`, `'player'`, `'client:<id>'`, `'compat:<script>'`, `'koja-mdt'`. `nil` for generated calls. |

**Can change:** nothing. **Can cancel:** yes, with a reason.

```lua title="server.lua"
-- no 911 calls from inside the prison
local prison = vector3(1690.0, 2600.0, 45.0)
exports['koja-dispatcher']:RegisterHook('callCreate', function(call)
    if call.kind == 'player' and #(call.coords - prison) < 250.0 then
        return false, 'no_signal'
    end
end)
```

### `incidentCreate`

Fires before every incident is created: dispatched calls, the `auto` fallback, `CreateIncident`, manual CAD incidents, dispatch-script alerts in `incident` mode, koja-mdt calls in `incidents` mode, field backup and panic, and training incidents.

| Field | Type | Description |
|---|---|---|
| `category` | string | Category. |
| `priority` | number | `1` to `3` (clamped after the hook). |
| `coords` | vector3 | Where the incident is placed (the approximate point for unlocated calls). |
| `real` | vector3? | Real position when it differs from `coords` (dispatched generated calls). |
| `located` | boolean? | `false` when the caller did not give the location yet. |
| `label` | string? | Place label. |
| `need` | table | Real need per department. |
| `known` | table | Need known to the dispatcher. |
| `note` | string? | Incident note (cut to 200). |
| `source` | string | `'call'`, `'player'`, `'external'`, `'manual'` or `'mdt'`. |
| `origin` | string? | Resource or system that created it. |
| `owner` | number? | Dispatcher server id when a dispatcher created it. |
| `follow` | number? | Player server id the incident follows. |
| `mdtId` | number? | Linked koja-mdt call id. |
| `scenario` | string? | Scenario key for dispatched generated calls. |
| `training` | boolean? | `true` for a private training incident. |
| `respond`, `fail`, `work`, `points`, `exposureRate` | number? | Timings, score and danger rate when set by the creator. |

**Can change:** any field. Set `scenario` to give an API incident the stages, escalation and timings of a scenario. **Can cancel:** yes, with a reason.

:::hint{type="warning" title="Changed values are not re-validated"}
Values you return are used as they are. Keep `category` to the [category list](/new/dispatcher/api/overview), `coords` to a `vector3` or `{ x, y, z }`, and `need` / `known` to `{ police, ems, fire }` numbers.
:::

```lua title="server.lua"
-- Fleeca robberies are always priority 1 with three police units
exports['koja-dispatcher']:RegisterHook('incidentCreate', function(data)
    if data.training then return end
    if data.category == 'robbery' and data.label and data.label:lower():find('fleeca') then
        return { priority = 1, need = { police = 3 }, known = { police = 3 } }
    end
end)
```

### `unitAssign`

Fires before a unit is assigned to an incident (CAD, `AssignUnits`, `AutoAssign`, koja-mdt attach, backup).

| Field | Type | Description |
|---|---|---|
| `unit` | string | Unit id (`'ai:3'`, `'p:12'`). |
| `src` | number? | Player server id for player units. |
| `callsign` | string | Callsign. |
| `incident` | number | Incident id. |

**Can change:** nothing. **Can cancel:** yes. With `fleet.enabled`, the internal fleet hook already blocks resting units and units waiting for repair (`fleet_unavailable`).

```lua title="server.lua"
-- probationary officers cannot be sent to priority 1 calls
exports['koja-dispatcher']:RegisterHook('unitAssign', function(data)
    if not data.src then return end
    local incident = exports['koja-dispatcher']:GetIncident(data.incident)
    if incident and incident.priority == 1 and Player(data.src).state.probation then
        return false, 'probation'
    end
end)
```

### `shiftStart`

Fires after the job, distance, station and capacity checks pass and right before the shift starts.

| Field | Type | Description |
|---|---|---|
| `source` | number | Player server id. |
| `console` | string | Station id, or `'remote'` for the remote CAD. |
| `remote` | boolean | `true` for the remote CAD. |

**Can change:** nothing. **Can cancel:** yes, with a reason shown to the player as `errors.<reason>`.

```lua title="server.lua"
exports['koja-dispatcher']:RegisterHook('shiftStart', function(data)
    if data.remote and not IsPlayerAceAllowed(data.source, 'dispatch.remote') then
        return false, 'not_dispatcher'   -- existing locale key: "not a dispatcher" message
    end
end)
```

---

## After hooks

Every after hook below is also fired as the server event `koja-dispatcher:<name>` with the same payload (see [Events](/new/dispatcher/api/events)). Private training calls and incidents do not fire call or incident after hooks.

### Calls

| Hook | Fires when | Payload |
|---|---|---|
| `callCreated` | A call starts ringing. | The [public call](/new/dispatcher/api/server-exports). |
| `callAnswered` | A dispatcher answers. | `{ id, kind, handler }`. `handler` is the dispatcher's server id. |
| `callEnded` | A call is done (dispatched, hung up, prank, missed, expired, closed in the MDT). | `{ id, kind, incident, handler }`. `incident` is the new incident id when it was dispatched. |
| `callMissed` | A generated call rang longer than `calls.patience`. | `{ id, caller, scenario, label, waited }`. `waited` in seconds. |
| `callDropped` | A dispatcher hung up on a generated call that was not a prank. | `{ id, caller, scenario, label, handler }` |

```lua title="server.lua"
exports['koja-dispatcher']:RegisterHook('callEnded', function(call)
    if call.incident then
        print(('call #%d became incident #%d'):format(call.id, call.incident))
    end
end)
```

### Incidents

| Hook | Fires when | Payload |
|---|---|---|
| `incidentCreated` | An incident was created. | The [public incident](/new/dispatcher/api/server-exports). |
| `incidentUpdated` | `UpdateIncident`, a scenario event changed it, or a multi-stage scenario moved to the next stage. | The public incident. |
| `incidentEnded` | It was resolved, failed or cancelled. | The public incident with `status`, `outcome` and `endedAt`. |

```lua title="server.lua"
exports['koja-dispatcher']:RegisterHook('incidentEnded', function(incident)
    if incident.status == 'failed' then
        print(('incident #%d failed after %ds'):format(incident.id, incident.endedAt - incident.createdAt))
    end
end)
```

### Units

| Hook | Fires when | Payload |
|---|---|---|
| `unitAssigned` | A unit was assigned. | `{ unit, src, callsign, incident }` |
| `unitStatus` | Any unit status changed (AI and players). | `{ unit, src, callsign, status, previous }` |
| `unitAdded` | A player went on duty as a field unit. | The [public unit](/new/dispatcher/api/server-exports). |
| `unitRemoved` | A player unit went off duty, left the server or started a dispatcher shift. | `{ unit, src, callsign }` |

`src` is `nil` for AI units.

```lua title="server.lua"
exports['koja-dispatcher']:RegisterHook('unitStatus', function(data)
    if data.src and data.status == 'onscene' then
        TriggerClientEvent('my-hud:onScene', data.src)
    end
end)
```

### Shifts, settings and admin

| Hook | Fires when | Payload |
|---|---|---|
| `shiftStarted` | A dispatcher started a shift. | `{ source, name, console }` (`console` is the station id or `'remote'`) |
| `shiftEnded` | A shift ended (also on disconnect). | `{ source, name, minutes, stats, earned, rating }` |
| `settingChanged` | A setting changed (admin menu, `SetSetting`, reset). | `{ key, value, previous }` |
| `adminAction` | An admin did something in `/dispatchadmin`. | `{ source, name, text }` |

`stats` is `{ answered, calls, resolved, failed, missed, pranks, coded, instructions, score }`. `rating` is 1 to 5 stars, `earned` the pay (0 when unpaid, too short or the player disconnected).

`adminAction.text` is a short log line, for example `set mode.calls = real`, `reset all settings`, `added station station_2`, `moved station mrpd`, `created job dispatch`, `saved scenario my_bank`.

```lua title="server.lua"
exports['koja-dispatcher']:RegisterHook('shiftEnded', function(shift)
    if shift.minutes >= 60 and shift.rating >= 4 then
        exports['my-bonus']:Give(shift.source, 500)
    end
end)
```

### Pursuits and mass casualty incidents

| Hook | Fires when | Payload |
|---|---|---|
| `pursuitStarted` | A pursuit incident got a live suspect vehicle. | `{ incident, x, y, category }` |
| `pursuitEnded` | The pursuit ended. | `{ incident, outcome, seconds }`, `outcome` is `'caught'`, `'lost'` or `'cancelled'` |
| `mciStarted` | A mass casualty incident opened. | `{ incident, patients, x, y }` |
| `mciEnded` | The MCI incident ended. | `{ incident, transported, treated, seconds, status }` |

```lua title="server.lua"
exports['koja-dispatcher']:RegisterHook('pursuitStarted', function(p)
    exports['my-news']:Breaking(('Police chase in progress near %.0f, %.0f'):format(p.x, p.y))
end)
```

### Voice

| Hook | Fires when | Payload |
|---|---|---|
| `voiceConnected` | Caller and dispatcher were put on a voice channel. | `{ call, channel, provider, caller, handler }`. `caller` and `handler` are server ids. |
| `voiceEnded` | The voice call ended. | `{ call, channel, caller, handler, handlerKind, reason, duration }` |

`reason` is `ended`, `dispatched`, `dispatcher`, `caller`, `unit`, `dropped`, `shift`, `disabled` or `api`. `handlerKind` is `'dispatcher'`, or `'unit'` after a transfer.

```lua title="server.lua"
exports['koja-dispatcher']:RegisterHook('voiceConnected', function(v)
    exports['my-phone']:ShowCallScreen(v.caller, '911', v.channel)
end)
```

---

## Reference

| Hook | Kind | Payload |
|---|---|---|
| `callCreate` | before | `{ kind, scenario, caller, source, coords, label, message, origin }` |
| `incidentCreate` | before | creation data (any field changeable) |
| `unitAssign` | before | `{ unit, src, callsign, incident }` |
| `shiftStart` | before | `{ source, console, remote }` |
| `callCreated` | after | public call |
| `callAnswered` | after | `{ id, kind, handler }` |
| `callEnded` | after | `{ id, kind, incident, handler }` |
| `callMissed` | after | `{ id, caller, scenario, label, waited }` |
| `callDropped` | after | `{ id, caller, scenario, label, handler }` |
| `incidentCreated` | after | public incident |
| `incidentUpdated` | after | public incident |
| `incidentEnded` | after | public incident |
| `unitAssigned` | after | `{ unit, src, callsign, incident }` |
| `unitStatus` | after | `{ unit, src, callsign, status, previous }` |
| `unitAdded` | after | public unit |
| `unitRemoved` | after | `{ unit, src, callsign }` |
| `shiftStarted` | after | `{ source, name, console }` |
| `shiftEnded` | after | `{ source, name, minutes, stats, earned, rating }` |
| `settingChanged` | after | `{ key, value, previous }` |
| `adminAction` | after | `{ source, name, text }` |
| `mciStarted` | after | `{ incident, patients, x, y }` |
| `mciEnded` | after | `{ incident, transported, treated, seconds, status }` |
| `pursuitStarted` | after | `{ incident, x, y, category }` |
| `pursuitEnded` | after | `{ incident, outcome, seconds }` |
| `voiceConnected` | after | `{ call, channel, provider, caller, handler }` |
| `voiceEnded` | after | `{ call, channel, caller, handler, handlerKind, reason, duration }` |

`boloCreated` is an event only, not a hook: listen with `AddEventHandler('koja-dispatcher:boloCreated', ...)`.
