Custom apps
Any resource can add an app to Pulse Phone: an icon on the home screen and in the App Hub, a page of its own HTML shown inside the phone, and a message channel between that page and the resource's own Lua. The phone draws the frame, the status bar, notifications, badges and the Home / back gestures, so a custom app looks and behaves like a built-in one.
A complete working example is in pulse-phone-app-template/
("Field Notes": list/add/delete notes, client-side location, server storage, a notification
that deep-links to a note, badge, light/dark). Copy it, rename it, go.
pulse-phone-devapi-demo/ ("Dev Kit") calls
every native sheet, starts an Island activity from its page and rings the player from a script.
ensure phone
ensure pulse-phone-app-template
In the server console, phone_api prints the whole API.
1. Register the app (server)
exports.phone:RegisterApp({
id = 'rsp_notes', -- unique, letters/digits/_ (max 32); the event namespace
name = 'Field Notes', -- home screen label (max 24)
colour = '#F2A93B', -- icon tile, #rrggbb
icon = 'ui/icon.svg', -- see "Icons" below; or glyph = 'shield'
description = 'Quick notes.', -- App Hub (max 160)
developer = 'Your Studio', -- catalogue data (max 32)
version = '1.0.0', -- App Hub "Updates" shows version changes
job = { 'police' }, -- optional: only these jobs see the app
badge = true, -- may set a count on its icon
notify = true, -- may raise notifications (default true)
page = { kind = 'frame', url = 'ui/index.html' },
})
- Register from the server. Re-registering the same id replaces the declaration (use it
to bump
version).exports.phone:UnregisterApp(id)removes it. When your resource stops, its apps are removed automatically; when the phone restarts it forgets add-ons, so register again ononResourceStartfor the phone (the template does both). - A rejected declaration prints the exact field and reason in the server console.
- Players already online get the app immediately; no reconnect.
Page
page.url |
served from |
|---|---|
'ui/index.html' (recommended) |
your resource: https://cfx-nui-<your_resource>/ui/index.html |
'nui://my_res/ui/index.html' |
rewritten to the address above |
'https://…' |
an external page (still sandboxed) |
List the files under files { } in your fxmanifest. Do not set ui_page for it: the
phone shows it inside its app frame.
No HTML at all? page.kind may instead be list, form, detail or board - declarative
pages the phone renders in its own style; send SendAppMessage(src, id, 'page', {...}) to
replace the page live. (Frame apps are what this document is about.)
Icons
icon may be a path in your resource ('ui/icon.png', 'ui/icon.svg'), a URL, or an
inline SVG string ('<svg xmlns=… viewBox=…>…</svg>', under 16 KB). Draw it full-bleed and
square; the phone applies its own rounded mask. Or use a built-in glyph (shield, cross,
wrench, cart, bank, key, car, house, briefcase, radio, camera, pin, bolt,
book, chart, dice, fuel, gavel, heart, lock, note, plane, tag, truck) on
colour.
Installing and removing
Registered apps are installed by default (an MDT must be there when the shift starts).
Players can remove one from the App Hub (it stays removed across reconnects) and get it back
there; the App Hub shows name, icon, description, version history and an Updates list when
your version changes. (developer is carried in the catalogue data; the App Hub does not
print it yet.) Job-gated apps only appear for players with the job.
2. The page (HTML/CSS/JS)
Include the SDK from the template before your script:
<script src="rspulse.js"></script>
<script src="app.js"></script>
RSPulse.on('context', ctx => { /* ctx.number, ctx.theme, ctx.safeArea, ctx.textScale … */ })
const notes = await RSPulse.request('notes:list') // your SERVER Lua answers
const where = await RSPulse.request('where', null, { side: 'client' }) // your CLIENT Lua answers
RSPulse.send('shared', { id }) // fire-and-forget to your Lua (client + server)
RSPulse.on('message:notes:added', note => …) // pushed by your Lua
RSPulse.on('deeplink', route => …) // opened from a notification: 'note/abc'
RSPulse.notify({ title: 'Saved', body: '…', deepLink: 'note/abc' })
RSPulse.badge(0)
RSPulse.open('messages') // another app; or '<yourId>/route'
RSPulse.home() // leave the app
RSPulse.haptic('success') // selection, impactLight/Medium/Heavy/Rigid/Soft, success, warning, error
Native UI: pickers, alerts and sheets the phone draws
Your page can ask the phone to draw its own contact picker, photo picker, alert, confirm, action sheet, text prompt (with the phone's own keyboard) and camera. They look and move exactly like the phone's, and they are the only way a page gets a contact or a photo: nothing is read silently, the page receives just the one thing the player taps, and the Camera opens only after the player agrees.
Every call returns a Promise. It rejects with an Error whose message is the reason:
'cancelled' (the player said no), 'busy' (a sheet is already open), 'rate-limited',
'not-visible' (your app is not the one on screen), 'unknown-method', or
'bad-params: <what is wrong>'.
const who = await RSPulse.pickContact({ title: 'Send To' }) // { name, number }
const pic = await RSPulse.pickPhoto({ title: 'Evidence' }) // { url, taken } (url: show it in an <img>)
await RSPulse.alert({ title: 'Shift Started', message: 'On duty until 18:00.', button: 'Got It' })
const yes = await RSPulse.confirm({ title: 'Delete Report?', message: 'This cannot be undone.',
confirm: 'Delete', cancel: 'Keep', destructive: true }) // true | false
const pick = await RSPulse.actionSheet({ title: 'Share Location', actions: [
{ id: 'pin', label: 'Drop a Pin' }, { id: 'stop', label: 'Stop Sharing', destructive: true },
] }) // 'pin' | 'stop'
const plate = await RSPulse.prompt({ title: 'Plate Number', placeholder: 'e.g. 46EEK572',
maxLength: 8, keyboard: 'text' }) // the text typed
| Call | Parameters (all text is trimmed; longer text is refused, not cut) | Answers |
|---|---|---|
pickContact |
title? (40) |
{ name, number } of the contact tapped |
pickPhoto |
title? (40) |
{ url, taken } of the photo tapped (videos and deleted photos are not offered) |
alert |
title (80), message? (300), button? (20) |
nothing, once dismissed |
confirm |
title (80), message? (300), confirm? (20), cancel? (20), destructive? |
true / false |
actionSheet |
title? (80), actions: 1-6 of { id (1-32 of A-Z a-z 0-9 _ -, unique), label (40), destructive? } |
the id tapped |
prompt |
title (80), message? (200), placeholder? (60), value?, maxLength? (1-500, default 120), keyboard? ('text', 'number', 'decimal', 'phone'), secure?, confirm? (20) |
the text |
capturePhoto |
state? (any plain data, up to 2048 characters as JSON) |
see below |
Unknown parameters are refused (bad-params: unknown parameter "…"), so a typo shows up at once.
Taking a photo. The phone runs one app at a time, so the Camera replaces your page while
the player takes the photo, and your page loads again when they come back. The answer therefore
arrives as a capture event after your page's ready, carrying the state you passed in so you
can put the page back where it was:
RSPulse.on('capture', ({ ok, url, error, state }) => {
if (ok) attachPhoto(state.reportId, url) // url: the saved photo, as pickPhoto returns it
else showNotice(error) // 'cancelled' | 'timeout'
})
await RSPulse.capturePhoto({ reportId: 42 }) // resolves { opened: true } once the Camera is opening
The player first sees "“Your App” Would Like to Use the Camera" with Don't Allow / Open Camera
(Don't Allow rejects 'cancelled'). While the Camera is open the Island shows Photo for
Your App with Cancel. The first photo saved sends the player straight back to your app; leaving
the Camera without one, Cancel, or five minutes, answers ok: false. The photo is also in the
player's Photos, like any photo they take.
An Island activity from your page
One Island activity per add-on, on the Island and the Lock Screen, leading back into your app:
await RSPulse.activity.start({ title: 'Tow truck', detail: 'On its way', countdown: 240,
glyph: 'truck', colour: '#FF9F0A', route: 'job/42' })
await RSPulse.activity.update({ detail: 'Arriving', compact: '1 min' })
await RSPulse.activity.end()
Fields as in EXPORTS.md section 14 (title 40, detail 60, compact 12, progress 0-1 or
countdown seconds with endOnZero, glyph, colour), with two differences: a tap always opens
your app, at route (there is no link), and without a glyph it shows your app's icon.
Updates faster than one per 500 ms are merged (the last one wins). A second activity is refused
('limit-resource'); start again with the same id replaces it. It stays when your page closes -
that is what it is for - and ends when you end it or when your resource stops.
For server- and client-side activities (no page needed) see EXPORTS.md section 14.
Look like the phone
rspulse.js sets, from the first paint (the phone puts them in the page address) and
again whenever the player changes them:
<html data-theme="light|dark">- follow it; the phone switches automatically at dusk if the player chose Automatic.--rsp-safe-top(status bar),--rsp-safe-bottom(the Home swipe band),--rsp-safe-left(the back-swipe edge),--rsp-safe-right.--rsp-text-scale(Settings › Display › Text Size), classrsp-reduce-motion.
Keep tappable controls out of the bottom and left bands: those strips belong to the phone (Home swipe/tap and back swipe work over your page) and do not reach it.
Developing in a browser
Open ui/index.html directly in a browser: rspulse.js detects it is not inside the phone,
sends a fake context, and answers request from RSPulse.devHandlers[event] - the template
fills those in, so the whole UI works with live reload before you ever start the game.
There are no native sheets in a plain browser: a call rejects 'not-in-phone' unless you give it
a stand-in, RSPulse.devSdk.pickContact = () => ({ name: 'Ava', number: '555-0142' }).
3. Your Lua
Server
-- fire-and-forget from the page (and 'open' / 'close' when the app is shown / left)
AddEventHandler('phone:app:rsp_notes', function(src, event, payload) end)
-- requests: call reply(result) - now or later (after a DB read). 10 s timeout.
AddEventHandler('phone:app:rsp_notes:request', function(src, event, payload, reply)
if event == 'notes:list' then return reply(loadNotes(src)) end
reply(nil) -- always answer unknown events
end)
exports.phone:SendAppMessage(src, 'rsp_notes', 'notes:added', note) -- to one player's page
exports.phone:BroadcastApp('rsp_notes', 'dispatch', data, 'police') -- to every player (job filter optional)
exports.phone:Notify(src, { app = 'rsp_notes', title = '…', body = '…', deepLink = 'note/abc' })
exports.phone:SetBadge(src, 'rsp_notes', 3)
exports.phone:GetPhoneNumber(src) -- '481-2097' or nil
exports.phone:SourceOfNumber('481-2097') -- src or nil
exports.phone:Set('rsp_notes', owner, 'key', value) -- per-app key/value storage
exports.phone:Get('rsp_notes', owner, 'key', function(value) end)
A notification's deepLink is the route inside your app: tapping it opens your app and
your page receives deeplink with that route (also when it is already open).
Client (optional)
For what only the game client knows (position, vehicle, inventory UI):
AddEventHandler('phone:app:rsp_notes', function(event, payload) end) -- incl. 'open'/'close'
AddEventHandler('phone:app:rsp_notes:request', function(event, payload, reply) end) -- side = 'client'
exports.phone:SendAppMessage('rsp_notes', 'env', { inVehicle = true }) -- to the page, no server trip
exports.phone:IsAppOpen('rsp_notes')
4. Security model (read this)
- The page runs in an
<iframe sandbox="allow-scripts allow-forms">withoutallow-same-origin: it cannot read the phone's document, storage or NUI callbacks, cannot navigate the top window, cannot open popups. - The phone accepts a message only from your iframe's window, and scopes every action to your app id: a page cannot notify, badge or message as another app. Game (NUI) messages are only accepted from the phone's own window, so a page cannot forge them.
- Messages reach only the resource that registered the app, and only for players who can see it (job gating is re-checked on every message and request on the server).
- Each page is rate-limited (60 messages/second; excess is dropped with a console warning).
- Contacts, photos and the camera only by consent. The page never gets a list: the phone
draws the picker and the page receives the one contact or photo the player taps. The Camera
opens only after the player answers the phone's own prompt. The native sheets answer only
while your app is the one on screen (
'not-visible'otherwise), one at a time ('busy'), and at most 8 in 20 seconds ('rate-limited'), so a page cannot trap the player in a loop of alerts. - Every parameter crossing the frame is checked by type and length on the phone side; anything
else is refused with
bad-params, never passed through. - Treat every payload as client input. It came from a player's machine. Validate and
clamp it, and never take an identity from it - use
srcandGetPhoneNumber(src). Config.Addons.allowCustomPages = false(phone config) restricts add-ons to the declarative pages.
5. Protocol reference (for pages not using rspulse.js)
Page → phone: parent.postMessage({ rspulse: 1, type, ... }, '*')
| type | fields | effect |
|---|---|---|
ready |
- | phone replies context, then delivers anything queued, then a pending deeplink |
send |
event, payload |
client phone:app:<id> + server phone:app:<id> |
request |
id, event, payload, side? |
answered by response with the same id |
notify |
title, body, deepLink? |
notification (if notify ~= false) |
badge |
count |
icon badge (if badge = true) |
open |
link |
'<app>' or '<app>/<route>'; your own id → deeplink |
home |
- | leave the app |
haptic |
kind |
system haptic |
typing |
active |
a text field has focus: the character stops walking while typing |
sdk |
id, method, params |
a native sheet or Island activity (methods: pickContact, pickPhoto, alert, confirm, actionSheet, prompt, capturePhoto, activity.start, activity.update, activity.end); answered by response with the same id |
Phone → page: { rspulse: 1, app, type, ... }
| type | fields |
|---|---|
context |
version, number, theme, textScale, reduceMotion, safeArea{top,bottom,left,right}, size{width,height}, locale |
message |
event, payload |
response |
id, ok, data or error (timeout, unknown-app, failed; for sdk: cancelled, busy, rate-limited, not-visible, unknown-method, bad-params: …) |
deeplink |
route |
capture |
requestId, ok, url or error (cancelled, timeout), state: the photo a capturePhoto asked for, sent after ready |
Page address parameters: rsp_app, rsp_theme, rsp_safe_top, rsp_safe_bottom.
6. Not supported (yet)
- Registering from the client (apps are server-registered so job gating and the App Hub stay authoritative).
- Paid apps / prices in the App Hub.
- Landscape pages.
- A page reading the phone's own data (the contact list, messages) in bulk: by design a page only ever gets the one contact or photo the player picks. Anything more, ask your Lua, which can use the phone's server exports.
Post in #pulse-issues (we go through it every week), or open a ticket in #open-a-ticket if it's urgent. Paste the start-up check from your server console.