Customisation
tCAD's eight hooks: display names, callsign schemes, call, duty and warrant event taps, a deny hook, an open condition, and the assignment notice. What each one receives and what is deliberately withheld.
Behaviour you can change lives in two files, both shipped readable rather than encrypted so you can edit them. They are yours to merge forward on an update — see Updating:
| File | Hooks |
|---|---|
config/sv_functions.lua | displayName, formatCallsign, onCallEvent, onUnitEvent, onWarrantEvent, denyMethod |
config/cl_functions.lua | canOpenCAD, onAssignmentNotice |
config.lua holds values; these two hold behaviour. See Configuration.
Two properties of every hook
They are validated at boot. Product code carries an expected file version and a name-to-arity table, so a hook that is missing or has the wrong signature is named in the console at startup rather than failing as a nil call mid-shift. Run tcad_hooks to see the current state.
Each one declares what happens if it raises, and the default is always the product's own behaviour — with exactly one exception, denyMethod, noted below. A broken hook must not be able to stop a shift.
A hook gains a field, never an argument. The copy you carry forward is as old as the day you wrote it, so a signature has to stay valid indefinitely — which is why it is frozen on the day it ships.
⛔ What the hooks deliberately withhold
Every hook is narrowed to the minimum that does its job, and the narrowing is an allowlist rather than trust in the consumer.
The reasoning is specific: the most likely thing anybody builds on an event hook is a Discord webhook — that is, publishing to a chat server. This product's own history has two shipped defects of exactly that shape, where the permission boundary was correct and the payload crossed it anyway. So:
onCallEventdoes not carry the complainant's phone number.onWarrantEventcarries acharacterId— a row id — and no name and no SSN. Turning an id into a person needs a gated operation.denyMethoddoes not receive the request payload. Acivilian.updatepatch can contain medical notes; handing every request body to owner-written code would make this hook the widest personal-data egress in the resource, for the sake of a decision that should be about who and which method.- No hook receives a player's licence identifier unless it needs one. You get
source, which is good for this tick;denyMethodgetskeybecause a blocklist has to survive a reconnect.
displayName(serverId, fallback)
Replace the name tCAD shows for a player. Return a string to override, or nil/the fallback to accept it.
Config.displayName = function(serverId, fallback)
-- ESX:
-- local player = ESX and ESX.GetPlayerFromId(serverId)
-- if player then
-- return ('%s %s'):format(player.get('firstName'), player.get('lastName'))
-- end
return nil
endThe name you return is written into call timeline entries as the actor, so it is persisted and read by everyone who can open that call. Return a character name or a callsign — not an email address, not a Discord handle, and nothing you would not show a stranger.
serverId is a connection slot and is reused. Never store one; tCAD keys records by the licence identifier and does that itself.
formatCallsign(request)
Normalise or refuse a callsign as a unit goes on duty.
| Field | |
|---|---|
.callsign | exactly what the player typed |
.departmentId | number |
.deptName | string or nil |
.agencyType | law_enforcement, fire_ems, dispatch, civilian, business, judicial |
.source | the player's server id, for your own lookups |
| Return | Effect |
|---|---|
| a string | use this callsign instead |
nil | accept what the player typed (the default) |
false, string | refuse, and show that string to the player |
Config.formatCallsign = function(request)
local sign = tostring(request.callsign or ''):upper()
if request.agencyType == 'law_enforcement' and not sign:match('^%d+%-%a+%-%d+$') then
return false, 'LEO callsigns look like 1-ADAM-12 on this server'
end
return sign
endtCAD ships with no numbering scheme — every server has its own. What it will keep enforcing whatever you return is that a callsign is non-empty and unique within its department, folded for case and punctuation. That rule is in the storage layer and this hook cannot switch it off.
This is the only hook that can refuse an operation, and it can only refuse this one. If it raises, the callsign is used exactly as submitted: a broken scheme must not stop a shift going on duty.
Do not return a name here. A callsign is read aloud on the radio and appears on every timeline entry.
onCallEvent(event)
Fired after a call changes. Side effects only; the return value is ignored.
.kind is one of created, priority, status, held, released, cancelled, closed.
Config.onCallEvent = function(event)
if event.kind ~= 'created' then return end
if event.priorityId ~= 1 then return end
-- your webhook here
end.callNumber is present on created only. The change operations return an id, not a number, and looking one up to fill the field would be a storage read on every status change. Keep your own id-to-number map if you need it.
.description and .address are carried — they are the call, and a relay without them says nothing. They are still roleplay content written by one of your players. If your webhook posts to a public channel, decide that on purpose.
onUnitEvent(event)
Fired after a unit goes on duty, goes off duty, or changes status. .kind is duty.on, duty.off or status.
.detachedCount on duty.off is a count, not the calls. "This unit left three calls" is what an alert needs; the calls themselves would put dispatch content into a hook whose job is attendance.
There is no position hook. If you need live positions, read them from the unit position push or from the unit list.
onWarrantEvent(event)
Fired after a warrant is created or ruled on. .kind is created, approved, denied, served or recalled.
.characterId names which record the warrant concerns. It is a row id and not an identity — see what the hooks withhold.
denyMethod(request)
Called on every CAD request, after tCAD's own authority gate has already said yes.
| Field | |
|---|---|
.method | e.g. calls.create, civilian.view |
.permission | the capability key the method named, or nil when it is open beyond use |
.source | the player's server id |
.key | their licence identifier — durable, so a blocklist survives a reconnect |
.callsign, .departmentId | the identity they acted as |
| Return | Effect |
|---|---|
nil or false | allow — tCAD's own decision stands |
| a string | refuse, and show that string to the player |
local BANNED = { ['license:abc123'] = true }
Config.denyMethod = function(request)
if BANNED[request.key] then
return 'CAD access is suspended for this account.'
end
endCost. This is on the hottest path in the resource: it runs on every request, including unit position updates. The shipped body returns immediately. Keep yours O(1) — a table lookup, not a loop over players and not a query.
canOpenCAD()
Called every time the player presses the open key or types /tcad, before the panel is shown.
| Return | Effect |
|---|---|
nil or true | open it (the default) |
false, string | refuse, and show that string as a notification |
tCAD ships with no condition at all: it opens whenever asked. Servers want different ones — not while dead, not while cuffed, not while driving at speed — and none of those are things the CAD can know.
Checked on open only, never on close. A player already looking at the CAD when they get shot can always shut it — refusing a close would trap them behind a full-screen panel with NUI focus, which is the one failure here a player cannot recover from without reconnecting.
If it raises, the CAD opens. A dispatcher locked out of the board by a typo in a condition is worse than a dead officer reading their MDT.
onAssignmentNotice(notice)
Replace the in-world notice a unit gets when they are assigned to a call. A dispatch CAD has to be able to reach a unit who is not looking at it; the shipped notice is a tLib toast.
| Field | |
|---|---|
.callId | number |
.callsign | the callsign the assignment names, or nil |
.title, .message | the shipped toast's text |
| Return | Effect |
|---|---|
nil | show the shipped toast (the default) |
false | suppress it — you have handled it, or you want no notice at all |
Config.onAssignmentNotice = function(notice)
exports.ox_lib:notify({ title = notice.title, description = notice.message })
return false
endReturning nil after doing your own thing gives you both, which is almost never what you want.
No call number, type, address or caller is carried, and that is not a bug. The underlying push carries the call id, the callsign and the department and nothing else, so anything richer would have to be reconstructed from a client-side cache that is empty for every call created before this player joined — meaning the notice would sometimes name a call, sometimes not, and could name a stale one. A notice whose job is "go and look at your CAD" does that job without it.
If it raises, the shipped toast is shown anyway. The push has already reached the panel regardless — it is always mounted, visible or not — so the board is correct whatever happens here.
Was this page helpful?
TIMMYG Studios