Docs
Concepts

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:

  1. creates a new isolate and injects the global helper functions;
  2. injects settings, one global per hydrated preference input, and location;
  3. evaluates your whole source file as a classic script;
  4. sets state to the instance's persisted state;
  5. calls the handler by name;
  6. copies state back 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 = 0 at the top level is 0 again next time. Put anything that must survive in state.
  • No timers of your own. setTimeout does not exist; runIn and friends are persisted by the hub and re-armed after a reboot.
  • Handlers must be top-level named functions. subscribe(sensor, {}, onChange) records the name onChange; the hub looks it up on the global object when the event fires. Arrow functions assigned to const are not globals and cannot be found.
  • Lifecycle hooks (installed, updated, uninstalled, run) are not awaited; write them as plain functions. Event handlers may be async when you need to await a service call.

What persists

PersistsDoes not persist
state (per instance)any top-level variable
subscriptions made with subscribe / subscribeToLocationEventanything you cached from getState()
schedules and timers (schedule, runIn, runOnce, runDaily, sunrise / sunset)closures
settings (what the installer chose)

Limits

LimitValue
Heap per isolate8 MB
Evaluating the source file30 s
Synchronous time inside a handler10 s
Source size128 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.

On this page