Skip to main content

ha-axi

An Agent eXperience Interface (AXI) CLI for Home Assistant.

Home Assistant's REST API will happily tell you a lamp is off, and even the name it displays. It will not tell you where that name came from, which room the lamp is in, or whether that room came from the entity or from the device behind it — and it cannot change any of them. The entity, area and device registries are reachable only over the WebSocket API, so administering an installation from a script means hand-rolling a WebSocket client, or clicking through the UI instead.

That is the first of the two jobs ha-axi exists for. The second is getting a service call right — checked before it is sent, and explained when Home Assistant refuses it with a status code and no body. Everything else the tool does — states, templates, arbitrary REST paths, arbitrary WebSocket commands — is plumbing those two need, and is table stakes anywhere.


The registries REST does not expose

One command renames an entity and moves it to a room, over the WebSocket API, resolving the area by name or by id:

$ ha-axi entity update light.example_lamp --name 'Reading Lamp' --area 'Example Room'
entity: light.example_lamp
updated[2]: area_id,name
name: Reading Lamp
area: Example Room
area_id: example_room
area_source: entity

The answer is the resulting registry row, not an echo of the request: updated names the fields that actually changed, and area_source says where the area in that row came from. That last field is the one a caller cannot work out for itself, and it matters because of this:

$ ha-axi entity get sensor.example_bridge_dawn
entity:
  entity_id: sensor.example_bridge_dawn
  name: Example Bridge Next dawn
  area: Example Hall
  area_id: example_hall
  platform: sun
  domain: sensor
  device_id: 8465672c0d0394de228e85c53dcc041b
  original_name: Next dawn
  disabled: false
  hidden: false
  entity_category: diagnostic
  unique_id: 01M0QEH70H9SQQBCX9E2GF8RKM-next_dawn
  icon: ""
  area_source: device

That entity's own registry row holds area_id: null and name: null. Home Assistant still places it in Example Hall, because an entity with no area of its own inherits its device's, and it still displays it as Example Bridge Next dawn, because an entity's name is composed from two registries: a name somebody set wins outright, and otherwise the device's display name is joined to the entity's own half. A client that reads the entity row alone reports that entity as unassigned and nameless. Both are wrong, and both are wrong silently.

ha-axi applies both rules in every view that names an entity, so the filters agree with what a user sees:

$ ha-axi entity list --area 'Example Hall' --limit 3 --fields entity_id,name,area,platform
count: 3 of 9 matched (23 total)
entities[3]{entity_id,name,area,platform}:
  sensor.example_bridge_dawn,Example Bridge Next dawn,Example Hall,sun
  binary_sensor.example_bridge_rising,Example Bridge Solar rising,Example Hall,sun
  sensor.example_bridge_dusk,Example Bridge Next dusk,Example Hall,sun
help[3]:
  Run `ha-axi entity get <entity_id>` for one entry in full
  Run `ha-axi entity list --limit 9` to see all 9
  Run `ha-axi entity update <entity_id> --name "<name>" --area <id|name>` to change one
$ ha-axi entity list --search 'Example Bridge' --limit 2 --fields entity_id,name,original_name
count: 2 of 9 matched (23 total)
entities[2]{entity_id,name,original_name}:
  sensor.example_bridge_dawn,Example Bridge Next dawn,Next dawn
  binary_sensor.example_bridge_rising,Example Bridge Solar rising,Solar rising
help[3]:
  Run `ha-axi entity get <entity_id>` for one entry in full
  Run `ha-axi entity list --limit 9` to see all 9
  Run `ha-axi entity update <entity_id> --name "<name>" --area <id|name>` to change one

--search matches the composed name, which is why searching for the name a user reads finds the entity even though no registry row contains that string. original_name stays available as a field when you want the entity's own half alone.

The same fallback keeps the arithmetic honest. area list counts an entity into the area it is really in, and the per-area counts plus unassigned_entities sum to the size of the registry:

$ ha-axi area list
count: 3 areas
unassigned_entities: 12
areas[3]{area_id,name,entities,devices,floor_id}:
  example_hall,Example Hall,9,1,""
  example_room,Example Room,2,0,""
  example_study,Example Study,0,0,""
help[3]:
  Run `ha-axi entity list --area <id|name>` to see what one area holds
  Run `ha-axi area update <id|name> --name '<name>'` to rename one
  Run `ha-axi entity list --area none` to find entities with no area

entity_id is not stable identity and nothing here encourages treating it as such: filter by area, search by name, or list what one device supplies with entity list --device <device_id> — the only route from an opaque device id to its entities, because that id is not searchable and should not be. Areas accept a name or an id anywhere <id|name> appears, and an ambiguous name is an error rather than a guess. entity get and area get show one entry in full; area get also reports the icon, floor and aliases that area list leaves out.

The typed write surface is entity update--name, --area and --icon, each with a matching --clear-* that falls back to what the integration supplies, plus --new-id to rename the entity_id itself — together with area create and area update, and device get and device update on the registry behind them. Deleting an area is deliberately not given a typed command; ha-axi ws area.delete is there if you mean it, and the same goes for disabling or deleting a device.

The device is usually the level a name or an area is wrong at, which is why device update exists rather than a loop over entities: an entity with no area of its own inherits its device's, and an entity with no name of its own is named after its device, so one correction here moves every entity that device supplies and leaves nothing behind still saying the old thing. It takes a device_id or the displayed name, and --name/--area with matching --clear-* flags, the same shape entity update has. One asymmetry is Home Assistant's rather than this tool's: a device carries two names, and only name_by_user is writable. --name sets it, --clear-name removes it and falls back to the integration's own name, and device get reports both alongside a name_source saying which one is showing.

Service calls that are checked, not forwarded

Home Assistant will accept a service call that cannot possibly do anything and answer 200 with an empty list. Three different outcomes arrive on the wire looking identical, and a client that forwards the call and prints the reply reports all three as success.

An entity that cannot do the thing is dropped in silence when it was reached through an area or a device. ha-axi reads the capability the service publishes and says so before sending:

$ ha-axi service call cover.set_cover_position --target-area example_room --data position=50
error: "cover.set_cover_position needs a supported_features bitmask containing any of 4, and no entity the target matched has one: cover.example_blind reports 3"
code: UNSUPPORTED_CAPABILITY
help[4]:
  Run `ha-axi state list --area example_room --domain cover` to see what is there
  Run `ha-axi area list` to see each area's id and how much it holds
  Run `ha-axi service get cover.set_cover_position` to see what this service targets
  Run the same command with --no-check to send it anyway

The published requirement is a list of alternatives and is read as one, because that is Home Assistant's own rule: media_player.volume_up names both VOLUME_SET and VOLUME_STEP, since core backs a player that cannot step with one that can set. A speaker with only the first is not gated, because it works. The requirement is also only read for the service's own domain — reolink.ptz_move targets button entities and names a camera feature, and checking a button against a camera's bits would refuse every call. --no-check exists because a published requirement is an integration's claim about itself, and a wrong claim must not become a wall.

A target that matched nothing exits 1 and says so, rather than reporting a successful no-op:

$ ha-axi service call light.turn_on --target-area example_study
error: "area example_study matched 0 entities light.turn_on can act on, so the call did nothing"
code: NO_ENTITIES_TARGETED
help[3]:
  Run `ha-axi state list --area example_study --domain light` to see what is there
  Run `ha-axi area list` to see each area's id and how much it holds
  Run `ha-axi service get light.turn_on` to see what this service targets

A call that genuinely had nothing to do is a success, and says which:

$ ha-axi service call light.turn_off --target-entity light.example_lamp
service: light.turn_off
changed: light.turn_off accepted with 0 states changed
target: entity light.example_lamp matched 1 entity; which reported no state change
help[2]:
  Run `ha-axi state get <entity_id>` to see an entity's current state
  Run `ha-axi service get light.turn_off` to see what this service targets

Home Assistant returns the states that actually changed, so [] means both "everything was already as asked" and "nothing was reached at all" and it never says which. ha-axi resolves the target when, and only when, the change set is empty and a target was given: reaching nothing exits 1, reaching something exits 0 with the count and any entity that was unavailable and therefore skipped. Which domains a service can reach is read from its published target, never guessed from its name.

A refusal carries no reason at all. Home Assistant renders a rejected service call as an empty 400 — the status line and no body — so an unknown service, an undeclared field and a missing required one are indistinguishable on the wire. (Two refusals are worse still: a named entity lacking a capability, and a --response call that matched nothing, arrive as a plain-text 500 with a fixed apology.) ha-axi fetches the service model at that point and answers from it:

$ ha-axi service call light.turn_on --target-entity light.example_lamp --data brightnes=180
error: light.turn_on does not accept field brightnes
code: UNKNOWN_SERVICE_FIELD
help[2]:
  fields for light.turn_on: transition, rgb_color, color_temp_kelvin, brightness_pct, brightness_step_pct, effect, rgbw_color, rgbww_color, color_name, hs_color, xy_color, brightness, brightness_step, white, profile, flash
  Run `ha-axi service get light.turn_on` for their types and which are required

A call that succeeds pays for none of that: the explanation is failure-path only. The model is never cached — an integration added or removed rewrites it, and nothing signals when.

service get renders the same model on demand, from the installation itself, so it is never stale and never describes integrations you do not have:

$ ha-axi service get cover.set_cover_position
service: cover.set_cover_position
name: set_cover_position
description: ""
response: none
target: entity domain cover; supported_features matching any of 4
fields[1]{field,required,type,description}:
  position,true,number,""
help[2]:
  Run `ha-axi service call cover.set_cover_position --target-entity <entity_id> --data position=<value>` to call it
  Run `ha-axi state get <entity_id>` and compare its supported_features attribute

What ha-axi deliberately does not do is turn that model into commands. Home Assistant publishes enough metadata to generate a typed command per service, and generating them would mean roughly 77 nouns and 327 subcommands wrapping the one command that already reaches all of them — each firing at a device without asking whether it can do the thing. Consuming the model to validate, explain and recover costs one shared dependency and none of that. --data key=value reaches every field of every service, forever, with no metadata to go stale.

Install

pip install ha-axi
# or run it without installing
uvx ha-axi entity list --area 'Example Room'

From a checkout:

pip install -e ".[dev]"
scripts/install-hooks.sh   # point core.hooksPath at .githooks

Configure

Nothing about any particular installation is baked in: the base URL and token come from the environment, and the entity table is fetched at runtime. Two variables, and nothing else — a third, optional one makes the session read-only:

export HA_URL=https://homeassistant.example.com   # or HASS_SERVER
export HA_TOKEN=<long-lived access token>          # or HASS_TOKEN

Create the token in Home Assistant on your profile page, under Security → Long-lived access tokens.

There is deliberately no --token flag and no credential file. A token on a command line leaks into shell history and the process table; a token in a file leaks into commits. The environment is the only channel. Anything token-shaped is redacted before it reaches stdout or stderr, so a credential cannot escape through an error message or a debug line either. Two more invariants hold around the URL: a redirect that changes scheme or host is refused rather than followed, because urllib would copy the Authorization header onto it, and any user:password@ in HA_URL is stripped and registered as a secret before the base URL is ever printed. A bare host defaults to https://, never http://.

Check both transports at once:

$ ha-axi doctor
healthy: true
checks[4]{check,status,detail}:
  read_only,ok,"HA_AXI_READ_ONLY is not set: writes are allowed"
  environment,ok,HA_URL and HA_TOKEN are set
  rest,ok,API running. (version 2026.8.3)
  websocket,ok,"authenticated, 23 registry entries in 3 areas"
version: 2026.8.3

doctor exits non-zero when any leg fails, so it works as a gate in a script or a hook.

Read-only sessions

A third variable makes a session incapable of changing anything:

export HA_AXI_READ_ONLY=1

Every write is then refused before it is sent, and the refusal does not care which route the write took. A typed command:

$ ha-axi entity update light.example_lamp --name 'Something Else'
error: "`ha-axi entity update` is a write, and HA_AXI_READ_ONLY is set"
code: READ_ONLY
class: usage
help[3]:
  This session is read-only; the command was refused before anything changed
  Reads still work, e.g. `ha-axi state list`, `ha-axi entity list`, `ha-axi area list`
  Unset HA_AXI_READ_ONLY to allow writes; it is a switch, so any non-empty value enables it

The raw WebSocket escape hatch, which is where the registry writes actually live:

$ ha-axi ws --raw config/area_registry/create --param name='Bypass Attempt'
error: "`ha-axi ws` is a write, and HA_AXI_READ_ONLY is set"
code: READ_ONLY

And the raw REST escape hatch:

$ ha-axi api POST /services/light/turn_on --field entity_id=light.example_lamp
error: "`ha-axi api` is a write, and HA_AXI_READ_ONLY is set"
code: READ_ONLY

Each of those last two prints the same three help lines as the first; only the head of the output is reproduced here. All three exit 2, and the area registry is unchanged afterwards: the refusal is reached before either transport is opened, and before the token is even read.

Four things about it are deliberate, and the first two are why it is worth having at all.

  • It is a variable, never a flag. A flag is omitted by exactly the caller that most needs it. Setting it in the environment covers every command the session runs, including the ones an agent composes rather than a person types.
  • Every subcommand and every WebSocket command carries an explicit classification, and the default is a write. Nothing is inferred from a command's name or from an HTTP verb: service call mutates through a surface that looks like any other POST, and the WebSocket command set does not follow REST conventions at all. A command nobody classified is refused, so the failure mode of forgetting is a refusal rather than an unguarded mutation — and a test enumerates both command tables and fails on the first declaration that has none.
  • It is a switch, not a boolean. Any non-empty value enables it, 0 and false included. Parsing the value is how a guard comes to be off while an operator believes it is on; unsetting the variable is the only way to allow writes.
  • Refused commands stay visible. They are still listed in --help and in the command table, because an agent that cannot see the command it needs cannot work out why its plan is impossible.

Enforcement sits at three points — the dispatcher, RestClient.request and WsClient.send_command — and never inside a command body, so a new command is guarded whether or not its author knew there was a gate. The two transports are the load-bearing pair: a classification is a claim a module makes about itself, and a test pins that a module claiming to be a read and posting anyway is still refused. A guard that held on one transport and not the other would be worse than none, because it would reassure without protecting.

Reads are untouched, including template render — a POST that renders server-side and changes nothing. ha-axi doctor reports the mode as its first check, and the no-argument view prints read_only: on when it is set, so a session knows what it is before it plans anything.

What it does not cover. ha-axi api hands an opaque path straight to the installation, so there the method is the only fact available and the rule errs closed: GET, HEAD and OPTIONS pass, everything else is refused, including a POST that happens not to change anything. Any Home Assistant endpoint that mutated on a GET would pass that check — none does, and the same assumption is the one every read-only HTTP proxy makes, but it is an assumption rather than a guarantee. ha-axi ws --raw is judged by the API type it names: one a declared command already names as a read passes, and an undeclared type is refused.

Everything else it reaches

These are table stakes for any Home Assistant client. They are here because the two sections above need them and because an agent that has the tool should not have to leave it — not because they are what ha-axi is for.

  • state list / state get — the runtime view over REST: what an entity is doing right now and its attributes, with --domain, --state, --search, --fields and --limit.
  • service list / service get — discover what an installation can be asked to do.
  • template render — render a Jinja template server-side, from --template, --template-file or stdin. It sees every entity Home Assistant knows about.
  • api — any authenticated REST path, with --field, --body and --query. The escape hatch for anything with no typed command.
  • ws — any WebSocket command. ws --list prints the declared names, ws <name> --param k=v sends one, and ws --raw <api/type> sends a type that has no declared name yet. Adding a declared command is one entry in REGISTRY in src/ha_axi/ws.py; the auth handshake, id correlation and error translation are shared.
  • doctor — environment and connection checks over both transports.
  • setup — install the agent integrations on this machine (below).
  • context — the ambient document a session hook prints. Reads the environment and the command table only, so it reaches nothing and exits 0 with nothing configured (below).

The whole command surface, and the transport each half runs on:

Command Transport What it does
ha-axi entity list|get|update WebSocket The entity registry: names, areas, platforms, entity ids
ha-axi area list|get|create|update WebSocket The area registry
ha-axi device list|get|update WebSocket The device registry: device names and areas, which entities inherit
ha-axi service list|get|call REST Discover services, read one's fields, and call them
ha-axi state list|get REST Entity states and attributes as they are right now
ha-axi template render REST Render a Jinja template server-side
ha-axi ws WebSocket Any WebSocket command, declared or raw
ha-axi api REST Any authenticated REST path
ha-axi doctor both Environment and connection checks
ha-axi setup Install the agent integrations
ha-axi context The ambient document a session hook prints

--help on any command is the authoritative reference: it lists every flag per subcommand, with defaults and two or three worked examples. Nothing here duplicates it, and it works with no configuration present.

state and entity are different views, and both are needed

state is the runtime view over REST — what an entity is doing, and the name it displays. entity is the registry view over WebSocket — an entity's stable identity: the name a user set, the area it belongs to, the integration that supplied it. All of those live only in the registry; states live only over REST.

--area works the same on state list, entity list and device list. On state list it costs one extra registry round-trip, because areas live over the WebSocket, and it is paid only when the flag is passed:

$ ha-axi state list --area 'Example Room'
count: 2 of 2 matched (23 total)
states[2]{entity_id,name,state}:
  light.example_lamp,Reading Lamp,off
  cover.example_blind,Example Blind,closed
help[2]:
  Run `ha-axi state get <entity_id>` for one entity's full attributes
  Run `ha-axi state list --domain light` to narrow by domain

That flag exists because an agent that learns --area on entity list will reach for it on state list. A filter is not the same as importing registry columns into the runtime view, which is why area is still not a state list --fields choice.

Output format

Structured TOON on stdout by default, which is roughly 40% cheaper in tokens than the equivalent JSON:

$ ha-axi state list --domain light --domain cover
count: 2 of 2 matched (23 total)
states[2]{entity_id,name,state}:
  light.example_lamp,Reading Lamp,off
  cover.example_blind,Example Blind,closed
help[1]:
  Run `ha-axi state get <entity_id>` for one entity's full attributes
  • --human renders aligned tables for a person.
  • --json emits raw JSON.
  • Errors go to stdout too, in the same structured shape, and carry the command that fixes them. stderr carries only diagnostics (--debug), which agents do not read.
  • Exit codes: 0 success — including idempotent no-ops — 1 error, 2 usage error. A read-only refusal is a 2: the verdict is reached without touching the installation, and no argument to the same command changes it. A 401 is a 1, because only the server could have said it.
  • Unknown flags and extra arguments are rejected by name rather than ignored, with the subcommand's valid flags listed inline so the correction takes one turn, not two:
$ ha-axi state list --domian light
error: unknown flag --domian for `state list`
code: UNKNOWN_FLAG
help[2]:
  valid flags for `state list`: --area, --domain, --state, --search, --limit, --fields (--help always allowed)
  Run `ha-axi state --help` for the full reference

One documented deviation: help[N]: blocks render one suggestion per line rather than as a delimiter-joined TOON array. Suggestions are command lines that routinely contain commas, and this is the shape the AXI standard and the sibling AXI CLIs use. Every data structure is strict TOON, and "strict" is a test result rather than a claim: all 179 of the specification's own conformance fixtures are vendored into the suite and every one of them has to pass.

Error codes

Every failure carries a code naming the one thing that went wrong, and a class naming the kind of thing it is. The class is what to switch on: it is the difference between retrying, changing the arguments, and fetching a different token.

$ ha-axi state list
error: "could not reach Home Assistant: [Errno 111] Connection refused"
code: UNREACHABLE
class: transport
help[2]:
  Check HA_URL points at a reachable Home Assistant instance
  Run `ha-axi doctor` to test the connection
class what happened what to do next
usage the invocation is wrong; nothing was sent change the command line
config this machine is not set up to talk to Home Assistant change the environment
transport Home Assistant was not reached, or is not serving change nothing and retry
auth the credential was rejected mint a new long-lived access token
permission the credential was accepted; the caller is not permitted use a different account, or lift the block on the instance
not_found the subject named does not resolve to one thing that exists here look it up and ask again
refused the subject exists and this request was refused change the arguments
internal a bug in ha-axi report it

Three of those are the ones that used to be indistinguishable, and they demand opposite responses. An agent that cannot tell a rejected token from a command this version does not have retries the token forever; one that cannot tell either from an unreachable host reports that Home Assistant is down when it is not. permission is a fourth: Home Assistant answers "your credential is fine, you are not allowed" on both transports — a banned address over REST, an account that is not an administrator over the WebSocket — and a new token fixes neither.

The whole vocabulary, which is closed:

  • usageUNKNOWN_COMMAND, UNKNOWN_SUBCOMMAND, MISSING_SUBCOMMAND, UNKNOWN_FLAG, MISSING_VALUE, MISSING_ARGUMENT, UNEXPECTED_ARGUMENT, CONFLICTING_FLAGS, UNKNOWN_FIELD, BAD_LIMIT, BAD_TIMEOUT, BAD_JSON, BAD_PAIR, BAD_SERVICE, MISSING_PATH, MISSING_NAME, MISSING_TEMPLATE, MISSING_COMMAND, MISSING_PARAM, NO_CHANGES, NO_SUCH_COMMAND, UNREADABLE, UNREADABLE_FILE, UNWRITABLE, READ_ONLY
  • configNOT_CONFIGURED, BAD_URL, BAD_TOKEN, MISSING_DEPENDENCY, REDIRECT_REFUSED
  • transportUNREACHABLE, TIMEOUT, TLS_ERROR, CONNECTION_DROPPED, UNAVAILABLE, WS_HANDSHAKE, WS_CLOSED, WS_PROTOCOL
  • authUNAUTHORIZED
  • permissionFORBIDDEN
  • not_foundNOT_FOUND, NO_SUCH_ENTITY, NO_SUCH_AREA, AMBIGUOUS_AREA, NO_SUCH_DEVICE, AMBIGUOUS_DEVICE, NO_SUCH_DOMAIN, NO_SUCH_SERVICE, NO_ENTITIES_TARGETED, NO_SUCH_WS_COMMAND, NO_WEBSOCKET_API
  • refusedBAD_REQUEST, METHOD_NOT_ALLOWED, SERVER_ERROR, API_ERROR, INVALID_FORMAT, NOT_ALLOWED, NOT_SUPPORTED, HOME_ASSISTANT_ERROR, SERVICE_VALIDATION_ERROR, TEMPLATE_ERROR, UNKNOWN_SERVICE_FIELD, MISSING_SERVICE_FIELD, UNSUPPORTED_CAPABILITY, RESPONSE_REQUIRED, RESPONSE_NOT_SUPPORTED
  • internalINTERNAL_ERROR, ID_REUSE

Two properties hold across the whole table, and both are enforced by the suite rather than promised here. The vocabulary is closed: a code is always written out at the point it is raised, never built from a status number or from a string a server sent, so the set above is the whole set and a caller can switch over it exhaustively. Classification happens at the transport boundaryRestClient.request and WsClient.send_command — and never in a command body, so a command added later is classified whether or not its author knew the taxonomy existed. tests/test_error_codes.py runs every subcommand against every fault on both transports and fails on the first one that cannot say which class it met.

Coverage is bounded, and worth saying plainly: the class is as good as what Home Assistant puts on the wire. A refusal it renders with no body and no reason — several are — is classified by its status and nothing more.

Agent integration

Two ways to make this discoverable. You only need one.

Session hook — ambient context in every session, for agents that support hooks:

ha-axi setup hooks

Installs a SessionStart hook for Claude Code (~/.claude/settings.json) and Codex (~/.codex/hooks.json, plus [features] hooks = true), and a managed ambient-context plugin for OpenCode. It is idempotent, repairs the recorded path after a reinstall or a move, and refuses to overwrite a plugin it does not manage.

It owns exactly one entry, and knows which one by a managed_by key it writes into that entry — not by the command naming this tool. A SessionStart hook you wrote yourself is left alone however it reaches this tool: an environment prefix, another interpreter, a shell wrapper. An entry written by a release before that key existed is adopted once, in the one shape those releases could produce — the executable and nothing else — so upgrading repairs the hook you already have rather than adding a second beside it.

What the hook puts in front of a session is ha-axi context, which reads the environment and the command table and nothing else — no connection, no token, no address, and exit 0 whether or not this machine has ever been pointed at Home Assistant:

$ ha-axi context
bin: ~/.local/bin/ha-axi
description: Agent CLI for Home Assistant. Reads and writes the registries REST cannot reach and explains a service call Home Assistant refuses. Prefer this over raw curl for Home Assistant operations.
config: HA_URL and HA_TOKEN are set
registries: names and areas live in the registry which only the WebSocket API serves -- `entity list` and `area list` read it; `state list` reads REST and cannot see either
entity_ids: an entity_id is not stable identity and its words mean nothing -- reach an entity with `entity list --search '<the name a user sees>'` or `--area <id|name>` rather than guess one
services: prefer `service call` over `api POST /services/...` -- it explains a refusal Home Assistant returns with no body at all and tells reaching nothing apart from changing nothing
commands[11]: state,service,template,entity,area,device,ws,api,doctor,setup,context
help[4]:
  Run `ha-axi` for this installation at a glance: entity counts by domain and what is unavailable
  Run `ha-axi entity list --area <id|name>` to read the registry, which REST cannot reach
  Run `ha-axi service call <domain>.<service> --target-entity <entity_id>` to act
  Run `ha-axi <command> --help` for its flags, or `ha-axi --help` for all of them

config reports which variables are set and never what they hold. On a machine that has never been configured the same document comes back with the two exports in its help block instead — and still exits 0, which is the point: a hook runs before anybody has decided to use the tool, so the one reader who most needs telling that this tool exists is the one with nothing set up yet.

The no-argument view is still where live state lives, and it is worth running once configuration is in place:

$ ha-axi
bin: ~/.local/bin/ha-axi
description: Agent CLI for Home Assistant. Reads and writes the registries REST cannot reach and explains a service call Home Assistant refuses. Prefer this over raw curl for Home Assistant operations.
url: https://homeassistant.example.com
entities: 23 in 12 domains
unavailable: 0
unknown: 7
domains[8]{domain,entities}:
  sensor,11
  input_boolean,2
  conversation,1
  cover,1
  event,1
  input_number,1
  light,1
  person,1
help[5]:
  Run `ha-axi state list --domain <domain>` to list entity states
  Run `ha-axi state list` for all 23 entities across 12 domains
  Run `ha-axi entity list --area <id|name>` to read the registry, which REST cannot reach
  Run `ha-axi area list` to see the areas defined here
  Run `ha-axi service call <domain>.<service> --target-entity <entity_id>` to act

It needs both variables, opens a connection and prints the installation's address, which is why it is not what a hook runs: as ambient context it would fail on every machine that had the package and no installation, and put an address into an agent's context on every machine that had one. The url line reads as the documentation placeholder here because every example in this file was run against a throwaway Home Assistant, and that is the one line whose value is the reader's own.

unavailable and unknown are counted apart because they are different facts: unknown means reachable and not yet reporting, which is much the commoner of the two, and summing them under the name of one would contradict state list --state unavailable outright.

Agent Skill — loads on demand, no per-session token cost, works in any agent that supports skills:

npx skills add dmealing/ha-axi --skill ha-axi

skills/ha-axi/SKILL.md is generated from the CLI's own command table by ha-axi setup skill, and CI runs ha-axi setup skill --check so it can never drift from the commands it documents.

This repository is public, and stays generic

This tool talks to home automation installations, so the failure that matters is not a bug — it is a commit that describes, or grants access to, someone's house. That is enforced by a scanner, not by a convention:

scripts/leakcheck.py            # scan every tracked file
scripts/leakcheck.py --staged   # scan what a commit would actually record
scripts/leakcheck.py --commit-msg <path>   # scan a commit message
scripts/leakcheck.py --pull-request <n>    # scan a pull request's title and body
scripts/leakcheck.py --rules    # list the rules and what each one catches
scripts/leakcheck.py --demo     # self-test: prove every rule still fires

What it catches. Run --rules for the live list; today it is JWTs (what a Home Assistant token looks like), RFC1918 and CGNAT addresses, IPv4/IPv6 link-local and unique-local addresses, .local /.lan/.localdomain hostnames, *.ui.nabu.casa remote-access hostnames, geographic coordinates (/api/config returns the house's), MAC and Zigbee IEEE addresses, absolute home directories, emails outside the reserved documentation domains, and literal bearer credentials.

Each file is scanned twice: once per line, and once condensed, with whitespace, quotes, backslashes and + removed from the whole file. A credential split across lines or assembled by concatenation is invisible to a line pass, and splitting a token across fragments is exactly how one hides — deliberately or not. The condensed pass runs only the token rules, because joining arbitrary lines can fuse unrelated digits into a plausible address, and a guard that cries wolf gets bypassed.

What it does not catch, stated plainly so the coverage is not mistaken for more than it is: a generic public hostname or IP that happens to be someone's instance, a secret that is neither JWT-shaped nor bearer-prefixed, anything inside a binary or an image, and any shape no rule describes. The scanner narrows the ways a leak can happen; it does not make review unnecessary.

A line that must legitimately keep one of these shapes carries leakcheck: allow=<rule>. The exemption is per rule on purpose — a blanket marker would switch off every rule on that line, including one nobody was thinking about, which is how a live credential hides behind a suppressed lint.

It runs in four places:

scripts/install-hooks.sh   # points core.hooksPath at .githooks
  • .githooks/pre-commit blocks the commit locally, including the very first commit in a repository, which has no HEAD to diff against.
  • .githooks/commit-msg scans the message, which is a separate channel from file content and just as public.
  • CI runs --demo first — proving the scanner still detects what it claims — and then scans the whole tree. The demo's own output is published too, so it reports findings without the values. Bypassing the local hooks only delays the failure.
  • The pull request itself, on every open, push and edit. A title and a body are published the moment they are written, are in no checkout and pass under no hook, so nothing above reaches them — and tooling routinely pastes captured output into a body, where a pytest header carries a rootdir: line holding an absolute path. It fails the check when it cannot read the pull request rather than reporting a clean it cannot support, and it reports the field, line and rule of a match — plus the offset when the finding's pass read the text as written — without printing the match: a CI log is more public than the page it came from. For the same reason a pull request cannot carry an allow= marker — in a file that marker is committed and reviewed, and in a body it is an off-switch anyone can add after every check has run.

A commit message is checked as well as scanned. release-please builds the changelog and the version bump from commit messages, and when its parser cannot read one it says so at debug level, drops the commit and exits 0 — a merged fix that is never published, with a green release run over it. So .githooks/commit-msg also runs scripts/commitcheck.py, and the release workflow re-checks every commit since the last tag. Rich commit bodies are the point of this history and nothing here restricts them; the one shape to know is that a body line must not begin with a word run straight into an unclosed or nested parenthesis — `Decimal(repr(v))` at a line start is refused, and the same phrase one word further along the line is fine. This repository has never lost a release to it, and tests/fixtures/commit-messages/ records how narrowly. Run scripts/commitcheck.py --rules for the grammar rule and its citation.

Fixtures, tests, docs and examples use invented identifiers throughout: light.example_lamp, area Example Room, https://homeassistant.example.com. Test fixtures build credential shapes at run time rather than embedding literals, so the suite that proves the scanner works does not itself trip it.

Testing

pip install -e ".[dev]"
pytest
ruff check . && ruff format --check .

No live installation and no live token are needed, which is the point. The suite runs against real local servers on loopback: an http.server for REST, and a real websockets server that performs the same auth_required / auth / auth_ok handshake Home Assistant does.

Covered by tests:

  • the TOON encoder against the specification's rules — tabular, keyed tabular, list and inline forms, quoting, escaping, delimiters, root forms — and against the specification's own encode fixtures, every one of them, vendored byte-for-byte from toon-format/spec and run on every pytest. The case count is asserted too, so a fixture that stops being collected fails the suite instead of quietly lowering the score;
  • every command's output shape, filters, field selection, limits and empty states;
  • the read-only gate, by enumerating both command tables and failing on the first subcommand or WebSocket command that carries no classification — so the guard is whole because of the sweep, not because somebody remembered every command;
  • the WebSocket protocol beyond the happy path: the handshake, an unexpected greeting, a message arriving mid-authentication, auth rejection, event and pong frames interleaved with results, id correlation, a non-JSON frame, a socket closed mid-command, both directions of the error boundary, implicit connect, and a registry larger than the library's default frame size;
  • credential containment: a cross-origin redirect refused rather than followed, a token that cannot be a header rejected before it reaches one, URL userinfo stripped and redacted, an unexpected exception rendered as a structured error on stdout instead of a traceback, and stderr asserted clean and redacted — the gap that let two escapes ship;
  • flag validation, renamed-flag hints, exit codes, --help in every position (and never stolen from a flag value), and --help for every command without any configuration present;
  • the leak scanner adversarially: every rule against the shape it claims, every rule against content that must not trip it, the split/concatenated/percent-encoded evasions, the scoped allow marker, and both git hooks end to end through a real git commit;
  • hook installation: idempotency, path repair, atomic writes, leaving other tools' and the user's own hooks alone, collapsing a duplicate managed entry wherever it sits, and every value the Codex features flag can already hold — including the ones that used to make the tool append a duplicate TOML key its parser refuses.

Would need a live installation to confirm: that a real Home Assistant accepts the exact request bodies built here — config/entity_registry/update field names, return_response behaviour, and the service-data keys individual integrations validate — how a very large registry behaves in practice, and whether an integration's published supported_features requirement agrees with the one its Python actually enforces. The doubles implement the documented protocol and enforce the parts of it that are known: the REST double rejects a nested service-call target the way Home Assistant does, refuses an unknown service with the same empty 400 and no body, and drops an unavailable or incapable entity in the same silence. So they verify this client against the specification rather than against a particular server build.

What the doubles do not get to invent is the shape of ordinary data. Their fixtures carry the distribution a real installation has — entries named entirely by their device, entries that name only their own half, a has_entity_name of each setting, a disabled entry with no state at all, an unknown state as well as an unavailable one, services and fields that publish no prose — and tests/test_double_fidelity.py asserts each of those shapes is still present. Every one of them was absent once, and each absence cost a defect that a green suite could not see.

Continuous integration

Workflow Runner Triggers What runs
ci.yml self-hosted push to main, nightly, manual leak scan, lint, pytest on 3.9–3.12, generated-skill check
hygiene.yml ubuntu-latest pull_request, including edited the leak scan of the tree, and the two checks that read the pull request's own title and body
release.yml ubuntu-latest push to main, manual release-please, and the OIDC publish when a release PR merges

A pull request therefore shows one hosted check, and that is deliberate. The leak scan is the gate that has to run before a human reads a diff; everything heavier runs on the maintainer's own machine, where the full matrix is free and does not queue behind anyone. edited is in that trigger list because a pull request's title and body are published the moment they are written, exist in no checkout, pass under no hook, and can be rewritten after every other check has run. ci.yml never triggers on pull_request and must not start to — this repository is public and that runner is a personal workstation, so a pull-request trigger would give any contributor code execution on it.

Releasing

Version bumps and the changelog are driven from conventional commits by release-please, which opens a release PR on every push to main. Merging that PR builds the distribution, smoke-tests the built wheel, and publishes to PyPI through trusted publishing — an OIDC exchange, so no long-lived PyPI token exists in this repository or anywhere else.

Trusted publishing requires a one-time configuration on PyPI by the repository owner (project ha-axi, owner dmealing, workflow release.yml, environment pypi) before the first publish succeeds.

License

MIT.

tests/fixtures/toon-spec/ vendors the TOON specification's conformance fixtures, which are MIT licensed and copyright their authors; the upstream licence, the commit they came from and the refresh recipe are recorded beside them in PROVENANCE.md.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ha_axi-0.7.0.tar.gz (295.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ha_axi-0.7.0-py3-none-any.whl (111.0 kB view details)

Uploaded Python 3

File details

Details for the file ha_axi-0.7.0.tar.gz.

File metadata

  • Download URL: ha_axi-0.7.0.tar.gz
  • Upload date:
  • Size: 295.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for ha_axi-0.7.0.tar.gz
Algorithm Hash digest
SHA256 3b82b97c1b6f013e1bf34c1412b5b584e53ef4f088338d5baafa831ddaf1e51d
MD5 2e3c8692138adb4e6c3e1bc9725f4960
BLAKE2b-256 72a08622fbb3997112645880cc490533c1a4b3048606204041ab1d60d6c82166

See more details on using hashes here.

Provenance

The following attestation bundles were made for ha_axi-0.7.0.tar.gz:

Publisher: release.yml on dmealing/ha-axi

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file ha_axi-0.7.0-py3-none-any.whl.

File metadata

  • Download URL: ha_axi-0.7.0-py3-none-any.whl
  • Upload date:
  • Size: 111.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for ha_axi-0.7.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a288a65e2ac651f686a598373d3194bc68116cee1bcc02184e1d9975960c118a
MD5 c3d76328e05991b9f33394a8fd4207ab
BLAKE2b-256 a028e20aa1642ac9d1a7f08727de64fbca0eb317396a95963d4bcf5b9510a18b

See more details on using hashes here.

Provenance

The following attestation bundles were made for ha_axi-0.7.0-py3-none-any.whl:

Publisher: release.yml on dmealing/ha-axi

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.7.1

2 files

This release

0.7.0 This release

2 files

0.6.0

2 files

0.5.1

2 files

0.5.0

2 files

0.4.0

2 files

0.3.4

2 files

0.3.3

2 files

0.3.2

2 files

0.3.1

2 files

0.3.0

2 files

0.2.0

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page