lumo-inventory — integration guide
Everything below is live in this build. Keys referenced here are defined in config/config.lua. Exports work the same on QBCore, Qbox, ESX and standalone.
Item exports (server)
The qb-inventory export set, plus a few additions. source is a player id; stash ids work where noted.
| Export | What |
|---|---|
AddItem(source, item, amount, slot?, info?, reason?) | Add; false when it does not fit |
AddItemOrDrop(source, item, amount, slot?, info?, reason?) | Add, or drop at the player's feet when full |
RemoveItem(source, item, amount, slot?, reason?) | Remove |
HasItem(source, item or {items}, amount?) | Has it / all of them |
GetItemCount(source, item or {items}) | Total amount |
GetItemByName(source, item), GetItemsByName(source, item), GetItemBySlot(source, slot) | Look up |
CanAddItem(identifier, item, amount) | Fits by weight and slots? (player or stash) |
SetItemData(source, item, key, value, slot?) | Change one metadata field |
ClearInventory(source, keep?), SetInventory(source, items) | Bulk |
OpenInventory(source, stashId, {label, maxweight, slots}) | Open a stash |
CreateShop({name, label, coords, items, groups?}), OpenShop(source, name) | Shops (rules below) |
CreateUsableItem(item, function(source, item) … end) | What using an item does (any framework) |
RefreshItems() | Re-read the item list (ESX: after adding items to the items table) |
UseItem(source, item) | Use an item as if from the hotbar |
Shop rules are checked when the shop opens (groups) and again on every purchase (the item fields), on every framework:
| Field | On | Meaning |
|---|---|---|
groups | shop | Only these jobs or gangs may open it: 'police', { 'police', 'sheriff' } or { police = 2 } (minimum grade) |
requiredJob, requiredGang | item | Same formats, job only / gang only |
requiredGroups | item | Job or gang, same formats |
requiredGrade | item | Minimum job grade |
requiredDuty | item | Must be on duty |
requiredLicense | item | A licence name, or a list where any one is enough. Read from metadata.licences (QBCore, Qbox, standalone) or esx_license's user_licenses (ESX, where any drive* licence counts as driver) |
On ESX, scripts can also keep using xPlayer.addInventoryItem, removeInventoryItem, getInventoryItem, canCarryItem, addWeapon … — they are routed here (see INSTALLATION.md → ESX).
Experience / progression
XP lives on the character's metadata (metadata.experience): QBCore/Qbox players.metadata, ESX users.metadata, standalone the character (or licence). It saves with the character; no extra table. Categories are scored as the sum of their tracks, so levelling gunsmithing also advances trades. One-time milestone claims are stored in metadata.experience.claimed.
On first login after the standalone reputation system is retired, existing player_reputation points are imported once into matching Experience tracks. The marker is stored in metadata.experience.migrations, and the old SQL rows are retained as a rollback backup.
Awarding XP from another resource
Server side (preferred):
lua
exports['lumo-inventory']:AddExperience(src, 'gunsmithing', 25, 'weapon bench')Returns the amount actually granted after the per-track daily cap (Config.Experience.dailyCap, 2500/day by default). Returns 0 if the track is unknown, XP is disabled, or the player has hit the cap.
Or, if you cannot call an export from where you are:
lua
TriggerEvent('qb-inventory:server:AddExperience', src, 'gunsmithing', 25, 'weapon bench')That event refuses anything arriving from a client, so it is server-only.
Reading progression
lua
exports['lumo-inventory']:GetExperience(src, 'gunsmithing') -- lifetime XP on a track
exports['lumo-inventory']:GetCategoryExperience(src, 'trades') -- summed across its tracks
exports['lumo-inventory']:GetExperienceLevel(src, 'gunsmithing') -- current level (track or category)
exports['lumo-inventory']:GetExperiencePerk(src, 'crafting', 'craftSpeed')GetExperiencePerk returns the best unlocked value for that effect (0 if none), which is how the crafting bench shortens its own timers. Gate content with it:
lua
if exports['lumo-inventory']:GetExperienceLevel(src, 'mechanic') < 5 then
return QBCore.Functions.Notify(src, 'You are not skilled enough for this repair', 'error')
endAwarding XP with no code changes at all
Config.Experience.itemXp maps an item name to a track. lumo-inventory sees every item that enters a player's inventory, so adding an entry there gives XP for gathering and loot without touching the resource that grants the item. reasonXp takes priority when the producing resource needs an exact discipline:
lua
itemXp = {
metalscrap = { track = 'recycling', xp = 1 },
goldbar = { track = 'heists', xp = 30 },
},
reasonXp = {
{ prefix = 'qb-bankrobbery:', track = 'heists', xp = 18 },
},Internal shuffles (moving, splitting, stacking, dropping, rollbacks) are excluded, so you cannot farm XP by dragging one item back and forth.
Tracks owned by another resource
Some disciplines already run their own progression. Listing one in Config.Experience.externalTracks shows it in the Experience tab without the inventory re-deciding its numbers:
lua
externalTracks = {
farming = { resource = 'lumo-farming', profile = 'GetFarmerProfile', ladder = 'GetExperienceLadder' },
},profile(src)returns{ xp = <lifetime XP> };ladder()returns{ levels = { { level, cumulative, title?, perk? }, ... } }.- Levels, XP-to-next and the level cap come from that ladder, so the tab always agrees with the owner's own menus. A category that is only one external track (Agriculture) shows that track's level, not a re-summed one.
- The total is mirrored into
metadata.experience.tracks, soGetExperienceandGetExperienceLevelkeep answering while the owner restarts; the last published ladder is cached for the same reason. AddExperiencerefuses these tracks (returns 0), no daily cap applies and no milestone rewards are offered — the owner already pays for the work. Award the XP in the owner, and fireqb-inventory:client:experienceGainedthere if you want the inventory's toast and rank-up sound.
Client side
lua
exports['lumo-inventory']:IsEquipped('phone') -- something in the PHONE bay?
exports['lumo-inventory']:IsEquipped('radio', 'radio') -- that specific item?
exports['lumo-inventory']:GetEquippedItem('armor') -- the item table, or nilEvents you can listen for:
lua
AddEventHandler('qb-inventory:client:onExperienceGained', function(payload)
-- payload = { track, label, amount, level, levelUp, reason }
end)
AddEventHandler('qb-inventory:client:equipSlotChanged', function(slotKey, item)
-- item is nil when the bay was emptied
end)Server side:
lua
AddEventHandler('qb-inventory:server:onLevelUp', function(src, trackKey, level) end)Equip bays
Bays are reserved slot numbers above Config.MaxSlots (see Config.SpecialSlots) — ordinary inventory slots, just outside the visible grid. Order is gadget1, phone, backpack, gadget2, armor, radio.
What a bay accepts is decided in nui/src/types.ts (SPECIAL_SLOTS), matched by item name, because essentially every QBCore item is declared type = 'item'.
- Phone / Radio — gate opening. qb-phone and qb-radio ask
IsEquippedand fall back to their old behaviour if lumo-inventory is not running. - Armor —
info.durabilityis the armour value. A vest carries 75 ped armour, heavy 100, scaled by condition. Damage is written back and persists; at 0 the plate is destroyed. - Backpack —
capacity/capacityWeighton the shared item become the nested grid.
Crafting
Recipes live in Config.Crafting.Recipes. Ingredients are taken up front and rolled back if any removal fails; the product is granted on a server timer. Crafting XP is awarded on completion, and the craftSpeed perk shortens the timer.
lua
{ key = 'lockpick', item = 'lockpick', amount = 1, category = 'Tools', seconds = 6,
ingredients = { { item = 'metalscrap', amount = 8 }, { item = 'plastic', amount = 3 } } },The portable field menu exposes simple recipes. Placed benches open specialist recipes. The Underground Weapons Bench uses gunsmithing XP for recipe locks; all other benches use crafting XP.
Weapon components and finishes
Open a weapon's context menu and choose Attachments. The workshop asks qb-weapons for that exact weapon's GTA component map, so the same inventory item works on any compatible pistol, SMG, shotgun, AR, MG or sniper rifle.
- Ammu-Nation (weapon licence): clip, flashlight, grip, small scope, luxury finish, and standard tint kits.
- Underground Weapons Bench: suppressor, drum, every optic (medium through thermal), muzzle brakes, MK II barrels and all MK II tint kits.
- Gunsmithing XP: earned by completing recipes at the Underground Weapons Bench. Higher-end optics, brakes, barrels and finishes unlock progressively.
Installed parts and tints are saved on the individual weapon item. Removing a part returns its item to the inventory; applying a finish consumes one matching tint kit. The server validates ownership, compatibility, weapon slot and serial for every install/remove request.
Full bags never swallow items
exports['lumo-inventory']:AddItem, the qb-inventory shim, Player.Functions.AddItem and the ox compat AddItem all go through AcquireItem: when a player's bag is full (weight or slots), the item is dropped at their feet as an ordinary ground drop that anyone can pick up, and the call still returns true. A player past Config.Overflow.maxDropsPerPlayer piles gets the item added to their nearest pile within mergeDistance (10 m). An explicit slot, a stash / trunk / drop target, and cash keep the strict result. Inside this resource, moves, splits, swaps, gives and rollbacks call the strict global AddItem, so a failed transfer still bounces back instead of landing on the floor. Ground drops are cleaned up after Config.CleanupDropTime minutes.
Big items are carried in the hands
Any item whose Config.DropSystem.Items entry has size = 'bulky' (TVs, microwaves, printers, crates...) is an ordinary inventory item. While a player has one anywhere in their inventory, their character carries it in both arms, and everyone nearby sees it. With several, the one in the lowest slot is shown. Carrying blocks sprint, jump, weapons, cover and entering vehicles. To free the hands, drop the item on the ground or move it into a trunk or stash through the inventory. Big items have no Hold / Throw / Place, and nothing else can be held while carrying one.
Other resources need nothing special: give the item with AddItem (a full bag drops it at the feet). To make a new item a carried one, add it to DropSystem.Items with a model and size = 'bulky'.
How it works: shared/carry.lua holds the rule. server/carry.lua publishes the carried item name on the player state bag lumoCarry, and client/carry.lua draws a local prop on every carrier and applies the pose and control limits to the local player.
Positioning a big item: /carrygrip. Carry the item and run /carrygrip. The item comes off your hand and you stand still. Place it with the mouse gizmo; the key hints show at the bottom centre:
| Key | Does |
|---|---|
| LMB | Drag the arrows (move) or the rings (rotate) |
| W / R | Move mode / rotate mode |
| Q | Toggle item axes / world axes |
| G | Free the mouse to look around, and back |
| H | Cycle which hand/chest bone it attaches to |
| Enter or E | Save |
| Backspace | Cancel |
On save the placement is measured against the bone and the item is re-attached there. The gizmo keys are the engine's +gizmo* bindings (from object_gizmo, or registered here if it is absent). The first save on a client also measures which rotation order AttachEntityToEntity uses, and remembers it in that client's KVP.
Saving (needs the command ace or lumo.carrygrip) applies to that item for every player at once and is kept in the resource KVP across restarts. The config line is also printed to F8. /carrygrip reset puts the item back on the default two-hand box grip (rpemotes' box: left hand 60309, 0.025, 0.08, 0.255, -145, 290, 0). Priority: saved grip, then the item's carry config, then the box grip.
Admin commands
| Command | Purpose |
|---|---|
/testkit | Fills every equip bay plus loose gear for testing |
/givexp [id] [track] [amount] | Grant XP |
/dropdebug | Lists live drops with distance and lock state |
Database
Tables are created on first start (see INSTALLATION.md). On QBCore, migrations/2026-08-23-experience.sql optionally widens players.metadata from TEXT to LONGTEXT: protective only, since every resource calling SetMetaData shares that 64 KB budget and an overflow corrupts the whole character row.