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

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-dispatch v1.0+ installed and running
  • pma-voice — required. AI dispatch detects channel-join via pma-voice's universal setPlayerRadio event, so any radio UI that calls exports['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

  1. Sign up at platform.openai.com (or log in if you already have an account).
  2. 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.
  3. 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.
  4. 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.
  5. (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

  1. 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, never setrsetr replicates the value to every connected player. With set the convar is server-only and the key is never sent to clients.

  2. Provision the AI dispatch radio channels. Each channel listed in Config.AIDispatch.Channels is 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 any N listed in Channels.

    The defaults ship with three channels enabled: [911] = police, [921] = EMS (ambulance), [931] = Fire. Uncomment the BCSO / SASP entries (or add custom ones) in shared/config/ai_dispatch.lua to bring up additional departments. Make sure each entry's job field matches a job that exists in your framework AND is listed in Config.ResponderGroups.

  3. (Optional) Adjust configuration in oxide-dispatch/shared/config/ai_dispatch.lua:

    • Config.AIDispatch.Enabled — master switch
    • Config.AIDispatch.Provider — global default ('realtime' or 'chained', default chained); each channel can override
    • Config.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 fallback
    • Config.AIDispatch.Integrations.Mdt — priority list of MDT backends powering the 8 MDT-flavored AI tools. Ships with oxide-police, ps-mdt, wasabi_mdt, and lb-tablet; write your own per the MDT Bridge Development guide. Set an entry's enabled = false to disable it.
    • Config.AIDispatch.Realtime.Model / .Voice — Realtime model + default voice
    • Config.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 label
    • voice — override default voice for this channel
    • provider — override 'realtime' / 'chained' for this channel
    • whitelist — per-channel identifier allowlist (takes precedence over the global)
  4. Restart oxide-dispatch (and oxide-police if installed).

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

AspectRealtimeChained
Latency (PTT release → first word)~500 ms~1.5–2.5 s
Cost shapeMetered per audio token (active talk only)Metered per transmission
Vendor surfaceOpenAI onlyOpenAI by default; per-stage pluggable
Mic→speaker qualityExcellent (single-pass)Very good (lossy STT/TTS round-trip)
Tool calling9 tools on any channel, +8 MDT tools on police-flavored channelsSame set
Cross-officer RADIO historyYesYes
Conversation memoryIn-session (held by OpenAI)In-session (held server-side)
Setup complexityOne API keyOne API key
Tier requirementAny 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:

ScenarioEst. 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:

ScenarioEst. 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 IdleTimeoutMin minutes 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.
  • Set a hard monthly spending cap on your OpenAI account (Dashboard → Limits).
  • Monitor the ai_dispatch audit category in oxide-logger for 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:

  1. npm install groq-sdk in the resource directory
  2. In index.js, define an async function sttGroq (cfg) { ... } alongside sttOpenAI, returning the same { ok, transcript } / { ok:false, error, detail } shape
  3. Register it: sttAdapters.groq = sttGroq
  4. Set Config.AIDispatch.Chained.STT.Provider = 'groq' and add set oxide:ai-dispatch:groq_key gsk-... to server.cfg (set, not setr — 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_alert
  • attach_responder / detach_responder / set_responder_status
  • set_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 from Config.ResponderStatuses for the channel's group, so radio shorthand like "10-8" / "in service" is understood as a natural-language hint and mapped onto the available id
  • trigger_panic
  • list_active_alerts — filtered to alerts whose jobs list 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_bolo
  • list_on_duty_officers
  • check_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:

  1. DISPATCH UPDATE notes: when any officer files or cancels an alert, every dispatcher whose channel's job is in the alert's jobs recipient list receives a system note. An alert filed with jobs = {'lspd', 'bcso'} shows up on BOTH LSPD and BCSO dispatcher channels; one filed with jobs = {'lspd'} only reaches LSPD dispatch.
  2. 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_denied
  • token_minted
  • alert_created, alert_cancelled, alert_updated
  • responder_attached, responder_detached, responder_status_set
  • officer_status_set
  • panic_triggered
  • plate_lookup, person_lookup, person_profile, custody_check
  • bolo_created, bolo_cleared, bolo_listed, on_duty_listed
  • rebroadcast_stream_started, rebroadcast_stream_ended
  • context_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)

Next Steps