Skip to content

Bridge ​

The bridge is how every tm_* resource talks to a framework, inventory, target, notification, text UI or radial menu system without knowing which one is installed. Feature resources call tm.fw, tm.inventory, tm.target, tm.notify, tm.showTextUI and tm.radial and never a framework API directly.

lua
local job = tm.fw.getJob(source)          -- same table shape on ox_core, Qbox, QBCore and ESX
if tm.fw.hasGroup(source, { police = 2 }) then ... end
tm.inventory.addItem(source, 'water', 2)
tm.target.addSphere({ coords = coords, radius = 1.5, options = { ... } })

How it loads ​

  • tm_lib/data/config.lua detects what is installed once at boot (tm.getConfig() returns { framework, inventory, target, notify, textui, radial }).
  • tm.fw, tm.inventory and tm.target are imports. The first access runs tm.bridge.load(kind) inside the calling resource, which loads bridge/<kind>/<name>/<side>.lua, checks it against the contract below and logs every missing function once. A missing optional function is nil, so check before calling an optional one.
  • Adapters live in tm_lib/bridge/framework/<name>/, bridge/inventory/<name>/ and bridge/target/<name>/, one server.lua and/or client.lua each. Client files must match the bridge/**/client.lua entry in the manifest files list.
  • tm_lib itself loads each adapter once and re-emits the framework's native events as normalized events (below), so consumers never listen to framework events.

Rules every adapter follows ​

  1. Same arguments, same return shape on every framework. Normalize inside the adapter.
  2. Never throw. Wrap framework calls in pcall where they can fail. On failure return nil, 'reason' (or false, 'reason' for actions). A framework that cannot do something returns nil, 'unsupported' and says so in a comment at the top of its file.
  3. Never call a framework function that blocks (a database call, a callback round trip) from a function documented as cheap. Anything that hits the database says so in its doc line and is only allowed where listed.
  4. Framework tables are only read inside an adapter. If a feature needs players or player_vehicles data it goes through a contract function (for example getVehicleOwner), implemented per framework.
  5. No globals, and no UI calls except through tm. Use tm.log for logging and lib.require/require only if needed.
  6. Detect, do not assume. Framework exports must be fetched lazily (the framework may start after tm_lib) and re-fetched after a restart of the framework resource. Cache a handle, but re-resolve it in AddEventHandler('onResourceStart', ...) for that resource. Soft dependencies check GetResourceState(name) == 'started' before calling.
  7. Cache. Anything called per frame or per request (identifier, job) is read from the framework's own cached player object, never rebuilt.

Vocabulary (normalized) ​

TermMeaning
sourceServer id (number).
identifierThe active character id as a string: ox_core charId, Qbox/QBCore citizenid, ESX identifier. Stable across sessions.
licenseThe Rockstar license identifier string (license:...) of the account, from the player identifiers.
gender'male', 'female' or 'other'. Map each framework's native values.
account'cash' and 'bank' are the two normalized accounts ('money' is accepted as an alias for 'cash'). Any other account name is passed through unchanged for that framework (for example 'black_money', 'crypto').
job{ name, label, grade, gradeName, gradeLabel, isBoss, onDuty }. grade is the framework's own number (so it can be passed back to setJob). name/label strings, isBoss/onDuty booleans (onDuty is true when the framework has no duty concept).
group filterA string ('police'), an array ({ 'police', 'sheriff' }) or a map of name to minimum grade ({ police = 2 }).

Framework contract ​

Server (tm.fw, file bridge/framework/<name>/server.lua) ​

Required:

FunctionReturnsNotes
getPlayers()source[]Loaded players only (a character is active).
isLoaded(source)booleanA character is active for this source.
getIdentifier(source)string?Character id. nil when not loaded.
getLicense(source)string?Account license.
getName(source)string?'First Last' of the active character.
getCharacter(source){ identifier, firstName, lastName, birthDate, gender, phone? }?birthDate as the framework stores it, phone only if the framework has one.
getSource(identifier)number?Online source for a character id.
getJob(source)job?See vocabulary.
setJob(source, name, grade)boolean, string?Grade is the framework's own number.
setDuty(source, onDuty)boolean, string?false, 'unsupported' where there is no duty.
getGroups(source)table<string, number>Every job, gang and group the character is in, name to grade. Empty table when none.
hasGroup(source, filter)string?, number?Name and grade of the first match, otherwise nil. Duty is not considered.
getMoney(source, account)number0 for an unknown account or player.
addMoney(source, account, amount, reason?)boolean, string?amount is a positive integer.
removeMoney(source, account, amount, reason?)boolean, string?Fails (returns false, 'insufficient') instead of going negative.
getMetadata(source, key)anyCharacter metadata (hunger, licenses, anything the framework stores).
setMetadata(source, key, value)boolean, string?
isAdmin(source)booleanStaff check the admin hub uses: the framework's admin permission or ace group.admin. Console (0) is not an admin here.

Optional (implement when the framework makes it possible, otherwise leave nil):

FunctionReturnsNotes
getVehicleOwner(plate){ identifier, name }?Reads the framework's vehicle table. May query the database.
getPlayerObject(source)anyThe framework's native player object, for escape hatches. Not for general use.
listen(emit)noneOnly called once by tm_lib itself. Subscribe to native events and call emit(eventName, ...) with the normalized names below.

Client (tm.fw, file bridge/framework/<name>/client.lua) ​

Required: isLoaded(), getIdentifier(), getName(), getGender(), getJob() (job), getGroups() (same shape as server), hasGroup(filter), isOnDuty(), isDead() (dead or in last stand, whichever the framework tracks). Optional: listen(emit) as above (client side).

Normalized events ​

Fired by tm_lib once, as plain local events on the side they happen. Listen with AddEventHandler:

EventServer argumentsClient arguments
tm_lib:fw:playerLoadedsourcenone
tm_lib:fw:playerUnloadedsource, identifiernone
tm_lib:fw:jobChangedsource, jobjob
tm_lib:fw:dutyChangedsource, onDutyonDuty

Behavior every adapter shares (agreed while writing the four adapters) ​

  • getJob() is nil only when no character is loaded. A character with no job has a real job named unemployed (ox_core builds one, because it has groups instead of jobs). Client isOnDuty() is false while no character is loaded.
  • getGroups and hasGroup include jobs, gangs and framework groups, but leave out placeholder entries (the "no gang" none of QBCore and Qbox) and the ESX permission group (that one is staff data, read through isAdmin).
  • removeMoney never goes negative: it returns false, 'insufficient'.
  • Money calls can yield. ox_core accounts query the database, and so does bank on some setups. Never call money functions per frame.
  • On QBCore and ESX the server adapters keep a per-source cache of identity, job and group reads. The framework's own events invalidate it, a short time limit is the safety net, and money and metadata are never cached. Still never call framework reads in a per-tick loop. Client reads are cached.
  • setMetadata can return true, 'volatile' (accepted but not saved, ox_core outside status keys) or false, 'unsupported_type' (ESX refuses booleans).
  • Duty mapping: QBCore and Qbox have a duty flag. ESX 1.16 sets duty through setJob. ox_core has no duty, so the active group stands in for it. Older ESX has none and setDuty returns false, 'unsupported'.
  • isBoss: QBCore and Qbox use the framework flag. ESX uses the grade named boss. ox_core uses an account role of manager or owner.
  • getVehicleOwner reads the framework's vehicle table through tm.db and may take a database round trip.
  • Client adapters keep their local mirror of the player current from module load, in every resource that loads them, because the mirror has to be live where it is read. listen(emit) (called once by tm_lib) only switches on the normalized events.

Inventory contract ​

Server (bridge/inventory/<name>/server.lua) ​

FunctionReturns
getItemCount(source, item, metadata?)number
hasItem(source, item, count?, metadata?)boolean
canCarry(source, item, count, metadata?)boolean
addItem(source, item, count, metadata?)boolean, string?
removeItem(source, item, count, metadata?)boolean, string?
getItems(source){ { name, label, count, slot, metadata }... }
getItemLabel(item)string?
getItemSlot(source, slot){ name, count, slot, metadata }?
setItemMetadata(source, slot, metadata)boolean
clearInventory(source)boolean
registerStash(id, label, slots, maxWeight, owner?)boolean
registerShop(id, shop)boolean (shop = { name, items = { { name, price, count? }... } })
openStash(source, id)boolean

Client (bridge/inventory/<name>/client.lua) ​

openPlayerInventory(), openStash(id), openShop(id), getImagePath() (URL prefix for item images, used by the UI kit), isBusy().

tm_lib/data/bridge.lua lists the required functions per kind and side. The contract check logs each one that is missing.

Inventory behavior every adapter shares ​

  • metadata arguments are partial matches (an item matches when it has at least those keys). setItemMetadata replaces the slot's metadata. An adapter whose inventory has no item metadata (the ESX native inventory) sets supportsMetadata = false and answers nil, 'unsupported' or false, 'unsupported', so check the field before relying on metadata.
  • registerStash(id, label, slots, maxWeight, owner?, groups?, coords?): owner is nil for a shared stash, true for one stash per player, or a string for a fixed owner. groups uses the group filter. coords limits opening to a place. registerShop(id, shop) accepts the same groups and coords inside shop. Stashes, shops and target registrations are remembered and registered again when the inventory or target resource restarts, so ensure order does not matter.
  • items filters (target options, shops) mean all of the listed items are required.
  • isBusy() is true while the inventory is open or busy.
  • Client openPlayerInventory, openStash and openShop return true, or false, reason when the inventory cannot do it (ESX native). On qb-inventory, which has no client open API, the client asks the same resource's server adapter, so a resource that opens stashes or shops from the client on qb-inventory must also touch tm.inventory on its server side. The call returns true once the request is sent.
  • Optional server function: openShop(source, id).
  • Capacity: canCarry is exact on ox_inventory and qb-inventory, computed from the limits in its config.lua on ps-inventory (optimistic true if they cannot be read), and optimistic on the framework-native QBCore-style inventories.
  • ps-inventory shares the qb-inventory adapter.

Target contract (bridge/target/<name>/client.lua) ​

Option table (normalized, translated by each adapter): { name, label, icon, distance, groups, items, canInteract(entity, distance, coords, name), onSelect(data), event, serverEvent }. groups uses the group filter, items is an item name or array.

FunctionReturns
addEntity(netIds, options) / addLocalEntity(entities, options)none
addModel(models, options)none
addSphere({ coords, radius, options, debug? })zone id
addBox({ coords, size, rotation, options, debug? })zone id
removeZone(id), removeEntity(netIds, names?), removeLocalEntity(entities, names?), removeModel(models, names?)none
disable(state)none, hides and ignores targets while true

target = 'none' loads a no-op adapter so resources never branch on it.

Target adapters may return true or false, reason from registration functions (zones return an id). qb-target has no onSelect, serverEvent or groups, so its adapter wraps them: groups is checked through tm.fw.hasGroup, canInteract coordinates are the zone center or the entity position (qb-target has no hit point), and a box rotation is passed as the PolyZone heading.

Notify and text UI contract (bridge/notify/<name>/client.lua, bridge/textui/<name>/client.lua) ​

These two differ from the others in three ways. They are client only, they are never auto detected (ox_lib is always installed, so detection would pick it on every server and the Twilight Moon interface would never show), and they run inside tm_lib only. A resource keeps calling tm.notify(...), tm.showTextUI(...) and friends, and tm_lib's dispatcher (resource/interface/client/notify.lua and textui.lua) normalizes the request and hands it to the adapter. The server sets setr tm:notify <name> and setr tm:textui <name> (default tm, the built in interface). An adapter that defines isAvailable() is skipped while it returns false, and the built in interface shows instead, so a stopped notify resource never swallows a message. A failing adapter call is logged, never thrown.

Adaptertm:notifytm:textui
tmbuilt in toasts (default)built in prompts (default)
ox_liblib.notifylib.showTextUI (one prompt at a time)
qbx_coreexports.qbx_core:Notifynone, Qbox uses ox_lib
qb-coreQBCore:Notify eventDrawText, ChangeText, HideText (one at a time)
es_extendedesx:showNotification eventESX.TextUI, ESX.HideUI (one at a time)
okokNotifyAlert exportnone
wasabi_notifynotify exportnone
noneshows nothingshows nothing

Not bridged yet: ps-ui (archived, merged into ps_lib, and it has no notify or text UI to call) and okokTextUI (no official documentation to write the adapter against). Both can be added as a custom adapter from the templates below.

Notify (show, dismiss, clear, all required) ​

FunctionNotes
show(item)item is { id, type, tone, message, caption, duration, position, icon, iconColor, avatar, color, progress, spinner, html, multiLine }. id is always set, message always a string. tone is type reduced to 'success', 'error', 'warning' or 'info', so an adapter for a system with fixed types maps tone, not type. duration is milliseconds, 0 keeps it until dismissed, nil means the system's default.
dismiss(id)Removes one toast early. Systems that cannot (every foreign adapter here) do nothing.
clear()Removes every toast. Same rule.

Text UI (show, hide required, multi and single flags) ​

FunctionNotes
show(item)item is { id, message, type, tone, icon, iconColor, avatar, color, position, html, multiLine }. The dispatcher already skips a call whose content did not change.
hide(id)Takes the prompt with this id off the screen.

An adapter whose system shows one prompt at a time sets single = true. The dispatcher then shows the newest prompt, and when it is hidden it shows the newest one still open instead of leaving the screen empty. isTextUIOpen is answered by the dispatcher from its own list, so it works the same on every adapter. Prompts opened by a resource are closed when that resource stops, whatever the adapter.

Writing your own notify or text UI adapter ​

Copy a template, edit the marked lines, and set the convar to custom (the radial menu has a template too, see the next section):

ForCopyToConvar
Notificationstemplates/bridge/notify/client.luabridge/notify/custom/client.luasetr tm:notify custom
Text UItemplates/bridge/textui/client.luabridge/textui/custom/client.luasetr tm:textui custom

Each template is a working adapter for an imaginary resource, with comments on every field the adapter receives. It includes the optional isAvailable(), so a stopped resource falls back to the built in UI. Restart tm_lib after changing it. If the custom file is missing, boot logs it and the built in toasts and prompts show instead. The full walkthrough, for every kind of adapter, is Writing your own bridge.

What a foreign system cannot do ​

Each adapter lists what it ignores at the top of its file. In short: ox_lib, qbx_core, qb-core and es_extended cannot remove a toast early (so ongoing toasts and duration = 0 fall back to the system's default duration), none of them render html, spinner or avatar, and the text UI of all three is one prompt at a time with fewer positions than the built in nine zones. The qb-core, ESX, ox_lib and wasabi_notify adapters are written against those systems' public docs, qbx_core's against its source, and okokNotify's against community guides only (it is a paid script with no public docs). None has been run in game yet.

Radial menu contract (bridge/radial/<name>/client.lua) ​

The radial (wheel) menu follows the notify and text UI rules: client only, never auto detected, and run inside tm_lib. A resource keeps calling tm.radial.addItem(...) and friends (see Radial menu), and tm_lib's dispatcher (resource/interface/client/radial.lua) keeps the model and hands it to the adapter. The server sets setr tm:radial <name> (default tm, the built in wheel). An adapter that defines isAvailable() is skipped while it returns false, and the built in wheel serves instead.

Adaptertm:radial
tmbuilt in Twilight Moon wheel (default, F1, rebindable)
ox_liblib.addRadialItem, lib.removeRadialItem, lib.registerRadial, lib.hideRadial, lib.disableRadial, lib.getCurrentRadialId
qb-radialmenuAddOption and RemoveOption exports
noneshows nothing

Functions ​

addItem, removeItem, registerMenu, close and disable are required. removeMenu, open and isOpen are optional.

FunctionNotes
addItem(item)Adds an item to the main wheel, or replaces the one with the same id.
removeItem(id)Removes it.
registerMenu(menu)menu is { id, title?, items }, items already sorted. Replaces a menu with the same id. The dispatcher registers a menu before the items that point at it.
removeMenu(id)Optional. Forgets a menu.
open(menuId?)Optional. Returns true, or false, reason. A system that only opens from its own key leaves it out or answers false, 'unsupported'.
close()Closes the wheel if it is open.
disable(state)While state is true the wheel must not open.
isOpen()Optional. Whether the wheel is on screen.
isAvailable()Optional, like notify.
resourceOptional field, the name of the resource that draws the wheel. tm_lib then notices when it stops or restarts (see below).
dynamicOptional flag, set only by the built in wheel: it evaluates canShow itself every time it opens.

item is { id, label, icon, order, menu, keepOpen, disabled, onSelect, canShow }, and menu is { id, title, items }. An adapter never sees a callback of another resource: onSelect is a closure that runs the dispatcher's own activate (the item's onSelect, event, serverEvent and command, each protected), and canShow is a closure that calls the resource's function protected. An item that opens a menu has menu set and no onSelect, the adapter opens that menu. icon is a string or an ox_lib style tuple, so map it or leave it out.

Behavior every adapter shares ​

  • The dispatcher keeps the model. It pushes every change to the active adapter, and when the active adapter changes it moves the whole model: menus first, then the items in display order (order, then the order they were added in), then the disabled flag, after closing and emptying the one it came from.
  • The configured system stopping, or being missing at boot, moves the model into the built in wheel. When the system starts again (or restarts, which wipes its own items) the model is moved back after a short delay. An adapter must set resource for a restart that is faster than that delay to be noticed. Nothing is sent to a system that was stopped.
  • Everything a resource added is removed from the active adapter when that resource stops, and everything tm_lib added is removed when tm_lib stops. Adapters never clear a system's whole wheel, so items other resources gave it directly stay.
  • canShow on systems that cannot hide an item per open: the item is shown, and picking it does nothing while canShow says no. disabled items on those systems do nothing either.
  • An adapter call that fails is logged, never thrown. false, reason from an adapter is logged at debug level only.
  • The Twilight Moon key (tm_radial) only acts while the built in wheel is the active one.

What each bundled adapter cannot do ​

ox_libqb-radialmenu
Open from Luano, open answers false, 'unsupported'no
Close from Luayes (lib.hideRadial)no
Disableyesno, disable has no effect
Remove a menuno, a registered menu stays (nothing points at it)menus are nested into the items that open them, so removing one republishes those items
isOpenonly true inside a submenuno
orderonly when the model is moved in, incremental changes are listed in the order they were addedignored
canShow, disabledchecked when the item is pickedchecked when the item is picked
IconsFont Awesome names onlyFont Awesome names only
Menu titlesnot shownnot shown

The ox_lib adapter is written against the public ox_lib docs. The qb-radialmenu adapter is written from how illenium-appearance and Qbox's qbx_radialmenu call it, because the copy installed on the QB test server has no files in it, so its own source could not be read: the two exports are certain, the nested items list used for submenus is written from its public config format and is the least certain part. Neither has been run in game.

Writing your own radial menu adapter ​

ForCopyToConvar
Radial menutemplates/bridge/radial/client.luabridge/radial/custom/client.luasetr tm:radial custom

The template is a working adapter for an imaginary resource and follows the same steps as the others in Writing your own bridge. If the custom file is missing, boot logs it and the built in wheel is used.