Skip to content

Your own app on it

A working example is in example/mic_phone_demo. Copy the folder, change the names, and you have an app.

my_app/
fxmanifest.lua
client.lua registers the app
server.lua anything that touches money or the database
ui/index.html your page
ui/icon.svg your icon
-- fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
client_script 'client.lua'
server_script 'server.lua'
files { -- WITHOUT THIS your page and icon are not served and the frame comes up blank
'ui/index.html',
'ui/icon.svg',
}

The files block is the one thing people forget. FiveM only serves what a resource lists there, so a page that is not in it answers 404 and your app opens to nothing.

local function register()
exports.mic_phone:AddApp({
id = 'phonedemo',
name = 'Field Notes',
icon = ('https://cfx-nui-%s/ui/icon.svg'):format(GetCurrentResourceName()),
ui = ('%s/ui/index.html'):format(GetCurrentResourceName()),
})
end
AddEventHandler('mic_phone:ready', register) -- fires on start, and after every restart of the phone

That is the whole contract, and it is the whole of it: nothing is configured on the phone, and nothing is restarted. Start your resource and the app is on the Home Screen — default = true puts it there, default = false puts it in the App Store instead. Either way the decision is yours, in your own file.

Config.AppList.Custom exists so a server owner can overrule you — price your app, hand it out pre-installed, turn it off. It is never needed for your app to work.

Four things you do not have to do, each of which other phones make you:

  • You do not remove the app before adding it. Registering the same id from the same resource updates it. Only a different resource claiming an id somebody already owns is refused, and told why.
  • You do not wait for the phone. mic_phone:ready fires when it can take apps and again every time it restarts, so your app comes back on its own after restart mic_phone.
  • You do not clean up when your resource stops. Your apps go with it. A stopped script never leaves an icon behind that does nothing.
  • You do not restart the phone. An app registered while somebody is holding theirs appears on it there and then.
idletters, numbers, _ and -. Unique across the server
namewhat the icon says
subtitle, description, features, versionthe App Store page
developer, categoryshown in the App Store
colorthe icon’s background when there is no icon
icona URL. https://cfx-nui-<resource>/path.png serves it from your own folder
uiresource/path/index.html, your app’s page. Leave it out for an app that only runs Lua. A path inside a resource on this server, never an external URL — see below
landscapethe page wants the phone unfolded
size, imageswhat the App Store page shows. Pictures must be https://
defaulttrue puts it straight on the Home Screen instead of in the App Store
onOpen, onClosecalled when it comes up and goes away

You cannot set your own price. There is no price field, and that is deliberate: the number would live on the client, and a price the client declares is a price the client can change — the App Store would show one and the server would charge nothing. Pricing is Config.AppList.Custom, written by the server owner and read by the server. If you want your app to cost money, ask them to add a line; it is one.

The city has the last word. Whatever you ask for here is a request. Config.AppList.Custom in the phone’s own config can price your app, hand it out pre-installed, or turn it off entirely, without anybody editing your resource. That is deliberate — a server owner should not have to fork a script to charge for it.

Your page is an iframe. It cannot reach into the phone, read the messages, or draw outside its own rectangle; the phone cannot reach into it either. Everything crosses in messages.

ui has to be a path inside a resource on this server. A frame needs the page’s Content-Security -Policy to allow its origin, and CSP has no way to say “any cfx-nui- resource” — a wildcard is only allowed as a whole leftmost label, so https://cfx-nui-* cannot be written and the directive has to be opened to https: for your frame to load at all. That is wide, so the phone narrows it on the other side: it builds the origin from your resource path itself and refuses anything that is already a URL. If you want to serve your page from somewhere else, serve it from your own folder.

In your page, two lines:

parent.postMessage({ type: 'phone:ready' }, '*'); // say you have loaded
const send = m => parent.postMessage({ type: 'phone:message', message: m }, '*');

and listen:

window.addEventListener('message', e => {
if (e.data?.type === 'phone:theme') document.documentElement.dataset.theme = e.data.theme;
if (e.data?.type === 'phone:message') { /* from your Lua */ }
});

In your Lua:

exports.mic_phone:SendAppMessage('phonedemo', { kind = 'hello' })
AddEventHandler(GetCurrentResourceName() .. ':phone:message', function(message)
-- from your page. Routed here by the resource that owns the app, so nothing else can send these
end)

Send whatever you like from onOpen. A message for a page that has not loaded yet waits for it and arrives in order the moment it says phone:ready. Other phones warn you not to do this because their message races the frame; here it does not, because sending something when your app opens is the obvious thing to do and obvious things should work.

phone:theme arrives with the phone’s light/dark and its language when your page says hello. Follow it and your app stops looking like a website somebody bolted on.

Your page can ask the phone to do things it already knows how to do — show a notification, open the camera, let somebody pick a photo or a contact. Fifteen lines set it up, and then every question is one await:

let next = 1; const waiting = new Map();
const ask = (what, args) => new Promise(resolve => {
const id = next++; waiting.set(id, resolve);
parent.postMessage({ type: 'phone:ask', id, what, with: args || {} }, '*');
});
window.addEventListener('message', e => {
if (e.data?.type === 'phone:answer') { const done = waiting.get(e.data.id); if (done) { waiting.delete(e.data.id); done(e.data.value); } }
});
const me = await ask('me'); // { number, name }
const photo = await ask('photos.pick'); // { id, url, width, height } or null
const shot = await ask('camera.take'); // the same, from the real Camera
const person = await ask('contacts.pick'); // { name, number } or null
await ask('notify', { title: 'Field Notes', body: 'Something happened.' });
await ask('toast', { text: 'Saved.' });
const look = await ask('theme'); // { theme, language, tablet }
await ask('close'); // put yourself away
QuestionWhat comes back
me{ number, name }what other phones already see, and nothing else about the person
notifytruefiled under your app, so silencing it in Settings silences you
toasttruea line at the bottom of the screen
photos.pickone photo, or nullopens the phone’s library; null means it was dismissed
camera.takeone photo, or nullopens the real Camera, waits, and brings you back
contacts.pickone contact, or nullopens a sheet of names
theme{ theme, language, tablet }the same values phone:theme pushes
closetruegoes back to the Home Screen

And the half that gives something back to the phone:

await ask('message.compose', { number: '555-0110', text: 'Your order is ready.' });
await ask('call.start', { number: '555-0110' });
await ask('share.text', { text: 'Look at this.' }); // → { to, number } or null
await ask('share.photo', { url: photo.url });
await ask('photos.save', { url: someImageUrl }); // → { id, width, height } or null
await ask('waypoint.set', { x: 219, y: -855 });
const money = await ask('money.balance'); // { balance }
QuestionWhat comes back
message.composetrueopens Messages with the words in the box. A human presses send
call.starttruerings the number
share.text / share.photo{ to, number }, or nullopens a picker, then composes to whoever was chosen
photos.save{ id, width, height }keeps a picture your app made, in Photos, like a camera shot
waypoint.settruea pin on the map
money.balance{ balance }reading only — money moves on the server, below

Nothing is sent on somebody’s behalf without them seeing it. message.compose fills the box and leaves the send button to a person; sharing asks who it is for every time. An app that could text your contacts silently would be a worse thing to have than an app that cannot text at all.

Money moves on the server, from your own server file, because a price a client can argue with is not a price. Three doors, and which one you want depends on who moved it:

-- somebody else already took the money; this only writes the line in the Wallet
exports.mic_phone:AddTransaction(src, { title = 'Tequi-la-la', detail = 'Two drinks', amount = -40 })
-- take it, and write the line. false, 'funds' and nothing moved when they cannot pay
local ok, tx = exports.mic_phone:Charge(src, 250, { title = 'Field Notes', detail = 'Pro upgrade' })
-- give it, and write the line
exports.mic_phone:Pay(src, 1000, { title = 'Delivery', detail = 'Job finished' })
exports.mic_phone:GetBalance(src)

All three update the Wallet on screen at once. Charge and Pay fail whole or succeed whole: a refused charge leaves the balance and the history exactly as they were.

Two things hold this up, and they are why the list is short rather than generous:

Your app never gets data nobody handed it. contacts.pick does not return the address book — it opens a sheet, a person taps one name, and that one name comes back. Asking in a loop does not read the phone; every answer costs a tap. The same is true of photos.

Only the app on screen may ask. A question from a frame that is not the open app is not answered and opens nothing, so a page left running somewhere cannot act while somebody is doing something else.

If what you need is not on that list, it is not that the phone refuses — it is that nobody has added it. Adding one is an entry in PHONE_APP_ASKS in html/apps/store/app-api.js and nothing else.

exports.mic_phone:RegisteredApps() --> every app registered on this client, and by whom
exports.mic_phone:IsAppOpen('phonedemo') --> is it on screen
exports.mic_phone:OpenAppId() --> which custom app is, or nil
exports.mic_phone:OpenApp('phonedemo') --> open it, if the phone will open at all

RegisteredApps is the one to reach for when your app is not showing up. The server’s phoneapps prints what the config says, which is a different list — your app lives on the client that registered it, and appears in phoneapps only if somebody gave it an entry in Config.AppList.Custom.

An app that is neither default = true nor installed in the config sits in the App Store until somebody downloads it. If yours is registered and you cannot find it on the Home Screen, that is usually why.

What you seeAlmost always
The app is nowhereRegisteredApps() first. If it is not there, AddApp was refused — the reason is printed in the client console (F8), not the server’s
It is registered but not on the Home ScreenIt is in the App Store: you set default = false, or the owner put How = 'store' in Config.AppList.Custom
The app opens to a blank pageYour ui file is not in the files block of your fxmanifest
Refused to frame in the consoleYour ui is a URL. It has to be a path inside a resource on this server
The page loads, messages never arriveYour page never sent phone:ready. Nothing is delivered until it does — it is held, not lost, so it all arrives at once when you do send it
no answer in 12000msYou called something the phone does not have. Check the name against the list above

phoneapps on the server prints the config, not your app — an app registered by a resource lives on the client that registered it, and appears there only if somebody gave it a Config.AppList.Custom entry. exports.mic_phone:RegisteredApps() is the list that machine actually has.

The rest of the phone’s API — the state bags, calls, contacts, notifications — is in Exports and state bags.