How smart apps run
A fresh sandbox for every handler, state as the only memory, handlers found by name, and the time-zone trap.
Read this page before writing anything else. Most smart-app bugs come from assuming a long-running program; a smart app is a set of short-lived function calls.
One isolate per handler
Smart-app code runs inside an isolated-vm V8 isolate on the hub. The isolate is created for a single handler invocation and thrown away afterwards. For every event, timer or lifecycle call the hub:
- creates a new isolate and injects the global helper functions;
- injects
settings, one global per hydrated preference input, andlocation; - evaluates your whole source file as a classic script;
- sets
stateto the instance's persisted state; - calls the handler by name;
- copies
stateback out, persists it, and disposes the isolate.
Consequences:
- Top-level code runs on every handler call. Keep it to declarations. Do not subscribe, schedule or
call services at the top level; do that in
installed/updated. - No module-level mutable memory. A
let counter = 0at the top level is0again next time. Put anything that must survive instate. - No timers of your own.
setTimeoutdoes not exist;runInand friends are persisted by the hub and re-armed after a reboot. - Handlers must be top-level named functions.
subscribe(sensor, {}, onChange)records the nameonChange; the hub looks it up on the global object when the event fires. Arrow functions assigned toconstare not globals and cannot be found. - Lifecycle hooks (
installed,updated,uninstalled,run) are not awaited; write them as plain functions. Event handlers may beasyncwhen you need toawaita service call.
What persists
| Persists | Does not persist |
|---|---|
state (per instance) | any top-level variable |
subscriptions made with subscribe / subscribeToLocationEvent | anything you cached from getState() |
schedules and timers (schedule, runIn, runOnce, runDaily, sunrise / sunset) | closures |
| settings (what the installer chose) |
Limits
| Limit | Value |
|---|---|
| Heap per isolate | 8 MB |
| Evaluating the source file | 30 s |
| Synchronous time inside a handler | 10 s |
| Source size | 128 KiB |
The handler timeout counts time inside the isolate. Awaiting a device command does not count against it. Exact values are generated on Limits and quotas.
A handler that throws is counted against the instance (visible in the app as its exception count) but does not stop the instance. A paused instance skips every handler until it is resumed; pausing a parent also pauses its children.
The time-zone trap
Scheduled work (schedule, runDaily) fires in the home's time zone. But new Date() inside
the isolate reflects the hub process clock, which is usually UTC. So:
// ❌ wrong: hub process time zone, usually UTC
if (new Date().getHours() >= 22) { … }
// ✅ right: the home's wall clock
const now = formatDateParts(new Date(), getHomeTimeZone());
if (Number(now.hour) >= 22) { … }Epoch arithmetic (Date.now(), getTime()) is zone-independent and fine. The validator rejects
local-time Date getters outright in AI-generated automations; treat them as off limits everywhere.
Reading state is synchronous, acting is asynchronous
entity.getState() and entity.getAttributes() return immediately from the hub's state cache.
Service calls (entity.turnOn()) return a promise that resolves when the hub has dispatched the
command; the device reports its new state through a normal entity change later. Do not read the state
back on the next line and expect it to have changed.