Small status aggregator for showing Claude/Codex activity on an awtrix-ng clock.
The service is intentionally light:
- one Go binary, stdlib only
- single AWTRIX HTTP transport (no MQTT — see
STYLE.md§11) - bearer-token auth on write endpoints
- HTTP input endpoint for laptop-side producers
A single awtrix-ng clock on the local network (configure yours in config.json):
- AWTRIX HTTP URL:
http://<awtrix-ip>(or let mDNS/UDP discovery find it — seeRUNBOOK.md→ "Discovery & mDNS") - Firmware: awtrix-ng (
boardType: "awtrixng"onGET /api/v1/device)
Ember drives a clock running awtrix-ng —
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-ng docs
for flashing and the API reference. See RUNBOOK.md →
"awtrix-ng flashing, backup, and first-boot" for moving a TC001 over from AWTRIX3.
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_publish_retries_total,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: 60-token burst, 5 tokens/sec
sustained refill per IP, 5-minute idle-bucket eviction. The burst is
sized for one Mac's whole fan-out — menu-app polling plus a producer
per session, all sharing a source IP — reconnecting at once after a
network stall. /admin/* is rate-limited too, deliberately: it
authenticates with the same token, so its 401s must consume budget or
an attacker throttled on /v1/* could probe the token at full speed
there instead. Read endpoints (/healthz, /state, /version,
/metrics, the previews) 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": 60,
"refill_per_sec": 5,
"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
buttonCallback (PUT /api/v1/system) at this service so it POSTs each press
to POST http://<service-host>:3627/hooks/awtrix/button — the menu app's
Device tab can set this with one click (PUT /v1/device/buttons), or see
RUNBOOK.md → "Device button → Pomodoro control" to do it
by hand. Mapping (on press-down): middle = pause/resume/start, right =
skip phase, left = stop (the old AWTRIX3 left+right chord is gone).
While a timer runs the service sets blockNavigation:true +
autoTransition:false on the device so the buttons drive the timer instead of
switching apps, and restores both when it stops.
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.