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:
- What is published: the sensors, scripts that only read and that the hub runs every so often.
- What can be called: the buttons, scripts the hub runs, with fixed arguments, when someone presses them.
# 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
- The machines don't change. The hub runs the scripts over SSH as it does for the agent: the machine only accepts those of its packs, with the arguments their header declares.
-
A sensor only reads. Its script has to say
read_only = true, or the hub refuses it, and the machine refuses every read. - Pressing a button chooses nothing. The script runs with the arguments written on the hub, and never one limen would have a person confirm. As buttons we declare only what we are fine with anyone who reaches the broker pressing, such as restarting a container.
- A machine that stops offering a script takes it away. Its sensor or button turns unavailable in Home Assistant.
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.