Widget API
The contract between GEM and widget code. A widget written for GEM — a code widget authored in the widget editor, or one shipped inside an application — talks to the platform through one object, gem. This page is the list of what that object promises. Anything on it that is not listed here is an implementation detail and may change without notice.
The contract is versioned: WIDGET_API_VERSION in src/gem/widget_api.js is 1. A package that relies on a member added later names the GEM version that introduced it in its manifest's requires.min_gem_version, and the installer refuses to install it on anything older. A member listed here keeps its name and its meaning; removing one follows the same deprecation and shim rule as a socket event.
Reaching gem
- A code widget compiled by GEM runs with
gemas a global. It is the application instance, the same object the platform's own components use. - A bundled component in the GEM source imports it:
import GemApp from '../gem/app'; const gem = GemApp.getInstance();
Everything below is called on that object.
Commands
| Member | What it does |
|---|---|
command(payload) | Send a command. {zone_id, command, args} or {device_id, command, args}, by id, or zone / device by name. See Common Frontend API for the payload shapes. Subject to the caller's role, UI scope and elevation on the server. |
macro(id) | Run a macro by id. |
Live values
| Member | What it does |
|---|---|
subscribe(target, id, callback) | Live updates for a zone, device or other target. Returns a token. |
unsubscribe(target, id, token) | Stop them. Call it in onDestroy. |
getAttributes(target, id) | Every attribute of a target. |
getAttributeValue(target, id, name) | One attribute's value. |
getAttribute(target, id, name) | One attribute's row. {} when missing, which is truthy. |
setAttribute(target, id, name, value, type) | Write one. |
Data
query(model, where), queryOne(model, where), insertModel(model, data), updateModel(model, id, data), deleteModel(model, id). Every call is subject to the same server gates as any client: reads are scrubbed of secure values, writes are refused where the role does not allow them, and a row an application created cannot be deleted from here.
Screen
| Member | What it does |
|---|---|
goto(href) | Navigate. /?page=<name> switches the page within the current UI; inside an application the address stays /app/<app_id>?page=<name>. |
showMessage({message, severity}) | Raise a toast. severity is success, info, warning or error. An application that draws its own toasts registers a handler (below) and receives the same object. |
speak(text, {voice, rate}) | Say something on this device with the browser's speech synthesis. |
mount(Component, target, props) | Mount one of gem.components into an element the widget owns, and get back {component, destroy()}. Props are fixed at mount; to change them, destroy and mount again. See Embedding a page. |
Context
| Member | What it does |
|---|---|
getUser() | Who is signed in: {authenticated, id, username, name, roles, elevated, must_change_password}. authenticated: false with id: 0 when nobody is. From the server, never from anything the client holds — use it to hide a control, never to protect one; the server decides. elevated is session elevation: a panel signed in with a PIN reads false there and may still hold an elevated role, so it is not a reliable predictor of what the server will allow. |
getUIs() | The UIs this user may open. |
getSettings() | The effective settings of the current UI. |
ui | The current UI row. |
settings | The settings already loaded for it. |
initialized | true once the socket is connected and settings are loaded. Widgets mounted at boot should wait for the initialized store rather than call the server on mount. |
components | The platform's building blocks a widget may render: Button, Keypad, Slider, ZoneMap, CameraZoneViewer, PageEmbed and the rest. |
zoneControls | Inline zone controls keyed by control name, for embedding a control without its modal. |
applicationEntry | When the current UI was reached through /app/<app_id>, the application it belongs to: {app_id, instance, name, label, version, ui_id}. Otherwise null. The widget editor's live preview of a widget an application provides sets it too, and links that application's app.css, so read the application's UI from here rather than from gem.ui. |
Shared state and events
Widgets on one UI often need to agree on something — which map is selected, whether the view follows alarms — and to tell each other when it changes. Two small pieces exist for that so every shell does not reinvent them.
appState— a Svelte store shared by every widget on the current UI.gem.appState.update(s => ({...s, selectedMap: 'perimeter'})); read it with$gem.appStatein a component, orget(gem.appState). It resets when the UI changes.on(event, fn),emit(event, payload),off(event, fn)— a client event bus.onreturns the unsubscribe; call it inonDestroy. Listeners are cleared when the UI changes. A listener that throws is reported and the others still run.resetAppState()— clears both. The platform calls it on a UI switch; a shell may call it itself.
Neither is persisted and neither leaves the browser. State that must survive a reload or be shared between devices belongs in an attribute.
Replacing a system dialog
An application with nav_layout: none draws its whole screen. Four things the platform draws on top of it can be replaced through gem.dialogs:
gem.dialogs.register('toast', {show(msg) { /* {message, severity} */ }});
gem.dialogs.register('blocker', {show({message, seconds}) {}, hide() {}});
gem.dialogs.register('disconnected', MyOfflineOverlay); // a Svelte component
gem.dialogs.register('pin', MyPinPrompt); // receives {next, verify, proceed}
register(name, null) or unregister(name) puts the platform's back. The platform default renders whenever nothing is registered. dialogs.overrides is a store a host can subscribe to. The PIN prompt receives verify(code), which resolves true for a good code, and proceed(), which takes the user on to the protected route; a replacement that skips verify has not bypassed anything, because the route's resource token is issued by the server only after the same check.
The sign-in screen is not registered from widget code, because it renders before any widget has run. An application declares it in its manifest instead (below). Server-side mechanisms — the session, elevation, the command scope gate, PIN verification — are not client concerns and are never replaceable.
An application's sign-in screen
An application names a code widget as its sign-in screen with entry.login in its manifest, or with Sign-in screen in step 3 of the Create tab (Applications). Someone who opens the application's route signed out is sent to the sign-in page, which mounts that widget in place of GEM's keypad.
It runs before anyone is signed in, so the gem global does not exist yet. Everything the widget may use arrives as props:
| Prop | What it is |
|---|---|
signIn(username, secret, twoFactorToken?) | Sign in. A PIN signs in with no username: signIn(null, pin). Resolves the server's answer: {success: true}, after which the platform finishes the sign-in and opens the application, or {error} to show. {error: 'two_factor_auth'} means a verification code was sent (remote_login: true when that is because the sign-in is from off the local network): ask for it and call signIn again with the same credential and the code. |
application | {app_id, instance, name, label} of the application being signed into. |
next | Where the user is going once signed in. |
components | {Keypad}, the platform's PIN pad, for a screen that wants it. |
<script>
let {signIn, application} = $props();
let code = $state('');
let error = $state('');
async function submit() {
const r = await signIn(null, code);
if (!r.success) { error = r.error || 'Not accepted'; code = ''; }
}
</script>
The page is dressed in the entry UI's theme and its app.css. If the widget is not a compiled code widget, or fails to load, GEM's keypad renders instead. The screen only collects the credential: the server checks it exactly as it checks the keypad's, with the same lockouts and session rules.
Owning the screen
On a UI with nav_layout: none the platform draws no top bar, media bar or page header. They are not mounted at all, rather than hidden, so they load nothing. The router fills the viewport. Loading the UI itself (its settings, page overrides and theme) does not depend on the bars and runs the same way on every layout. The media session behind the media bar keeps running, so the AV stores (avZoneID, avSourceID, now-playing) still follow the room for any AV widget the application uses. The room picker and the warmup blocker still render unless the application replaces the blocker.
Embedding a page
gem.components.PageEmbed renders one UI page inside a widget, by name or id. It finds pages linked to the current UI, plus the global ones, so it works for every session and not only an admin. Mount it with gem.mount, and mount again when the page changes:
<script>
let {config = {}} = $props();
let activeTab = $state('overview');
let host = $state(null);
$effect(() => {
if (!host) return;
const page = gem.mount(gem.components.PageEmbed, host, {name: activeTab});
return () => page.destroy();
});
</script>
<div bind:this={host}></div>
gem.mount puts the component on the platform's own Svelte runtime. Rendering a platform component directly from widget code (<svelte:component this={gem.components.PageEmbed} …/>) works on a production build, where widgets and the platform share one runtime. A development server gives the platform a separate copy, and a component rendered across that boundary neither updates nor tears down. gem.mount works on both.
This is how a shell stays on screen while the content underneath changes: the shell's own tabs pick which page it embeds, and each page is still a bento grid an integrator can compose in the page editor. A page meant to hold a single shell widget sets Edge to edge in its page settings, which removes the grid's padding and gaps and gives the one widget the whole surface. A widget on an edge-to-edge page fills the page and is never auto-scaled, so a shell can size itself with height: 100% or position: absolute; inset: 0 alike.
What happens at install
A package that ships widget source is compiled in a scratch directory when it is inspected, before anything is written. A widget that does not compile on this GEM appears in the compatibility list beside a missing driver or an old version, and the package cannot be installed until it does. Install and upgrade run the same check, so a package installed by script is held to the same bar.
Theming from a widget
The platform's own components read design tokens that the base stylesheet always declares — see Theme Contract. An application that owns its CSS re-themes the platform widgets it embeds by overriding those variables at its root, and nothing else about their styling is promised.