
- ESX
- QBCore
By Luke

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 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>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.
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.
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.





Chezza Studios
