· limen

Home Assistant, with limen as the limit

limen was built to let an AI agent into our machines without giving it a shell. Now it also brings their state to Home Assistant, where we run the office from, under the same rule: each machine decides what can run on it, and limen decides what goes out over MQTT and what can be pressed from there.

limen, the agent's limit

limen is an MCP server that reaches each machine over SSH. Each machine says in its limen.toml which files can be read and which packs of scripts it offers, and each script is a tool with the arguments its header declares: limen checks them before running it, and there is no shell or command line. The limits are on the machine, not on the agent: every new permission goes through a pull request.

The same limit, for Home Assistant

The hub, limen serve, already talked to every machine. With [mqtt] it also connects to a broker and uses Home Assistant's discovery: each machine shows up as a device, with no integration to install. And on the hub we decide two things:

# the hub's limen.toml
[mqtt]
enabled = true
url = "mqtt://192.168.1.229:1883"
user = "limen"
password_file = "mqtt-password"

[[mqtt.sensor]]
node = "nas"
id = "disk_used"
script = "disk_used"
args = { path = "/srv" }
every = "60s"
discovery = { unit_of_measurement = "%" }

[[mqtt.button]]
node = "nas"
id = "restart_immich"
script = "restart_container"
args = { name = "immich_server" }

A machine offering a script doesn't bring it to Home Assistant: only what is written on the hub gets there.

A sensor is a script that prints a value

limen parses nothing it runs, here neither: a sensor's state is the script's output, already redacted. So scripts for sensors print one value, rounded so it doesn't change on every read. The system pack brings cpu_used, memory_used, disk_used, temperature and gpu, and the docker pack, container_state:

# packs/system/disk_used
#!/bin/sh
#: description = "Percent used of the file system that holds a path, as a whole number"
#: read_only = true
#: [args.path]
#: type = "string"
#: default = "/"
#: pattern = '^/[A-Za-z0-9/._-]{0,255}$'
set -eu
df -P -- "$LIMEN_ARG_PATH" | awk 'NR == 2 { sub(/%/, "", $5); print $5 }'

When a field of some JSON is needed, Home Assistant's value_template takes it, not limen.

Where the limit still is

What about lnxlink?

lnxlink is the natural companion for bringing a Linux machine to Home Assistant: it is installed on each machine, publishes dozens of sensors and offers actions such as shutting it down. We prefer the agent and Home Assistant to come in through the same door: there is nothing more to install or update on the machines, and everything Home Assistant sees or presses is a script the machine offers and we declared on the hub. The price: a state is as fresh as its every, each read is a run over SSH, there are only the sensors there is a script for, and if the hub stops, Home Assistant sees everything as unavailable.

The details are in limen's specification, and how to write a script for a sensor, in its docs/scripts.md.