MDT Bridge Development

Write a custom MDT bridge so the AI dispatcher can run plate, person, BOLO, custody, and on-duty lookups against your server's own MDT.

The AI Radio Dispatcher lets officers tune to a department radio channel and talk to an AI dispatcher. Beyond filing alerts and attaching responders, the AI can also run MDT lookups — plate / person / profile / BOLO CRUD / on-duty roster / custody check.

Those last 8 tools need real data. A bridge is the adapter between the AI tool layer and your server's MDT — whether that's oxide-police, ps-mdt, or something custom.

TL;DR — Drop a Lua file in server/modules/ai_dispatch/mdt/, implement 10 methods, register it with MdtBackend.Register, add it to fxmanifest.lua and Config.AIDispatch.Integrations.Mdt, restart oxide-dispatch. Done.

Four reference bridges ship with oxide-dispatch and are intentionally left out of the escrow bundle so you can read them as working examples:

  • server/modules/ai_dispatch/mdt/oxide_police.lua — wraps oxide-police's server exports (the exports pattern)
  • server/modules/ai_dispatch/mdt/ps_mdt.lua — reads/writes ps-mdt's MySQL tables directly (the direct SQL pattern)
  • server/modules/ai_dispatch/mdt/wasabi_mdt.lua — wraps wasabi_mdt's server exports (exports pattern; registers as 'wasabi_mdt')
  • server/modules/ai_dispatch/mdt/lb_tablet.lua — direct SQL against lb-tablet's police tables (registers as 'lb-tablet')

The interface contract lives in server/modules/ai_dispatch/mdt/init.lua — the single source of truth.

When you need one

You need to write a bridge when:

  • Your server runs an MDT that isn't oxide-police, ps-mdt, wasabi_mdt, or lb-tablet
  • You've built a custom in-house MDT
  • You want to mix MDT data from multiple sources

When no Integrations.Mdt entry is enabled with its named resource started, the 8 MDT tools are stripped from the LLM's tool schema entirely — the AI never sees them and can't call them. Note the schema gate is Config.GetActiveMdtBackend(), which checks only config-enabled + resource state; it does not verify MdtBackend.Register was actually called. A started-but-unregistered backend (e.g. a name mismatch) still advertises the 8 tools, and every call then fails at runtime with mdt_unavailable. The remaining 9 core tools (alert CRUD, panic, status, list active) still work either way; the AI dispatcher just can't run MDT lookups.

Minimal example

This is the smallest legal bridge — enough to load, register, and stop the AI from advertising MDT tools when nothing else is available. All methods return safe defaults that mean "no data." Once it's loading clean, replace each method body with real lookups.

server/modules/ai_dispatch/mdt/my_mdt.lua
if GetResourceState('my-mdt') == 'missing' then return end

MdtBackend.Register('my-mdt', {
    IsPlayerOnDuty    = function(src)         return true              end,  -- defer to your auth elsewhere
    SearchVehicle     = function(src, plate)  return {}                end,  -- no matches
    SearchPerson      = function(src, query)  return {}                end,
    GetPersonProfile  = function(src, charId) return false             end,  -- no such person (nil means "lookup failed" for this method)
    CheckPlateForBolo = function(plate)       return nil               end,  -- no BOLO match
    GetActiveBolos    = function(src)         return {}                end,
    CreateBolo        = function(src, type, data) return false, 'not_implemented' end,
    ClearBolo         = function(src, boloId)     return false, 'not_implemented' end,
    GetOnDutyOfficers = function()            return {}                end,
    GetActiveCustody  = function(charId)      return nil               end,  -- not in custody
})

Add to fxmanifest.lua server_scripts and Config.AIDispatch.Integrations.Mdt. Restart oxide-dispatch. The AI will now offer the 8 MDT tools, but every call returns "no record." Fill in real implementations method-by-method from here.

The bridge interface

Every bridge implements 10 methods. See mdt/init.lua for the source of truth.

Query methods (return data or nil)

IsPlayerOnDuty(src)         -- boolean
SearchVehicle(src, plate)   -- table[]  : list of vehicle rows
SearchPerson(src, query)    -- table[]  : list of citizen rows
GetPersonProfile(src, cid)  -- table    : full dossier | false (no such person) | nil (lookup failed)
CheckPlateForBolo(plate)    -- table    : matching BOLO or nil
GetActiveBolos(src)         -- table[]  : active BOLO rows
GetOnDutyOfficers()         -- table    : source-keyed officer map
GetActiveCustody(cid)       -- table    : custody record or nil

Return nil (not {}) when the call could not complete (missing table, schema mismatch, exception). Return an empty table {} when the call completed but matched no rows. The AI tool layer distinguishes "lookup_failed" from "found: false" using exactly this signal.

GetPersonProfile exception. This method returns a single record, not a list, so its "no match" signal is different: return the dossier table when the person exists, false when there is no such person, and nil only when the lookup itself failed. Returning nil for "not found" makes the AI dispatcher announce a lookup failure instead of "no record on file". (alert_bridge.lua maps nillookup_failed and a non-table return → found: false for this method specifically.)

Mutation methods (return success tuple)

CreateBolo(src, type, data) -> (boolean ok, integer|string idOrErr)
ClearBolo(src, boloId)      -> (boolean ok, string|nil err)

type is 'person' or 'vehicle'. data is { title, description, plate?, charId? }plate is required for vehicle BOLOs, charId for person BOLOs.

Return shapes

These shapes list the fields that alert_bridge.lua actually reads off your bridge's results. Extra fields are ignored — feel free to include more if it makes your bridge code simpler, but only the listed fields are required.

SearchVehicle row (only rows[1] is read):

{
    plate        = 'ABC123',
    model        = 'sultan',
    vehicle_type = 'car',
    state        = 'active',   -- pass through whatever your MDT/framework reports
    char_id      = 'ABC12345',
    first_name   = 'John',
    last_name    = 'Doe',
}

SearchPerson row (up to 5 rows surfaced to the AI):

{
    char_id         = 'ABC12345',
    first_name      = 'John',
    last_name       = 'Doe',
    date_of_birth   = '1990-05-12',
    gender          = 0,
    state_id        = 'STATE_ID',
    job             = 'mechanic',
    active_warrants = 0,
    is_online       = true,
}

GetPersonProfile result:

{
    identity = {
        first_name    = 'John',
        last_name     = 'Doe',
        date_of_birth = '1990-05-12',
        gender        = 0,
        state_id      = 'STATE_ID',
    },
    vehicles = { { plate = 'ABC123', model = 'sultan' } },
    records  = {
        -- Each row needs ONE of: title, charge_title, or charge_id
        -- and (optionally) ONE of: created_at or date
        { title = 'Grand Theft Auto', created_at = '2026-05-15' },
    },
    warrants = { { id = 7, reason = '...', expires_at = '...' } },
    active_custody = nil,  -- shape below, or nil if not in custody
    is_online      = true,
}

CheckPlateForBolo result (nil if no match):

{ id = 17, title = 'Stolen sultan', description = 'Last seen heading north on Route 68' }

GetActiveBolos row:

{
    id           = 17,
    type         = 'vehicle',   -- or 'person'
    title        = 'Stolen sultan',
    description  = 'Last seen heading north on Route 68',
    plate        = 'ABC123',    -- vehicle BOLOs
    citizen_name = 'John Doe',  -- person BOLOs
    created_at   = '2026-05-20 14:32:00',
}

GetOnDutyOfficers map (keyed by serverId):

{
    [7] = {
        name       = 'John Doe',  -- or 'officer_name'
        callsign   = 'A-12',
        department = 'LSPD',      -- or 'department_name' or 'dept'
    },
}

GetActiveCustody record (nil = not in custody):

{
    remainingSeconds = 1200,    -- or 'remaining_seconds'
    sentenceSeconds  = 3600,    -- or 'sentence_seconds'; nil if not tracked
    stationId        = 'mrpd',  -- or 'station_id'; nil if not tracked
    officerName      = 'Smith', -- or 'officer_name'; nil if not tracked
}

Two reference patterns

Pattern A: Exports wrapper (oxide_police.lua)

When the target MDT already exposes server exports that match the interface, the bridge is a one-liner per method. The reference oxide_police.lua is ~30 lines total and the entire file is "delegate to exports['oxide-police']:Fn(...)."

Use this pattern when:

  • The MDT ships server exports for the operations you need
  • The export return shapes already match (or are close to) the interface above
  • You want minimal code in the bridge

Pattern B: Direct SQL (ps_mdt.lua)

When the target MDT only exposes NUI callbacks (meant for its in-game UI) or doesn't export anything useful, you read its database tables directly. The reference ps_mdt.lua queries mdt_profiles, mdt_bolos, mdt_reports_warrants, etc., and reshapes the rows.

Use this pattern when:

  • The MDT has a stable, documented SQL schema
  • Exports are missing, NUI-only, or auth-gated in a way that doesn't fit external callers
  • You need to JOIN MDT tables with framework tables (e.g., player_vehicles, players)

Writing your bridge — step by step

Create a new file

Create a new file in server/modules/ai_dispatch/mdt/. Name it after the resource it bridges (e.g., my_mdt.lua). The directory is already in escrow_ignore.

Guard against the resource being missing

The first line should bail out cleanly when your MDT isn't installed, so the file loads on every server without exploding:

if GetResourceState('your-mdt-name') == 'missing' then return end

Implement the 10 methods

Pick the pattern (exports / SQL / mixed) that fits your MDT. See the method-by-method guide below.

Register with MdtBackend

At the bottom of the file:

MdtBackend.Register('your-mdt-name', {
    IsPlayerOnDuty    = IsPlayerOnDuty,
    SearchVehicle     = SearchVehicle,
    SearchPerson      = SearchPerson,
    GetPersonProfile  = GetPersonProfile,
    CheckPlateForBolo = CheckPlateForBolo,
    CreateBolo        = CreateBolo,
    GetActiveBolos    = GetActiveBolos,
    ClearBolo         = ClearBolo,
    GetOnDutyOfficers = GetOnDutyOfficers,
    GetActiveCustody  = GetActiveCustody,
})

Three strings must match exactly (all case-sensitive): (1) the resource folder name on disk (the string ensure <name> uses), (2) the name passed to Register here, and (3) the name field in Config.AIDispatch.Integrations.Mdt. If any one differs, MdtBackend.Active() won't return your bridge.

Add the file to fxmanifest.lua

Under server_scripts, after mdt/init.lua and before alert_bridge.lua:

"server/modules/ai_dispatch/mdt/init.lua",
"server/modules/ai_dispatch/mdt/oxide_police.lua",
"server/modules/ai_dispatch/mdt/ps_mdt.lua",
"server/modules/ai_dispatch/mdt/wasabi_mdt.lua",
"server/modules/ai_dispatch/mdt/lb_tablet.lua",
"server/modules/ai_dispatch/mdt/your_mdt.lua",  -- add this line
"server/modules/ai_dispatch/alert_bridge.lua",

Add the bridge to the config

Add it to Config.AIDispatch.Integrations.Mdt in shared/config/ai_dispatch.lua:

Mdt = {
    { name = 'oxide-police', enabled = true },
    { name = 'ps-mdt',       enabled = true },
    { name = 'wasabi_mdt',   enabled = true },
    { name = 'lb-tablet',    enabled = true },
    { name = 'your-mdt',     enabled = true },  -- add this line
},

Entries are walked in order; first enabled + running wins. Reorder to change priority when multiple MDTs are installed simultaneously.

Restart oxide-dispatch

Watch the server console for mdt backend call failed warnings — those are pcall-trapped exceptions inside your bridge and indicate something is off.

Method-by-method implementation guide

IsPlayerOnDuty(src) → boolean

The session gate in alert_bridge.lua calls this before the 4 lookup tools (plate / person / profile / custody); BOLO create/list/clear and the on-duty roster skip the duty check. Most MDTs don't own duty — defer to the framework:

local function IsPlayerOnDuty(src)
    local data = olink.job.Get(src)
    return data ~= nil and data.onDuty == true
end

Only implement your own logic if your MDT genuinely owns the duty toggle (rare).

SearchVehicle(src, plate) → table[]

If your framework already has olink.vehicles.SearchByPlate, lean on it and reshape the rows to the interface fields (plate, model, vehicle_type, state, char_id, first_name, last_name).

alert_bridge calls CheckPlateForBolo separately after this method, so you don't need to attach BOLO data to the rows.

SearchPerson(src, query) → table[]

Similarly, olink.character.Search covers QB / QBX / ESX. Wrap it and merge in MDT-specific data (active warrant counts, online status):

local people = olink.character.Search(query, 10) or {}
local cids = {} ; for i, p in ipairs(people) do cids[i] = p.charId end

-- Batch all warrant counts in ONE query — see ps_mdt.lua:activeWarrantCounts
-- for the IN (...) pattern. Loop-SELECTs here will tank perf at scale.
local warrants = activeWarrantCounts(cids)

-- Then reshape each row: char_id, first_name, last_name, date_of_birth,
-- gender, state_id, job (string), active_warrants (from warrants[cid]),
-- is_online (look up via findSourceByCharId).

See ps_mdt.lua for the full implementation — activeWarrantCounts and findSourceByCharId are helpers you write inside your own bridge file. Both ~10 lines.

GetPersonProfile(src, charId) → table

Compose the dossier from multiple tables. Trim to ~10 of each list — alert_bridge.handle_get_person_profile further trims to 3 records / 3 warrants / 5 vehicles before sending to the AI, so emitting more is wasted bandwidth.

CheckPlateForBolo(plate) → table | nil

Simple SELECT ... WHERE type='vehicle' AND subject=? AND status='active' LIMIT 1. Return nil for no match, not {} — the caller does a truthiness check.

CreateBolo / GetActiveBolos / ClearBolo

Map your MDT's BOLO schema to the interface shape. If your MDT uses different type strings (e.g., 'citizen' instead of 'person'), do the translation in helper functions — keep aiTypeToYours / yoursToAi mappers at the top of the file.

For CreateBolo, resolve the suspect's actual name server-side (don't trust data.title for the citizen_name field) — the AI is allowed to confabulate, and you want a real identity attached:

local offline = olink.character.GetOffline(data.charId)
local subjectName = offline and (offline.firstName .. ' ' .. offline.lastName) or 'Unknown'

GetOnDutyOfficers() → table

Walk Config.ResponderGroups.police.jobs, collect on-duty sources via olink.job.GetPlayersWithJob, then JOIN your MDT's officer-profile table (callsign, badge, rank, department) in one IN (...) query.

GetActiveCustody(charId) → table | nil

Many MDTs record arrests historically but don't track "currently jailed." On QB and QBX servers, the convention is to write the remaining jail time (in minutes) to players.metadata.injail — this is what qb-policejob, qbx_police, and most jail/prison resources do. See ps_mdt.lua's GetActiveCustody for the pattern: read the metadata field for online players via olink.character.GetMetadata, and for offline players via a JSON_EXTRACT scalar query.

If your MDT tracks active custody itself, return that data. Otherwise the framework fallback above is fine; leave sentenceSeconds, stationId, and officerName as nil if you don't have them.

Return nil when the person is not in custody. The AI tool layer treats nil as "not in custody" and any table as "in custody."

Authentication and trust model

Bridges run server-side as trusted code. By the time a bridge method is invoked, the caller has already passed oxide-dispatch's own gates — an active AI radio session, plus on-duty status for the lookup methods (plate / person / profile / custody).

The src parameter is the officer's FiveM player source — use it for audit logging or per-officer data scoping. A couple of things worth knowing:

  • Re-running your MDT's own auth checks (like ps-mdt's CheckAuth) inside a bridge usually just duplicates the on-duty check that already passed. Skip them.
  • Most MDTs' NUI callbacks expect a player who has actively opened the MDT UI to register a session. The AI dispatcher hasn't done that, so calling those callbacks with the AI's src may not work — read the underlying data directly (SQL or non-NUI exports) instead.

Testing your bridge

  1. Did it load? Watch the server console at resource start. Your bridge file shouldn't raise any errors. To confirm MdtBackend.Register was called, add a temporary print('my-mdt bridge registered') line right after the Register({...}) call, restart, and look for it. Remove the print once verified.

  2. Is it the active backend? Temporarily add a Lua command at the bottom of your bridge file:

    RegisterCommand('mdtactive', function(src)
        print('active backend:', MdtBackend.Active())
    end, true)

    Run mdtactive on the server console. It should print your bridge's registered name. Disable your entry in Integrations.Mdt, restart — it should now print the next priority entry's name, or nil if nothing else is active. Remove the command before shipping.

  3. Schema gating. Join an AI dispatch police channel (e.g., 911) and ask the AI to run a plate. With at least one MDT active, the AI should attempt the lookup (you'll see the plate_lookup audit event). With every entry in Integrations.Mdt disabled, the AI should reply that it can't run MDT lookups — those tools are stripped from its schema entirely.

  4. Per-tool live calls. On a police AI channel, verbalize each of these and watch the audit log for matching events:

    Officer saysAI toolBackend methodAudit event
    "Run plate ABC123"lookup_plateSearchVehicle + CheckPlateForBoloplate_lookup
    "Any warrants on John Smith?"lookup_personSearchPersonperson_lookup
    "Pull full records on charId XYZ"get_person_profileGetPersonProfileperson_profile
    "Put out a BOLO on plate XYZ"create_boloCreateBolobolo_created
    "Any active BOLOs?"list_active_bolosGetActiveBolosbolo_listed
    "Clear BOLO 17"clear_boloClearBolobolo_cleared
    "Who's on shift?"list_on_duty_officersGetOnDutyOfficerson_duty_listed
    "Is charId XYZ in custody?"check_custodyGetActiveCustodycustody_check
  5. Audit log. Every tool call writes through olink.logger under resource oxide-dispatch, category ai_dispatch. Watch the log stream for the events above to confirm your bridge ran. Failed calls log a mdt backend call failed warning with backend=<your-name>, method=<…>, err=<…> — that's where pcall-trapped exceptions inside your bridge surface.

  6. Smoke-test isolation. Disable all other bridges in Integrations.Mdt, restart, and confirm your bridge alone backs every MDT call. This catches accidental dependencies where you assumed oxide-police or ps-mdt was filling in some field.

Caveats and gotchas

Return nil vs {}. The AI tool layer treats nil as "couldn't query" and {} as "matched nothing." Mixing these up produces misleading verbal replies ("I couldn't find that" vs "no matches").

  • Schema drift. If your bridge reads MDT tables directly (Pattern B), the MDT's column renames will silently break it. pcall every SQL call (the reference safeQuery helper in ps_mdt.lua shows the pattern) and log first failures so you notice quickly.
  • N+1 query traps. SearchPerson and GetOnDutyOfficers look up auxiliary data per row. Batch with IN (...) — never loop SELECTs inside an outer loop. Typical RP servers stay under 20 results, but the cost is invisible until it isn't.
  • Locale strings as errors. CreateBolo / ClearBolo should return short string error codes ('missing_plate', 'not_found') — the AI surfaces them verbally. Don't return full sentences.
  • BOLOs without parent reports. Some MDTs (like ps-mdt) link BOLOs to a parent report. If your bridge inserts BOLOs without creating a parent report, those BOLOs may render oddly in the MDT's UI. Accept this trade-off and document it, or implement parent-report creation as part of CreateBolo.
  • Multi-framework support. ps-mdt is QB/QBX only. If your MDT supports multiple frameworks, gate per-framework SQL via olink.framework.GetName() at the top of methods that touch framework tables. To let MdtBackend fall through to the next priority entry on an unsupported framework, skip the MdtBackend.Register call entirely at file load — check the framework first and return before registering. Once registered, a bridge stays active even if its methods return nil; the resolver doesn't re-check.
  • Concurrent MDTs. Only the highest-priority running bridge is used per call — MdtBackend does NOT fan out. If multiple MDTs hold different BOLO sets, only the active one is queried. Pick a clear priority order in Integrations.Mdt.

Sharing your bridge

Wrote a bridge that other servers could use? Share it in our Discord — drop the bridge file in the support channel and we'll take a look. If it's solid, we'll ship it as a first-party option in the next release of oxide-dispatch.

Next Steps