AI Dispatcher
The optional, experimental AI dispatcher for Tommy's Radio — the recommended hosted setup, the local engine as a fallback, what it knows, the tools it can use, and writing your own instructions.
A unit says "Dispatch, 1-Adam-12, I'll be 10-8" and gets an answer on the same channel, through the same radio chain. It is off until you enable it, and hears only the channels you list.
Set up hosted text and speech
Point text and speech at a hosted provider and give them API keys. Hosted text and speech
need no model downloads and no CPU on the game's machine.
- Choose a provider per capability in
config/config.lua(the table below lists them). - Store that provider's key in Admin Panel → Credentials.
- Leave
transcriptionalone unless you want to send unit audio off the machine.
With no keys at all, all three run on the local engine.
Where inference runs
Three independent decisions in config/config.lua. Left alone, all three run locally.
Config.aiDispatch = {
providers = {
-- text = { provider = "groq", model = "llama-3.3-70b-versatile" },
-- speech = { provider = "elevenlabs", model = "eleven_turbo_v2_5" },
-- transcription = { provider = "openai", model = "whisper-1" },
},
voices = { male = { "en_US-ryan-medium" }, female = { "en_US-amy-medium" } },
wakePhrases = { "dispatch" },
}| Capability | What it does | Providers |
|---|---|---|
text | Decides and writes the reply | local, openai, groq, vercel, openrouter |
speech | Says it | local, openai, groq, vercel, elevenlabs |
transcription | Hears the unit | local, openai, groq, vercel |
A baseUrl overrides the provider's own, so a compatible proxy works under that provider's name. ElevenLabs serves speech only — ask it for anything else and that capability falls back to local.
API keys
Keys go in Admin Panel → Credentials — not in a config file, and not in server.cfg. The page is write-only: store, replace and clear a key, and see its last four characters while rotating. A provider with no key falls back to local and names the missing one.
The local engine
Any capability with no API key runs here: CPU-only models beside your server, no account and no
port. They are small, and they sound and reason like it — move text and speech to
hosted providers once you are past trying the feature out.
The engine binaries ship inside the resource. The model files do not — about 2.5 GB, fetched once on request.
Turn the dispatcher on with no hosted provider and a card under Settings → AI Dispatcher names the missing files, with a Download model files button. From a console, run tradio_ai_download. The radio keeps working and nothing needs restarting. Point text at a hosted provider and the 2.5 GB model is never fetched.
| Requirement | Detail |
|---|---|
| Platform | Windows or Linux, x86-64. No GPU — these are CPU builds |
| RAM | ~2 GB free with both models resident (1.3 GB text, 0.2 GB hearing) |
| Disk | ~200 MB of engine binaries plus 2.5 GB of model files |
| Linux libc | glibc 2.34 or newer — Debian 12, Ubuntu 22.04 and newer |
| Cores | Half the box's cores per engine, rounded down, minimum one |
| Ports | None. Both bind 127.0.0.1, and no setting moves them off |
One server.cfg line is required. FXServer will not let a resource start a program unless you say so:
add_unsafe_child_process_permission tLibtLib starts the engines on tRadio's behalf, and the same line covers tAI. Naming tRadio here does nothing. Without it the console says the API is restricted and the dispatcher stays quiet.
Override the model locations only when several servers share one install:
-- Empty means this resource's own server/models/ directory.
Config.aiDispatch.localEngine = {
root = "", textModel = "", transcribeModel = "", voicesDir = "",
}An engine left behind by a killed server is cleared on the next start.
Reading back what the dispatcher did
Every turn is logged, answered or not:
[Radio-Server:ai] turn: K31 → unit_status(10-6), aired "K31 is now showing 10-6" (total 4210ms)
Traffic not meant for the dispatcher gets one line saying so, telling a missed wake phrase from unit-to-unit talk.
server/logs/ai-dispatch.jsonl holds the same turns with the transcript, tool results, the drafted reply and the aired reply. Read it when answers are odd rather than absent — a draft differing from what aired means a tool outcome rewrote it. It rolls over at 4 MB.
Sharing an engine with tAI
Run tAI as well and the two will not load the same model twice. Whichever starts first registers its engine with tLib; the other uses it over loopback. Matching is on what is loaded, named in server/models/artifacts.json:
{
"text": "qwen3-4b-instruct-2507-q4_k_m",
"transcription": "whisper-base.en-q5_1"
}Swap the weights out and you must edit that file. Two resources naming different models run two engines, costing another 1.3 GB of RAM.
How it appears on the channel
It takes a unit slot on each covered channel, and signs off when a human dispatcher takes that channel. Its transmissions are real, so patches, trunked sub-frequencies, announcement groups, scanners and 3D audio all work. Its voice is always IMBE, so it sounds vocoded on analog too.
Settings
Admin Panel → Settings → AI Dispatcher. All apply live.
| Setting | Default | Description |
|---|---|---|
| AI Dispatcher | false | Master switch. Off, nothing is transcribed |
| Channels | empty | Frequencies it monitors, comma-separated. Empty means none |
| Yield to a Real Dispatcher | true | Stand down while a human dispatcher is on the channel |
| Dispatch 911 Calls Automatically | true | Put new 911 calls on the air, assigning the nearest unit |
| Let the Dispatcher Answer 911 Calls | false | Pick the call up, talk to the caller, then put it on the air. The row above only relays a human's call |
| Let the Dispatcher Use Your CAD | true | Whether CAD actions are offered. See Connecting a CAD or MDT |
| Dispatcher Callsign | Dispatch | What it calls itself, and what units call it |
| Code Sheet | empty | code = meaning pairs — 10-4 = acknowledged. Up to 24. It reads codes back correctly and listens for those words. The biggest improvement to how reliably it hears units |
| Voice | female | Which of the voices lists it speaks with |
What it knows
Each turn it is told who is on the channel — callsigns, unit IDs, status, distance, who is keyed up — plus recent traffic and anyone who called while it was busy. It also gets every other channel on your system, so it uses your names.
A channel runs one exchange at a time. A unit keying up mid-answer is held, then acknowledged by callsign next transmission.
Alerts, tones and patches
These work on any server, with nothing to configure:
| Tool | What it is for |
|---|---|
channel_alert | Put one of your alerts up on a channel, or lift it |
channel_tone | Send a tone over the channel before an announcement |
patch_channels / unpatch_channels | Patch channels together, or take a patch down |
call_another_channel | Put traffic on another channel, return with the answer |
Alert and tone lists come from your own configuration. Direct and trunked channels cannot be patched. Your instructions say when to reach for one.
Your own instructions
Config.aiDispatchInstructions in config/sv_functions.lua is a blank page:
Config.aiDispatchInstructions = function(ctx)
return [[
Los Santos channels are LSPD. Blaine County is BCSO. OPS-3 is fire and EMS.
A unit calling a pursuit means SIGNAL 100 goes up. Lift it on code 4 or custody.
]]
endIt runs once per turn with the full context, so you can vary guidance per channel or unit. Keep it cheap. Up to 4000 characters.
Your text is added to tRadio's rules, never substituted.
How it carries itself
Config.aiDispatch.demeanor picks the manner it speaks with.
| Key | How it sounds |
|---|---|
measured | Steady and unhurried, whatever the traffic is. The default |
brisk | Impersonal, fewest words, off the air |
veteran | Unsurprised by anything and explaining nothing |
formal | Strict procedure throughout, no informality |
Blank, or absent, is measured. A misspelled key is also measured, and says so in the console with the four names.
A manner changes no limit: ten words, callsign first, and nothing that was not in the traffic, roster or a tool result.
What it is allowed to do
Everything it can act on is a tool. tRadio ships unit_status; register the rest in config/sv_functions.lua:
Config.aiDispatchTools = function()
return {
{
id = 'tow_request',
description = 'the unit is asking for a tow truck',
input = 'where the tow is needed, in the unit\'s own words',
run = function(ctx, input)
if input == '' then return false, 'you did not say where' end
TriggerEvent('my_dispatch:requestTow', ctx.serverId, input)
return true, 'a tow is en route to ' .. input
end,
},
}
end| Field | Required | What it is |
|---|---|---|
id | yes | Unique name — lowercase letters, digits, underscores |
description | yes | One line: when this tool applies |
input | no | One line: what the argument is. Omit if it takes none |
available | no | function(ctx) -> boolean. false hides it this transmission |
run | yes | function(ctx, input) -> ok, message |
The model names one tool and writes its argument in its own words. The argument is not validated before it reaches you. Every tool runs in a pcall. One transmission can take three actions, in order.
ctx is what it knows about the turn:
| Field | What it holds |
|---|---|
trigger | 'transmission' or 'call911' |
serverId, callsign, unitId | The unit who keyed up. 0 / '' on a call911 |
frequency, channelName | The channel it is happening on |
transcript | What the unit said. '' on a call911 turn |
roster | Channel units, nearest first — { serverId, callsign, unitId, status, note, distance, talking }. roster[1] is closest |
history | Recent traffic, oldest first, { who, text, own } |
waiting | Callsigns who called while the dispatcher was mid-turn |
call | On a call911 turn only: { id, caller, message, location } |
Calls
tRadio keeps no call records of its own. Config.aiDispatchIncidents connects four actions — create, attach a unit, clear a unit, close. Whichever you supply become tools; the rest it cannot do. Each is run(ctx, input) -> ok, message.
The input is free text, not a call id: "traffic stop, black Sultan, Vespucci" arrives as create(ctx, 'traffic stop, …'). Match it against your records; when you cannot, return false with a reason and the unit hears it.
911 calls arrive on their own. Hand it anything else the same way; only message is required, and it goes out on whichever monitored channel has units.
TriggerEvent('tradio:ai:call', {
source = 'CAD', -- said on the air as-is
caller = 'Jane Doe',
message = 'structure fire, smoke showing',
service = 'fire', -- prefers a channel named for it
priority = 'priority 1',
location = 'Vespucci Boulevard', -- else Config.aiDispatchLocation
coords = vector3(x, y, z), -- finds the nearest unit
})| Hook | What it does |
|---|---|
Config.aiDispatchUnitNote(serverId) | Adds a line beside a roster unit — "on a traffic stop". It is why a busy unit is passed over. Runs once per unit per turn, so read a cached value |
Config.aiDispatchLocation(coords) | Turns coordinates into an address. FiveM has no server-side street lookup, so without it a call carries none |
Connecting a CAD or MDT
Run a supported CAD or MDT and you can skip the hooks above. The dispatcher detects which one you run and takes the actions it supports. A dash means the system cannot do it, so it is never offered.
| LACORE | SonoranCAD | ImperialCAD | wasabi_mdt | tk_mdt | ps-mdt | |
|---|---|---|---|---|---|---|
| Run a plate | Yes | Yes | Yes | Yes | Flags only | — |
| Run a name | Yes | — | — | — | Warrants only | — |
| Open a call | Yes | Yes | Yes | Yes | Yes | Yes |
| Attach a unit | Yes | Yes | Yes | — | — | — |
| Clear a unit | Yes | Yes | — | — | — | — |
| Close a call | Yes | Yes | Yes | — | — | — |
| Add a note | Yes | Yes | Yes | — | — | — |
| Set a unit's status | Yes | Yes | — | — | — | — |
| Read your roster | Yes | Yes | Yes | Yes | Yes | — |
Limits of each system:
- Status writing is LACORE and SonoranCAD only. Elsewhere the CAD still learns a unit is on a call when it attaches them
- tk_mdt reports whether a vehicle is flagged, not who owns it, so the dispatcher says "nothing circulated on it", never "comes back clear". A name lookup is the same story
- Outside LACORE it can only act on calls it opened itself, and wasabi_mdt, tk_mdt and ps-mdt cannot attach a unit after a call exists
One system needs no configuration: it is found and logged at startup. With two, it prefers the one that can do more. To decide for it:
Config.aiDispatch = {
-- Only when two run at once: "lacore", "sonoran",
-- "imperial", "wasabi", "tkmdt", "psmdt"
cad = "",
}Your own hooks always win. Config.aiDispatchIncidents and Config.aiDispatchTools take precedence over the built-in integration for the same action. Credentials stay in the CAD's own resource, so tRadio never sees them. Most systems match a player to a CAD account through Discord, so one who has not linked Discord has no CAD identity.
Next steps
- What a unit hears on the air: Talking to the AI dispatcher
- It is quiet, or answering oddly: Troubleshooting
- Everything else in the admin panel: Configuration
Tommy's Radio
Still stuck? Support can answer it directly.
Was this page helpful?
TIMMYG Studios