Building Apps and Widgets
Developer guide for putting your own resource on oxide-tablet — registering apps and widgets, the host shim, the web side, notifications, lifecycle, and a complete example resource.
This guide is for developers who want their own resource to show up on oxide-tablet: as an app players open from the home screen, as a widget on the home screen, or both. It walks through the Lua side, the web side, notifications and lifecycle, and ends with a complete example resource you can copy.
Everything goes through the o-link bridge (olink.tablet.*). Your resource never depends on the tablet. When the tablet isn't installed, every call returns false or nil and your resource carries on with its own UI.
This is the tutorial. For every function, field and return value in one table, see the API Reference.
1. How the tablet hosts your resource
Apps
An app is your resource's own NUI page, shown inside the tablet in an <iframe>. The tablet draws the bezel, the status bar, the home screen, the loading screen and the notification tray. It also plays the hold animation and owns keyboard focus and ESC. Your page fills the screen area under the status bar.
The tablet builds the iframe address from what you register:
https://cfx-nui-<your-resource>/<url>?tabletHost=oxide-tablet&tabletApp=<app id>Any query table you register is merged into the address. tabletHost always comes first; every other parameter, yours and the tablet's, is sorted by name. When the player opened the tablet from an inventory item that carries an IMEI, the tablet also adds &tabletImei=<imei> (see 3.6).
Three things follow from this:
- The hosted page is a separate document from your normal
ui_page.SendNUIMessagereaches yourui_pageand never the copy inside the tablet. You talk to the hosted page witholink.tablet.Send. - FiveM serves the page from your resource, so the file must be listed in the
files {}block of yourfxmanifest.lua. - The page can still call your own
RegisterNUICallbackhandlers withfetch, the same way a normal NUI page does (see 4.5).
Widgets
A widget is a tile on the home screen. There are two kinds:
| Kind | Types | Who draws it |
|---|---|---|
| Template | stat, list, progress, text | The tablet. You push a small data table and the tablet renders it. No web code needed |
| Frame | frame | You. It's your own page in an iframe, like an app, sized to the widget |
Registration is per player
Apps and widgets are registered from client Lua, on each player's machine, and kept in memory. A player only has your app if their client registered it. That's how you decide per player: register a manager app only for managers, a gang app only for gang members.
The server can open the tablet, send messages, post notifications and push widget data, but it can't register anything.
How players get your app
Registering an app doesn't put it on anyone's home screen. What happens next depends on the server owner's tablet settings (see Configuration):
| Server setup | What happens to your app |
|---|---|
| App Store on (the default) | Listed in the tablet's App Store for players who pass your requires. It reaches a home screen only after the player installs it on that tablet |
Your app ID is in Config.Store.preinstalled | Installed on every tablet. Players can't uninstall it |
App Store off (Config.Store.enabled = false) | Installed automatically for every player who passes your requires |
The tablet item has an apps list in Config.Items | That item is a curated device. Only the apps on its list exist on it, and it has no store. Your app ID must be on the list |
A few details that matter to you:
- Installs are saved with the tablet item (by IMEI), or with the character when there's no item. They're saved by app ID, so keep your ID the same between versions.
- A newly installed app takes the first free cell on the home screen.
- An app that isn't installed also hides its linked widgets and its notifications.
- Put your app ID in your own documentation so server owners can preinstall it or add it to a curated item.
What requires filters
requires limits who can see your app:
olink.tablet.RegisterApp({
id = 'my_app',
label = 'My App',
icon = 'fa-solid fa-star',
resource = GetCurrentResourceName(),
requires = {
jobs = { 'police', 'sheriff' }, -- the job name o-link reports for the player
gangs = { 'lostmc' }, -- the gang name o-link reports for the player
duty = true, -- the job match also needs the player on duty
},
})- A player qualifies when they match either list.
dutyonly applies to thejobsmatch. - Leave
requiresout and every player qualifies. - It filters the home screen, the App Store, widget visibility, notification display, and taps on a tile, a widget or a notification.
- It does not filter
olink.tablet.Open(appId)called by your own resource. Opening by ID is your resource acting on its own behalf, so it works even for a player who hasn't installed the app or doesn't matchrequires. Check permissions yourself before you open. - The tablet re-checks
requiresevery time it draws the home screen. Onolink:client:jobChanged, and onolink:client:gangChanged(only raised if your gang resource raises it), it also re-filters an open tablet and closes your app if the player no longer qualifies.
If your rule isn't a fixed list of job or gang names (a boss grade, owning a business, a database flag), leave requires out and register or unregister the app yourself as the player's state changes. Section 7 shows how.
2. Before you start
The rules
-
Never make oxide-tablet a dependency. Don't list it in
dependenciesand don't callexports['oxide-tablet']. Your resource must start and work on servers without the tablet. -
Depend on o-link and talk through it. List
o-linkindependencies, start your resource after it, take the snapshot once, and useolink.tablet.*:olink = exports['o-link']:olink() -
Use a resource name made of letters, numbers,
-and_. The tablet only mounts iframe addresses that start withhttps://cfx-nui-followed by those characters. A dot in the name (my.app) fails that check: an app shows the "didn't respond" screen straight away, and a frame widget stays on "Loading". Browsers also lower-case the host part of an address, so keep the name lower case. That way the name your page reads back matchesGetCurrentResourceName(). -
Pass your own resource name as
resourcewhen you register. Your call reaches the tablet through o-link, so the tablet can't tell which resource called. It usesresourceto build the iframe address and to clean up when your resource stops. -
Build web assets with relative paths. Your page lives in a folder of your resource (for example
web/dist/index.html), so a Vite build needsbase: './'. Without it, scripts and styles fail to load inside the iframe.
Detect the tablet at runtime
local TABLET = 'oxide-tablet'
local function tabletAvailable()
if GetResourceState(TABLET) ~= 'started' then return false end
local getName = olink.tablet and olink.tablet.GetResourceName
if getName == nil then return false end
local ok, name = pcall(getName)
return ok and name == TABLET
endBoth checks are needed:
| Situation | GetResourceState('oxide-tablet') | olink.tablet.GetResourceName() |
|---|---|---|
| Tablet not installed | 'missing' | 'none' |
| Installed and running | 'started' | 'oxide-tablet' |
| Installed but stopped | 'stopped' | 'oxide-tablet' |
| Added to the server after o-link started | 'started' | 'none' until o-link restarts |
The state check runs first so o-link's placeholder functions never print their one-time was called with only fallback defaults loaded for "tablet" warning on servers without the tablet.
Avoid two tempting checks:
olink.supports('tablet')orolink.supports('tablet.RegisterApp'). o-link installs placeholder functions for every namespace, so these returntrueeven when there's no tablet.type(olink.tablet.RegisterApp) == 'function'. Functions in the o-link snapshot reach your resource as callable tables, so this test is alwaysfalsein your resource. Nil-check the member and call it, withpcallwhen you need to.
If your resource may run with an o-link build from before tablet widgets and notifications existed, nil-check olink.tablet.RegisterWidget and olink.tablet.Notify before calling them.
Fall back to your own UI
Tablet calls return false (or nil) whenever they can't do what you asked. The tablet may be missing, stopped or turned off by the owner, the player may be dead or downed, or your app may not be registered. Branch on the result and open your normal window instead:
RegisterCommand('myapp', function()
if tabletAvailable() and olink.tablet.Open('my_app') then return end
SetNuiFocus(true, true)
SendNUIMessage({ action = 'myapp:show' })
end, false)Register at boot and when the tablet starts
About a second after the tablet starts or restarts, it raises olink:client:tablet:ready with an empty registry. Your resource may start before or after the tablet, so register in both places. Registering the same ID again from the same resource replaces the definition, so doing it twice is harmless.
local function registerWithTablet()
if not tabletAvailable() then return end
olink.tablet.RegisterApp({
id = 'my_app',
label = 'My App',
icon = 'fa-solid fa-star',
resource = GetCurrentResourceName(),
})
end
AddEventHandler('olink:client:tablet:ready', registerWithTablet)
CreateThread(function()
Wait(1000)
registerWithTablet()
end)3. Register and open an app
3.1 Register the app
local RESOURCE = GetCurrentResourceName()
local APP_ID = 'my_app'
local ok = olink.tablet.RegisterApp({
id = APP_ID,
label = 'My App',
icon = 'fa-solid fa-star',
resource = RESOURCE,
url = 'web/dist/index.html',
color = '#2563eb',
order = 60,
readyTimeoutMs = 15000,
})
if not ok then
print(('[%s] oxide-tablet did not accept app %s'):format(RESOURCE, APP_ID))
end| Field | Required | Default | Notes |
|---|---|---|---|
id | Yes | — | 1 to 64 characters: letters, digits, _ and -. Prefix it with something unique to your resource |
label | Yes | — | Shown under the tile and in the App Store. Tiles in the dock show no label, so choose an icon that makes sense on its own |
resource | Yes | — | GetCurrentResourceName() |
icon | No | 'fa-solid fa-cube' | A Font Awesome 6 class string |
url | No | 'web/dist/index.html' | Path inside your resource. A leading / is removed. Must be in files {} |
query | No | — | Extra URL parameters, for example { tab = 'orders' }. The keys tabletHost, tabletApp, tabletWidget, tabletSize and tabletImei belong to the tablet: it drops them from your query, so pick other names |
color | No | The tablet's red | Six-digit hex such as '#2563eb'. Anything else falls back to the default |
order | No | 100 | Lower comes first on a fresh home screen and in the App Store |
readyTimeoutMs | No | The owner's setting (8000) | How long your page gets to report ready. See 4.3 |
requires | No | — | See What requires filters |
category, tagline, description, publisher, version | No | — | The App Store listing. See 3.2 |
RegisterApp returns false without saying why. The usual causes are:
idis missing, longer than 64 characters, or contains other characters (spaces, dots, colons).labelorresourceis missing or empty, orresourceisn't a resource on the server.- Another resource already registered that ID. The tablet uses
app_store,tablet_settingsandtablet_mapitself, andtablet_demowhile debug logging is on. - The tablet isn't started.
The fields system, native and always belong to the tablet's own apps and are ignored when another resource sends them.
3.2 App Store listing
olink.tablet.RegisterApp({
id = 'my_app',
label = 'My App',
icon = 'fa-solid fa-star',
resource = GetCurrentResourceName(),
category = 'business',
tagline = 'Track orders and deliveries',
description = 'See open orders, mark deliveries as done and get pinged when a new order comes in.',
publisher = 'Your Studio',
})| Field | Limit | If you leave it out |
|---|---|---|
category | One of business, finance, law, medical, social, utilities, other | utilities. Unknown values also become utilities |
tagline | 80 bytes | No subtitle |
description | 280 bytes | The description in your fxmanifest.lua |
publisher | 60 bytes | The author in your fxmanifest.lua |
version | 24 bytes | The version in your fxmanifest.lua |
Longer text is cut at the limit. Non-ASCII characters take more than one byte each, so keep that text well under the limit. In most cases an accurate description, author and version in your manifest is all the listing needs.
3.3 Open the app from Lua
-- Client
if olink.tablet.Open('my_app') then
-- the tablet is out and my_app is on screen (or loading)
end-- Server
olink.tablet.Open(source, 'my_app')Open(appId)takes the tablet out if it's put away, then shows your app. No tablet item is needed.- Calling it while your app is already on screen does nothing and returns
true. - It returns
falsewhen the owner turned the tablet off, the player is dead or downed, the app isn't registered on that client, or the player has the tablet's admin settings editor open. Open()with no app ID just takes the tablet out.- An API open starts no device session. If the player already had the tablet out from an item, that device stays.
- The server version only relays to the client, so
truemeans "sent", not "opened".
The client also has Close() (put the tablet away), CloseApp(appId) (back to the home screen if that app is on screen), IsOpen() and GetCurrentApp(). The server has Close(src).
3.4 Push data with Send
-- Client
olink.tablet.Send('my_app', { action = 'myapp:show', data = { orders = 3 } })-- Server
olink.tablet.Send(source, 'my_app', { action = 'myapp:show', data = { orders = 3 } })- The message has the same
{ action, data }shape asSendNUIMessage, so onemessagelistener on your page handles both.actionmust be a string. - It only reaches your app while it's on screen. Otherwise the client version returns
falseand nothing is delivered. The server version can't see the client, so it returnstrueonce relayed. - While your page is loading, messages wait in a queue (up to 100; the oldest is dropped first). They're delivered in order the moment the page reports ready.
When to send the first screen of data:
| Event | Fires when | Use it for |
|---|---|---|
olink:client:tablet:appOpened (appId) | Your app is put on screen: a tile tap, a widget tap, a notification tap or your own Open. It fires before Open returns | Fetching data from your server and sending it. The message waits in the queue until the page is ready |
olink:client:tablet:appReady (appId) | Your page reported ready and the queue was delivered. It fires again after the player presses Retry on the timeout screen | Sending the first screen when the data is already on the client |
Retry reloads your page and empties the queue, and appOpened does not fire again. If you only send from appOpened, a page that timed out comes back empty. Send from appReady too, or have the page ask for its data through a NUI callback when it loads.
appOpened fires before Open returns. If you need to tell your own Open apart from a tile tap, set a flag before you call Open.
If your resource keeps its own window as well, route every push through one function:
local function push(message)
if tabletAvailable() and olink.tablet.GetCurrentApp() == 'my_app' then
return olink.tablet.Send('my_app', message)
end
SendNUIMessage(message)
return true
endDon't SendNUIMessage your "open" message while your app is inside the tablet. It goes to your ui_page, which would then draw your own window on top of the game at the same time.
3.5 Events you can listen to
These are local client events. Subscribe with AddEventHandler.
| Event | Arguments | Fires when |
|---|---|---|
olink:client:tablet:ready | — | The tablet started or restarted. Register again |
olink:client:tablet:appOpened | appId | An app was put on screen |
olink:client:tablet:appReady | appId | That app's page reported ready |
olink:client:tablet:appClosed | appId, reason | The app left the screen. reason is home, closed, switch (another app opened) or unregistered |
olink:client:tablet:closed | — | The tablet was put away. Comes after appClosed when an app was open |
olink:client:tablet:widgetReady | widgetId | A frame widget's page reported ready |
olink:client:tablet:notificationTapped | id, appId, data | The player tapped a notification |
olink:client:tablet:deviceRenamed | device | The player renamed the tablet they're using. device is { imei, name, battery } |
3.6 Devices
A tablet opened from an inventory item is a device with an IMEI.
olink.tablet.GetDevice()on the client returns{ imei, name, battery }while the tablet is open.olink.tablet.GetDevice(src)on the server returns{ imei, name, item, slot }.- Both return
nilwhen the tablet is closed, when the open session was started by a resource rather than an item, or when the inventory can't store item metadata. - An app opened on a device gets
&tabletImei=<imei>in its URL. Treat that as display-only: key anything per device on the server'sGetDevice(src), never on an IMEI a client sent you.
4. The web side
4.1 Install the host shim
The host shim is one small file that handles the tablet's side of the conversation: it tells you whether you're hosted, reads your resource name, reports ready and forwards ESC. It comes in two versions with the same behaviour.
| Your UI is built with | Save the shim as | Load it with |
|---|---|---|
| Vite, Vue or another bundler | web/src/utils/host.js (ES module version) | import { ... } from './utils/host.js' |
Plain <script> tags | html/js/host.js (classic version) | <script src="js/host.js"></script> before your own scripts. The API is on window.OxideTabletHost |
The paths are a convention. Any path inside your web files works, as long as the classic version is listed in files {}. Copy the file exactly as it is, including the header comment.
// CANONICAL COPY: tools/templates/web_tablet_host.js
// Hosted-app shim for the oxide-tablet iframe contract. Copy verbatim to
// web/src/utils/host.js in the adopting resource; edit the template, never the copy.
//
// The tablet loads an app as an iframe of the owning resource's own bundle at
// https://cfx-nui-<resource>/<url>?tabletHost=oxide-tablet&tabletApp=<id>
// and a home-screen frame widget at
// https://cfx-nui-<resource>/<url>?tabletHost=oxide-tablet&tabletWidget=<id>&tabletSize=<small|medium|large>
// Parent -> iframe messages are { action, data }, the same shape as SendNUIMessage, so
// existing message listeners work unchanged (widget data arrives as action 'tablet:widgetData').
// iframe -> parent messages are
// { type: 'oxide-tablet', event: 'ready' | 'escape' | 'home' | 'close' | 'openApp', app, widget }.
const params = new URLSearchParams(window.location.search)
export const tabletHost = params.get('tabletHost') || null
export const hostedApp = params.get('tabletApp') || null
export const hostedWidget = params.get('tabletWidget') || null
export const hostedSize = params.get('tabletSize') || null
export const isHosted = !!(tabletHost && (hostedApp || hostedWidget) && window.parent && window.parent !== window)
// Resource name for NUI callbacks and asset URLs. The hostname is authoritative:
// GetParentResourceName() is injected by FiveM on top-level frames only and is not
// reliable inside a nested iframe.
export function getResourceName(fallback) {
const { hostname, protocol } = window.location
if (hostname.startsWith('cfx-nui-')) return hostname.slice(8)
if (protocol === 'nui:' && hostname) return hostname
if (!isHosted && typeof window.GetParentResourceName === 'function') return window.GetParentResourceName()
return window.__RESOURCE_NAME__ || fallback
}
// URL for one of this resource's own shipped files (locales, extra scripts). Same-origin
// with the page in both the nui:// and https://cfx-nui- worlds, so it needs no scheme
// assumptions; outside FiveM it falls back to the legacy nui:// form.
export function assetUrl(path, fallbackName) {
const { protocol, hostname, host } = window.location
const clean = String(path).replace(/^\/+/, '')
if (hostname.startsWith('cfx-nui-') || protocol === 'nui:') return `${protocol}//${host}/${clean}`
return `nui://${getResourceName(fallbackName)}/${clean}`
}
export function postToHost(event, extra) {
if (!isHosted) return false
window.parent.postMessage({ type: 'oxide-tablet', event, app: hostedApp, widget: hostedWidget, ...(extra || {}) }, '*')
return true
}
// Call once the root component has registered its message listeners: the tablet holds
// relayed messages until it sees this.
export const markReady = () => postToHost('ready')
export const requestHome = () => postToHost('home')
export const requestClose = () => postToHost('close')
// Frame widgets only: open the app the widget belongs to (or `app` explicitly).
export const requestOpenApp = (app) => postToHost('openApp', app ? { app } : undefined)
// Key events inside an iframe never reach the tablet document, so forward Escape and let
// the tablet apply its own ESC policy. Capture phase, so it runs before the app's own
// handler; returns the uninstaller.
export function forwardEscapeToHost() {
if (!isHosted) return () => {}
const onKey = (e) => {
if (e.key !== 'Escape') return
e.preventDefault()
e.stopPropagation()
postToHost('escape')
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}// CANONICAL COPY: tools/templates/web_tablet_host_classic.js
// Classic-script port of web_tablet_host.js for resources whose NUI is plain <script> tags
// with no bundler. Copy verbatim to html/js/host.js in the adopting resource and load it as
// the first script; edit the template, never the copy. Same API as the ESM shim, exposed as
// window.OxideTabletHost.
//
// The tablet loads an app as an iframe of the owning resource's own files at
// https://cfx-nui-<resource>/<url>?tabletHost=oxide-tablet&tabletApp=<id>
// and a home-screen frame widget at
// https://cfx-nui-<resource>/<url>?tabletHost=oxide-tablet&tabletWidget=<id>&tabletSize=<small|medium|large>
// Parent -> iframe messages are { action, data }, the same shape as SendNUIMessage, so
// existing message listeners work unchanged (widget data arrives as action 'tablet:widgetData').
// iframe -> parent messages are
// { type: 'oxide-tablet', event: 'ready' | 'escape' | 'home' | 'close' | 'openApp', app, widget }.
(function () {
const params = new URLSearchParams(window.location.search)
const tabletHost = params.get('tabletHost') || null
const hostedApp = params.get('tabletApp') || null
const hostedWidget = params.get('tabletWidget') || null
const hostedSize = params.get('tabletSize') || null
const isHosted = !!(tabletHost && (hostedApp || hostedWidget) && window.parent && window.parent !== window)
// Resource name for NUI callbacks and asset URLs. The hostname is authoritative:
// GetParentResourceName() is injected by FiveM on top-level frames only and is not
// reliable inside a nested iframe.
function getResourceName(fallback) {
const { hostname, protocol } = window.location
if (hostname.startsWith('cfx-nui-')) return hostname.slice(8)
if (protocol === 'nui:' && hostname) return hostname
if (!isHosted && typeof window.GetParentResourceName === 'function') return window.GetParentResourceName()
return window.__RESOURCE_NAME__ || fallback
}
// URL for one of this resource's own shipped files (locales, extra scripts). Same-origin
// with the page in both the nui:// and https://cfx-nui- worlds, so it needs no scheme
// assumptions; outside FiveM it falls back to the legacy nui:// form.
function assetUrl(path, fallbackName) {
const { protocol, hostname, host } = window.location
const clean = String(path).replace(/^\/+/, '')
if (hostname.startsWith('cfx-nui-') || protocol === 'nui:') return `${protocol}//${host}/${clean}`
return `nui://${getResourceName(fallbackName)}/${clean}`
}
function postToHost(event, extra) {
if (!isHosted) return false
window.parent.postMessage({ type: 'oxide-tablet', event, app: hostedApp, widget: hostedWidget, ...(extra || {}) }, '*')
return true
}
// Call once the root component has registered its message listeners: the tablet holds
// relayed messages until it sees this.
const markReady = () => postToHost('ready')
const requestHome = () => postToHost('home')
const requestClose = () => postToHost('close')
// Frame widgets only: open the app the widget belongs to (or `app` explicitly).
const requestOpenApp = (app) => postToHost('openApp', app ? { app } : undefined)
// Key events inside an iframe never reach the tablet document, so forward Escape and let
// the tablet apply its own ESC policy. Capture phase, so it runs before the app's own
// handler; returns the uninstaller.
function forwardEscapeToHost() {
if (!isHosted) return () => {}
const onKey = (e) => {
if (e.key !== 'Escape') return
e.preventDefault()
e.stopPropagation()
postToHost('escape')
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}
window.OxideTabletHost = {
tabletHost,
hostedApp,
hostedWidget,
hostedSize,
isHosted,
getResourceName,
assetUrl,
postToHost,
markReady,
requestHome,
requestClose,
requestOpenApp,
forwardEscapeToHost,
}
})()What the shim gives you
| Name | What it is |
|---|---|
isHosted | true inside the tablet (an app or a frame widget), false in your normal ui_page |
hostedApp | The app ID from the URL, or null |
hostedWidget | The widget ID from the URL, or null |
hostedSize | 'small', 'medium' or 'large' for a frame widget, otherwise null |
tabletHost | 'oxide-tablet' when hosted |
getResourceName(fallback) | Your resource name, read from the page address. fallback is only used outside FiveM |
assetUrl(path, fallback) | Full URL of a file in your resource, such as a locale JSON |
markReady() | Tell the tablet your message listeners exist |
requestHome() | Go back to the tablet's home screen |
requestClose() | Close your app. This also goes back to the home screen; it does not put the tablet away |
requestOpenApp(app?) | Frame widgets: open the widget's app, or the app ID you pass |
forwardEscapeToHost() | Hand ESC to the tablet. Returns a function that removes the listener |
postToHost(event, extra) | The low-level sender the others use |
Every sender does nothing and returns false when the page isn't hosted, so you can call them without checking isHosted first.
4.2 Detect hosted mode and fill the iframe
When isHosted is true:
- Drop your own chrome. No window frame, dark backdrop, bezel or drawn-on tablet. The tablet already has one.
- Fill the iframe. Give your root element
position: fixed; inset: 0(orwidth: 100%; height: 100%) and paint your own background. The iframe is transparent over a black screen. - Leave NUI focus alone. The tablet owns focus while it's out. Don't call
SetNuiFocusfrom Lua for a hosted app, and don't route a hosted close button through a NUI callback that calls it. - Don't start your own tablet animation or prop. The tablet already plays one.
- Load your own fonts and icons. Your page is a separate document and inherits nothing from the tablet.
- The player's Text size and Bold text choices in the tablet's Settings app don't reach your page (see Settings App).
4.3 Signal ready
Report ready once your message listeners exist:
window.addEventListener('message', onMessage)
markReady()- Until the tablet sees
ready, messages youSendwait in the queue. They're delivered the instant it arrives, so a listener you add aftermarkReady()misses them. - While it waits, the tablet shows your icon and "Loading My App".
- If
readydoesn't arrive in time, the tablet shows "My App didn't respond" with Retry and Home buttons. Your page stays loaded behind that screen, and a latereadyclears it. - The time limit is your
readyTimeoutMs. Without one, it's the owner's App load timeout in/tablet settings(Config.AppReadyTimeout: 8000 ms by default, 1000 to 30000). Pages that load fonts, icons or libraries from a CDN can need longer on a cold cache. Several Oxide Studios resources registerreadyTimeoutMs = 15000for that reason. - Retry reloads your page and empties the queue.
appReadyfires again when the reloaded page reports ready.
4.4 Receive messages
Messages from olink.tablet.Send arrive as message events whose event.data is { action, data }, exactly like SendNUIMessage. An existing handler works unchanged. The tablet also sends two messages of its own:
action | Sent to | When | data |
|---|---|---|---|
tablet:notification | Apps | The player tapped one of your notifications | The data you gave Notify |
tablet:widgetData | Frame widgets | The widget's data changed, and again after ready | What you gave SetWidgetData |
4.5 Call your own NUI callbacks
A hosted page calls your RegisterNUICallback handlers the usual way, with a POST to https://<your-resource>/<callback>. Build the address with getResourceName():
import { getResourceName } from './host.js'
export async function fetchNui(event, data = {}) {
const res = await fetch(`https://${getResourceName('my-tablet-app')}/${event}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
return res.json()
}- Don't use
GetParentResourceName()inside the tablet. FiveM provides it to top-level NUI pages, and it isn't reliable inside the tablet's iframe. The shim reads your name from the page address instead. - Keep focus changes out of callbacks a hosted page can reach (see 4.2).
4.6 ESC, Home and Close
Key presses inside your iframe never reach the tablet. Call forwardEscapeToHost() once when your page starts. It catches ESC before your own handlers and hands it to the tablet, which applies the owner's ESC setting (Config.EscapeBehavior): back to the home screen by default, or put the tablet away.
- Don't also close yourself on ESC while hosted.
- For your own Back or Close button, call
requestHome()orrequestClose()while hosted. Both return to the tablet's home screen;appClosedfires with reasonhomeorclosed. - In your own window, keep your usual close callback that hides the window and calls
SetNuiFocus(false, false). - To put the whole tablet away, call
olink.tablet.Close()from Lua.
4.7 The dev-mock trap
Many NUI templates decide "am I running in the game?" with a check like !!window.invokeNative, and serve fake data when it's false. FiveM only provides invokeNative to top-level NUI pages. Inside the tablet that check says "browser", and your app shows its mock data to real players. Accept hosted mode as well:
import { isHosted } from './host.js'
export function isNuiEnvironment() {
return isHosted || !!window.invokeNative
}4.8 Sizing: vh means the iframe
Inside an iframe, vh and vw measure the iframe, not the game screen.
- Apps get the tablet screen under the status bar, about 70% of the game screen's height. A page laid out in
vhfor a full-screen window comes out roughly 30% smaller. - Frame widgets get their cell block, and
100vhis the widget's own height: a couple of hundred pixels for small and medium widgets on a 1080p screen. Text sized invhbecomes tiny.
Lay hosted pages out with %, flexbox or grid, and size text in px or rem. If your existing layout is built in vh, you can instead render it on a fixed-size stage and scale that stage with a CSS transform.
4.9 Putting it together in Vue
web/src/utils/host.js is the shim from 4.1, and web/src/utils/nui.js holds the fetchNui from 4.5.
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { isHosted, markReady, forwardEscapeToHost, requestHome } from './utils/host.js'
import { fetchNui } from './utils/nui.js'
const visible = ref(isHosted)
const info = ref(null)
let stopForwarding = () => {}
function onMessage(event) {
const msg = event.data
if (!msg || typeof msg !== 'object') return
if (msg.action === 'myapp:show') {
info.value = msg.data
visible.value = true
} else if (msg.action === 'myapp:hide') {
visible.value = false
}
}
function close() {
if (isHosted) requestHome()
else fetchNui('myapp:close')
}
function onKey(event) {
if (event.key === 'Escape' && !isHosted) close()
}
onMounted(() => {
window.addEventListener('message', onMessage)
window.addEventListener('keydown', onKey)
stopForwarding = forwardEscapeToHost()
markReady()
})
onUnmounted(() => {
window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKey)
stopForwarding()
})
</script>
<template>
<div v-if="visible" :class="isHosted ? 'screen hosted' : 'screen window'">
<p>{{ info ? info.name : 'Loading' }}</p>
<button @click="close">Close</button>
</div>
</template>
<style scoped>
.screen { background: #0f1113; color: #f4f5f7; padding: 24px; box-sizing: border-box; }
.hosted { position: fixed; inset: 0; }
.window { position: fixed; top: 50%; left: 50%; width: 420px; transform: translate(-50%, -50%); border-radius: 12px; }
</style>5. Widgets
5.1 Register a widget
olink.tablet.RegisterWidget({
id = 'my_app_orders',
label = 'Orders',
resource = GetCurrentResourceName(),
type = 'stat',
sizes = { 'small', 'medium' },
icon = 'fa-solid fa-box',
color = '#2563eb',
app = 'my_app',
data = { value = 0, label = 'Open orders' },
})| Field | Required | Default | Notes |
|---|---|---|---|
id | Yes | — | Same rules as app IDs. Widget IDs are a separate list from app IDs, so a widget may share its app's ID. The tablet uses calendar and profile itself |
label | Yes | — | Shown in the widget gallery, and in the header of template widgets |
resource | Yes | — | GetCurrentResourceName() |
type | Yes | — | stat, list, progress, text or frame |
sizes | No | { 'small' } | The sizes players may pick. The first entry is the default |
icon, color | No | Cube icon, the tablet's red | Same as for apps |
app | No | — | The app to open when the widget is tapped. See 5.5 |
requires | No | — | Same shape as for apps |
order | No | 100 | Position in the widget gallery |
data | No | — | The first data to show. The same as calling SetWidgetData straight after |
url, query, readyTimeoutMs | No | url = 'web/dist/index.html' | Frame widgets only |
RegisterWidget returns false for a bad id, label or resource, an unknown type, an ID another resource already took, or when the tablet isn't started.
5.2 Sizes
The home screen is a grid of 8 cells across and 5 down on each page.
| Size | Cells (across x down) | Shape |
|---|---|---|
small | 2 x 2 | Square |
medium | 4 x 2 | Wide |
large | 4 x 4 | Big square |
Players pick one of your sizes in the gallery. If you later remove a size from the list, widgets placed at that size switch to your first entry.
Each template lays itself out for the cell it is in, so the same data reads differently at each size:
| Template | small | medium | large |
|---|---|---|---|
stat | Value at the foot of the card | Value centred and about half again as large | Value centred, largest type, icon beside it |
list | One line per row, colour as a dot | Rows with icon tiles; a summary takes its own column beside them | Tall rows with big tiles; a summary becomes a headline above a divider |
progress | Bar at the foot of the card | Centred, thicker bar, larger percentage | Centred, thickest bar, largest percentage |
text | Up to 4 lines | Up to 5 lines, slightly larger type | Up to 13 lines, largest type |
frame | Your page fills the cell; read tabletSize from the query string and lay out accordingly | — | — |
List a size only when your data suits it. A stat with a long value or a list with one row both read better small than stretched across a large cell.
5.3 Template widget data
The tablet draws a header with your widget's icon and label, then your data underneath. Until data arrives it shows a loading skeleton, and nil or an empty table brings the skeleton back. Encoded data can be at most 8 KB.
stat
One big value.
olink.tablet.SetWidgetData('my_app_sales', {
value = '$4,250', -- shown as text; a number works too
label = 'Sales today',
sub = '12 orders', -- optional
icon = 'fa-solid fa-cash-register', -- optional, medium and large only
trend = 8, -- optional, see below
})trend can be 'up', 'down' or 'flat', a number (shown as a signed percentage, +8%), or { dir = 'up', text = '+$980' } for a pill with its own label.
list
Rows, with an optional headline number above them.
olink.tablet.SetWidgetData('my_app_orders', {
title = 'Open orders', -- hidden when summary is set
summary = { -- optional headline
value = '$1,980',
label = 'Owed to you',
sub = '2 customers',
trend = { dir = 'up', text = '+$980' },
},
items = {
{ text = 'Burger Shot', sub = '2 crates', value = '$640', icon = 'fa-solid fa-burger', color = '#f59e0b', trend = 'up' },
{ text = 'Pillbox Hill', value = '$1,340' },
},
empty = 'No open orders', -- shown when items is empty
})| Size | Rows shown | Rows shown with a summary |
|---|---|---|
small | 3 | 2 |
medium | 3 | 3 (the summary gets its own column) |
large | 8 | 6 |
- Extra rows collapse into "+N more".
- A row without
iconshows the first letter oftexton a tile tinted withcolor. - On
small, each row is a single line: no icon tile,subor trend, andcolorbecomes a dot. - A row trend of
'flat'without text isn't drawn.
progress
olink.tablet.SetWidgetData('my_app_route', {
label = 'Route',
value = 7,
max = 12, -- optional, default 100
sub = '5 stops left', -- optional
color = '#22c55e', -- optional, defaults to the widget's color
})It shows a percentage and a bar. Medium and large widgets also show 7 / 12.
text
olink.tablet.SetWidgetData('my_app_notice', {
title = 'Notice', -- optional
body = 'The depot closes at 22:00 tonight.',
})The body is cut at 4 lines on small widgets, 5 on medium and 13 on large.
5.4 Update with SetWidgetData
-- Client
olink.tablet.SetWidgetData('my_app_orders', { value = 3, label = 'Open orders' })-- Server
olink.tablet.SetWidgetData(source, 'my_app_orders', { value = 3, label = 'Open orders' })- The tablet keeps the last value whether or not it's open, so push whenever the value changes. It's cheap: nothing is redrawn while the tablet is put away.
- The client version returns
falsewhen the widget isn't registered on that client or the data is over 8 KB. The server version returnstrueonce relayed. - The widget must be registered on the client before data from the server arrives, or the data is dropped. A common pattern is to register on the client and then ask your server for the first value.
- The tablet forgets all widget data when the character unloads. Push again after
olink:client:playerReady. - Template widgets preview in the gallery with the data you pushed, so push before the player has placed the widget.
5.5 Link a widget to an app
Set app to one of your app IDs and:
- Tapping the widget opens that app, with the same checks as tapping its tile.
- The widget only appears while that app is registered and visible to the player. If the player uninstalls the app, no longer matches its
requires, or you unregister it, the widget disappears too. Register the app before the widget. - The widget shows the app's badge.
A widget without app can't be tapped and doesn't depend on any install.
5.6 Players place widgets
Nothing is added to a home screen automatically. Players tap Edit home screen, then Widget, and pick your widget and a size from the gallery. The gallery lists every widget the player qualifies for, so let players know yours exists.
When you unregister a widget, a placed copy disappears, but the player's spot is remembered for when you register it again.
5.7 Frame widgets
A frame widget is your own page inside a widget cell.
olink.tablet.RegisterWidget({
id = 'my_app_live',
label = 'Live status',
resource = GetCurrentResourceName(),
type = 'frame',
url = 'html/widget.html',
sizes = { 'medium', 'large' },
icon = 'fa-solid fa-signal',
app = 'my_app',
readyTimeoutMs = 15000,
})
olink.tablet.SetWidgetData('my_app_live', { value = 42, label = 'Units online' })The iframe address is:
https://cfx-nui-<your-resource>/<url>?tabletHost=oxide-tablet&tabletWidget=<id>&tabletSize=<small|medium|large>plus your query. There's no tabletApp, so in the shim hostedApp is null, hostedWidget is the widget ID, hostedSize is the size, and isHosted is true.
How a frame widget behaves:
- Report ready the same way an app does. Until you do, the tablet covers the cell with your icon and "Loading". If
readydoesn't arrive in time, the cell says "Unavailable". Widgets have no Retry button. - Data arrives as
{ action: 'tablet:widgetData', data }. For a frame widget,datacan be any table you like. Afterready, the tablet sends the latest value again, so the same data may arrive twice. - The iframe only exists while its home-screen page is on screen. Switching pages, opening an app or putting the tablet away removes it, and it loads from scratch next time. Keep startup quick.
- Changing the size loads the page again with the new
tabletSize. - Taps inside your iframe don't reach the tablet. Call
requestOpenApp()to open the widget'sapp, orrequestOpenApp('other_app')for a different one. requestHome()andrequestClose()do nothing for a widget. Never callSetNuiFocusfor one.- Size in
pxor%, notvh(see 4.8). - The gallery previews frame widgets as their icon only.
- When the tablet was opened from an item with an IMEI, the address also carries
&tabletImei=<imei>, the same as an app. Treat it as display-only (see 3.6).
A complete widget page using the classic shim, saved as html/widget.html next to html/js/host.js:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
html, body { margin: 0; height: 100%; background: #0f1113; color: #f4f5f7; font: 14px system-ui, sans-serif; }
body { display: flex; flex-direction: column; justify-content: center; padding: 12px 16px; box-sizing: border-box; cursor: pointer; }
#value { font-size: 32px; font-weight: 600; }
body[data-size="small"] #value { font-size: 24px; }
</style>
</head>
<body>
<div id="value">-</div>
<div id="label">Waiting for data</div>
<script src="js/host.js"></script>
<script>
const host = window.OxideTabletHost
document.body.dataset.size = host.hostedSize || 'medium'
window.addEventListener('message', (event) => {
const msg = event.data
if (!msg || msg.action !== 'tablet:widgetData' || !msg.data) return
document.getElementById('value').textContent = msg.data.value
document.getElementById('label').textContent = msg.data.label
})
document.body.addEventListener('click', () => host.requestOpenApp())
host.markReady()
</script>
</body>
</html>Remember to list html/widget.html and html/js/host.js in files {}.
6. Notifications and badges
6.1 Post a notification
local id = olink.tablet.Notify({
app = 'my_app',
title = 'New order',
message = 'Burger Shot ordered 2 crates.',
icon = 'fa-solid fa-box',
id = 'order:1042',
data = { screen = 'orders', order = 1042 },
})
-- id is 'my_app:order:1042', or false| Field | Required | Notes |
|---|---|---|
app | Yes | One of your registered app IDs. Register the app first: a notification for an app the client doesn't know is dropped |
message | Yes | Up to 280 characters. Longer text is cut |
title | No | Up to 80 characters |
icon | No | A Font Awesome class. Defaults to your app's icon |
id | No | Your own ID: up to 64 bytes, not starting with #. Posting the same ID again replaces that notification, moves it to the top and shows the banner again, which suits "one alert per thing" such as low stock. Without an ID, every call adds a new notification |
data | No | A table of up to 8 KB, encoded. Never displayed. Handed to your app when the player taps the notification |
sound | No | false for a silent notification |
toast | No | true shows the banner even when your app is on screen. false never shows a banner or bubble; the notification still goes to the tray and the badge |
Where it appears:
- Tablet out: a banner drops in under the status bar, unless your app is already on screen (
toast = trueoverrides that). The notification is added to the tray, which opens from the status bar. - Tablet put away: a bubble springs in at the bottom right of the game screen, and the notification waits in the tray for the next time the tablet is opened.
- It's only shown to players who can see your app (installed, allowed by
requires, on the item's list). Until then it's kept quietly and shows up in the tray once they can.
The player's own choices in the tablet's Settings app apply on top: Do Not Disturb, banners or sound off, or your app switched off entirely (see Settings App). The owner can also turn off the bubbles or all notifications.
On the client, Notify returns false when:
- the app isn't registered on that client,
- the player switched your app's notifications off,
- the owner turned notifications off,
messageis missing or empty,dataisn't a table or is over 8 KB, oridis empty, longer than 64 characters or starts with#.
Notifications live in memory until they're tapped or dismissed. They're also gone after a character switch, when your app is unregistered or your resource stops, and once there are more than the owner's cap (Config.Notifications.maxStored, 100 by default, oldest first).
6.2 Notify from the server
local id = olink.tablet.Notify(source, {
app = 'my_app',
title = 'Delivery',
message = 'Your delivery was signed for.',
})The server can't see the player's client, so it returns the ID as soon as the notification is sent. Without your own id it generates one (my_app:s<n>), counted across every server-side Notify on the server, so one app's numbers are not consecutive. A returned ID means "relayed", not "shown": the client still drops it if your app isn't registered there or the player muted it.
6.3 When the player taps it
The tablet opens your app, with the same checks as tapping its tile. If the player can't open it, nothing happens.
If you set data, it's sent into your page as { action: 'tablet:notification', data }, waiting in the queue until your page is ready.
The notification is removed from the tray.
olink:client:tablet:notificationTapped fires with (id, appId, data).
appOpened fires during the first step. If your appOpened handler waits on your server before it sends, tablet:notification reaches your page first, so don't rely on the order.
Handle it on the page:
window.addEventListener('message', (event) => {
const msg = event.data
if (msg && msg.action === 'tablet:notification' && msg.data) {
window.location.hash = msg.data.screen
}
})Or in Lua:
AddEventHandler('olink:client:tablet:notificationTapped', function(id, appId, data)
if appId ~= 'my_app' or type(data) ~= 'table' then return end
olink.tablet.Send('my_app', { action = 'myapp:showOrder', data = { order = data.order } })
end)6.4 Badges
olink.tablet.SetBadge('my_app', 3) -- client only-
The tile shows the larger of your badge and the number of your notifications still in the tray (anything over 99 shows as 99+). A linked widget shows the same number.
-
That means
SetBadge('my_app', 0)doesn't hide waiting notifications. Once the player has seen them in your app, clear them:AddEventHandler('olink:client:tablet:appOpened', function(appId) if appId == 'my_app' then olink.tablet.ClearNotifications('my_app') end end) -
SetBadgereturnsfalsefor an app that isn't registered. -
Badges reset when the character unloads.
6.5 Dismiss and clear
-- Client
olink.tablet.DismissNotification('my_app:order:1042') -- one notification, by the id Notify returned
olink.tablet.ClearNotifications('my_app') -- all of your app's notifications-- Server
olink.tablet.DismissNotification(source, 'my_app:order:1042')
olink.tablet.ClearNotifications(source, 'my_app')Always pass your app ID to ClearNotifications. Without it, the call clears every app's notifications on that player's tablet, not just yours.
7. Lifecycle and cleanup
| What happens | What the tablet does | What you do |
|---|---|---|
| Your resource stops | Removes all your apps and widgets, closes your app if it's on screen (appClosed with reason unregistered), and drops your notifications | Nothing is required |
| The tablet stops | Fires appClosed and closed if it was open. Its registry is gone | Reset any "registered" flags and hosted-window state you keep |
| The tablet starts or restarts | Fires olink:client:tablet:ready about a second later | Register again, then push widget data and badges |
| The character unloads (logout or character switch) | Puts the tablet away and clears badges, notifications and widget data. Registrations stay | Unregister anything that belonged to that character. Re-sync and push data again after olink:client:playerReady |
Job change (olink:client:jobChanged) | Re-checks requires on an open tablet and closes your app if the player no longer qualifies | If you register by your own rule, re-sync |
Gang change (olink:client:gangChanged) | Same as a job change | Same as a job change |
You call UnregisterApp(id) | Closes the app if it's on screen (reason unregistered), drops its notifications and hides its linked widgets | — |
You call UnregisterWidget(id) | Removes the widget from the screen and keeps the player's spot for it | — |
If o-link restarts, restart your resource as well so it takes a fresh olink snapshot.
Register by your own rule
When requires can't express who should have the app, keep a flag and sync it whenever the answer might change. This example gives the app only to players whose job marks them as a boss:
local TABLET = 'oxide-tablet'
local APP_ID = 'my_app'
local registered = false
local function tabletAvailable()
if GetResourceState(TABLET) ~= 'started' then return false end
local getName = olink.tablet and olink.tablet.GetResourceName
if getName == nil then return false end
local ok, name = pcall(getName)
return ok and name == TABLET
end
local function isBoss()
local ok, job = pcall(olink.job.Get)
return ok and type(job) == 'table' and job.isBoss and true or false
end
local function unregisterApp()
if not registered then return end
registered = false
if tabletAvailable() then olink.tablet.UnregisterApp(APP_ID) end
end
local function syncTabletApp()
local want = tabletAvailable() and isBoss()
if want and not registered then
registered = olink.tablet.RegisterApp({
id = APP_ID,
label = 'My App',
icon = 'fa-solid fa-star',
resource = GetCurrentResourceName(),
}) and true or false
elseif not want then
unregisterApp()
end
end
AddEventHandler('olink:client:tablet:ready', function()
registered = false
syncTabletApp()
end)
AddEventHandler('onClientResourceStop', function(resource)
if resource == TABLET then registered = false end
end)
AddEventHandler('olink:client:playerReady', syncTabletApp)
AddEventHandler('olink:client:jobChanged', syncTabletApp)
AddEventHandler('olink:client:playerUnload', unregisterApp)
CreateThread(function()
Wait(1000)
syncTabletApp()
end)8. Complete example resource
A small resource that works with or without the tablet. With the tablet it registers My App and a Players nearby stat widget, and sends the app one message when its page is ready. Without the tablet, /myapp opens the same page as a normal window.
Files
html/js/host.js is the classic shim from 4.1, copied unchanged.
fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
name 'my-tablet-app'
author 'Your Name'
description 'Example app and widget for oxide-tablet. Shows your name and how many players are nearby.'
version '1.0.0'
ui_page 'html/index.html'
files {
'html/index.html',
'html/js/host.js',
'html/js/app.js',
}
client_script 'client.lua'
dependencies {
'o-link',
}The App Store listing takes its description, publisher and version from this file.
client.lua
olink = exports['o-link']:olink()
local RESOURCE = GetCurrentResourceName()
local TABLET = 'oxide-tablet'
local APP_ID = 'my_app'
local WIDGET_ID = 'my_app_players'
local windowOpen = false
local function tabletAvailable()
if GetResourceState(TABLET) ~= 'started' then return false end
local getName = olink.tablet and olink.tablet.GetResourceName
if getName == nil then return false end
local ok, name = pcall(getName)
return ok and name == TABLET
end
local function snapshot()
return {
name = GetPlayerName(PlayerId()),
players = #GetActivePlayers(),
}
end
local function pushWidget()
if not tabletAvailable() then return end
olink.tablet.SetWidgetData(WIDGET_ID, {
value = #GetActivePlayers(),
label = 'Players nearby',
icon = 'fa-solid fa-users',
})
end
local function registerWithTablet()
if not tabletAvailable() then return end
local ok = olink.tablet.RegisterApp({
id = APP_ID,
label = 'My App',
icon = 'fa-solid fa-star',
resource = RESOURCE,
url = 'html/index.html',
color = '#2563eb',
category = 'utilities',
tagline = 'A tiny example app',
})
if not ok then
print(('[%s] oxide-tablet did not accept app %s'):format(RESOURCE, APP_ID))
return
end
olink.tablet.RegisterWidget({
id = WIDGET_ID,
label = 'Players nearby',
resource = RESOURCE,
type = 'stat',
sizes = { 'small', 'medium' },
icon = 'fa-solid fa-users',
color = '#2563eb',
app = APP_ID,
})
pushWidget()
end
-- The tablet started or restarted with an empty registry.
AddEventHandler('olink:client:tablet:ready', registerWithTablet)
-- The tablet forgets widget data when a character unloads.
AddEventHandler('olink:client:playerReady', pushWidget)
-- The page is listening. Also fires again after the player presses Retry.
AddEventHandler('olink:client:tablet:appReady', function(appId)
if appId ~= APP_ID then return end
olink.tablet.Send(APP_ID, { action = 'myapp:show', data = snapshot() })
end)
RegisterNUICallback('myapp:getData', function(_, cb)
cb(snapshot())
end)
-- Only the standalone window takes NUI focus. Inside the tablet the page calls requestHome instead.
RegisterNUICallback('myapp:close', function(_, cb)
if windowOpen then
windowOpen = false
SetNuiFocus(false, false)
SendNUIMessage({ action = 'myapp:hide' })
end
cb('ok')
end)
RegisterCommand('myapp', function()
if tabletAvailable() and olink.tablet.Open(APP_ID) then return end
windowOpen = true
SetNuiFocus(true, true)
SendNUIMessage({ action = 'myapp:show', data = snapshot() })
end, false)
TriggerEvent('chat:addSuggestion', '/myapp', 'Open My App')
CreateThread(function()
Wait(1000)
registerWithTablet()
while true do
Wait(15000)
pushWidget()
end
end)html/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>My App</title>
<style>
html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: transparent; color: #f4f5f7; font: 15px system-ui, sans-serif; }
#app { display: none; flex-direction: column; gap: 12px; box-sizing: border-box; padding: 24px; background: #0f1113; }
body.open #app { display: flex; position: fixed; top: 50%; left: 50%; width: 420px; transform: translate(-50%, -50%); border-radius: 12px; }
body.hosted #app { display: flex; position: fixed; inset: 0; }
h1 { margin: 0; font-size: 22px; }
p { margin: 0; }
.row { display: flex; gap: 8px; }
button { padding: 8px 14px; border: 0; border-radius: 6px; background: #2563eb; color: #fff; font: inherit; cursor: pointer; }
button.ghost { background: rgba(255, 255, 255, 0.1); }
</style>
</head>
<body>
<div id="app">
<h1>My App</h1>
<p>Signed in as <strong id="name">-</strong></p>
<p><strong id="players">-</strong> players nearby</p>
<div class="row">
<button id="refresh">Refresh</button>
<button id="close" class="ghost">Close</button>
</div>
</div>
<script src="js/host.js"></script>
<script src="js/app.js"></script>
</body>
</html>html/js/app.js
(function () {
const host = window.OxideTabletHost
const resource = host.getResourceName('my-tablet-app')
const $ = (id) => document.getElementById(id)
if (host.isHosted) document.body.classList.add('hosted')
function callLua(name, data) {
return fetch(`https://${resource}/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data || {}),
}).then((res) => res.json()).catch(() => null)
}
function render(info) {
if (!info) return
$('name').textContent = info.name
$('players').textContent = info.players
}
// olink.tablet.Send (inside the tablet) and SendNUIMessage (own window) arrive the same way.
window.addEventListener('message', (event) => {
const msg = event.data
if (!msg || typeof msg !== 'object' || typeof msg.action !== 'string') return
if (msg.action === 'myapp:show') {
render(msg.data)
if (!host.isHosted) document.body.classList.add('open')
} else if (msg.action === 'myapp:hide') {
document.body.classList.remove('open')
}
})
$('refresh').addEventListener('click', () => callLua('myapp:getData').then(render))
$('close').addEventListener('click', () => {
if (host.isHosted) host.requestHome()
else callLua('myapp:close')
})
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !host.isHosted) callLua('myapp:close')
})
host.forwardEscapeToHost()
host.markReady()
})()Try it
Put my-tablet-app in your resources folder and add ensure my-tablet-app to server.cfg below ensure o-link, next to your other resources that use o-link. See Installation → Load Order.
Restart the server and join.
Type /myapp. The tablet comes out with My App on screen, showing your name and the player count. Press Close or ESC to go back to the home screen.
Open the tablet's App Store, find My App and install it. A tile appears on the home screen.
Tap Edit home screen, then Widget, and add Players nearby. Tapping the widget opens My App.
Stop oxide-tablet and type /myapp again. The same page opens as a normal window instead.
With Debug logging turned on in the Advanced section of /tablet settings, each player's F8 console prints lines such as [oxide-tablet] app registered: my_app (my-tablet-app). Debug logging also adds the tablet's own Demo app to the App Store, with two demo widgets in the gallery once it's installed. The Demo app is a working reference for this contract: it shows the parameters it was opened with and the resource name it resolved, calls its own NUI callback, and has Home and Close buttons. Turn debug logging off again on a live server.
9. Common mistakes checklist
Setup
oxide-tabletis not in yourdependencies, and you never callexports['oxide-tablet'].o-linkis in yourdependencies, and yourensureline comes afterensure o-link.- Your resource name has no dots and is lower case.
- Every page the tablet loads (and its scripts and styles) is listed in
files {}. - Vite builds use
base: './'.
Lua
- You detect the tablet with
GetResourceStateplusolink.tablet.GetResourceName(), notolink.supportsand nottype(...) == 'function'. - You register at boot and on
olink:client:tablet:ready. resourceisGetCurrentResourceName().- IDs are unique to your resource and stay the same between versions. You don't use the tablet's own IDs (
app_store,tablet_settings,tablet_map,tablet_demo,tablet_demo_stat,tablet_demo_frame,calendar,profile). querydoesn't rely ontabletHost,tabletApp,tabletWidget,tabletSizeortabletImei: the tablet drops those names.- You check permissions before
olink.tablet.Open(appId). It skipsrequiresand installs. - Hosted apps get data through
olink.tablet.Send, neverSendNUIMessage. - The first screen is sent from
appReady, or pulled by the page, so Retry doesn't leave it empty. - You never call
SetNuiFocusfor a hosted app or a widget, and don't play your own tablet animation while hosted. - Widget data and badges are pushed again after
olink:client:playerReady. - The app is registered before you
Notifyfor it, and before you register widgets linked to it. ClearNotificationsalways gets your app ID.- Per-device state is keyed on the server's
GetDevice(src), not ontabletImeifrom the page.
Web
- The host shim is copied unchanged from 4.1.
markReady()is called after yourmessagelistener is added.forwardEscapeToHost()is called, and your own ESC handler does nothing while hosted.- Close buttons call
requestHome()orrequestClose()while hosted. - The resource name comes from
getResourceName(), notGetParentResourceName(). - "Am I in the game?" checks accept
isHosted, so players never see mock data. - Your own frame, backdrop and bezel are hidden while hosted, and the page fills the iframe.
- Layout doesn't depend on
vhmeaning the game screen. - Pages that load from a CDN register a longer
readyTimeoutMs. - Frame widgets use
requestOpenApp()for taps and start quickly.
Where to Go Next
API Reference
Every tablet function, field, return value and event in one reference.
Features
What players see: home screen, App Store, widget gallery and notification tray.
Configuration
Config.Store, Config.Items, Config.AppReadyTimeout, Config.EscapeBehavior and Config.Notifications.
Settings App
The player's notification and accessibility preferences.
Admin Tools
/tablet settings, including debug logging.
Troubleshooting
Common problems and fixes.
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.
Troubleshooting
Fixes for the most common oxide-tablet problems — startup, opening the tablet, apps, home screens and saving, wallpapers, notifications, and settings.