Pulse PhoneDocs
Pulse Phone docs

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' },
})

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:

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)

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)

Still stuck?

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.

Open the Discord