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— wrapsoxide-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, orlb-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.
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 nilReturn 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 nil → lookup_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 endImplement 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
endOnly 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
srcmay not work — read the underlying data directly (SQL or non-NUI exports) instead.
Testing your bridge
-
Did it load? Watch the server console at resource start. Your bridge file shouldn't raise any errors. To confirm
MdtBackend.Registerwas called, add a temporaryprint('my-mdt bridge registered')line right after theRegister({...})call, restart, and look for it. Remove the print once verified. -
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
mdtactiveon the server console. It should print your bridge's registered name. Disable your entry inIntegrations.Mdt, restart — it should now print the next priority entry's name, ornilif nothing else is active. Remove the command before shipping. -
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_lookupaudit event). With every entry inIntegrations.Mdtdisabled, the AI should reply that it can't run MDT lookups — those tools are stripped from its schema entirely. -
Per-tool live calls. On a police AI channel, verbalize each of these and watch the audit log for matching events:
Officer says AI tool Backend method Audit 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 -
Audit log. Every tool call writes through
olink.loggerunder resourceoxide-dispatch, categoryai_dispatch. Watch the log stream for the events above to confirm your bridge ran. Failed calls log amdt backend call failedwarning withbackend=<your-name>,method=<…>,err=<…>— that's where pcall-trapped exceptions inside your bridge surface. -
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.
pcallevery SQL call (the referencesafeQueryhelper inps_mdt.luashows the pattern) and log first failures so you notice quickly. - N+1 query traps.
SearchPersonandGetOnDutyOfficerslook up auxiliary data per row. Batch withIN (...)— 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/ClearBoloshould 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 letMdtBackendfall through to the next priority entry on an unsupported framework, skip theMdtBackend.Registercall entirely at file load — check the framework first andreturnbefore 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 —
MdtBackenddoes NOT fan out. If multiple MDTs hold different BOLO sets, only the active one is queried. Pick a clear priority order inIntegrations.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.