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. SendNUIMessage reaches your ui_page and never the copy inside the tablet. You talk to the hosted page with olink.tablet.Send.
  • FiveM serves the page from your resource, so the file must be listed in the files {} block of your fxmanifest.lua.
  • The page can still call your own RegisterNUICallback handlers with fetch, 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:

KindTypesWho draws it
Templatestat, list, progress, textThe tablet. You push a small data table and the tablet renders it. No web code needed
FrameframeYou. 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 setupWhat 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.preinstalledInstalled 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.ItemsThat 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. duty only applies to the jobs match.
  • Leave requires out 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 match requires. Check permissions yourself before you open.
  • The tablet re-checks requires every time it draws the home screen. On olink:client:jobChanged, and on olink: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

  1. Never make oxide-tablet a dependency. Don't list it in dependencies and don't call exports['oxide-tablet']. Your resource must start and work on servers without the tablet.

  2. Depend on o-link and talk through it. List o-link in dependencies, start your resource after it, take the snapshot once, and use olink.tablet.*:

    olink = exports['o-link']:olink()
  3. Use a resource name made of letters, numbers, - and _. The tablet only mounts iframe addresses that start with https://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 matches GetCurrentResourceName().

  4. Pass your own resource name as resource when you register. Your call reaches the tablet through o-link, so the tablet can't tell which resource called. It uses resource to build the iframe address and to clean up when your resource stops.

  5. 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 needs base: './'. 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
end

Both checks are needed:

SituationGetResourceState('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') or olink.supports('tablet.RegisterApp'). o-link installs placeholder functions for every namespace, so these return true even 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 always false in your resource. Nil-check the member and call it, with pcall when 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
FieldRequiredDefaultNotes
idYes1 to 64 characters: letters, digits, _ and -. Prefix it with something unique to your resource
labelYesShown 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
resourceYesGetCurrentResourceName()
iconNo'fa-solid fa-cube'A Font Awesome 6 class string
urlNo'web/dist/index.html'Path inside your resource. A leading / is removed. Must be in files {}
queryNoExtra 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
colorNoThe tablet's redSix-digit hex such as '#2563eb'. Anything else falls back to the default
orderNo100Lower comes first on a fresh home screen and in the App Store
readyTimeoutMsNoThe owner's setting (8000)How long your page gets to report ready. See 4.3
requiresNoSee What requires filters
category, tagline, description, publisher, versionNoThe App Store listing. See 3.2

RegisterApp returns false without saying why. The usual causes are:

  • id is missing, longer than 64 characters, or contains other characters (spaces, dots, colons).
  • label or resource is missing or empty, or resource isn't a resource on the server.
  • Another resource already registered that ID. The tablet uses app_store, tablet_settings and tablet_map itself, and tablet_demo while 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',
})
FieldLimitIf you leave it out
categoryOne of business, finance, law, medical, social, utilities, otherutilities. Unknown values also become utilities
tagline80 bytesNo subtitle
description280 bytesThe description in your fxmanifest.lua
publisher60 bytesThe author in your fxmanifest.lua
version24 bytesThe 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 false when 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 true means "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 as SendNUIMessage, so one message listener on your page handles both. action must be a string.
  • It only reaches your app while it's on screen. Otherwise the client version returns false and nothing is delivered. The server version can't see the client, so it returns true once 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:

EventFires whenUse 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 returnsFetching 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 screenSending 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
end

Don'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.

EventArgumentsFires when
olink:client:tablet:readyThe tablet started or restarted. Register again
olink:client:tablet:appOpenedappIdAn app was put on screen
olink:client:tablet:appReadyappIdThat app's page reported ready
olink:client:tablet:appClosedappId, reasonThe app left the screen. reason is home, closed, switch (another app opened) or unregistered
olink:client:tablet:closedThe tablet was put away. Comes after appClosed when an app was open
olink:client:tablet:widgetReadywidgetIdA frame widget's page reported ready
olink:client:tablet:notificationTappedid, appId, dataThe player tapped a notification
olink:client:tablet:deviceRenameddeviceThe 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 nil when 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's GetDevice(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 withSave the shim asLoad it with
Vite, Vue or another bundlerweb/src/utils/host.js (ES module version)import { ... } from './utils/host.js'
Plain <script> tagshtml/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.

web/src/utils/host.js
// 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)
}
html/js/host.js
// 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

NameWhat it is
isHostedtrue inside the tablet (an app or a frame widget), false in your normal ui_page
hostedAppThe app ID from the URL, or null
hostedWidgetThe 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 (or width: 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 SetNuiFocus from 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 you Send wait in the queue. They're delivered the instant it arrives, so a listener you add after markReady() misses them.
  • While it waits, the tablet shows your icon and "Loading My App".
  • If ready doesn'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 late ready clears 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 register readyTimeoutMs = 15000 for that reason.
  • Retry reloads your page and empties the queue. appReady fires 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:

actionSent toWhendata
tablet:notificationAppsThe player tapped one of your notificationsThe data you gave Notify
tablet:widgetDataFrame widgetsThe widget's data changed, and again after readyWhat 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() or requestClose() while hosted. Both return to the tablet's home screen; appClosed fires with reason home or closed.
  • 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 vh for a full-screen window comes out roughly 30% smaller.
  • Frame widgets get their cell block, and 100vh is the widget's own height: a couple of hundred pixels for small and medium widgets on a 1080p screen. Text sized in vh becomes 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.

web/src/App.vue
<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' },
})
FieldRequiredDefaultNotes
idYesSame 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
labelYesShown in the widget gallery, and in the header of template widgets
resourceYesGetCurrentResourceName()
typeYesstat, list, progress, text or frame
sizesNo{ 'small' }The sizes players may pick. The first entry is the default
icon, colorNoCube icon, the tablet's redSame as for apps
appNoThe app to open when the widget is tapped. See 5.5
requiresNoSame shape as for apps
orderNo100Position in the widget gallery
dataNoThe first data to show. The same as calling SetWidgetData straight after
url, query, readyTimeoutMsNourl = '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.

SizeCells (across x down)Shape
small2 x 2Square
medium4 x 2Wide
large4 x 4Big 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:

Templatesmallmediumlarge
statValue at the foot of the cardValue centred and about half again as largeValue centred, largest type, icon beside it
listOne line per row, colour as a dotRows with icon tiles; a summary takes its own column beside themTall rows with big tiles; a summary becomes a headline above a divider
progressBar at the foot of the cardCentred, thicker bar, larger percentageCentred, thickest bar, largest percentage
textUp to 4 linesUp to 5 lines, slightly larger typeUp to 13 lines, largest type
frameYour 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
})
SizeRows shownRows shown with a summary
small32
medium33 (the summary gets its own column)
large86
  • Extra rows collapse into "+N more".
  • A row without icon shows the first letter of text on a tile tinted with color.
  • On small, each row is a single line: no icon tile, sub or trend, and color becomes 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 false when the widget isn't registered on that client or the data is over 8 KB. The server version returns true once 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.

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 ready doesn't arrive in time, the cell says "Unavailable". Widgets have no Retry button.
  • Data arrives as { action: 'tablet:widgetData', data }. For a frame widget, data can be any table you like. After ready, 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's app, or requestOpenApp('other_app') for a different one.
  • requestHome() and requestClose() do nothing for a widget. Never call SetNuiFocus for one.
  • Size in px or %, not vh (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:

html/widget.html
<!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
FieldRequiredNotes
appYesOne of your registered app IDs. Register the app first: a notification for an app the client doesn't know is dropped
messageYesUp to 280 characters. Longer text is cut
titleNoUp to 80 characters
iconNoA Font Awesome class. Defaults to your app's icon
idNoYour 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
dataNoA table of up to 8 KB, encoded. Never displayed. Handed to your app when the player taps the notification
soundNofalse for a silent notification
toastNotrue 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 = true overrides 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,
  • message is missing or empty,
  • data isn't a table or is over 8 KB, or
  • id is 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)
  • SetBadge returns false for 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 happensWhat the tablet doesWhat you do
Your resource stopsRemoves all your apps and widgets, closes your app if it's on screen (appClosed with reason unregistered), and drops your notificationsNothing is required
The tablet stopsFires appClosed and closed if it was open. Its registry is goneReset any "registered" flags and hosted-window state you keep
The tablet starts or restartsFires olink:client:tablet:ready about a second laterRegister 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 stayUnregister 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 qualifiesIf you register by your own rule, re-sync
Gang change (olink:client:gangChanged)Same as a job changeSame 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

fxmanifest.lua
client.lua
index.html
host.js
app.js

html/js/host.js is the classic shim from 4.1, copied unchanged.

fxmanifest.lua

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

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

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

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-tablet is not in your dependencies, and you never call exports['oxide-tablet'].
  • o-link is in your dependencies, and your ensure line comes after ensure 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 GetResourceState plus olink.tablet.GetResourceName(), not olink.supports and not type(...) == 'function'.
  • You register at boot and on olink:client:tablet:ready.
  • resource is GetCurrentResourceName().
  • 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).
  • query doesn't rely on tabletHost, tabletApp, tabletWidget, tabletSize or tabletImei: the tablet drops those names.
  • You check permissions before olink.tablet.Open(appId). It skips requires and installs.
  • Hosted apps get data through olink.tablet.Send, never SendNUIMessage.
  • The first screen is sent from appReady, or pulled by the page, so Retry doesn't leave it empty.
  • You never call SetNuiFocus for 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 Notify for it, and before you register widgets linked to it.
  • ClearNotifications always gets your app ID.
  • Per-device state is keyed on the server's GetDevice(src), not on tabletImei from the page.

Web

  • The host shim is copied unchanged from 4.1.
  • markReady() is called after your message listener is added.
  • forwardEscapeToHost() is called, and your own ESC handler does nothing while hosted.
  • Close buttons call requestHome() or requestClose() while hosted.
  • The resource name comes from getResourceName(), not GetParentResourceName().
  • "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 vh meaning 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