Small status aggregator for showing Claude/Codex activity on an AWTRIX3 clock.
The service is intentionally light:
- one Go binary, stdlib only
- single AWTRIX HTTP transport
- bearer-token auth on write endpoints
- HTTP input endpoint for laptop-side producers
A single AWTRIX3 clock on the local network (configure yours in config.json):
- AWTRIX prefix:
awtrix_xxxxxx(your device's MQTT topic prefix) - AWTRIX HTTP URL:
http://<awtrix-ip> - Firmware:
0.98
Ember drives a clock running AWTRIX 3 — Blueforcer's open-source firmware for the Ulanzi TC001 "Smart Pixel Clock" (or a self-built 32×8 matrix). Ember talks to it over the firmware's HTTP API; see the AWTRIX 3 docs for flashing and the API reference.
Ember is an independent project, not affiliated with or endorsed by Ulanzi or the AWTRIX firmware.
Built with the Homebrew Go toolchain:
go version
# go version go1.26.3 darwin/arm64cp config.example.json config.json
EMBER_TOKEN=dev-token go run ./cmd/ember -config config.jsonPost a demo running status:
curl -X POST http://localhost:3627/v1/status \
-H 'Authorization: Bearer dev-token' \
-H 'Content-Type: application/json' \
-d '{"source":"dt-mbp","tool":"codex","session":"awtrix","state":"running","message":"building"}'Post a waiting approval:
curl -X POST http://localhost:3627/v1/status \
-H 'Authorization: Bearer dev-token' \
-H 'Content-Type: application/json' \
-d '{"source":"dt-mbp","tool":"claude","session":"desktop","state":"waiting","message":"approve Bash"}'Drop a single session:
curl -X DELETE http://localhost:3627/v1/status \
-H 'Authorization: Bearer dev-token' \
-H 'Content-Type: application/json' \
-d '{"source":"dt-mbp","tool":"claude","session":"desktop"}'Wipe everything (admin):
curl -X POST http://localhost:3627/v1/clear -H 'Authorization: Bearer dev-token'Inspect current state (no auth):
curl http://localhost:3627/state | jqThe wire-protocol contract spec lives in the Obsidian vault (Superpowers Specs/ember/). Summary:
- Required fields:
source(laptop ID),tool(claude|codex| …),session,state(idle|running|waiting|done|error). - Producer emit policy: event on every transition, plus a 10s heartbeat while
running/waiting. - Server reaps idle sessions after
stale_seconds(default 25s);done/errorlinger fordone_ttl_seconds(default 30s). - Write endpoints require
Authorization: Bearer <EMBER_TOKEN>. EmptyEMBER_TOKENdisables auth. - Read endpoints (
GET /state,GET /healthz) are always unauthenticated.
The image is multi-stage and multi-arch (linux/amd64 + linux/arm64):
alpine builder → distroless static-debian12:nonroot runtime, ~7 MB,
runs as uid 65532, no shell.
Build (single-arch dev):
docker buildx build --platform linux/amd64 -t ember:dev --load .Build (multi-arch, intended target):
docker buildx build --platform linux/amd64,linux/arm64 \
-t ember:0.1.0 .(no --load because Docker can't multi-load locally; use --push once
a registry is wired up — Phase 2.)
Run:
docker run --rm -d --name ember \
-p 3627:3627 \
-e EMBER_TOKEN="$(cat ~/.config/ember/token)" \
-v /path/to/config.json:/etc/ember/config.json:ro \
ember:devOperator commands:
docker exec ember /ember version
docker exec ember /ember doctor # full diagnostic
docker exec ember /ember --print-config # what's in effect, secrets redacted
docker exec ember /ember healthcheck && echo OK
docker logs ember
# Operator HTTP (read):
curl http://localhost:3627/version
curl -H "Authorization: Bearer $EMBER_TOKEN" http://localhost:3627/admin/doctor
# Operator HTTP (mutate): hot-reload config without restart.
# Edit your bind-mounted config.json, then:
curl -X POST -H "Authorization: Bearer $EMBER_TOKEN" \
http://localhost:3627/admin/reloadThe binary's healthcheck subcommand defaults to probing
http://127.0.0.1:3627/healthz. If you bind the server to a non-default
port via config.json, set EMBER_HEALTHCHECK_URL to match.
The doctor subcommand defaults to http://127.0.0.1:3627/admin/doctor.
Override with --server-url. Use --offline for pre-flight checks before
starting the server.
TLS (optional): set both EMBER_TLS_CERT_FILE and EMBER_TLS_KEY_FILE
in the container environment to enable HTTPS. Setting only one is a
startup error; setting neither keeps the server on plain HTTP. The cert is
loaded once at startup — rotation requires a container restart. For a
self-signed homelab cert, issue it with subjectAltName=IP:127.0.0.1 so
the in-image healthcheck can validate the loopback target. Two helper
env vars exist for the in-image healthcheck client:
EMBER_HEALTHCHECK_CA_FILE— path to a PEM bundle to add to the trust pool (e.g. your homelab CA).EMBER_HEALTHCHECK_INSECURE=1— skip TLS verification entirely. Fine on a trusted LAN; the recommended easy path for self-signed setups.
The runtime image runs as UID 65532 (distroless nonroot). A cert mounted
read-only as 0600 root:root will fail to read. Either chmod 0644 the
files, chown 65532 them, or drop them into a volume that the runtime
can read. Example:
docker run --rm -d --name ember \
-p 8443:3627 \
-e EMBER_TOKEN="$(cat ~/.config/ember/token)" \
-e EMBER_TLS_CERT_FILE=/certs/cert.pem \
-e EMBER_TLS_KEY_FILE=/certs/key.pem \
-e EMBER_HEALTHCHECK_INSECURE=1 \
-v /path/to/config.json:/etc/ember/config.json:ro \
-v /path/to/certs:/certs:ro \
ember:dev/admin/doctor's http_listening detail reflects the live scheme
(scheme=https when TLS is on, scheme=http otherwise).
Prometheus /metrics: the server always exposes GET /metrics on
the public mux (no auth, never rate-limited). The body is Prometheus
text exposition format:
- counters:
ember_requests_total{pattern,status},ember_publish_total{result},ember_rate_limit_denied_total,ember_sessions_evicted_total - gauges:
ember_sessions_active,ember_uptime_seconds,ember_last_publish_unix,ember_last_publish_ok,ember_ratelimit_buckets,ember_build_info{revision,go_version}
Cardinality is bounded — request counts are labelled by Go 1.22's
matched route pattern, not by URL path, so a 404 spammer can't blow up
series. Requests rejected by requireAuth / adminRequireAuth count
against the outer prefix (/v1/, /admin/) rather than the specific
route. The endpoint deliberately doesn't export Go runtime metrics
(goroutines, GC) — that would require prometheus/client_golang,
which we skip in line with the stdlib-only choice.
/metrics is unauthenticated and exposes the binary's git revision plus
Go version. That is fine for the trusted-LAN deployment this service
targets; if you bridge it to anything wider, terminate /metrics behind
a reverse proxy that adds auth.
Scrape config snippet (Prometheus):
- job_name: ember
scrape_interval: 15s
static_configs:
- targets: ['homelab.lan:3627']Rate limiting: the server enforces a per-source-IP token-bucket
rate limit on /v1/* writes. Defaults: 10-token burst, 2 tokens/sec
sustained refill per IP, 5-minute idle-bucket eviction. /admin/*
and read endpoints (/healthz, /state, /version) are not
rate-limited. Tune via the rate_limit section of config.json
and reload with POST /admin/reload. To disable entirely, set
rate_limit.disabled: true. Note that scripts/image-smoke.sh
does not exercise /v1/* writes, so it does not validate the
rate-limit code path — that is covered by unit tests instead.
Smoke test (requires Docker):
./scripts/image-smoke.shThe script skips with a friendly message when Docker is missing.
The repo ships a personal Community Applications template at
deploy/unraid/ember.xml. It is
pre-filled for a homelab with an AWTRIX device on the local LAN.
The image is published to Docker Hub at dtarakanov/ember and is public.
No visibility toggle is needed. After your first v*.*.* tag push triggers
CI and the image lands on Docker Hub, you can proceed directly to the
install steps below.
-
In the Unraid web UI: Docker → Add Container → Template URL and paste:
https://raw.githubusercontent.com/tarakanof/ember/main/deploy/unraid/ember.xmlThe form populates with defaults. Don't click Apply yet.
-
From your Unraid host shell (Terminal plugin or SSH), stage the appdata folder:
mkdir -p /mnt/user/appdata/ember curl -fsSL -o /mnt/user/appdata/ember/config.json \ https://raw.githubusercontent.com/tarakanof/ember/main/config.example.json vi /mnt/user/appdata/ember/config.json # set awtrix.http_base_url(
viis universal on Unraid; usenanoif you have the plugin.) -
Back in the template UI: generate a bearer token (
openssl rand -hex 32from any shell) and paste into theEMBER_TOKENfield. Click Apply.
The runtime image is distroless: there is no shell inside the container, so the Unraid Docker UI's Console icon will fail. Run diagnostics from the Unraid host shell instead:
docker exec ember /ember doctor(Replace ember with whatever name you gave the container in
the template UI — the template defaults the Name to ember,
but Unraid lets you override it.)
Every check should show [OK]. If awtrix_reachable fails, your
awtrix.http_base_url in /etc/ember/config.json is wrong
or the device is offline. Edit the file on the Unraid host
(/mnt/user/appdata/ember/config.json) and run:
curl -X POST -H "Authorization: Bearer <EMBER_TOKEN>" \
http://<unraid-ip>:3627/admin/reloadto pick up the change without restarting the container.
When a new release is published (any v*.*.* git tag triggers CI to
push a new image to Docker Hub), click Force Update on the container in
the Unraid Docker UI. The new image pulls; the container restarts;
in-flight requests drain on stop.
To pin a specific version, edit the template's Repository field
to dtarakanov/ember:0.1.0 instead of :latest,
and Apply.
Stop and Remove the container from the Unraid Docker UI. The appdata
folder at /mnt/user/appdata/ember/ is preserved; remove
it manually (rm -rf) if you want a clean wipe. Pulled images stay
in Unraid's local Docker store until pruned via the Docker UI's
"Container Size" → cleanup flow. Old images on Docker Hub persist
indefinitely; delete unwanted versions via Docker Hub repository settings
if you care about hygiene.
The container is the server half. Producers (your Mac menu-bar
app and any ember-claude-producer cron) run on operator
laptops and POST to http://<unraid-ip>:3627/v1/status with the
bearer token. See:
macos/— native macOS menu-bar app (Ember.app, built with Xcode)cmd/ember-claude-producer/— claude-code session producer
Both honour EMBER_SERVER_URL and EMBER_TOKEN env vars; configure
them to point at your Unraid IP and the same token you set above.
{
"http": { "addr": ":3627" },
"awtrix": {
"http_base_url": "http://<awtrix-ip>",
"app_name": "ember",
"timeout_seconds": 5
},
"auth": { "status_token_env": "EMBER_TOKEN" },
"rate_limit": {
"disabled": false,
"burst": 10,
"refill_per_sec": 2,
"idle_evict_seconds": 300
},
"display": {
"idle_text": "AI idle",
"stale_seconds": 25,
"done_ttl_seconds": 30,
"heartbeat_seconds": 10,
"refresh_seconds": 5,
"notify_on_waiting": false
}
}An optional Pomodoro focus timer runs inside the same service/container. It is a top-priority display: while a timer is active it takes over the matrix (a graphics-first tomato/break pictogram, MM:SS countdown, and a remaining-time progress bar) and the device buttons drive it; when idle it shows nothing and the clock + AI-status behave normally.
Enable it in config.json:
"pomodoro": {
"enabled": true,
"focus_minutes": 25,
"short_break_minutes": 5,
"long_break_minutes": 15,
"rounds_before_long_break": 4,
"auto_start_next": false,
"sound": true,
"sound_melody": "",
"focus_color": "#FF3B30",
"break_color": "#2EE85E",
"db_path": "/var/lib/ember/pomodoro.db",
"button_callback": true
}Durations/colours/toggles are also editable at runtime from the macOS menu app's
settings window — a Pomodoro tab alongside the Status tab (and the menu's
Pomodoro section has Start / Pause / Resume / Skip / Stop plus today's stats;
"Pomodoro Settings…" opens that tab). Runtime edits persist in the SQLite DB
and survive restarts. Stats (completed pomodoros, focus minutes, streak,
per-day history) accumulate in db_path — mount a writable volume there in
Docker (the image ships /var/lib/ember as a VOLUME).
# Control (bearer auth):
POST /v1/pomodoro/start # {"phase":"focus"} (phase optional)
POST /v1/pomodoro/{pause,resume,stop,skip}
GET /v1/pomodoro/config
PUT /v1/pomodoro/config
# Read (no auth):
GET /v1/pomodoro/state
GET /v1/pomodoro/stats
# Device button callback (no auth — the device cannot send a token):
POST /hooks/awtrix/button # form: button=left|middle|right&state=1|0
To control the timer with the TC001's physical buttons, point the device's
button_callback dev option at this service so it POSTs each press to
POST http://<service-host>:3627/hooks/awtrix/button. Mapping (on press-down):
middle = pause/resume, right = skip phase, left = stop. While a timer
runs the service sets BLOCKN:true + ATRANS:false on the device so the buttons
drive the timer instead of switching apps, and restores both when it stops.
(If you run an MQTT broker instead, the same press semantics map to the
…/stats/buttonLeft|Select|Right topics — not wired by default.)
The stock TC001 piezo buzzer plays the phase-end chime as RTTTL; set
sound_melody to a melody filename on the device, or leave it blank for the
built-in chime.