API Reference

The olink.tablet API for oxide-tablet — server and client functions, reserved IDs, client events, hosted page messages, widget data shapes and integration examples.

This page is for developers who want their resource to appear on oxide-tablet: registering an app or a home-screen widget, opening the tablet, pushing widget data and sending notifications. Server owners don't need anything on this page.

Use o-link

The tablet's API is olink.tablet, provided by the o-link bridge. It's the only supported way to integrate. The client_exports and server_exports in oxide-tablet's fxmanifest.lua exist so o-link's adapter (o-link/modules/tablet/oxide-tablet/) can reach the tablet. Don't call them yourself: going through o-link keeps your resource working when the tablet is missing, stopped or restarting.

olink = exports['o-link']:olink()

Building your first app or widget? Follow Building Apps and Widgets. It walks through the web shim, the message protocol and a complete example. This page is the full reference behind that guide.

Integration Rules

Never depend on oxide-tablet

The tablet is optional. Don't list oxide-tablet in your fxmanifest.lua dependencies, and don't call exports['oxide-tablet']. Your resource must still work, with its own UI, when the tablet isn't installed.

Check availability the supported way

Pick one of these two approaches:

  1. Gate on the return value. Every function returns false (or nil) when the tablet can't do what you asked, whether it's missing, stopped, disabled, or refused the call. If olink.tablet.Open(APP_ID) returns false, open your own window instead.
  2. Check before tablet-only work, such as deciding whether to register at all:
local function isTabletReady()
    return GetResourceState('oxide-tablet') == 'started'
        and olink.tablet.GetResourceName() == 'oxide-tablet'
end

You need both checks:

  • GetResourceName() returns 'oxide-tablet' whenever o-link loaded the tablet adapter, even if the tablet has since been stopped.
  • GetResourceState alone doesn't tell you the adapter loaded. The adapter only loads if oxide-tablet was installed when o-link started. If the tablet was added later, olink.tablet stays on the stubs until o-link restarts.
  • Checking the resource state first also stops the stub from printing its one-time console warning.

Never gate on olink.supports('tablet') or olink.supports('tablet.RegisterApp'). o-link registers fallback stubs for the tablet namespace, so supports returns true even when the tablet isn't installed.

Register at your start and on the ready event

The tablet keeps its app and widget registry in memory on each client. Register in both places:

  • When your client starts. This works if the tablet is already running.
  • On olink:client:tablet:ready. The tablet fires this about one second after its client script starts. It covers the tablet starting after you, and a tablet restart, which empties the registry.

Registering the same id again from the same resource replaces the earlier definition, so doing both is safe. Track the result with the return value, and reset your "registered" flag on olink:client:tablet:ready and when onClientResourceStop fires for oxide-tablet.

Widget data, badges and notifications are cleared when the character unloads (olink:client:playerUnload). Registered apps and widgets stay. Push widget data again after the next olink:client:playerReady.

How apps and widgets disappear

  • Your resource stops. The tablet removes every app and widget whose resource field matches your resource name. If one of your apps is on screen, the tablet returns to Home (appClosed with reason unregistered) and clears that app's notifications.
  • You call UnregisterApp or UnregisterWidget. Same effect, immediately.
  • The tablet stops. Its registry is gone. If it was open, appClosed (reason closed) and closed fire. When it starts again, olink:client:tablet:ready fires and you register again.

Automatic removal depends on def.resource. Your call reaches the tablet through o-link, so the tablet can't see which resource called it. Always pass resource = GetCurrentResourceName().

A player's saved home-screen position for a removed app or widget is kept. When it registers again, it goes back to its old spot.

Visibility rules are not security

requires, the item allowlist and App Store installs only decide what the launcher, App Store, widgets and notifications show. Open(appId) bypasses all of them, and your hosted page calls your own resource's NUI callbacks. Check permissions on your server before you send data. A common pattern is to call your own server callback from olink:client:tablet:appOpened and close the app if it refuses.

Telling your own opens apart

olink:client:tablet:appOpened fires for launcher taps, notification taps, widget taps and your own Open calls. It can fire before Open returns. If you need to know who opened the app, set a flag before calling Open.

Reserved IDs

App IDs and widget IDs are separate namespaces. An ID that belongs to another resource can't be registered: the call returns false.

KindIDRegistered by the tablet
Appapp_storeThe App Store, while Config.Store.enabled is on
Apptablet_settingsThe Settings app, always
Apptablet_mapMaps, while Config.MapEnabled is on
Apptablet_demoDemo app, only while Config.Debug is on
WidgetcalendarCalendar, always
WidgetprofileID card, always
Widgettablet_demo_stat, tablet_demo_frameDemo widgets, only while Config.Debug is on

IDs must be 1 to 64 characters: letters, digits, _ and -.

Function Overview

The Stub default column is what the fallback stub returns when o-link loaded without the tablet adapter. Each stub function also prints a one-time [o-link] ... fallback defaults loaded for "tablet" warning. When the adapter is loaded but the tablet isn't started, the same values come back without a warning, except GetResourceName, which returns 'oxide-tablet'.

FunctionSideReturnsStub default
GetResourceName()Server + Clientstring'none'
Open(src, appId?)Serverbooleanfalse
Close(src)Serverbooleanfalse
Send(src, appId, message)Serverbooleanfalse
GetDevice(src)Servertable|nilnil
SetWidgetData(src, id, data)Serverbooleanfalse
Notify(src, def)Serverstring|falsefalse
DismissNotification(src, id)Serverbooleanfalse
ClearNotifications(src, appId?)Serverbooleanfalse
RegisterApp(def)Clientbooleanfalse
UnregisterApp(id)Clientbooleanfalse
Open(appId?)Clientbooleanfalse
Close()Clientbooleanfalse
CloseApp(appId)Clientbooleanfalse
IsOpen()Clientbooleanfalse
GetCurrentApp()Clientstring|nilnil
Send(appId, message)Clientbooleanfalse
SetBadge(appId, count)Clientbooleanfalse
GetDevice()Clienttable|nilnil
RegisterWidget(def)Clientbooleanfalse
UnregisterWidget(id)Clientbooleanfalse
SetWidgetData(id, data)Clientbooleanfalse
Notify(def)Clientstring|falsefalse
DismissNotification(id)Clientbooleanfalse
ClearNotifications(appId?)Clientbooleanfalse

Server Functions

The server functions relay a request to one player's client. A true result means the request was sent, not that the player's tablet acted on it. The client applies the same rules as the matching client function.

Every server function returns false (or nil) when src isn't a positive number.

GetResourceName

Name of the resource providing the namespace.

local provider = olink.tablet.GetResourceName()

Returns: string'oxide-tablet' when the adapter is loaded, 'none' from the stub.


Open

Opens a player's tablet, optionally straight into an app.

local sent = olink.tablet.Open(source, 'mechanic_orders')

Parameters:

NameTypeDescription
srcnumberPlayer server ID
appIdstring|nilApp to open. nil opens the home screen

Returns: booleantrue when relayed. false when appId isn't a string or nil, or the tablet isn't running.

An open through the API never needs a tablet item and never starts a device session. If the player already had the tablet out from an item, that device session continues and GetDevice(src) still returns it. See the client Open for when the client refuses.


Close

Puts a player's tablet away.

olink.tablet.Close(source)

Parameters:

NameTypeDescription
srcnumberPlayer server ID

Returns: booleantrue when relayed.


Send

Relays a message into a player's hosted app.

olink.tablet.Send(source, 'mechanic_orders', { action = 'setOrders', data = orders })

Parameters:

NameTypeDescription
srcnumberPlayer server ID
appIdstringYour app ID
messagetable{ action = string, data = any }, the same shape as SendNUIMessage

Returns: booleantrue when relayed. false when appId isn't a string or message isn't a table. The client drops the message unless the app is on screen. See the client Send.


GetDevice

The tablet item the player's open tablet was started from, resolved from the inventory on the server.

local device = olink.tablet.GetDevice(source)
if device then
    print(device.imei, device.name, device.item, device.slot)
end

Parameters:

NameTypeDescription
srcnumberPlayer server ID

Returns: table|nil

FieldTypeDescription
imeistring15-digit IMEI stamped in the item metadata
namestring|nilName the player gave the tablet, nil if never renamed
itemstringItem name, for example 'tablet'
slotnumberInventory slot the tablet was in when opened

Returns nil when the player has no device session:

  • the tablet was only ever opened by a resource (Open) rather than from an item or the key;
  • Config.RequireItem is off;
  • the inventory can't keep item metadata.

The device session ends when the client puts the tablet away, when the item leaves the player's inventory (checked every 5 seconds, which also closes the tablet), when the player drops, or when the character unloads. A keybind open that the client then refused, because the player was dead or downed, leaves one open until the item leaves their inventory, so a device can come back while nothing is on screen.

Key per-device state on this value. Never trust an IMEI sent by a client, including the tabletImei URL parameter.


SetWidgetData

Relays widget data to a player. Same effect as the client SetWidgetData.

olink.tablet.SetWidgetData(source, 'mechanic_open_orders', { value = '4', label = 'Open orders' })

Parameters:

NameTypeDescription
srcnumberPlayer server ID
idstringYour widget ID
datatable|nilWidget data (see Widget Data Shapes). nil shows the loading state

Returns: booleantrue when relayed. false when id isn't a string or data isn't a table or nil. The server doesn't check whether the widget is registered or how large the data is. The client does.


Notify

Relays a push notification to a player. The ID is fixed on the server, so it comes back straight away.

local id = olink.tablet.Notify(source, {
    app = 'mechanic_orders',
    message = 'A customer is waiting at Hayes Autos',
})

Parameters:

NameTypeDescription
srcnumberPlayer server ID
deftableSame fields as the client Notify

Returns: string|false — the notification ID app:id. The id part is def.id, or a generated s<n> when you leave it out. That number comes from one counter shared by every server-side Notify, so the numbers one app sees are not consecutive. Returns false when def isn't a table, def.app isn't a string, def.message is missing or empty, or def.data isn't a table or nil.

Relayed, not shown. The server can't see the player's client, so it returns an ID even when the client drops the notification: app not registered, app muted in the player's Settings app, notifications disabled, data over 8 KB, or an invalid id. Only the client return value reflects those.

Because generated IDs take the form s<number>, don't use custom IDs of that form for the same app, or they will replace each other.


DismissNotification

Removes one notification from a player's tablet.

olink.tablet.DismissNotification(source, 'mechanic_orders:order-12')

Parameters:

NameTypeDescription
srcnumberPlayer server ID
idstringThe full ID Notify returned

Returns: booleantrue when relayed. false when id isn't a string.


ClearNotifications

Removes every notification of one app, or all of them, from a player's tablet.

olink.tablet.ClearNotifications(source, 'mechanic_orders')

Parameters:

NameTypeDescription
srcnumberPlayer server ID
appIdstring|nilApp ID. nil clears every app

Returns: booleantrue when relayed. false when appId isn't a string or nil.

Client Functions

GetResourceName

local provider = olink.tablet.GetResourceName()

Returns: string'oxide-tablet' when the adapter is loaded, 'none' from the stub.


RegisterApp

Makes an app available on the tablet. Registering doesn't put it on anyone's home screen: players install it from the App Store, unless the owner turned the store off, preinstalled the app, or the tablet item lists it.

local ok = olink.tablet.RegisterApp({
    id = 'mechanic_orders',
    label = 'Work Orders',
    icon = 'fa-solid fa-wrench',
    resource = GetCurrentResourceName(),
    url = 'web/dist/index.html',
    requires = { jobs = { 'mechanic' }, duty = true },
    category = 'business',
    color = '#f59e0b',
})

Parameters: def table

FieldTypeRequiredDefaultDescription
idstringYes1 to 64 characters: letters, digits, _, -
labelstringYesName under the icon. Must not be empty
resourcestringYesYour resource name, GetCurrentResourceName(). Used for the page URL and for automatic removal
iconstringNo'fa-solid fa-cube'Font Awesome class
urlstringNo'web/dist/index.html'Page inside your resource. Leading / is removed
querytableNonilExtra URL query parameters, { name = value }
requirestableNonilWho sees the app. See below
ordernumberNo100Sort order, lower first. Ties sort by label
colorstringNonilSix-digit hex such as '#f59e0b'. Anything else falls back to #9B2C2C
readyTimeoutMsnumberNoConfig.AppReadyTimeout (8000)Milliseconds your page has to post ready before the tablet shows a retry screen
categorystringNo'utilities'App Store category: business, finance, law, medical, social, utilities, other. Case-insensitive; unknown values become utilities
descriptionstringNofxmanifest descriptionApp Store description. Trimmed and cut at 280 bytes
taglinestringNonilOne-line App Store subtitle. Cut at 80 bytes
publisherstringNofxmanifest authorCut at 60 bytes
versionstringNofxmanifest versionCut at 24 bytes

The fields system, native and always are reserved for the tablet's own apps and are ignored from other resources.

requires:

FieldTypeDescription
jobsstring[]Job names allowed to see the app
dutybooleanWith jobs: the player must also be on duty
gangsstring[]Gang names allowed to see the app
  • If both jobs and gangs are given, matching either one is enough.
  • Missing or empty lists don't restrict anything.
  • The tablet re-checks when olink:client:jobChanged or olink:client:gangChanged fires. If the app on screen is no longer allowed, it closes (reason closed).
  • requires only affects what's shown. See Visibility rules are not security.

Returns: booleantrue when registered or updated. false when:

  • def isn't a table, or id, label or resource is invalid;
  • GetResourceState(def.resource) is 'missing';
  • another resource already registered that id;
  • the tablet isn't running.

UnregisterApp

Removes an app.

olink.tablet.UnregisterApp('mechanic_orders')

Parameters:

NameTypeDescription
idstringApp ID

Returns: booleantrue when removed. false when the ID isn't registered or is one of the tablet's built-in apps (app_store, tablet_settings).

If the app is on screen, the tablet returns to Home (appClosed with reason unregistered). The app's notifications and badge are cleared. Widgets that name the app in app stay registered but are hidden until the app is registered again.

The tablet doesn't check which resource registered the app, so only unregister your own IDs.


Open

Opens the tablet, optionally straight into an app.

local shown = olink.tablet.Open('mechanic_orders')

Parameters:

NameTypeDescription
appIdstring|nilApp to open. nil opens the home screen

Returns: booleantrue when the tablet is open (and the app is on screen, if given). false when:

  • Config.Enabled is off;
  • appId isn't registered on this client;
  • the admin settings editor is open;
  • the player is dead or downed (the player also sees "You can't use a tablet while you're down.");
  • the tablet isn't running.

Behaviour:

  • Opening by ID is the resource acting on its own behalf. It skips requires, the item allowlist and App Store installs. Nothing is installed for the player. The app stays on screen until they leave it, until a job or gang change makes it no longer allowed by requires, or, on a curated tablet item, because it is not on that item's apps list.
  • Calling Open for the app already on screen returns true and changes nothing. Calling it for another app switches (appClosed with reason switch, then appOpened).
  • An API open starts no device session, so GetDevice() returns nil and Home shows the character's home screen. If the tablet was already out from an item, that device stays.
  • appOpened can fire before Open returns.

Close

Puts the tablet away.

olink.tablet.Close()

Returns: booleantrue when the tablet was open and is now closed. false when it wasn't open.

Fires appClosed (reason closed) for the app on screen, then closed.


CloseApp

Returns to the home screen if the given app is on screen.

olink.tablet.CloseApp('mechanic_orders')

Parameters:

NameTypeDescription
appIdstringYour app ID

Returns: booleantrue when that app was on screen and was closed (reason closed). false when appId isn't a string, the tablet isn't open, no app is on screen, or a different app is on screen.


IsOpen

local open = olink.tablet.IsOpen()

Returns: booleantrue while the tablet is out.


GetCurrentApp

local appId = olink.tablet.GetCurrentApp()

Returns: string|nil — ID of the app on screen, including the built-in apps. nil on the home screen or when the tablet is closed.


Send

Relays a message into your app's page.

olink.tablet.Send('mechanic_orders', { action = 'setOrders', data = orders })

Parameters:

NameTypeDescription
appIdstringYour app ID
messagetable{ action = string, data = any }. action must be a string

Returns: booleantrue when handed to the tablet UI. false when:

  • message.action isn't a string;
  • the tablet is closed;
  • appId isn't the app on screen;
  • the app is one of the tablet's own native apps (App Store, Settings, Maps);
  • the tablet isn't running.

Messages sent before your page posts ready are queued and delivered in order. The queue holds 100 messages and drops the oldest beyond that. It's emptied when the player presses Retry after a timeout.


SetBadge

Sets the number on your app's icon.

olink.tablet.SetBadge('mechanic_orders', 3)

Parameters:

NameTypeDescription
appIdstringYour app ID
countnumberBadge number. Rounded down; negative or non-numbers become 0

Returns: booleantrue when set. false when appId isn't registered.

The badge shown is the larger of your count and the app's pending notifications. SetBadge(appId, 0) doesn't hide notifications — call ClearNotifications(appId) once your app has handled them. Widgets that name the app in app show the same badge. Badges are cleared when the character unloads.


GetDevice

The device the open tablet was started from.

local device = olink.tablet.GetDevice()

Returns: table|nil

FieldTypeDescription
imeistring15-digit IMEI
namestring|nilName the player gave the tablet
batterynumberCharge as a whole percent, 0 to 100. It goes down while the tablet is out and is kept up to date while the tablet is open. It stays at its stored value when the owner has battery drain turned off

Returns nil when the tablet is closed, or the open session has no device: it was started by Open, the item isn't required, or the inventory can't keep metadata. For anything that matters, confirm on the server with GetDevice(src).


RegisterWidget

Makes a home-screen widget available. Players add widgets from the widget gallery. Nothing is placed on a screen automatically.

local ok = olink.tablet.RegisterWidget({
    id = 'mechanic_open_orders',
    label = 'Open Orders',
    resource = GetCurrentResourceName(),
    type = 'stat',
    sizes = { 'small', 'medium' },
    icon = 'fa-solid fa-wrench',
    app = 'mechanic_orders',
})

Parameters: def table

FieldTypeRequiredDefaultDescription
idstringYes1 to 64 characters: letters, digits, _, -. Separate from app IDs
labelstringYesName in the gallery and widget header
resourcestringYesGetCurrentResourceName()
typestringYes'stat', 'list', 'progress', 'text' or 'frame'
sizesstring[]No{ 'small' }Any of 'small' (2x2 cells), 'medium' (4x2), 'large' (4x4). Unknown and duplicate entries are dropped. The first entry is used when a saved size is no longer allowed. A page is 8x5 cells
iconstringNo'fa-solid fa-cube'Font Awesome class
colorstringNonilSix-digit hex accent
appstringNonilApp ID. The widget only shows while that app is visible to the player, shows the app's badge, and opens the app when tapped
requirestableNonilSame shape as the app's requires
ordernumberNo100Gallery sort order
datatableNonilInitial data. Same as calling SetWidgetData, with the same 8192-byte limit: data over the limit is dropped and the widget still registers
urlstringNo'web/dist/index.html'frame only: page inside your resource
querytableNonilframe only: extra URL query parameters
readyTimeoutMsnumberNoConfig.AppReadyTimeout (8000)frame only: time to post ready before the widget shows "Unavailable"

Returns: booleantrue when registered or updated. false when:

  • def isn't a table, or id, label, resource or type is invalid;
  • GetResourceState(def.resource) is 'missing';
  • another resource already registered that id;
  • the tablet isn't running.

Re-registering without data keeps the data already cached.


UnregisterWidget

olink.tablet.UnregisterWidget('mechanic_open_orders')

Parameters:

NameTypeDescription
idstringWidget ID

Returns: booleantrue when removed, false when not registered.

A placed widget disappears from the screen and its cached data is dropped. The player's saved slot is kept for when it registers again.


SetWidgetData

Pushes a widget's data.

olink.tablet.SetWidgetData('mechanic_open_orders', { value = '4', label = 'Open orders', trend = 'up' })

Parameters:

NameTypeDescription
idstringWidget ID
datatable|nilSee Widget Data Shapes. nil or an empty table shows the loading state

Returns: booleantrue when stored. false when the widget isn't registered, data isn't a table or nil, or data is larger than 8192 bytes once JSON-encoded.

The value is cached whether or not the tablet is open, so the home screen shows the latest data as soon as it appears. Calls are cheap: the tablet only redraws while open. Template widgets redraw; frame widgets receive { action = 'tablet:widgetData', data = data }. The cache is cleared when the character unloads.


Notify

Posts a push notification for a registered app. The tablet draws it; your app never does.

local id = olink.tablet.Notify({
    app = 'mechanic_orders',
    id = 'order-12',
    title = 'New work order',
    message = 'Vehicle 46EEK572 needs a repair',
    data = { orderId = 12 },
})

Parameters: def table

FieldTypeRequiredDefaultDescription
appstringYesA registered app ID
messagestringYesBody text. Cut at 280 characters
titlestringNonilTitle. Cut at 80 characters
iconstringNoThe app's iconFont Awesome class
idstring|numberNoGenerated #<n>Your own ID, namespaced per app. 1 to 64 bytes, must not start with #. Posting the same ID again replaces the earlier notification, moves it to the top and shows the banner again
datatableNonilAt most 8192 bytes JSON-encoded. Stays in Lua until the player taps, then goes to your page as { action = 'tablet:notification', data = data }
soundbooleanNotruefalse never plays the sound
toastboolean|nilNonilControls the banner and bubble. See the table below

Returns: string|false — the ID app:id (for example mechanic_orders:order-12). false when:

  • def.app isn't registered;
  • the player switched that app's notifications off in their Settings app;
  • Config.Notifications.enabled is off;
  • message is missing or empty;
  • data isn't a table or is over 8 KB;
  • id is invalid;
  • the tablet isn't running.

Where it shows:

toastTablet open, your app on screenTablet open, anything else on screenTablet put away
nilTray onlyBanner + trayBubble at the bottom right
trueBanner + trayBanner + trayBubble at the bottom right
falseTray onlyTray onlyNothing on screen, kept for the tray
  • Bubbles also need Config.Notifications.showWhenClosed.
  • The player's Do Not Disturb, Banners and Sound switches apply on top of this table.
  • The sound plays whenever the table calls for a banner or bubble, unless sound = false, sound is off for the server or the player, or the player has Do Not Disturb on. The player's Banners switch hides the banner but doesn't silence the sound.
  • A notification only appears for an app that is visible on this tablet (installed, allowed by requires and by the item). Otherwise it's kept silently, counts towards the badge, and shows in the tray once the app is visible.

Notifications stay until tapped, dismissed or cleared. They're dropped beyond Config.Notifications.maxStored (oldest first), on character unload, and when the app is unregistered or its resource stops. Tapping opens the app under the same rules as a launcher tap, relays data, removes the notification and fires notificationTapped.


DismissNotification

olink.tablet.DismissNotification('mechanic_orders:order-12')

Parameters:

NameTypeDescription
idstringThe full ID Notify returned

Returns: booleantrue when removed, false when not found.


ClearNotifications

olink.tablet.ClearNotifications('mechanic_orders')

Parameters:

NameTypeDescription
appIdstring|nilApp ID. nil clears every app

Returns: booleantrue for a valid argument, even if nothing was removed. false when appId isn't a string or nil, or the tablet isn't running.

oxide-tablet's fxmanifest.lua also lists two server exports, GetSetting and SetSetting. They belong to the in-game settings system that Oxide resources with a /<resource> settings editor share. They are not part of olink.tablet: the o-link adapter doesn't wrap them, the API above doesn't include them, and no Oxide resource calls them. There is no supported o-link path to them. Calling them means depending on oxide-tablet directly, which the integration rules rule out for apps and widgets.

What they do, for completeness:

ExportReturnsBehaviour
GetSetting(key)anyThe current value of Config[key] on the server, for example 'RequireItem'. nil for unknown keys. Returns the shared/config.lua value until settings finish loading from the database at boot
SetSetting(key, value)booleanSaves the value to oxide_settings, sends it to every client and applies runtime effects: Items re-registers usable items, and Enabled = false closes every open tablet

SetSetting has no admin check. It returns false before settings finish loading, for values containing NaN or infinity, and for invalid values of MapEnabled, MapAutoBlips, MapMode, MapLocations and MapBlipOverrides. Other keys are not checked against the limits the settings editor enforces.

Server Events

oxide-tablet fires no server-side events for other resources. Its oxide:tablet:* net events and oxide-tablet:server:* callbacks are internal. Don't trigger or listen to them.

Client Events

All tablet events are local client events fired with TriggerEvent. Listen with AddEventHandler. They aren't net events, so RegisterNetEvent isn't needed.

olink:client:tablet:ready

The tablet's client started or restarted, about one second after its script loads. (Re)register your apps and widgets here, then push widget data.

AddEventHandler('olink:client:tablet:ready', function()
    -- the registry is empty: register again
end)

olink:client:tablet:appOpened

An app was put on screen by a launcher tap, a notification tap, a widget tap or an Open call. Fires before the page has loaded, and can fire before Open returns. Also fires for the built-in apps.

AddEventHandler('olink:client:tablet:appOpened', function(appId)
    -- appId: string
end)

olink:client:tablet:appReady

The app's page posted ready, and queued Send messages were delivered. The built-in apps report ready immediately.

AddEventHandler('olink:client:tablet:appReady', function(appId)
    -- appId: string
end)

olink:client:tablet:appClosed

An app left the screen.

AddEventHandler('olink:client:tablet:appClosed', function(appId, reason)
    -- appId: string, reason: 'home' | 'closed' | 'switch' | 'unregistered'
end)
ReasonWhen
homeThe player went Home: the Home control, ESC while Config.EscapeBehavior is 'home', or the page posted home
closedThe tablet was put away, the page posted close, CloseApp was called, or the app is no longer allowed or installed
switchAnother app opened in its place
unregisteredUnregisterApp, the registering resource stopped, or a built-in app was switched off

olink:client:tablet:closed

The tablet was put away for any reason: the player, the key, ESC, using the item again, death or downed state, the item leaving the inventory, Config.Enabled turned off, the admin settings editor opening, character unload, a script, or the tablet stopping. Fires after appClosed.

AddEventHandler('olink:client:tablet:closed', function()
end)

olink:client:tablet:widgetReady

A frame widget's page posted ready while the tablet is open. Queued data was delivered.

AddEventHandler('olink:client:tablet:widgetReady', function(widgetId)
    -- widgetId: string
end)

olink:client:tablet:deviceRenamed

The player renamed the tablet they have open.

AddEventHandler('olink:client:tablet:deviceRenamed', function(device)
    -- device: { imei = string, name = string, battery = number }
end)

olink:client:tablet:notificationTapped

The player tapped a notification (a banner or a tray entry). By the time this fires, the tablet has switched to the app, queued { action = 'tablet:notification', data = data } for your page (only when data was set and the app isn't a built-in one), and removed the notification. If the app wasn't already on screen, its page may still be loading. Anything you Send from here is queued behind it.

AddEventHandler('olink:client:tablet:notificationTapped', function(id, appId, data)
    -- id: string ('app:id'), appId: string, data: table|nil
end)

Hosted Page Messages

Apps and frame widgets are iframes of your resource's own files. Building Apps and Widgets covers the web side step by step; this section is the reference.

Iframe URLs

App:

https://cfx-nui-<resource>/<url>?tabletHost=oxide-tablet&<params>

<params> are tabletApp=<id>, your query entries, and tabletImei=<imei> when the tablet was opened from an item. They're sorted by name and URL-encoded. The names tabletHost, tabletApp, tabletWidget, tabletSize and tabletImei belong to the tablet: it drops them from your query.

Frame widget:

https://cfx-nui-<resource>/<url>?tabletHost=oxide-tablet&<params>&tabletSize=<small|medium|large>

<params> are tabletWidget=<id>, your query entries (minus the same reserved names), and tabletImei=<imei> when the tablet was opened from an item. Changing the widget's size reloads the iframe with the new tabletSize.

The tablet only loads URLs that start with https://cfx-nui-<name>/, where <name> is letters, digits, _ and -. A resource whose name contains any other character (such as .) can't be hosted.

tabletImei is informational. Confirm it on the server with GetDevice(src).

Tablet to your page

Posted with postMessage in the same { action, data } shape as SendNUIMessage.

actiondataSent toWhen
Your own actionYour dataAppsYou called Send. Queued until ready
tablet:notificationThe notification's dataAppsThe player tapped a notification that had data. Queued until ready
tablet:widgetDatatable or nullFrame widgetsYou called SetWidgetData, and again right after your page posts ready if data is cached

Your page to the tablet

window.parent.postMessage({ type: 'oxide-tablet', event: 'ready', app: appId }, '*')

Messages must come from the hosted iframe and have type: 'oxide-tablet'. For apps, a message whose app names a different app is ignored.

eventAppsFrame widgets
readyDelivers queued messages and stops the load timer. Post it only after your message listeners existDelivers queued messages, resends cached data, fires widgetReady
escapeThe tablet applies its ESC rule (Config.EscapeBehavior)Same as apps
homeBack to the home screen (appClosed reason home)Ignored
closeBack to the home screen (appClosed reason closed). It doesn't put the tablet awayIgnored
openAppIgnoredOpens the message's app, or the widget's app if none is given, under launcher-tap rules

If an app doesn't post ready within readyTimeoutMs, it shows that the app "didn't respond" with a Retry button. A widget shows "Unavailable". Never call SetNuiFocus from a hosted page: the tablet owns focus.

Widget Data Shapes

All shapes are Lua tables passed to SetWidgetData (client or server). Every field is optional unless marked required. Colors are six-digit hex strings.

stat

FieldTypeDescription
valuestring|numberThe big number. Shows when missing
labelstringLine under the value
substringSmall grey text
iconstringFont Awesome class beside the value, on medium and large only
trendsee trendArrow pill

list

FieldTypeDescription
titlestringHeading, shown only without summary
summarytableHeadline number: { value (required), label?, sub?, trend? }. Drawn above the rows, or as its own column on medium
itemstable[]Rows: { text, value?, sub?, icon?, color?, trend? }
emptystringText when items is empty. Defaults to "Nothing here yet"

Rows shown per size: small 3 (2 with summary), medium 3 (3), large 8 (6). Extra rows collapse into "+N more". On small, rows are one line: no icon tile, no sub, no trend, and a colored dot when color is set. Elsewhere a row shows icon (or the first letter of text) on a tile tinted with color.

progress

FieldTypeDescription
labelstringText above the bar
valuenumberCurrent value. Non-numbers count as 0
maxnumberDefaults to 100. Must be above 0
substringSmall text under the bar
colorstringBar color. Falls back to the widget's color

The percentage is clamped to 0 to 100. medium and large also show value / max.

text

FieldTypeDescription
titlestringHeading
bodystringText. Clipped at 4 lines on small, 5 on medium and 13 on large

frame

Anything. It's posted into your iframe as { action = 'tablet:widgetData', data = data }.

trend

Used by stat, list summaries and list rows:

ValueShows
'up', 'down', 'flat'Arrow only
numberArrow from the sign, text as a signed percentage (12 shows +12%)
{ dir = 'up', text = '+$980' }Arrow with your own text

Integration Examples

App with a fallback window

Registers a work-orders app for on-duty mechanics, re-registers when the tablet restarts, and falls back to the resource's own window when the tablet isn't there.

client/tablet.lua
olink = exports['o-link']:olink()

local APP_ID = 'mechanic_orders'
local registered = false

local function isTabletReady()
    return GetResourceState('oxide-tablet') == 'started'
        and olink.tablet.GetResourceName() == 'oxide-tablet'
end

local function registerTabletApp()
    registered = false
    if not isTabletReady() then return end
    if olink.tablet.RegisterApp({
        id = APP_ID,
        label = 'Work Orders',
        icon = 'fa-solid fa-wrench',
        resource = GetCurrentResourceName(),
        requires = { jobs = { 'mechanic' }, duty = true },
        category = 'business',
        color = '#f59e0b',
    }) then
        registered = true
    end
end

-- Tablet already running when this resource starts
CreateThread(registerTabletApp)

-- Tablet started after us, or restarted
AddEventHandler('olink:client:tablet:ready', registerTabletApp)

AddEventHandler('onClientResourceStop', function(resource)
    if resource == 'oxide-tablet' then registered = false end
end)

local function openOwnWindow()
    SetNuiFocus(true, true)
    SendNUIMessage({ action = 'open' })
end

RegisterCommand('orders', function()
    if registered and olink.tablet.Open(APP_ID) then return end
    openOwnWindow()
end, false)

Checking access when the app opens

requires only hides the tile. This asks the server every time the app opens and backs out if the player isn't allowed.

client/tablet.lua
AddEventHandler('olink:client:tablet:appOpened', function(appId)
    if appId ~= APP_ID then return end
    local orders = olink.callback.Trigger('mechanic:server:getOrders')
    if not orders then
        olink.tablet.CloseApp(APP_ID)
        olink.notify.Send('You are not on duty as a mechanic.', 'error')
        return
    end
    olink.tablet.Send(APP_ID, { action = 'setOrders', data = orders })  -- queued until the page is ready
end)

A stat widget fed from the server

client/tablet.lua
-- Register next to the app, from the same two places
local WIDGET_ID = 'mechanic_open_orders'

local function registerTabletWidget()
    if not isTabletReady() then return end
    olink.tablet.RegisterWidget({
        id = WIDGET_ID,
        label = 'Open Orders',
        resource = GetCurrentResourceName(),
        type = 'stat',
        sizes = { 'small', 'medium' },
        icon = 'fa-solid fa-wrench',
        app = APP_ID,  -- hidden unless the app is visible; tapping opens it
    })
end

CreateThread(registerTabletWidget)
AddEventHandler('olink:client:tablet:ready', registerTabletWidget)
server/tablet.lua
olink = exports['o-link']:olink()

local function pushOpenOrders(src, count)
    olink.tablet.SetWidgetData(src, 'mechanic_open_orders', {
        value = count,
        label = 'Open orders',
        sub = 'Waiting for a mechanic',
        trend = count > 5 and 'up' or 'flat',
    })
end

The server posts one notification per order. Reposting the same order replaces it. The client clears the app's notifications once the player opens it.

server/tablet.lua
local function notifyNewOrder(src, orderId, plate)
    return olink.tablet.Notify(src, {
        app = 'mechanic_orders',
        id = 'order-' .. orderId,  -- same order, same notification
        title = 'New work order',
        message = ('Vehicle %s needs a repair'):format(plate),
        data = { orderId = orderId },  -- your page receives { action = 'tablet:notification', data = ... } on tap
    })
end
client/tablet.lua
AddEventHandler('olink:client:tablet:appOpened', function(appId)
    if appId ~= APP_ID then return end
    -- SetBadge(APP_ID, 0) can't hide pending notifications; clearing them does
    olink.tablet.ClearNotifications(APP_ID)
end)

AddEventHandler('olink:client:tablet:notificationTapped', function(_, appId, data)
    if appId ~= APP_ID or type(data) ~= 'table' then return end
    olink.tablet.Send(APP_ID, { action = 'showOrder', data = { orderId = data.orderId } })
end)

Next Steps