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.luafx_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 phoneThat 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:readyfires when it can take apps and again every time it restarts, so your app comes back on its own afterrestart 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.
What an app can say about itself
Section titled “What an app can say about itself”id | letters, numbers, _ and -. Unique across the server |
name | what the icon says |
subtitle, description, features, version | the App Store page |
developer, category | shown in the App Store |
color | the icon’s background when there is no icon |
icon | a URL. https://cfx-nui-<resource>/path.png serves it from your own folder |
ui | resource/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 |
landscape | the page wants the phone unfolded |
size, images | what the App Store page shows. Pictures must be https:// |
default | true puts it straight on the Home Screen instead of in the App Store |
onOpen, onClose | called 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.
Talking to your page
Section titled “Talking to your page”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 loadedconst 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 theseend)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.
Asking the phone for something
Section titled “Asking the phone for something”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 nullconst shot = await ask('camera.take'); // the same, from the real Cameraconst person = await ask('contacts.pick'); // { name, number } or nullawait 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| Question | What comes back | |
|---|---|---|
me | { number, name } | what other phones already see, and nothing else about the person |
notify | true | filed under your app, so silencing it in Settings silences you |
toast | true | a line at the bottom of the screen |
photos.pick | one photo, or null | opens the phone’s library; null means it was dismissed |
camera.take | one photo, or null | opens the real Camera, waits, and brings you back |
contacts.pick | one contact, or null | opens a sheet of names |
theme | { theme, language, tablet } | the same values phone:theme pushes |
close | true | goes 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 nullawait ask('share.photo', { url: photo.url });await ask('photos.save', { url: someImageUrl }); // → { id, width, height } or nullawait ask('waypoint.set', { x: 219, y: -855 });const money = await ask('money.balance'); // { balance }| Question | What comes back | |
|---|---|---|
message.compose | true | opens Messages with the words in the box. A human presses send |
call.start | true | rings the number |
share.text / share.photo | { to, number }, or null | opens 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.set | true | a 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 Walletexports.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 paylocal ok, tx = exports.mic_phone:Charge(src, 250, { title = 'Field Notes', detail = 'Pro upgrade' })
-- give it, and write the lineexports.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.
Asking about the phone
Section titled “Asking about the phone”exports.mic_phone:RegisteredApps() --> every app registered on this client, and by whomexports.mic_phone:IsAppOpen('phonedemo') --> is it on screenexports.mic_phone:OpenAppId() --> which custom app is, or nilexports.mic_phone:OpenApp('phonedemo') --> open it, if the phone will open at allRegisteredApps 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.
When it does not work
Section titled “When it does not work”| What you see | Almost always |
|---|---|
| The app is nowhere | RegisteredApps() 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 Screen | It 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 page | Your ui file is not in the files block of your fxmanifest |
Refused to frame in the console | Your ui is a URL. It has to be a path inside a resource on this server |
| The page loads, messages never arrive | Your 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 12000ms | You 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.