Skip to content

Menu Consumers

Build a standalone resource on tLib's shared menu: ownership, the lifecycle adapter, host slots, and where a page may sit.

A consumer declares pages on the server and handles rows on the client. The screen, the keyboard hints and the breadcrumb are tLib's; the page, its rows and what they do are yours.

OwnerOwns
tLibSchema, rendering, navigation, authority, dispatch, state/effects, the shared adapters.
ConsumerFeature behaviour, menu declarations, configuration, commands, its own state.
Optional hostPublic slots, placement policy, allowlists, ordering. Hosting does not make it the engine.

ConfigureMenuInterface is shared player-UI policy, not plugin branding; use namespace or page themes for a consumer's visual identity.

A minimal consumer

lua
fx_version 'cerulean'
game 'gta5'
dependency 'tLib'
tlib_min_version '1.0'
shared_script '@tLib/imports.lua'
server_script 'server.lua'
client_script 'client.lua'
lua
local owner, menu = GetCurrentResourceName(), exports['tLib']
local lifecycle = tlib.lifecycle
assert(type(lifecycle) == 'table' and lifecycle.version == 1, 'Update tLib: lifecycle API v1 required')
lifecycle.watch({ resource = 'tLib', install = function(ctx)
    ctx.once('namespace', function()
        return menu:DefineMenuNamespace({ ace = 'mytools.use' })
    end)
    ctx.once('page', function()
        return menu:RegisterMenu({
            id = owner .. ':tools', title = 'Tools', parent = 'main',
            items = {{ id = owner .. ':hello', label = 'Hello', kind = 'action' }},
        }, {
            ace = 'mytools.use', rootVisible = true,
            actions = { [owner .. ':hello'] = { ace = 'mytools.use', clientAction = 'hello' } },
        })
    end)
end })
lua
local owner, menu = GetCurrentResourceName(), exports['tLib']
local lifecycle = tlib.lifecycle
assert(type(lifecycle) == 'table' and lifecycle.version == 1, 'Update tLib: lifecycle API v1 required')
local registration = lifecycle.watch({ resource = 'tLib', install = function(ctx)
    ctx.once('actions', function()
        return menu:RegisterMenuActions({ hello = function()
            menu:ShowToast('hello', 'info', 'Tools', 'Hello!')
        end })
    end)
end })
RegisterCommand('mytools', function()
    if not registration.ready() then return end
    local ok, reason = menu:OpenMenu(owner .. ':tools')
    if not ok then print(reason) end
end, false)

Grant mytools.use to the group that should have it. The server still checks the namespace, page and action ACE. Decode data into locals before passing it as an export's last argument: a multi-return Lua function can fill the optional declaredOwner parameter.

Shared lifecycle adapter (API v1)

tlib.lifecycle.watch({ resource, install, cleanup?, readyEvent? }) runs in the consumer VM, so its subscriptions survive a dependency restart.

ReturnsDescription
ready()Registration completed for the current dependency generation.
retry()Request a new bounded attempt, if not already busy or ready.
invalidate()Cancel pending work, clear step checkpoints, invoke cleanup.
dispose()Invalidate and release event subscriptions. Idempotent.

It retries up to 300 times 100 ms apart and reports the real last error. A supplied readyEvent must carry the dependency's resource name as its first argument; unrelated events are ignored.

install(ctx) should throw on failure. ctx.once(name, fn) requires fn to return true (or false, reason to fail) and skips successful steps on later attempts in the same generation — one step per non-idempotent registration. All steps reset on dependency stop, and duplicate start/readiness events do not reinstall. ctx.isActive() lets yielding setup code check its lifetime.

Avoid native gameplay effects in registration; use authorized actions and SetMenuEffect for ongoing behaviour and revocation cleanup. An exhausted registration can retry on a later readiness event or an explicit retry().

Assert the import version — lifecycle.version, menuhost.version — not the resource's fxmanifest.lua version.

Optional host (API v1)

Any server resource can expose navigation slots:

lua
local host = tlib.menuhost
assert(type(host) == 'table' and host.version == 1, 'Update tLib: menu host API v1 required')
host.install({
    ['tools.extensions'] = {
        menu = GetCurrentResourceName() .. ':tools',
        enabled = true,
        -- resources = { somePlugin = true },
        -- order = { ['somePlugin:entry'] = 10 },
    },
})

Install once, after declaring the host's pages. It exposes the host-scoped server exports RegisterPluginMenu({ id, slot, menu, label, description? }) and UnregisterPluginMenu(id). Readiness is tlib:menu:hostReady(hostResource).

A plugin watches its configured host with the lifecycle adapter and calls RegisterPluginMenu in a checkpointed step after registering its own target page. The same owner and id replaces an existing link, so retrying is safe, and only the calling resource's namespace may be targeted. Host policy controls allowed callers and ordering; player permissions are checked independently by tLib.

The host adapter composes slots sharing a page, so do not also write that page's dynamic link collection with SetMenuLinks. When the engine restarts, consumers recreate their own pages and host links: invalidate the host-link watcher when engine registrations are lost, then retry it after the page has registered.

Placement is not permission ancestry

RegisterMenu(page, { ace, actions, rootVisible = false }) suppresses only the automatic shared-root link. It does not hide the page from authorized direct access, host links or search, and it changes no permission. Omit it or use true for a root entry.

Keep an independently usable page parented to main. Parenting it to a host page also makes that host's ancestor permissions mandatory, including for direct command access.

The shared "Send feedback" row

tLib adds one row of its own to every namespace's entry page — the page whose parent is main or absent. You do not register it and cannot.

The dialog sends the player's message to TIMMYG Studios with the resource name, its manifest version, and tLib's random per-install id. Nothing else — no player name, licence, Steam or Discord id, no server name, no IP.

  • It does not need telemetry consent and is unaffected by tlib_telemetry. See Convars.
  • It is not permission-gated, and no ACE hides it.

It appears only on an entry page, is hidden while the player is filtering, and a page with no owner: prefix — the shared main root — gets no row.

Verifying a consumer

Test in game against a tLib restart, a permission revocation, a direct OpenMenu on a root-hidden page, and a host that starts after you. Refresh resource discovery before a reload — source edits alone do not update a running resource.

Still stuck? Support can answer it directly.

Was this page helpful?