This package provides a Home Assistant gatekeeper for Gadgets. It lets a Gadget read state from a connected HA instance, call services on devices (turn lights on/off, set thermostats, lock doors, etc.), edit Lovelace dashboards, render templates, and read history — all mediated through the standard Gadgets approval queue.
The gatekeeper uses Home Assistant's long-lived access token (LLAT) + URL combination. There's no OAuth flow because:
- Each HA instance has a different URL (no central directory).
- LLATs don't expire (10-year default lifetime).
- LLAT is the path every other HA-adjacent tool uses, so users are familiar with it.
- It works for both Nabu Casa Cloud users and self-hosted users on a LAN.
When the user connects an account, they paste their HA URL and an LLAT into a form. The gatekeeper validates by calling GET /api/, then stores both in the per-user Durable Object.
The gatekeeper makes HTTP and WebSocket calls to the configured HA URL. On a Cloudflare-hosted Gadgets deployment, that means HA must be publicly reachable (via Nabu Casa, Cloudflare Tunnel, port-forwarding, etc.). On a self-hosted (workerd) deployment, LAN addresses like http://homeassistant.local:8123 and http://192.168.x.x:8123 work fine — that's the intended setup for users running Gadgets on the same network as their HA.
Five granularities are exposed; each comes with its own configurator UI for picking a specific resource from the connected HA instance:
| Granularity | What it grants |
|---|---|
| Whole instance | Access to every area, device, entity, dashboard, and service. |
| Area | A single area (room) — its devices and entities only. |
| Label | All entities carrying a particular HA label. |
| Device | A single physical device and the entities it provides. |
| Entity | A single entity (light, sensor, switch, etc). |
Bindings expose one of HomeAssistantSession (whole-instance), Area, Label, Device, or Entity, depending on the granularity granted. Common operations:
// Whole-instance:
const config = await session.getConfig();
const areas = await session.listAreas();
const lights = await session.listEntities({ domain: "light" });
// Capability-based:
const light = await session.getEntity("light.kitchen");
await light.turnOn({ brightness: 200 });
const state = await light.getState(); // reflects the simulated post-write state
// Area-scoped service call (affects every entity in the area):
const livingRoom = await session.getArea("living_room");
await livingRoom.callService("light", "turn_off");
// Templates:
const temp = await session.renderTemplate("{{ states('sensor.outside_temp') | float }}");
// Dashboards:
const dashboard = await session.getDashboard("lovelace");
const dashboardConfig = await dashboard.getConfig();
// ... mutate the JSON ...
await dashboard.saveConfig(dashboardConfig);See src/types.d.ts for the complete API and @example blocks for every method.
Every read calls authorizeObservation and every write goes through submitAction. Writes do not execute against HA until the user approves them.
Until approval, reads reflect a simulated post-action world: e.g. after entity.turnOn(), an immediate entity.getState() shows state: "on" even though HA hasn't been touched. This lets agents chain reads and writes without waiting for user approval. Simulation predicts final states only — no transition timing, and unrecognized service calls (custom integrations, scenes, scripts, templates) leave state untouched.
See the APPROVAL & SIMULATION section at the top of src/types.d.ts for the full list of caveats.
- Caching. Every read does a fresh registry fetch. Once caching is added, area / device / label-scoped reads will be much cheaper.
- Hooks (push events).
setHookis a no-op. WebSocketsubscribe_events/subscribe_entitieswould enable aHomeAssistantHookinterface — e.g. a Gadget receiving a callback when a motion sensor fires.
- Service calls go through the WebSocket API. HA's REST
POST /api/servicesendpoint expects target fields flattened at the top level of the body and has uneven support for area / label / floor targets across versions. The WebSocketcall_servicecommand is HA's modern path and supports the full target shape natively. - All actions get an integer
idassigned by a per-DO counter. Pending actions are stored underpending:<id>in DO storage so reads can simulate the post-action state. - Defensive validation. Malformed action bodies (e.g. an agent passing a single options object instead of positional arguments) fail synchronously with a rich error message that includes a suggested corrected call.
src/
├── homeassistant.ts # main: Vendor, UserAccount, User, GatekeeperImpl, Session impls
├── homeassistant-api.ts # REST + WS clients (incl. timeouts and error-body sanitization)
├── approvals.ts # describeAction, executeAction, applyRevertForEntity, ...
├── simulation.ts # pure overlay-at-read-time helpers
├── registry-utils.ts # shared resolveTargets helper
├── types.d.ts # public Session/Area/Label/Device/Entity/Dashboard interfaces
└── configurator/ # 5 picker UIs (instance / area / label / device / entity)