Build a small phone-style NUI app first to learn the complete open, message, callback and close lifecycle. This example shows a session note that stays while the resource runs. It has no calls, messaging service, database or framework dependency; adding those requires separate server-side authorization and persistence.
Create three files
Create resources/[local]/tutorial_phone/ with fxmanifest.lua, client.lua and index.html. The example targets the GTA V Legacy gta5 resource runtime. Check Cfx.re’s Enhanced migration guidance before adapting it to an Enhanced deployment.
fxmanifest.lua:
fx_version 'cerulean'
game 'gta5'
ui_page 'index.html'
files { 'index.html' }
client_script 'client.lua'
client.lua:
local visible = false
local ready = false
local function closePhone()
visible = false
SetNuiFocus(false, false)
SendNUIMessage({ action = 'close' })
end
RegisterCommand('tutorial_phone', function()
if not ready then
print('Phone UI is loading; try again shortly.')
return
end
if visible then closePhone(); return end
visible = true
SetNuiFocus(true, true)
SendNUIMessage({ action = 'open' })
end, false)
RegisterNUICallback('ready', function(_, cb)
ready = true
cb({ ok = true })
end)
RegisterNUICallback('close', function(_, cb)
closePhone()
cb({ ok = true })
end)
AddEventHandler('onClientResourceStop', function(name)
if name == GetCurrentResourceName() and visible then
SetNuiFocus(false, false)
end
end)
index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Session note</title>
<style>
body { margin: 0; background: transparent; font: 18px system-ui; }
#phone { position: fixed; right: 4vw; bottom: 4vh; width: min(320px, 85vw);
padding: 24px; box-sizing: border-box; color: #fff; background: #18222d;
border-radius: 20px; }
[hidden] { display: none !important; }
textarea { box-sizing: border-box; width: 100%; min-height: 140px; font: inherit;
margin: 12px 0; padding: 8px; }
button { font: inherit; padding: 10px 16px; cursor: pointer; }
:focus-visible { outline: 3px solid #67d7ff; outline-offset: 3px; }
</style>
</head>
<body>
<section id="phone" role="dialog" aria-label="Session note" hidden>
<h1>Session note</h1>
<label for="note">What will your character do next?</label>
<textarea id="note" maxlength="500"></textarea>
<p>This note clears when the resource restarts.</p>
<button id="close" type="button">Close</button>
<p id="error" role="status"></p>
</section>
<script>
const phone = document.getElementById('phone');
const note = document.getElementById('note');
const error = document.getElementById('error');
async function call(name) {
const response = await fetch(`https://${GetParentResourceName()}/${name}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}'
});
if (!response.ok) throw new Error('NUI callback failed');
return response.json();
}
window.addEventListener('message', ({ data }) => {
if (data?.action === 'open') {
phone.hidden = false; error.textContent = ''; note.focus();
} else if (data?.action === 'close') {
phone.hidden = true;
}
});
async function close() {
try { await call('close'); }
catch { error.textContent = 'Could not close. Check the F8 console.'; }
}
document.getElementById('close').addEventListener('click', close);
window.addEventListener('keydown', event => {
if (event.key === 'Escape' && !phone.hidden) { event.preventDefault(); close(); }
});
call('ready').catch(() => console.error('Phone readiness callback failed.'));
</script>
</body>
</html>
Start and verify
Add ensure tutorial_phone to the active server configuration. On your test server, run refresh and ensure tutorial_phone, connect, then run tutorial_phone in F8. The app should open and focus its note field.
Type a note, close with the button and reopen. The note should remain for this resource session. Test Escape, repeated open/close and stopping the resource while open; game input should be released. Reconnecting or restarting the resource creates a new UI session and clears the note.
Inspect the NUI boundary
The browser calls the resource callback by its actual parent name. Lua always replies through cb, and the browser handles a failed request. Follow Cfx.re’s NUI debugging instructions to inspect console and callback requests on your client.
Add real phone features deliberately
An existing phone resource may expose its own app SDK; use that supported integration instead of running a second full phone. Persistent contacts need character ownership checks on the server, validated lengths, parameterized queries and a migration for the installed schema.
Real calls need the chosen voice resource’s supported integration. Never accept an arbitrary character identifier from the UI as proof of access, or claim a contacts form is a complete secure phone system.