# admin-canvas The sys-admin-llm's display surface, from [Phase 13 of the project plan](../docs/project-plan.md). Where `digest-engine`/`digest-web` (Phase 12) render a scheduled 4x/day synthesis run, this is the on-demand equivalent: whenever Home Assistant's tool-calling LLM (the household's "sys-admin-llm", in its admin/ops-facing role — e.g. answering "show me the kitchen outlet's power draw") decides something should be shown, it pushes a small JSON document here and the thin client's admin workspace (`4:admin`, see `hosts/thin-client/agent/thinclient_agent/admin_canvas.py`) picks it up on its next poll. Same read/write split as the digest, for the same reason (the write side needs to validate untrusted-ish input; the read side is dumb, static, and LAN-published): - **`admin-canvas`** (this directory) — a small always-on Python HTTP service. Accepts `POST /show` and `POST /media/`, both bearer-token gated, writes to a shared `output/` volume. Has **no published port** — reachable only from other containers on the compose network (i.e. Home Assistant), the same trust boundary `mosquitto`/`homeassistant` already share. - **`admin-web`** (nginx, defined in `setup-container-host.sh`, not here) — serves `output/`, `render/canvas-sdk/`, and `render/templates/canvas.html` read-only to the LAN, exactly like `digest-web`. **This does not add a network path to the thin client.** The MQTT "Show admin canvas" button only ever tells the thin client to switch workspace and open its fixed, locally-configured `canvas.html` URL — see the security-boundary note in `hosts/thin-client/README.md`. Content reaches that page over a completely separate path: sys-admin-llm → HA service call → here → `admin-web` → the browser's own poll. ## Configure ```sh cp admin-canvas/admin-canvas.env.example /opt/smart-home/admin-canvas/admin-canvas.env openssl rand -hex 32 # put the result in ADMIN_CANVAS_TOKEN below chmod 600 /opt/smart-home/admin-canvas/admin-canvas.env $EDITOR /opt/smart-home/admin-canvas/admin-canvas.env ``` `ADMIN_CANVAS_TOKEN` is required — the service fails closed (rejects every request) while it is empty, not "auth optional". The same value has to be pasted into HA's `rest_command:` config below; there is no way for this repo to push it there for you, same as every other credential pair in this project that spans two machines. ## The `/show` schema ``` POST /show Authorization: Bearer Content-Type: application/json { "windows": [ { "kind": "...", "title": "...", "content": {...} }, ... ] } ``` Overwrites `output/latest.json` wholesale — there is no history, no merge, no per-run directories like the digest has; this is a single "what's on screen right now" document, because unlike the digest, nothing here needs to know whether a previous push was ever viewed. `admin-canvas/server.py`'s `validate_show_payload()` rejects anything that doesn't match this shape (HTTP 400) before it is ever written. ### `stat` — a big number ```json { "kind": "stat", "title": "Kitchen fridge", "content": { "value": 42, "unit": "W", "label": "current draw", "trend": "up" } } ``` `trend` is optional: `"up"` / `"down"` / anything else renders as flat. ### `chart` — dependency-free inline SVG (bar or sparkline) ```json { "kind": "chart", "title": "Kitchen outlets — right now", "content": { "type": "bar", "unit": "W", "points": [ { "label": "Fridge", "value": 42 }, { "label": "Kettle", "value": 1800 } ] } } ``` ```json { "kind": "chart", "title": "Living room draw — last 6h", "content": { "type": "sparkline", "unit": "W", "points": [180, 172, 190, 640, 210, 205] } } ``` ### `image` / `video` — media already uploaded via `POST /media/` ```json { "kind": "image", "title": "CPU — last 24h (Netdata)", "content": { "src": "media/netdata-cpu.png", "alt": "CPU utilization graph" } } ``` `content.src` **must** match `media/` — no scheme, no leading `/`, no `..`. This is the same "a payload never becomes a URL host" invariant `thinclient_agent/mqtt_discovery.py` documents for the thin client's own control surface, applied here to what a browser is told to fetch. Upload the file first: ```sh curl -X POST "http://:8092/media/netdata-cpu.png" \ -H "Authorization: Bearer $ADMIN_CANVAS_TOKEN" \ --data-binary @netdata-cpu.png ``` Allowed extensions: `.png .jpg .jpeg .gif .webp .mp4 .webm`. Filenames are allowlist-validated (`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$` plus that extension list) — anything else is refused with HTTP 400, specifically so nothing that isn't a media file can ever land in the directory `admin-web` serves statically. ### No `kind` — plain text/markdown-ish content Omit `kind` entirely to reuse the exact same tiny renderer the digest windows use (bold/italic/inline-code/paragraphs, or a bulleted list if `content` is an array). All kinds accept optional `x`/`y`/`w`/`h` (percentages) for a deliberately composed layout; omitted, a window just takes its place in the canvas's normal grid flow. ## Example: wiring this to Home Assistant **Nothing under this repo builds the HA side** — same convention as every other HA integration point in this project (see `hosts/thin-client/README.md`'s note on the Lovelace card nobody here builds either). This is what the household's own HA config needs, sketched for reference: ```yaml # configuration.yaml rest_command: admin_canvas_show: url: "http://admin-canvas:8092/show" method: POST headers: Authorization: "Bearer !secret admin_canvas_token" Content-Type: "application/json" payload: "{{ payload }}" ``` A sys-admin-llm tool call (AI Task / Assist) that wants to show the kitchen outlet's current draw would then resolve the reading from whatever HA entity/history holds it, build the `stat` JSON above, and call `rest_command.admin_canvas_show(payload=)`. Getting the thin client to actually display it is a second, separate step — the existing "Show admin canvas" MQTT button/service call documented in `hosts/thin-client/README.md`. ## Local testing without HA ```sh cd admin-canvas ADMIN_CANVAS_TOKEN=devtoken ADMIN_CANVAS_OUTPUT_DIR=/tmp/admin-canvas-output python3 server.py & curl -X POST http://localhost:8092/show \ -H "Authorization: Bearer devtoken" -H "Content-Type: application/json" \ -d '{"windows":[{"kind":"stat","title":"Kitchen fridge","content":{"value":42,"unit":"W","label":"current draw"}}]}' python3 -m http.server 8094 --directory /tmp/admin-canvas-output & # crude stand-in for admin-web ``` Then open `render/templates/canvas.html` directly in a browser (or serve `render/` alongside the directory above) to see it render — no container host required.