Phone Notifications
A banner on your phone when an agent needs input, finishes, or hits an error — the menu-bar flame, for the hours you are not at the desk.
At the Mac, the flame answers the question for you: something is waiting, something went ready, something hit an error, everything else is still working. Away from it, that signal is in another room. Irrlicht Elfdans is the phone half of it — a lock-screen banner on the three transitions that want a human, and nothing else.
Elfdans is not a second dashboard. It is a web app your own relay serves, which you add to your home screen: no App Store listing, no account, no Irrlicht-operated server anywhere in the path. And it is off until you deliberately pair a phone — a Mac with no relay configured behaves exactly as it did before, with no subscription, no service worker installed, and no relay traffic at all.
Tapping a banner takes you to the session it was about — focusing Elfdans if it is already open, or launching it on that session if it is not.
What you need first
This is the part that surprises people: phone notifications need a relay. irrlichtrelay is the standalone fan-out server a daemon can push its session events to — the same one that already lets the macOS app and the web dashboard watch a Mac from somewhere else. Browser push subscriptions and the installed web app are both bound to the origin that served them, so the relay needs one stable HTTPS hostname.
There is no project-run service to sign up for. You host the relay, in one of two shapes.
| Shape | Pick this if… | The origin |
|---|---|---|
A small VPS. The reference deployment is Oracle Cloud’s Always Free tier — whose free compute is ARM, so match the binary to uname -m |
…you want the relay to outlive the Mac. It stays connected while the Mac sleeps, which is what makes the “Mac disconnected” watchdog banner meaningful | Your own domain, TLS from Caddy or nginx |
The Mac itself, behind Tailscale. No extra hardware; you build irrlichtrelay from a checkout and give it its own IRRLICHT_HOME, separate from the daemon’s |
…you want zero extra infrastructure and accept that when the Mac is off the relay is too — with the daemon down there are no transitions to report anyway | A *.ts.net name via tailscale serve (tailnet-only) or tailscale funnel (public), identical on every network the Mac joins |
--auth
A relay that can push is by definition reachable from the internet, and every push endpoint is an abuse without an identity — so under --auth off the relay never builds its push service at all. No signing key is generated, GET /api/v1/push/info answers {"enabled":false} with the reason, every other push route answers 403 naming the fix, and the pairing UI stays hidden. Run the relay with --auth tokens-file. This holds on a tailnet too, where the network already gates access: one security model, not two.
Setting up the relay
-
Get the binary
Building from a checkout works everywhere and needs only Go:
cd core go build -o /usr/local/bin/irrlichtrelay ./cmd/irrlichtrelayThe release build also produces Linux tarballs,
irrlichtrelay-linux-amd64.tar.gzandirrlichtrelay-linux-arm64.tar.gz; check the latest release for the version you want and build from source if it is not there yet. If you use a tarball, keep itsbin/andResources/web/directories together — the relay finds the dashboard it serves at../Resources/webrelative to the binary, and a lone binary answers503on/. A from-source build installs no dashboard at all, so pointIRRLICHT_UI_DIRat a copy ofplatforms/web/; the operator guide below has the details. -
Serve it over TLS, with auth on
The usual shape is the relay on loopback behind a TLS-terminating reverse proxy:
irrlichtrelay serve --addr 127.0.0.1:7839 --auth tokens-fileThe relay can also terminate TLS itself with
--tls-certand--tls-key(both flags are required together). -
Issue tokens
One for the daemon, one for the browser you will pair from. Each secret is printed once:
irrlichtrelay token issue --label "laptop-daemon" irrlichtrelay token issue --label "my-browser"Run the
tokensubcommand as the same user andIRRLICHT_HOMEthe relay runs under, so both read one file. A serving relay re-reads it on change, so issuing and revoking need no restart. -
Point the daemon at it
Either set
IRRLICHT_RELAY_URLandIRRLICHT_RELAY_TOKEN(see Configuration), or enter the URL and token under Settings → Sources in the macOS app or the dashboard. The daemon dials out, so it needs no inbound port and works behind NAT. -
Open the dashboard the relay serves
Browse to the relay’s own HTTPS address and enter the browser token under Settings → Sources there. This matters: the pairing endpoints are same-origin, so the Elfdans section appears on the page the relay served — not on the daemon’s local dashboard at
127.0.0.1:7837, which has no push endpoints to offer.
This page stops where the operational detail starts. Reverse-proxy configs, the systemd unit, the launchd agent for a relay on the Mac, and the Oracle Cloud specifics — its firewall is two gates and the console shows you one; it reclaims idle instances, and a relay is the idle profile by construction — all live in the operator guide, examples/relay/DEPLOY.md. The wire protocol and the push REST surface are in docs/relay-protocol.md.
Pairing a phone
Pairing happens once per phone. After it, daily use needs no gestures at all.
-
On the Mac: mint a code
In the dashboard the relay serves, open Settings and find Irrlicht Elfdans, then press Pair a phone…. You get an eight-character code shown as
XXXX-XXXX, with a countdown beside it. Its alphabet leaves out the characters that misread when they are typed from a screen — noI,L,O,U,0or1— so there is nothing to guess at.If the section offers a code box but no button to mint one, the relay token is not entered under Settings → Sources on that page — minting needs an authenticated client, because the code inherits its workspace from that token.
-
On the phone: open the same URL
Type the relay’s address into the phone’s browser. There is no QR code yet — the URL and the eight characters are what you carry across.
-
Add to Home Screen, then open it from there
Use the browser’s share menu to add the page to the home screen, close the tab, and launch Elfdans from the new icon.
-
Inside the installed app: type the code
Open Settings → Irrlicht Elfdans again — this time it offers a code field — enter the code, press Pair this phone, and allow notifications when the phone asks.
One code, one attempt. It expires after ten minutes and works exactly once. Wrong, expired and already-used codes all get the same answer on purpose, so a mistyped code is not distinguishable from a stale one — mint a fresh one either way. Ten failed attempts inside a minute refuse every further attempt, correct ones included, until that minute drains.
Declining the notification prompt does not spend the code: Elfdans asks the phone before it asks the relay, so you can turn notifications on in the phone’s settings and press Pair this phone again with the same code.
Redeeming a code issues an ordinary bearer token for that phone, which is why un-pairing needs no separate machinery — the phone shows up in irrlichtrelay token list like any other client. Once paired, the Elfdans section on the phone turns into a delivery-health line and an Unpair button.
What actually buzzes
Elfdans is deliberately quiet: ten agents flapping for a minute must not mean ten buzzes. The relay decides what to send, with these defaults.
| What happened | What the phone does |
|---|---|
| A session enters waiting | Banner as soon as the relay sees it — latency is the whole feature |
| A session goes working → ready | Banner after a 7-second hold-down. If the session starts working again inside those seconds, nothing is sent at all |
| Anything → working | Never. Starting work is not news |
| A subagent transitions | Never — the parent session’s own transitions cover it |
| The same session again | One banner per session: a newer one replaces the older instead of stacking. At most one per session per edge per 60 seconds |
| More than three banners inside 20 seconds | They collapse into a single “N agents need attention”, which refreshes silently while the burst lasts |
| A session Irrlicht has just learned about — first sighting, or a snapshot after the Mac reconnected | Silent. Roaming onto a new network does not replay the day; only a genuine state change since the last snapshot notifies |
| A session disappears | Nothing, and any pending hold-down is cancelled with it |
| The Mac’s daemon disconnects | After a 60-second grace — reconnects while roaming take seconds — one “Mac name disconnected”. When it returns, that same banner is replaced by “Mac name reconnected” without a second buzz. This is the one notification the Mac can never send about itself |
Each notification also carries a lifetime, which bounds how long the push service holds it for a phone that is off: an hour for waiting, ten minutes for ready and for the daemon banners. A question that was asked an hour ago is still a question; a ready from an hour ago is noise.
Every phone paired through the same relay workspace — the isolation unit a token belongs to — receives the same notifications. Filtering per phone or per project, “this phone only wants project X”, does not exist yet.
What Apple and Google can see
The accurate line is: nothing readable leaves your infrastructure. Not “nothing leaves your machine” — the relay is part of the path, and it is yours.
| Who | What they see |
|---|---|
| Your Mac | Everything, exactly as before — the daemon is unchanged by any of this |
| Your relay | Full session state, which is already true of any relay you run: it caches what your daemon forwards so clients can render it. It also composes the payloads it pushes |
| Apple / Google’s push service | The delivery endpoint, the timing of each message, and 2 KiB of ciphertext. Payload encryption is mandatory and terminates on the device, and every payload is padded to that same fixed size — so the length says nothing either |
| Your phone | The structured payload: ids, labels, states. Never prose — the sentence you read on the lock screen is composed on the phone itself |
Timing is a named, accepted leak. Whoever carries the pushes learns when your agents need you, which is a work-rhythm signal. Batching would hide it and ruin the feature, so it is weighed and accepted rather than mitigated — it belongs on this page rather than in a footnote nobody reads.
One more, for the same reason: an attacker who reads the relay’s data directory gets no session content and no usable bearer tokens, which are stored hashed. What they do get is the signing key and the subscription registry, and those two together let them send arbitrary notifications to your paired phones until you pair them again.
When it breaks
Start with the health line. Open Elfdans on the phone: the Irrlicht Elfdans section reads something like “Push registered via web.push.apple.com — last delivery at 14:32 succeeded”, or names the failure instead. Delivery health lives only in the relay’s memory, so shortly after a relay restart it says “no delivery attempted since the relay started” — that is honesty about not knowing, not a report that something failed.
It repairs itself where it can. Opening the app re-subscribes when the phone’s subscription has died (iOS invalidates them silently after long idle periods) and re-registers when the relay lost its record of this phone. The token issued at pairing is the durable identity; the subscription is only a delivery address, which is why re-registering is enough.
Un-pairing, from either end. The Unpair button removes the delivery address on the relay, drops the subscription on the phone, and forgets the token locally — the token record itself stays in token list until you revoke it. From the relay side, irrlichtrelay token revoke <id> is the whole story: the phone’s stream access closes with WebSocket code 4401, and its stored subscription resolves to no workspace, so it receives nothing further. The app then says it is no longer paired and offers to pair again.
Nothing arrives at all. Check, in this order:
- Send a test notification, from the button under the health line. It sends one push down the same path a real notification takes and tells you what came back, so it splits the problem in two: a banner means delivery is fine and the fault is between your agents and the relay, while a named failure (“the push service answered 403”, “this phone’s subscription no longer exists”) is the answer itself. If it arrives, the checks below are where to look next; if it does not, the sentence it prints is already the diagnosis.
curl https://<your-relay>/api/v1/push/info— it should answer{"enabled":true,…}. Anenabled:falsenames its own reason, and the usual reason is a relay running with--auth off.- The connection-status tooltip in the dashboard — if the daemon is not connected to the relay, there are no transitions to notify about. A daemon that was connected and then dropped becomes a banner of its own after 60 seconds; one that never connected is simply absent, and nothing will arrive.
- What the sessions actually did. A session that never left working is not a missed notification.
Keep the relay’s four files. The relay’s data directory holds tokens.json, vapid-keys.json, push-subscriptions.json and daemon-roster.json, all mode 0600. Copy those and keep the hostname, and every paired phone survives a host rebuild untouched. No session content is ever written to the relay host — before this feature or after it.
vapid-keys.json in particular
It is the relay’s signing identity, and every phone’s subscription is bound to it. If the file is lost the relay mints a new identity on its next start, and phones paired against the old one have to be paired again — the self-heal above covers a lost subscription, not a lost signing key. A file that exists but does not parse is refused at startup rather than quietly replaced: the error names the file and your two options, because minting a fresh identity behind your back would orphan every paired phone.