A remote control for a Mac that keeps every file, every secret and every photo on the machine itself. Four ways in — a chat bot, Apple Shortcuts over SSH, a web app on your phone, and an encrypted relay for when you are out — all sharing one core that knows nothing about any of them.
All 42 actions, the database, the transcription. Nothing leaves the machine unless you ask it to.
128 lines that forward bytes they cannot decrypt. Off by default; nothing is stored there.
This started as a Telegram bot that could check a battery level. It became something more useful once the interesting question stopped being what can it do and turned into how do I reach it — from a chat, from a shortcut, from a browser, from another country. What follows is the architecture that made those four answers possible without writing the features four times, and the things that went wrong on the way.
Everything hangs off one decision: the core does not know how it is being called. Each action is a plain function that takes text arguments and returns a result — some text, maybe a file, and a flag saying what kind. It imports no networking library at all, which is verified by a test that loads it with the chat library blocked from importing.
Four front ends. Each one translates its own world into a call, and turns the result back into whatever it can display.
Battery, disk, files, camera, clipboard, reminders, power. Plain functions returning a structured result.
Reminders and notes survive restarts. Anything still pending is rescheduled at startup, and anything that fell due while the machine was off is reported instead of swallowed.
The payoff is that adding a fifth way in means writing a transport, not touching a single action — and that a bug fixed in one place is fixed everywhere. The chat front end shrank from 1,561 lines to 629 when the actions moved out of it, because what was left was only the part that is genuinely about chat: keyboards, file uploads, voice notes.
Familiar, works from any device, free push notifications. The weak point is privacy: bot conversations are not end-to-end encrypted, and this thing sends webcam photos and screenshots.
Apple Shortcuts ships a "run script over SSH" action. One shortcut per command, on the home screen or by voice, and they sync to every device through iCloud. No third party involved at all.
The machine serves a small mobile page with every action as a button. Add it to the home screen and it behaves like an app; photos and screenshots render right in the page.
For when you are not home. A tiny server forwards messages it cannot read, because the phone and the Mac encrypt to each other. Off unless you switch it on.
Generated from the source, so this list cannot drift from reality. Nothing here reaches outside the machine except the three that ask the internet a question: weather, exchange-free location lookup, and the speed test.
No command matches that.
Reaching the machine from your own network is easy. Reaching it from a train is where most designs quietly give up their privacy: the usual answer is a tunnel that terminates encryption on someone else's server, which then sees every screenshot you ask for.
The alternative is to keep the server but make it blind. The phone and the machine already share one secret — the key you scanned to pair them — so both sides derive an encryption key from it and talk through a forwarder that only ever sees ciphertext:
key = HKDF-SHA256(shared secret, info: "relay-v1")
mailbox = base64url(SHA256(shared secret + "mailbox-v1"))
message = base64url(nonce ‖ AES-GCM(key, payload) ‖ tag)
The mailbox is how the relay knows which two devices a message belongs to, and it is a hash: useless for decrypting anything, and unguessable without the secret. The server stores nothing beyond a couple of minutes, and every route it exposes takes and returns opaque bytes.
One detail made this pleasant to build: the encryption primitives line up exactly across Swift and the browser. A key derived with CryptoKit and one derived with WebCrypto come out byte for byte identical, so the phone encrypts in JavaScript and the Mac decrypts in Swift with no glue and no third-party crypto library. That also solved a constraint — the bundled Python runtime has only the standard library, which has no modern authenticated encryption in it.
A script that needs a terminal, a package manager and a config file edited by hand is not a product; it is a thing only its author can run. Turning it into something installable meant a native menu bar app with the runtime inside it.
The interface is native rather than scripted, which keeps launch instant and makes the thing signable for distribution. The Python runtime travels inside the bundle, so the app runs on a machine with nothing installed — verified by launching it from an unrelated folder with an empty environment. That turned out to be easy for an unglamorous reason: the service only ever needed the standard library, so there was nothing to install into the bundle at all.
These are the things that only show up when you stop testing the happy path and actually drive the thing from a phone. Most of them would bite anyone building something similar.
macOS grants camera, microphone and screen recording to whatever launched the
process. A command arriving over SSH runs outside your graphical session, so all three are
denied — photos and screenshots failed from the phone while everything else worked.
Granting permissions does not help; neither does launchctl asuser, which needs
root.
The fix is a small service started from your own session, which therefore has the permissions. Remote commands hand it the request instead of doing the work themselves.
It is /usr/bin:/bin:/usr/sbin:/sbin — no Homebrew. The bot cheerfully
reported that a tool "is not installed" when it was installed, just not on that path. Resolve
the binaries you depend on by full path rather than trusting the environment.
A Mac with Homebrew has at least two, and your libraries are in exactly one. The shell
usually resolves python3 to the other one. A launcher that trusts the name dies
on a missing module; one that probes each candidate for the import it needs does not.
When the bar runs out of room, macOS collapses the overflow behind a chevron. The app launched perfectly and looked like it had done nothing at all — the worst possible first impression. A welcome window on first run costs half an hour and removes the entire problem.
Keep an app's code anywhere under Documents and macOS greets the user with "wants to access your documents" before they have done anything. Ship the code inside the bundle and keep data in Application Support: no prompt, and the app becomes movable.
Running transcription on-device is the right call for privacy, but the machine learning runtime behind it is over 300 MB — six times the rest of the app. Worse, the feature failed after recording a meeting, which is the most expensive moment to fail. Check for the capability before you promise it, and never lose the audio even when the transcript cannot be produced.
Service workers require HTTPS, so a page served from a machine on your own network cannot be cached. Turn off Wi-Fi and there is no page to load at all — the graceful fallback to the relay never runs, because the code that would run it never arrives. Serve the page from somewhere reachable and let it talk back through the relay.
The corollary: a page served over HTTPS cannot call an address on the local network either. Browsers block that as mixed content. Those two facts together decide the whole topology.
The chat API only lets one process read messages. Two instances evict each other in a loop; if the error handler reports failures through the chat itself and a supervisor restarts the process, you wake up to hundreds of notifications sent overnight.
Three defences, all cheap: a file lock so a second instance refuses to start, a rule that the same error is only reported once in a window, and a supervisor that gives up after a few rapid crashes instead of retrying forever.
Long-running jobs that pause for confirmation are the defining annoyance of working alongside agents: you leave, it asks a question, and nothing happens for an hour. The process state does not distinguish that from waiting on the network — both look asleep. What gives it away is that its accumulated CPU time stops advancing between two samples.
Look only at the foreground process of each terminal; if that is the shell itself, the
terminal is simply idle. Beware that the process listing hands you paths containing spaces,
escapes newlines, and that a shell running -c something is not idle at all.
A queue needs a write to be visible on the very next read. Eventually-consistent key-value storage is the wrong tool and will lose or duplicate messages; a small SQL database is the right one, and usually has a far more generous write allowance too.
This is remote-control software. It reads files, watches through a camera, listens through a microphone and can power the machine down. That reach is the point, and it also means the account controlling it becomes as sensitive as the login password. Five things are worth building in from the first commit rather than bolted on later.
The uncomfortable part worth saying out loud: a chat bot is the most convenient front end and the least private one, because those conversations are not end-to-end encrypted. For a tool that ships webcam photos, that is the weakest link in the chain — which is exactly why the other three ways in exist.