The phone operating system
for FiveM servers.
WYRM OS is rde_phone's app platform: one SDK, a real design system, and a registry any resource can plug an app into — without touching a line of the phone's own code. No React. No Module Federation. No CDN dependencies.
Overview
rde_phone is a FiveM smartphone resource built for one purpose: making it fast to give any other resource its own polished phone app, without that resource needing to know anything about NUI internals, iframes, or cross-resource plumbing.
Most FiveM phone frameworks solve this with Module Federation — a React app split across resources, sharing a runtime, loaded via a remoteEntry.js. It works, until it doesn't: shared React versions drift, federation chunks fail to resolve, and debugging a broken remote entry eats a night you didn't plan for. rde_phone doesn't use it at all.
<iframe> loading plain HTML/CSS/JS. Built-in apps get zero special treatment. If you can write a script tag, you can build a phone app.
Why this exists
The alternative most servers ship with couples every app's frontend to a shared bundler, a shared React tree, and a shared point of failure. One app's dependency bump can break every other app on the phone. rde_phone's iframe-per-app model means a broken app is a broken app — not a broken phone.
Design pillars
| Pillar | What it means in practice |
|---|---|
| Zero dependency | Icons, fonts, and the app SDK all ship inside the resource. No CDN calls at runtime — CEF's network layer is not something you want in your critical path. |
| One bridge | Every app talks to Lua through a single sdk:request relay. No per-app NUI callback wiring. |
| Real registry | Apps register themselves at runtime via an export. The homescreen has no hardcoded app list. |
| Config-driven i18n | English by default, other languages are additive — never hand-translated per app. |
Quick Start
Installing rde_phone and getting the base OS running.
1. Dependencies & load order
rde_phone depends on ox_core, ox_lib, oxmysql, ox_inventory, and pma-voice (for Calls). Any resource that registers an app should depend on rde_phone so it always starts first.
-- load order matters
ensure ox_core
ensure ox_lib
ensure oxmysql
ensure ox_inventory
ensure pma-voice
ensure rde_phone
-- any app resource goes after rde_phone
ensure rde_banking
2. Register the phone item
The phone opens only if the player is holding the item — no item, no phone. Register it in ox_inventory's own item list:
['phone'] = {
label = 'Phone',
weight = 190,
stack = false,
close = true,
client = {
export = 'rde_phone.use_phone',
},
},
3. Open it
F2 by default (Config.OpenKey), or use the item from the inventory. Both routes go through the same server-validated check — pressing the key with no phone in your inventory does nothing but show an error toast.
Architecture
Every request from every app — built-in or third-party — takes the same four-hop path.
The iframe boundary
Apps never call Lua directly — NUI only talks to the resource that served the current page. So the app calls the SDK, the SDK posts a message to the parent window (the phone shell), and the shell is the only thing that ever actually calls fetch() against a NUI endpoint.
Cross-resource apps
A third-party app doesn't have to live inside rde_phone's own files. Any resource can serve its own app and reference rde_phone's shared assets by their cross-resource NUI origin:
<link rel="stylesheet" href="https://cfx-nui-rde_phone/web/theme/rdui-phone.css" />
<script src="https://cfx-nui-rde_phone/web/vendor/lucide.js"></script>
<script src="https://cfx-nui-rde_phone/web/sdk/rde-phone-sdk.js"></script>
Your app's own backend calls don't go through RDEPhone.request() though — that only reaches rde_phone's own server callbacks. For your own data, fetch() your own resource's NUI endpoint directly. See Your First App for the full pattern.
RDEPhone.request(action) = talk to rde_phone. Plain fetch('https://your_resource/endpoint') = talk to your own resource. Apps typically use both.
SDK Reference
One script tag gives every app the full SDK surface:
<script src="../../sdk/rde-phone-sdk.js"></script>
| Method | Signature | Notes |
|---|---|---|
| request | RDEPhone.request(action, payload) → Promise | Calls a rde_phone:server:* callback. Rejects on {error:true} or an 8s timeout. |
| on | RDEPhone.on(event, callback) | Subscribes to shell pushes: sms:received, phone:stateSync, custom events you broadcast. |
| notify | RDEPhone.notify({title, body, icon}) | Native-looking toast, only while your app's iframe is loaded. For background notifications, see Push Notifications. |
| close | RDEPhone.close() | Closes the whole phone, e.g. after a successful action. |
| t | RDEPhone.t(key, ...args) → string | Translates a key from rde_phone's own Config.Locales. %s placeholders fill in order. |
| ready | RDEPhone.ready → Promise | Resolves once locale + player language are loaded. Await this before your first render if you use t(). |
| getLanguage | RDEPhone.getLanguage() → string | Current cached language code, e.g. 'en'. |
| setViewfinder | RDEPhone.setViewfinder(enabled) | Drops the phone bezel to fullscreen-transparent so the game world shows through — built for the Camera app, usable by any app. Always turn it back off yourself; the shell doesn't do it automatically. |
Example: fetching and rendering data
await RDEPhone.ready;
const contacts = await RDEPhone.request('getContacts');
list.innerHTML = contacts.map(c => `<div>${c.name}</div>`).join('');
RDEPhone.on('sms:received', (msg) => {
RDEPhone.notify({ title: 'New message', body: msg.body, icon: 'message-circle' });
});
Your First App
A complete, working app in under 40 lines. No build step, no bundler — this file runs as-is.
1. The Lua side
Register a server callback for your app's data, and register the app itself so it shows up on the homescreen.
lib.callback.register('my_app:cb:phoneGetGreeting', function(src)
local player = Ox.GetPlayer(src)
return { message = 'Hello, ' .. (player and player.name or 'stranger') }
end)
local function RegisterMyApp()
pcall(function()
exports.rde_phone:RegisterPhoneApp({
slug = 'my_app',
title = 'My App',
icon = 'sparkles', -- any Lucide icon name
color = '#a855f7', -- optional icon tint
url = ('https://cfx-nui-%s/web/phone/index.html'):format(GetCurrentResourceName()),
order = 70,
})
end)
end
AddEventHandler('onResourceStart', function(name)
if name ~= GetCurrentResourceName() then return end
SetTimeout(500, RegisterMyApp)
end)
AddEventHandler('rde_phone:ready', RegisterMyApp) -- covers the other start order too
2. The client-side bridge
NUI can only reach the resource that served the page — you need one relay callback so your app can reach your own server callbacks.
RegisterNUICallback('phone:request', function(data, cb)
CreateThread(function()
local ok, result = pcall(function()
return lib.callback.await('my_app:cb:phone' .. data.action, false, data.payload)
end)
cb(ok and result or { error = true })
end)
end)
3. The app itself
<link rel="stylesheet" href="https://cfx-nui-rde_phone/web/theme/rdui-phone.css">
<script src="https://cfx-nui-rde_phone/web/sdk/rde-phone-sdk.js"></script>
<div id="greeting"></div>
<script>
const resourceName = GetParentResourceName();
async function myRequest(action, payload) {
const r = await fetch(`https://${resourceName}/phone:request`, {
method: 'POST', body: JSON.stringify({ action, payload }),
});
return r.json();
}
(async () => {
const { message } = await myRequest('GetGreeting');
document.getElementById('greeting').textContent = message;
})();
</script>
App Registry
The homescreen has no hardcoded app list — every icon on it, including the four built-in apps, came from a call to RegisterPhoneApp.
| Field | Required | Notes |
|---|---|---|
| slug | Yes | Unique identifier, e.g. 'banking'. |
| title | One of title / titleKey | Literal string, shown as-is. |
| titleKey | One of title / titleKey | Translation key — resolved per-player against Config.Locales, only meaningful for apps sharing rde_phone's own locale table. |
| icon | Yes | Any Lucide icon name. |
| color | No | Hex string. Developer-set icon tint — not a user setting. |
| url | Yes | Full URL, typically https://cfx-nui-<your_resource>/.... |
| order | No | Sort position, lower first. Built-ins use 10–90. |
Ordering-race safety
If your resource starts before rde_phone, the export call would fail. Handle both directions:
AddEventHandler('onResourceStart', function(name)
if name ~= GetCurrentResourceName() then return end
SetTimeout(500, RegisterMyApp) -- covers: rde_phone already running
end)
AddEventHandler('rde_phone:ready', RegisterMyApp) -- covers: rde_phone starts after you
Simplest fix: just declare rde_phone as a dependency in your fxmanifest.lua and FXServer guarantees the order — the event fallback is defense in depth for when someone restarts resources individually.
Removing an app
exports.rde_phone:UnregisterPhoneApp('my_app')
Localization
English is the default everywhere. Every string — Lua-side messages and NUI-side UI text — lives in exactly one place: Config.Locales in config.lua. Neither Lua nor the NUI keep a second copy.
Config.DefaultLanguage = 'en'
Config.Locales = {
en = { greeting = 'Hello, %s' },
de = { greeting = 'Hallo, %s' },
}
Lua side reads via Config.GetString(key, ...). NUI apps read the exact same table through the SDK:
await RDEPhone.ready;
title.textContent = RDEPhone.t('greeting', playerName);
RDEPhone.t() caches the language once at load. If your app changes the language while already open (like the Settings app does), keep your own small local translator tied to the live value instead of waiting for the SDK's cache to catch up.
Push Notifications
RDEPhone.notify() only works while your app's iframe is actually loaded. For events that happen while the player is on a different app — or has the phone closed entirely — use the generic server-side push API:
exports.rde_phone:PushNotification(targetSource, {
title = 'Bank',
body = 'Wire received: $500',
icon = 'landmark',
app = 'banking', -- ties the badge count to your homescreen icon
})
Gated server-side by phone possession — same check as every other phone action. If the target doesn't have a phone on them, it silently no-ops, exactly like a real phone not vibrating in a drawer.
Design System
Reusable classes from theme/rdui-phone.css. Link it once, get every component below for free.
Glass buttons
Semi-transparent, blurred, with a diagonal shine — reserved for primary actions. Tint is driven by CSS custom properties, so any color works without redefining the blur/shine/border treatment.
<div class="glyph rdui-glass" style="--glass-c1:20,255,20;--glass-c2:0,60,0;--glass-glow:0,255,0;"></div>
Buttons, toggles, badges
| Class | Use for |
|---|---|
| .rdui-btn.solid | Primary action button |
| .rdui-btn.ghost | Cancel / secondary action |
| .rdui-toggle / .on | Settings-style switch |
| .rdui-modal-backdrop / .rdui-modal | Bottom-sheet dialog |
| .rdui-list-item | Row in a scrollable list |
| .rdui-avatar | Circular icon/photo slot |
| .rdui-fab | Floating action button, bottom-right |
| .rdui-empty | Empty-state message |
| .rdui-badge.count | Notification count dot |
Icons
Lucide, self-hosted — web/vendor/lucide.js. After changing the DOM, call lucide.createIcons() again; it only converts <i data-lucide> tags present at call time.
refreshIcons() unconditionally once at script load for static markup — not only after a successful data fetch. Every early return (empty state, error state) otherwise skips it and those icons stay invisible.
Common Pitfalls
Real bugs found and fixed building the built-in apps and three production ports. Worth reading before you hit them yourself.
window.prompt() / alert() / confirm() freeze the NUI
FiveM's NUI renders through CEF off-screen rendering, which cannot display a native browser dialog. The call blocks forever waiting on a modal response that never comes — the whole game stops responding to input, and only restart your_resource recovers it.
Fix: build a small in-page HTML modal instead. See the bottom-sheet pattern in Design System.
MySQL.query() without .await is fire-and-forget
If a resource restarts moments after a player saves something, the write may never land — the connection tears down before the async query completes. Looks like "settings randomly reset," is actually a race condition.
Fix: use .await on every write inside a callback. Callbacks already run in their own coroutine — there's no reason not to.
local Config = {} in a shared_script only exists in that file
shared_scripts don't capture return values like require() does — each file is its own Lua chunk. A local table in config.lua is invisible from server.lua or client.lua, which then see a nil global and error on first use.
Fix: declare shared config as a bare global — Config = {}, no local, no return.
lib.callback.await() only works client → server
It's tempting to call it from inside another server callback to reuse logic. It silently times out — there's no server-to-server variant.
Fix: extract the shared logic into a plain Lua function both callbacks call directly.
ID-selector CSS beats a class-based tint every time
#homescreen { background: ... } will always override a .wp-crimson class on the same element — ID specificity wins regardless of source order in the stylesheet.
Fix: don't declare the property at the ID level at all if a class is meant to own it.
A child element's own background can silently defeat setViewfinder()
setViewfinder(true) only toggled #phone-frame and #phone-screen to transparent. #app-view — the container hosting the app iframe, sitting directly above at z-index: 20 — kept its own solid background: var(--bg) fill that nothing ever cleared. One opaque layer sitting on top of an otherwise fully transparent stack is enough: the Camera app rendered a black screen every single time it opened.
Fix: #phone-screen.viewfinder-mode #app-view { background: transparent; }. Any app calling setViewfinder(true) now actually sees through — not just the two elements someone remembered to toggle.
A scripted cam reinvents a feature the engine already ships
The original Camera app built its own view with CreateCamWithParams + RenderScriptCams — manual position math, manual FOV, manual framing for standard vs. selfie mode. Every one of those is a place to get it subtly wrong, and it's the exact class of setup that produced the black-screen bug above.
Fix: CellCamActivate(true, true) + CreateMobilePhone(1) is the same native mechanism the base game's own "Take a Selfie" feature runs on. Pose, framing, and front/back facing are all handled by the engine — less code, and no custom cam math left to debug.
playerJoining fires before the character is loaded
It fires the instant a client connects — before character selection. Ox.GetPlayer(source) is still nil at that point, so anything reading the player's identifier there (a phone-number registry, a permissions cache, anything keyed by stateId/charId) silently gets nil back and the write never happens. Nothing errors. It just looks like the feature never ran, for every single player, every single session.
Fix: listen for ox:playerLoaded instead — it only fires once the character is actually loaded server-side. Also re-run that registration for already-connected players on your own onResourceStart, or a mid-session resource restart leaves them missing until they reconnect.
A WebAudio AudioContext starts suspended until a real user gesture
Chromium — and FiveM's CEF NUI is Chromium — creates every AudioContext in a suspended state until a genuine click or keydown resumes it. A server-pushed event that auto-opens the UI, like an incoming call or a notification, is not a gesture. Every sound queued into a suspended context throws no error and produces no audio — it just vanishes, which looks exactly like a missing sound file until you check audioContext.state.
Fix: call .resume() defensively before every sound (cheap no-op once it's already running), and additionally grab the very first pointerdown/keydown anywhere in the NUI as a one-time unlock, so the context is usually already awake by the time something urgent needs to play.
pma-voice call channels: use the client export, not the server one
pma-voice ships both exports['pma-voice']:setPlayerCall(source, channel) (server) and exports['pma-voice']:setCallChannel(channel) / addPlayerToCall(channel) (client, called locally by each participant) — both documented, both accept the same channel int. Only the client-side path is what shipped, production phone scripts actually use. Calling the server export alone left each client's own local voice state never actually updating, with nothing in the console to point at — the call UI worked, the channel number was correct server-side, and the two players still couldn't hear each other.
Fix: server tells each participant's client "you're in this call now" (a plain TriggerClientEvent), and each client calls exports['pma-voice']:addPlayerToCall(channel) / removePlayerFromCall() on itself. No server-side pma-voice export needed at all.
Only wiring the call UI for the person receiving it
The ring → accept → connected flow naturally gets built for the callee first, since that's the side with a decision to make. That left the caller staring at a static "Calling…" string in the dialer that never updated — no indication the call connected, no way to see duration, no hangup button, because the caller's client had never been told about any of those state changes in the first place.
Fix: every event the callee gets (incoming, accepted, ended) needs a caller-side equivalent (outgoing, accepted, ended), pushed to both sources from the same server-side call state machine, driving the exact same overlay component in a different visual state — not a second, simplified UI for "the other side."
Built-in Apps
Messages
Threads, chat view, live delivery — gated by phone possession on the receiving end, same as every push.
Calls
Dialer + call log with a full ring → connect → hangup overlay on both ends of the call, not just the callee. Real voice via pma-voice's client-side channel export. Ring timeout auto-resolves to "missed."
Contacts
List, add, tap-to-call, tap-to-message shortcuts into the other apps.
Settings
Wallpaper (presets + custom image URL or a photo straight from the Gallery), ringtone/SMS tone selection with live preview, language switch.
Camera
Standard + selfie mode via the native CellCamActivate / CreateMobilePhone engine — the same mechanism the base game's own "Take a Selfie" feature runs on, not a hand-rolled scripted cam. Front/back facing is a native toggle, HUD and radar auto-hide for the shot, and capture goes through screenshot-basic — no external upload service. Uses RDEPhone.setViewfinder() to go fullscreen-transparent.
Gallery
Grid, full-size viewer, delete, and "Set as Wallpaper" — feeds straight back into Settings' custom-wallpaper field as a data URI.
screenshot-basic's plain requestScreenshot() — a base64 data URI straight back to the callback, never uploaded anywhere. Stored as-is in a LONGTEXT column. Same pattern already proven at scale for rde_crew's graffiti artwork: no Fivemanage, no Imgur, no Discord webhook, no external point of failure.
Case Studies
Three production resources ported from NPWD/Module Federation onto this SDK. Same architecture, three very different feature sets.
rde_banking
Full banking: deposit, withdraw, transfer, investments, loans, prestige tiers.
- Offline-safe payouts via a dedicated balance-adjustment export
- Core money logic shared between the world-UI and the phone app — one source of truth, not duplicated
rde_carservice
Garage vehicle delivery & pickup, called from the phone instead of walking to a fixed NPC.
- The delivery/pickup animation logic never changed — the phone just triggers the same NUI callbacks the world-menu already used
rde_bodyguards
Squad orders, roster, and a public marketplace — list a bodyguard, anyone can browse and buy.
- Marketplace purchases run through rde_banking's offline-safe export so a seller gets paid even while logged out
- Atomic
UPDATE ... WHERE status='active'claim prevents double-selling under concurrent buys
FAQ
Do apps need a build step?
No. Every app is plain HTML/CSS/JS served as-is. No bundler, no package.json, no compile step between editing a file and seeing the change.
Can I use React or Vue in my app?
Nothing stops you from including a framework via its own script tag inside your app's iframe — it's fully isolated from every other app. You'd be opting back into the exact dependency surface rde_phone was built to avoid, but the architecture doesn't forbid it.
What if my app needs data rde_phone doesn't expose?
That's expected — your app talks to your own resource's server callbacks via the client-side phone:request relay pattern in Your First App, not through rde_phone at all.
Does the registry survive a resource restart?
The registry itself lives in rde_phone's memory. If your resource restarts, it re-registers on its own onResourceStart. If rde_phone restarts, every dependent resource re-registers via the rde_phone:ready event.
Can I remove the built-in apps?
Call exports.rde_phone:UnregisterPhoneApp(slug) with 'sms', 'calls', 'contacts', or 'settings' — they go through the exact same registry as everything else, no special protection.
Why does pma-voice need to be running for Calls to work at all?
Calls piggyback entirely on pma-voice's own call-channel system for real voice — rde_phone only handles the dialer UI, ringing state, and call log. No pma-voice, no audio; the call would still connect and show "Connected" with a running timer, just silently.
Changelog
Calls: caller now gets the same full overlay as the callee (was stuck on a static "Calling…" string with no way to see when the call connected or hang up) — outgoing/accepted/ended now wired symmetrically to both sides. pma-voice integration switched to the client-side setCallChannel/addPlayerToCall export exclusively — the server-side export was leaving each client's local voice state never actually flipping over. Ringtone/SMS-tone silence fixed: the shared audio module now resumes a suspended AudioContext before every sound and on first NUI interaction. Phone-number presence tracking moved off playerJoining onto ox:playerLoaded — the old hook fired before the character was loaded, so the identifier lookup came back nil and a player's number never made it into the online routing table, causing "No player with that number" on otherwise-valid calls.
Camera engine rebuilt on native CellCamActivate / CreateMobilePhone (was a hand-rolled scripted cam) — fixes a black-screen edge case in setViewfinder(), adds native front/back facing and HUD/radar auto-hide during capture. New physical pull-out/put-away animation on open/close — a real hand prop, config-driven via Config.PhonePullOutAnim.
Camera + Gallery — standard/selfie capture and screenshot-basic, no external image host. New RDEPhone.setViewfinder() SDK method.
Public bodyguard marketplace, offline-safe cross-resource bank transfers.
rde_carservice and rde_bodyguards ported off NPWD onto the SDK.
rde_banking ported off NPWD — first production proof of the app registry.
Dynamic app registry shipped — homescreen is no longer hardcoded.
WYRM OS visual identity — RDWE-UI design tokens, self-hosted Lucide icons, glass buttons.
Core phone shipped — SMS, Calls (real voice via pma-voice), Contacts, Settings.