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
On a Linux host with systemd, the installer does the download, checksum verification, service setup, first token, and HTTPS proxy setup:
curl -fsSL https://irrlicht.io/relay.sh | sh -s -- --domain relay.example.com
Use --tailscale instead when Tailscale is already installed. The installer prints the relay URL and bearer token for the Mac. It never changes the firewall. The manual steps below remain useful for a custom deployment.
-
Get the binary
Building from a checkout works everywhere and needs only Go:
cd core go build -o /usr/local/bin/irrlichtrelay ./cmd/irrlichtrelayRelease v0.6.3 and later publish
irrlichtrelay-linux-amd64.tar.gzandirrlichtrelay-linux-arm64.tar.gz. 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). -
Enroll the Mac with one URL
Start the relay with
--public-url https://<your-relay>, then mint a one-time enrollment URL:irrlichtrelay enroll new --public-url https://<your-relay> --label "laptop"In the macOS app, open Settings → Advanced Settings → Sources, paste the printed
https://<your-relay>/enroll/XXXX-XXXXURL into the enrollment field and press Join. The app redeems the code for its own token, stores it in the Keychain, fills in the relay URL and turns on Publish to relay — the running daemon picks it up with no relaunch. The daemon dials out, so it needs no inbound port and works behind NAT.The code works once and expires after ten minutes. A code that is mistyped, expired or already used gets one answer by design, so the app says to mint a fresh one rather than guessing which. A relay started with
--auth offhas no token store and cannot enroll at all. Runenroll newas the same user andIRRLICHT_HOMEthe relay runs under. Without--public-urlit prints only the bare code: paste that once the relay URL below is filled in with anhttps://orwss://address, or typehttps://<your-relay>/enroll/in front of it. -
Or: issue tokens by hand
The route enrollment replaces, still useful for a daemon on Linux or a browser. One token 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. Then either setIRRLICHT_RELAY_URLandIRRLICHT_RELAY_TOKEN(see Configuration), or enter the URL and token under Settings → Sources in the macOS app or the dashboard.To pair from a browser instead of the Mac app, browse to the relay’s own HTTPS address and enter the browser token under Settings → Sources there. 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
Start the relay with
--public-url https://<your-relay>. Then open the relay dashboard or the macOS app’s relay settings and press Pair a phone…. You get a QR, the same selectable URL, and an eight-character fallback code. The QR contains only the short-lived code. It never contains your relay token.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: scan the QR
Scan the QR with the phone. The code-specific install page opens in the browser. If no QR is available, open the relay address and use the fallback code.
-
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: confirm the pairing
Open Elfdans and press Pair this phone. Then allow notifications when the phone asks. The QR flow fills the code. If QR pairing is unavailable, enter the displayed
XXXX-XXXXcode here instead.
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 |
| A session enters error | Banner immediately — no hold-down, because an error is cleared only by the next successful turn, so there is no flap to wait out |
| 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 and error, ten minutes for ready and for the daemon banners (working is never sent, so it has none). 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 — plus enroll-codes.json once a desktop enrollment code has been minted (irrlichtrelay enroll new), which holds only hashed desktop enrollment codes that expire after ten minutes and need no backup. Copy the four 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.