AI Radio Dispatcher
AI-powered radio dispatchers for oxide-dispatch — provider modes, setup, costs, capabilities, and troubleshooting.
AI-powered dispatchers running on dedicated radio channels — one channel per framework job (LSPD, BCSO, SASP, AMR EMS, fire, etc.). Officers tune to their department's channel, press PTT, and the AI dispatcher listens, responds verbally, and files structured alerts through the existing dispatch pipeline.
Two provider modes are supported:
- Realtime — OpenAI Realtime over WebRTC (
gpt-realtime). Best quality + true real-time latency (~500ms). Token-priced at $32/$64 per 1M audio in/out tokens; effective cost depends heavily on talk-density and prompt caching. - Chained (default) — Whisper-1 STT → gpt-4o-mini LLM → tts-1 TTS pipeline running through a Node.js sidecar in this resource.
2s round-trip per transmission, dramatically cheaper at typical RP load ($5–10/mo per active officer). Per-stage providers are pluggable.
See Provider modes for the cost/feature comparison.
Privacy Notice — Read First
When this feature is enabled, officer voice audio is transmitted to OpenAI's servers (United States) for transcription and response generation.
- In Realtime mode: audio + transcripts go to OpenAI over a WebRTC session.
- In Chained mode: audio is sent to OpenAI Whisper (STT), transcripts go to OpenAI Chat Completions (LLM), and the reply audio is generated by OpenAI TTS. All three stages route through the Node.js sidecar in this resource.
This includes:
- The officer's microphone audio while pressing PTT on the AI channel
- Verbatim transcripts of what the officer said (Whisper transcription)
- The AI's spoken responses (sent back as audio)
- Officer profile context (name, callsign, department, in-game location)
- Conversation history accumulated during each channel-join session
You — the server owner — are responsible for disclosing this to your players. Depending on jurisdiction, this may be a legal requirement (GDPR for EU-hosted servers, similar privacy frameworks elsewhere).
Recommended player-facing disclosure
Add the following to your server rules / privacy policy:
Our server uses an AI dispatcher for police roleplay. When a police officer tunes their in-game radio to the AI Dispatch channel and presses their push-to-talk key, their voice is transmitted to OpenAI's servers for processing. OpenAI's data handling is governed by their privacy policy at https://openai.com/policies/privacy-policy. This processing only occurs for officers actively on the AI Dispatch radio channel — civilian voice and other channels are not transmitted to OpenAI.
If your jurisdiction requires affirmative consent before personal data transmission, you should NOT enable this feature without first implementing a consent flow.
Data NOT transmitted
- General proximity voice / pma-voice chatter (only happens when on AI channel + PTT pressed)
- Civilian phone calls
- Any audio from players who haven't joined the AI dispatch channel
- Player identifiers beyond the speaker's in-game character profile
Installation
Prerequisites
oxide-dispatchv1.0+ installed and runningpma-voice— required. AI dispatch detects channel-join via pma-voice's universalsetPlayerRadioevent, so any radio UI that callsexports['pma-voice']:setRadioChannel(n)works (oxide-police, qb-radio, npwd-radio, custom panels, /commands, etc.)- A supported MDT (optional) — the in-game police computer officers use to run plates and look people up. The 8 MDT-backed AI tools (plate / person / BOLO / custody / dossier) unlock when any supported MDT is enabled and running:
oxide-police,ps-mdt,wasabi_mdt,lb-tablet, or a custom bridge per the MDT Bridge Development guide. The core AI dispatcher works without one. - A radio UI of your choice to let officers tune to the AI channel
- An OpenAI API account with billing enabled. The Realtime API is available to any paid developer; you do not need a specific tier to use it. Your tier only affects how many concurrent sessions you can run (Tier 5 is ~100 simultaneous sessions; Tier 1 caps lower). For a typical RP server with a handful of officers on duty at once, Tier 1 is sufficient.
Getting an OpenAI API key
- Sign up at platform.openai.com (or log in if you already have an account).
- Add a payment method under Settings → Billing. The Realtime API is paid-only — free trial credits do not unlock it. A minimum $5 prepaid deposit moves you into Tier 1 immediately.
- Set a hard spending limit under Settings → Billing → Limits. A monthly cap that matches your dispatch budget is the single most important safety rail — once hit, all OpenAI requests start returning 429 instead of charging you.
- Go to API keys and click Create new secret key. Name it (e.g. "oxide-dispatch") and copy the
sk-...value — you only get to see it once. - (Optional, for Realtime only) Verify your tier at Settings → Limits. Tier auto-upgrades as your historical spend grows: $5 spent → Tier 1, $50 → Tier 2, $100 → Tier 3, etc.
Setup steps
-
Add the OpenAI API key as a server convar in your
server.cfg:set oxide:ai-dispatch:openai_key sk-...your-key-here...Use
set, neversetr—setrreplicates the value to every connected player. Withsetthe convar is server-only and the key is never sent to clients. -
Provision the AI dispatch radio channels. Each channel listed in
Config.AIDispatch.Channelsis bound to a single framework job. Add each channel to whatever radio UI your server uses so its responders can tune to it.- oxide-police: open the police admin panel and add the channel to your department(s). Label and min-grade are up to you.
- Other radio resources: follow that resource's docs — anything that ultimately calls
exports['pma-voice']:setRadioChannel(N)will trigger AI dispatch for anyNlisted inChannels.
The defaults ship with three channels enabled:
[911] = police,[921] = EMS (ambulance),[931] = Fire. Uncomment the BCSO / SASP entries (or add custom ones) inshared/config/ai_dispatch.luato bring up additional departments. Make sure each entry'sjobfield matches a job that exists in your framework AND is listed inConfig.ResponderGroups. -
(Optional) Adjust configuration in
oxide-dispatch/shared/config/ai_dispatch.lua:Config.AIDispatch.Enabled— master switchConfig.AIDispatch.Provider— global default ('realtime'or'chained', default chained); each channel can overrideConfig.AIDispatch.Channels— map of channel number → channel entry (see below)Config.AIDispatch.IdleTimeoutMin— auto-close idle realtime sessions (default 10 min; chained skips)Config.AIDispatch.Access.IdentifierWhitelist— global identifier allowlist fallbackConfig.AIDispatch.Integrations.Mdt— priority list of MDT backends powering the 8 MDT-flavored AI tools. Ships withoxide-police,ps-mdt,wasabi_mdt, andlb-tablet; write your own per the MDT Bridge Development guide. Set an entry'senabled = falseto disable it.Config.AIDispatch.Realtime.Model/.Voice— Realtime model + default voiceConfig.AIDispatch.Chained.STT|LLM|TTS.Provider/.Model— per-stage swaps for chained mode (default all-OpenAI)
Channel entry fields:
job(required) — framework job name bound to this channel (e.g.'lspd','bcso','ambulance','fire')label— UI labelvoice— override default voice for this channelprovider— override'realtime'/'chained'for this channelwhitelist— per-channel identifier allowlist (takes precedence over the global)
-
Restart
oxide-dispatch(andoxide-policeif installed). -
Verify by joining as an on-duty police officer, opening the radio panel, tuning to channel 911, pressing PTT, and saying "Radio check". The AI should reply "Loud and clear" (or similar) through your headset.
Provider modes
| Aspect | Realtime | Chained |
|---|---|---|
| Latency (PTT release → first word) | ~500 ms | ~1.5–2.5 s |
| Cost shape | Metered per audio token (active talk only) | Metered per transmission |
| Vendor surface | OpenAI only | OpenAI by default; per-stage pluggable |
| Mic→speaker quality | Excellent (single-pass) | Very good (lossy STT/TTS round-trip) |
| Tool calling | 9 tools on any channel, +8 MDT tools on police-flavored channels | Same set |
| Cross-officer RADIO history | Yes | Yes |
| Conversation memory | In-session (held by OpenAI) | In-session (held server-side) |
| Setup complexity | One API key | One API key |
| Tier requirement | Any paid OpenAI account (tier sets concurrent-session cap) | Any paid OpenAI account |
When to pick Realtime: quality matters more than cost, you're already a heavy OpenAI user, you want sub-second turn-taking for fast-paced calls.
When to pick Chained: you want predictable monthly costs, your server doesn't need sub-second AI replies, or you want lower bills. Tradeoff: officers hear a noticeable beat between releasing PTT and hearing the AI reply. Most groups report this feels natural for radio RP — real dispatchers pause too.
Switch modes by changing Config.AIDispatch.Provider and restarting. Sessions don't carry across mode changes.
Cost notes
Realtime mode
OpenAI's gpt-realtime is token-priced, not minute-priced. Audio tokenizes at 1 token per 100ms of user audio (input) and 1 token per 50ms of assistant audio (output):
- Audio input: $32 per 1M tokens (= ~$0.32 per hour of officer talk)
- Audio output: $64 per 1M tokens (= ~$1.28 per hour of dispatcher talk)
- Cached input: $0.40 per 1M tokens (huge savings on the repeated system prompt + tool schema)
- Session/system prompt: ~3K–5K tokens of instructions + tool schema per session, plus a small per-turn text overhead
Realistic per-minute cost depends entirely on talk density:
- Light dispatch use (officer talks a few seconds, dispatcher replies briefly): ~$0.02–$0.05/min of active talk with caching
- Heavy back-and-forth: ~$0.10–$0.20/min uncached
- Idle session time costs almost nothing (no audio = no audio tokens)
Rough monthly estimates assuming the idle sweeper closes forgotten sessions:
| Scenario | Est. monthly cost |
|---|---|
| 1 officer, ~30 min of actual talk/day | ~$10–$25/mo |
| 4 officers, ~1 hour of actual talk/day each | ~$80–$200/mo |
| 8 officers, heavy use (~2 hours talk/day each) | ~$300–$700/mo |
If the idle sweeper is disabled or set too high, costs can balloon — set a hard spending cap on your OpenAI account regardless.
Chained mode (default all-OpenAI stack)
Per-transmission cost (each PTT release):
- STT (Whisper-1): ~$0.0001 per 1-second transmission ($0.006/min)
- LLM (gpt-4o-mini, $0.15/$0.60 per 1M in/out): ~$0.0005 per transmission (~3K system+history tokens in, ~150 tokens out)
- TTS (tts-1, $15 per 1M chars): ~$0.003 per 200 characters of reply
Roughly $0.003–0.005 per officer-transmission. At typical RP load:
| Scenario | Est. monthly cost |
|---|---|
| 1 officer, 50 transmissions/day | ~$5/mo |
| 4 officers, 100 transmissions/day each | ~$50/mo |
| 8 officers heavy use, 200 transmissions/day each | ~$200/mo |
Cheaper than Realtime mode at every scale because you only pay for the seconds you actually transmit — no idle session overhead at all.
Built-in cost protections
- Idle sweeper (realtime only): realtime sessions with no PTT/transcript activity for
IdleTimeoutMinminutes are auto-closed. Default 10 min. Chained mode skips the sweeper since idle sessions cost nothing. - Per-officer alert rate limit: max 3 dispatch alerts per officer per 60s. Applies to both modes.
Recommended additional measures for production
- Set a hard monthly spending cap on your OpenAI account (Dashboard → Limits).
- Monitor the
ai_dispatchaudit category inoxide-loggerfor session count and unusual activity. - In realtime mode, lower
IdleTimeoutMin(e.g. to 5) if cost is a concern.
Configuration reference
Full config block — see shared/config/ai_dispatch.lua for the live defaults.
Config.AIDispatch = {
Enabled = true,
Provider = 'chained', -- global default; channels can override
IdleTimeoutMin = 10, -- realtime only; chained skips the sweeper
RateLimit = {
alerts = 3, -- max alerts/officer/window
windowSec = 60,
},
Channels = {
[911] = { job = 'police', label = 'Police Dispatch (AI)', voice = 'ash' },
-- [912] = { job = 'bcso', label = 'BCSO Dispatch (AI)', voice = 'echo' },
-- [913] = { job = 'sasp', label = 'SASP Dispatch (AI)', voice = 'ash' },
[921] = { job = 'ambulance', label = 'EMS Dispatch (AI)', voice = 'shimmer' },
[931] = { job = 'fire', label = 'Fire Dispatch (AI)', voice = 'sage' },
},
Access = {
IdentifierWhitelist = {}, -- global fallback; channels can override
},
Integrations = {
Mdt = { -- MDT backends powering the 8 MDT-flavored AI tools
{ name = 'oxide-police', enabled = true },
{ name = 'ps-mdt', enabled = true },
{ name = 'wasabi_mdt', enabled = true },
{ name = 'lb-tablet', enabled = true },
},
},
Realtime = {
Model = 'gpt-realtime',
Voice = 'alloy', -- default if channel.voice unset
},
Chained = {
STT = { Provider = 'openai', Model = 'whisper-1' },
LLM = {
Provider = 'openai',
Model = 'gpt-4o-mini',
MaxHistoryTurns = 20,
MaxToolCascade = 4,
},
TTS = {
Provider = 'openai',
Model = 'tts-1',
Voice = 'onyx', -- default if channel.voice unset
},
},
RadioFX = { -- radio-static filter on the AI's voice
Enabled = true,
BandpassLow = 400, -- Hz; with BandpassHigh, narrows the voice like a real radio
BandpassHigh = 3400,
Distortion = 0.04, -- soft-clip amount (0 = clean)
NoiseFloor = 0.008, -- background hiss level (0 = off)
NoiseLow = 1000, -- bandpass on the noise bed
NoiseHigh = 4500,
ChainedGain = 1.85, -- per-mode output gain on the AI voice
RealtimeGain = 1.85,
},
}The dispatcher's system prompt lives in shared/config/ai_dispatch_prompt.lua.
Acknowledgement clips (Config.AIDispatch.Acks in shared/config/ai_dispatch_acks.lua): in chained mode, a tool call can take a few seconds, so the moment the AI decides to run a tool, a short pre-rendered phrase ("Running that plate.", "Stand by, plate check.") plays immediately to fill the silence. Clips are rendered once per voice on server startup and cached on disk under cache/acks/<voice>/; edit the phrase lists freely or set Acks.Enabled = false to turn the feature off.
Access control
By default, the server enforces a job gate: a player's framework job must equal the channel's bound job, or belong to the same responder group (e.g. any job listed in Config.ResponderGroups.police.jobs can use a police channel). Players who don't match are denied — they get an access-denied notify, the session never opens (logged as access_denied with reason job_mismatch), and the channel stays a normal pma-voice channel for them. To replace that job gate with an explicit allowlist — e.g. only trusted testers during a soft launch — populate Config.AIDispatch.Access.IdentifierWhitelist in shared/config/ai_dispatch.lua:
Access = {
IdentifierWhitelist = {
'license:11aabb22ccddeeff...',
'discord:1234567890',
},
},Accepts any FiveM identifier prefix (license:, license2:, steam:, discord:, fivem:, live:). A player matches if ANY of their identifiers appears in the list. When a whitelist is configured (per-channel or global), it REPLACES the default job gate: only listed identifiers can open a session, even if their job qualifies. When the list is empty, the default job gate applies. Either way, denied players get a notify and the session never opens, even if they have radio channel access.
Adding a new chained-mode provider
The Node.js sidecar lives in a single file at server/ai_dispatch_node/index.js (single-file because FiveM's Node runtime virtualizes module paths and trips on cross-file relative requires). To add Groq's free-tier Whisper as an STT option:
npm install groq-sdkin the resource directory- In
index.js, define anasync function sttGroq (cfg) { ... }alongsidesttOpenAI, returning the same{ ok, transcript }/{ ok:false, error, detail }shape - Register it:
sttAdapters.groq = sttGroq - Set
Config.AIDispatch.Chained.STT.Provider = 'groq'and addset oxide:ai-dispatch:groq_key gsk-...toserver.cfg(set, notsetr— same key-safety rule as the OpenAI convar)
The Lua orchestrator passes cfg.provider through unchanged — no Lua changes required to swap vendors.
Capabilities (what the AI can do)
Every AI dispatch channel gets the core 9 tools. Police-flavored channels (any job listed in Config.ResponderGroups.police.jobs — lspd, bcso, sasp, sheriff, statetrooper, etc.) get an additional 8 MDT-backed tools when at least one MDT backend in Integrations.Mdt is enabled and running (oxide-police, ps-mdt, wasabi_mdt, lb-tablet, or any custom bridge — see the MDT Bridge Development guide). EMS / fire / custom-job channels get only the core 9.
Always available on every channel (9):
create_dispatch_alert/cancel_dispatch_alert/update_dispatch_alertattach_responder/detach_responder/set_responder_statusset_officer_status— enum + natural-language hints adapt to the channel's job (a police channel uses available/busy/code4/10-7; EMS uses available/oncall/enroute/onscene/transport/10-7; fire uses available/enroute/onscene/returning/10-7). The status ids come straight fromConfig.ResponderStatusesfor the channel's group, so radio shorthand like "10-8" / "in service" is understood as a natural-language hint and mapped onto theavailableidtrigger_paniclist_active_alerts— filtered to alerts whosejobslist includes this channel's job
Police-flavored channels only, requires an active MDT backend (8):
lookup_plate— MDT-backed (returns warrants + BOLO inline)lookup_person— MDT-backed (returns warrant count inline)get_person_profile— slim dossier (records, warrants, vehicles, custody)create_bolo/list_active_bolos/clear_bololist_on_duty_officerscheck_custody
When a channel's job isn't in Config.ResponderGroups.police.jobs, or no MDT backend in Integrations.Mdt is currently active (all disabled, or all named resources stopped), the 8 MDT tools are stripped from that channel's schema — the model literally never sees them and won't try to call them.
Cross-officer awareness
The system shares context between sessions in two ways:
- DISPATCH UPDATE notes: when any officer files or cancels an alert, every dispatcher whose channel's job is in the alert's
jobsrecipient list receives a system note. An alert filed withjobs = {'lspd', 'bcso'}shows up on BOTH LSPD and BCSO dispatcher channels; one filed withjobs = {'lspd'}only reaches LSPD dispatch. - RADIO transcripts: every transmission's transcribed words — both the officer's and the AI's reply — are pushed to other sessions on the same channel (same job) as RADIO notes.
This means LSPD Officer B can ask "BOLO on that vehicle" after LSPD Officer A ran a plate, and B's AI will know what "that vehicle" refers to. But a BCSO deputy on the BCSO channel doesn't get LSPD's RADIO chatter — only the DISPATCH UPDATE if an alert explicitly addressed BCSO.
Each session is its own independent OpenAI session — they're not literally sharing one AI — but the shared system notes give the illusion of a coordinated dispatcher tracking each channel.
TTS audio is also scoped by job: when one LSPD dispatcher replies, every other LSPD officer hears the audio rebroadcast. BCSO listeners on a different channel do not. (Rebroadcast is keyed to the channel's job, not the channel number — identical to channel scope in the default one-channel-per-job setup; the only way they differ is if you bind two channel numbers to the same job, in which case both channels share the audio.)
Audit logging
All AI dispatch activity is logged through olink.logger under the oxide-dispatch resource, ai_dispatch category. Useful audit events:
channel_joined,channel_left,session_dropped,session_idle_closed,access_deniedtoken_mintedalert_created,alert_cancelled,alert_updatedresponder_attached,responder_detached,responder_status_setofficer_status_setpanic_triggeredplate_lookup,person_lookup,person_profile,custody_checkbolo_created,bolo_cleared,bolo_listed,on_duty_listedrebroadcast_stream_started,rebroadcast_stream_endedcontext_broadcast_pushed
Set oxide-logger to Debug level temporarily (type oxlog level oxide-dispatch debug in the server console) to capture per-chunk WebRTC telemetry and individual tool-call traces.
Troubleshooting
Disabling the feature
Set Config.AIDispatch.Enabled = false in the config and restart. No officer will be able to join the AI session even if they're on the radio channel. The radio channel itself remains functional for normal pma-voice radio chatter.
To remove the feature entirely without uninstalling oxide-dispatch:
Set Config.AIDispatch.Enabled = false
Remove the radio channel entry from your radio UI (oxide-police admin panel or whichever resource provisioned it)
Clear the API key convar (optional but recommended)