Skip to content

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.

  1. Choose a provider per capability in config/config.lua (the table below lists them).
  2. Store that provider's key in Admin Panel → Credentials.
  3. Leave transcription alone 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.

lua
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" },
}
CapabilityWhat it doesProviders
textDecides and writes the replylocal, openai, groq, vercel, openrouter
speechSays itlocal, openai, groq, vercel, elevenlabs
transcriptionHears the unitlocal, 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.

RequirementDetail
PlatformWindows 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 libcglibc 2.34 or newer — Debian 12, Ubuntu 22.04 and newer
CoresHalf the box's cores per engine, rounded down, minimum one
PortsNone. 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:

cfg
add_unsafe_child_process_permission tLib

tLib 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:

lua
-- 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:

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.

SettingDefaultDescription
AI DispatcherfalseMaster switch. Off, nothing is transcribed
ChannelsemptyFrequencies it monitors, comma-separated. Empty means none
Yield to a Real DispatchertrueStand down while a human dispatcher is on the channel
Dispatch 911 Calls AutomaticallytruePut new 911 calls on the air, assigning the nearest unit
Let the Dispatcher Answer 911 CallsfalsePick 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 CADtrueWhether CAD actions are offered. See Connecting a CAD or MDT
Dispatcher CallsignDispatchWhat it calls itself, and what units call it
Code Sheetemptycode = 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
VoicefemaleWhich 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:

ToolWhat it is for
channel_alertPut one of your alerts up on a channel, or lift it
channel_toneSend a tone over the channel before an announcement
patch_channels / unpatch_channelsPatch channels together, or take a patch down
call_another_channelPut 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:

lua
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.
    ]]
end

It 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.

KeyHow it sounds
measuredSteady and unhurried, whatever the traffic is. The default
briskImpersonal, fewest words, off the air
veteranUnsurprised by anything and explaining nothing
formalStrict 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:

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
FieldRequiredWhat it is
idyesUnique name — lowercase letters, digits, underscores
descriptionyesOne line: when this tool applies
inputnoOne line: what the argument is. Omit if it takes none
availablenofunction(ctx) -> boolean. false hides it this transmission
runyesfunction(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:

FieldWhat it holds
trigger'transmission' or 'call911'
serverId, callsign, unitIdThe unit who keyed up. 0 / '' on a call911
frequency, channelNameThe channel it is happening on
transcriptWhat the unit said. '' on a call911 turn
rosterChannel units, nearest first — { serverId, callsign, unitId, status, note, distance, talking }. roster[1] is closest
historyRecent traffic, oldest first, { who, text, own }
waitingCallsigns who called while the dispatcher was mid-turn
callOn 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.

lua
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
})
HookWhat 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.

LACORESonoranCADImperialCADwasabi_mdttk_mdtps-mdt
Run a plateYesYesYesYesFlags only—
Run a nameYes———Warrants only—
Open a callYesYesYesYesYesYes
Attach a unitYesYesYes———
Clear a unitYesYes————
Close a callYesYesYes———
Add a noteYesYesYes———
Set a unit's statusYesYes————
Read your rosterYesYesYesYesYes—

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:

lua
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

Still stuck? Support can answer it directly.

Was this page helpful?