Skip to content

// How to

Connect Home Assistant

Halbert does not replace Home Assistant. It connects to one, reads what the house is doing, and asks Home Assistant to change it.

That is two decisions, not one, and they are configured in two different places. Connecting gives Halbert eyes: it can read the entities Home Assistant exposes to it. Autonomy gives it hands, and it starts with none. A machine that has just been connected is working correctly when it refuses to turn on a light.

  • A Home Assistant instance this machine can reach over HTTP.
  • A long-lived access token from it. In Home Assistant: Settings → People → Your Profile → Long-Lived Access Tokens.

That token is full house control — locks, alarm, garage. Halbert stores it at ha_config.json in its data directory, written 0600, and masks it back to its first eight characters whenever the configuration is read.

Open the Home panel. Until a connection is saved, the panel is a single card, Connect to Home Assistant, with three fields: Home Assistant URL, Access Token, and a Verify SSL certificate checkbox. Fill them in and press Connect.

The card saves the configuration and immediately tests it, so an accepted form means a reachable instance — not merely a stored address.

The same thing over the API, which is what the card calls:

Terminal window
$ TOKEN=$(cat ~/.local/state/halbert/api-token)
$ curl -s -X POST http://localhost:8000/api/home/config \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"url": "http://homeassistant.local:8123", "token": "<long-lived token>",
"verify_ssl": true}'
{"status": "ok", "config": {"url": "http://homeassistant.local:8123",
"token": "<first eight characters>...", "verify_ssl": true, "visible_domains": [...]}}

The token comes back masked in that reply, and in every read after it. What is on disk is not.

Terminal window
$ curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/home/status
{"connected": true, "configured": true, "message": "API running."}

The two fields answer different questions. configured means a URL and a token are both saved; connected means Halbert just asked Home Assistant and got an answer. Rotate the token in Home Assistant without updating it here and the same call says so:

{"connected": false, "configured": true, "error": "Invalid HA token or unauthorized"}
Terminal window
$ curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/home/entities \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["count"], "entities")'
3 entities

Unfiltered, this does not return everything Home Assistant has. It returns everything in the configured visible_domains list, which arrives with a default:

light, switch, climate, lock, cover, fan, media_player, vacuum, binary_sensor, sensor, person, device_tracker, alarm_control_panel.

Anything outside that list — an automation, a script, an input_boolean — is simply absent from the answer, with no indication that it was filtered. Ask for a domain by name and the filter does not apply:

Terminal window
$ curl -s -H "Authorization: Bearer $TOKEN" \
"http://localhost:8000/api/home/entities?domain=lock" \
| python3 -c 'import json,sys; print([e["entity_id"] for e in json.load(sys.stdin)["entities"]])'
['lock.front_door']

GET /api/home/areas lists the areas defined in Home Assistant, and GET /api/home/entity/{entity_id} reads one entity’s state.

Terminal window
$ curl -s -X POST http://localhost:8000/api/home/service \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"domain": "light", "service": "turn_on", "entity_id": "light.desk_lamp"}'
{"detail":"Blocked by autonomy gate: Autonomy level is 'observe' — no device commands permitted"}

That is a 403, and it is the design. The autonomy level starts at observe, which means perceive and report, never act. Nothing about the connection is wrong.

Level What it means
observe Read the house. No device command ever leaves, whatever it is.
suggest Same refusal, with a message saying the action would need approving.
act Low-risk actions go through on their own. Anything consequential is refused.
orchestrate Consequential actions go through too, immediately.

There is no control for this in the interface. It is autonomy_level in being.yml, in the configuration directory:

autonomy_level: act

The level is re-read on every call, so the change takes effect on the next request — no restart. Repeating the call above:

Terminal window
$ curl -s -X POST http://localhost:8000/api/home/service \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"domain": "light", "service": "turn_on", "entity_id": "light.desk_lamp"}'
{"success": true, "entities": []}

And the same level, on two other things:

lock.unlock 403 Not auto-executed at autonomy level 'act':
Requires proposal (governance Level 2 > 1)
shell_command 403 Blocked by autonomy gate: Forbidden by governance:
Domain 'shell_command' is forbidden (physical safety risk)

Raising autonomy moved exactly one of the three. The lock is refused for now; shell_command is refused at every level there is.

The autonomy level says how far Halbert may go. A second classification says how far each action is, and the two are compared on every call.

Level Domains At act At orchestrate
0 light, fan, media_player, vacuum runs runs
1 climate, switch, humidifier, number, select, text, button, siren, remote, water_heater, and the input_* helpers runs runs
2 lock, alarm_control_panel, cover, valve, scene, camera, person, device_tracker, notify, tts, conversation refused runs
3 shell_command, python_script, script, automation, rest_command, command_line, hassio, homeassistant, recorder, backup never never

Two of those placements look strict and are deliberate. cover is Level 2 as a whole domain because the domain cannot tell a bedroom blind from a garage door. Level 3 is not a list of devices at all — it is the set of domains that turn Halbert may adjust the lights into Halbert may run anything on the hub, so it is refused at every autonomy level including the highest.

A domain that is not in any of those sets never runs on its own, at any level, including orchestrate:

Not auto-executed at autonomy level 'orchestrate': Domain 'weird_domain' is not one I have
been told how to judge, so I will ask before acting on it

Not knowing what something does is not a reason to do it quickly.

To keep one domain stricter than the rest, autonomy_overrides in being.yml takes a per-domain level that wins over the global one:

autonomy_level: act
autonomy_overrides:
lock: suggest

A Home Assistant service call can name its target in several ways, and some of them cannot be resolved to entities without Home Assistant’s own registries. Rather than assume, the gate refuses and says so:

Terminal window
$ curl -s -X POST http://localhost:8000/api/home/service \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"domain": "light", "service": "turn_off", "entity_id": "all"}'
{"detail":"Blocked by autonomy gate: This call targets 'entity_id: all', which I cannot resolve
to specific entities, so I cannot tell whether it touches something you have forbidden.
Name the entities instead."}

The same answer comes back for a call aimed at an area_id, device_id, floor_id or label_id. Name the entities and it proceeds normally.

Once a connection exists, the house is also reachable from the conversation: Halbert gains a tool for reading an entity’s state and a tool for calling a service. The service tool runs through the same gate as the API above, so at observe it answers that it may not, and at act a lock comes back as a sentence naming the level rather than a lock that opened.

  • Autonomy has no control in the interface. It is a file. The Home panel shows the connection and the entities and says nothing about what Halbert is permitted to do with them.
  • Nothing turns a refused action into a proposal. Two of the four levels describe themselves in terms of proposals, and no code path queues one from the house. The words are ahead of the mechanism.
  • The cancel window is not implemented. It is computed, reported, and never waited on.
  • The ha_connection capability tracks a different file. It reads ha_url and ha_token from being.yml, not the ha_config.json the Home panel writes — so connecting through the panel leaves that capability reading off. It gates only the step that copies a connection out of being.yml, so nothing else changes; see the capability reference.
  • Two places can hold the connection. A ha_url and ha_token in being.yml are copied into ha_config.json at startup if that file has no connection yet. An existing connection is never overwritten.
  • A Home Assistant-side integration exists but is not documented here. The repository carries one, and this page does not walk through installing it, because that was not performed.

Guardrails and approvals covers why the gate is shaped this way rather than how to satisfy it. Stage and approve a command is the same question asked about this machine’s own shell instead of the house.