← Back to the feed
Tech10 min read

Building Perch: a private lookout for my Mac mini

A running build log for Perch, an open-source portal for Mac heartbeats, system resources, and tmux sessions. Starting with the backend and the limits of what a heartbeat can tell me.

Updated

On this page 5 sections

I want to check on my Mac mini from my laptop or phone: when it last reported, how much memory it is using, and which tmux sessions are there. I also want that page to remain available when the Mini stops reporting.

I'm building Perch for that. It's an MIT-licensed, self-hosted status portal. Each person runs their own installation with their own Google account, database, and machine credentials. There is no central Perch service.

This is a running build log. I'll update the current status and add dated entries as the implementation develops. The landing page and dashboard are now implemented, and the landing page is live on Vercel. The interactive sample dashboard runs locally. Hosted Google sign-in and Redis are not configured yet, and no Perch agent has been installed on my Mini.

The landing page also offers a downloadable Mac agent ZIP. It is a source-based agent requiring Node.js 22 and a configured Perch server. A native menu bar app has not been built.

The page has to outlive the heartbeat

Hosting the status page on the machine it monitors would make the page disappear at exactly the wrong moment. Perch's current architecture puts the web application on Vercel and the latest snapshots in Upstash Redis. A small Node.js agent on the Mac sends outbound HTTPS heartbeats. The Mac does not need an inbound port.

The stored snapshot gives the page something useful to show during an interruption, provided the hosting and storage services are available. It also creates an obligation to describe that data carefully. A heartbeat from twelve minutes ago is evidence about twelve minutes ago. It cannot establish whether the Mac is currently powered off, asleep, disconnected, or running with a failed reporting agent.

The dashboard needs to distinguish fresh data, a stale snapshot, a metric that could not be collected, and sample data. Missing heartbeats mean the current state is unknown.

What the collector knows

The agent uses Node built-ins to collect CPU utilization, allocated memory, root disk space, uptime, and hostname. It also reads tmux session metadata: session names, window counts, attached clients, and the number of panes whose foreground command is codex.

That last field has a narrow meaning. A foreground Codex process doesn't tell me whether a task is working, finished, or waiting for input. Perch does not read terminal output or conversation history, and the first version has no remote execution or browser terminal.

Eventually, I want structured activity from my separate Codex harness. That integration has not been built. The harness remains its own project; Perch should consume an explicit status report when one exists.

There is now optional Tailscale reporting too: the local client's state, this machine's MagicDNS name, and its private IP addresses. It requires an explicit agent setting because those addresses become part of the hosted snapshot. Perch does not collect other devices in the tailnet or need a Tailscale admin token.

Private reads, separate writes

The backend verifies a Google ID token against the configured owner, checks a browser-bound login nonce, and issues a signed HttpOnly session cookie. Each machine has a separate heartbeat token that can write only that machine's snapshot. An agent token cannot read the status API.

Those boundaries have local tests. Google sign-in, hosted Redis, and the optional launchd installer still need integration testing. The deployed landing page, dashboard route, and unconfigured API responses have been checked. Passing a mocked authentication test is useful, but it doesn't prove the whole deployment works.

Sessions, login nonces, and stored snapshots are now scoped to the configured installation and owner. This guards against accidental record collisions, but each person still needs a separate database and secrets. I also fixed a browser race that could put a private snapshot back on screen after sign-out. The security document records the tested boundaries and remaining limits.

The budget target is a free-tier installation for one machine, ideally $0 a month and roughly $1 at most. That's a target, not an enforced billing cap. Heartbeats and dashboard reads consume provider quotas; I'll verify the plans and actual usage as part of deployment.

Try the local dashboard

The current checkout requires Node.js 22. From a clone of the repository:

npm ci
npm run demo

Open http://localhost:8787/app for the interactive sample dashboard, or http://localhost:8787/ for the landing page. Keep the terminal running and press Control-C to stop it. The raw sample JSON is still available at /api/machines.

This mode binds to the local machine, requires no Google or Redis credentials, and cannot accept real heartbeats. It shows fictional machines, not the state of the Mac running the command. The state selector lets you inspect missed heartbeats, first-heartbeat onboarding, unavailable metrics, and a connection error.

Build log

October 4, 2026: backend audit and an unavailable metric

The first implementation included the collector, heartbeat ingestion, owner authentication, bounded snapshot history, an optional LaunchAgent installer, and 12 local tests.

The first audit found a small reporting error with a useful lesson: when tmux session discovery succeeded but pane discovery failed, the collector reported zero Codex panes. Zero suggests that collection succeeded and found none. The fix preserves an unavailable value through ingestion instead. Session counts now also require integers.

At this point, the suite reached 16 passing tests. The additional coverage checks the unavailable pane count, rejected expired or unauthorized sessions, logout origin enforcement, and the public configuration response's allowed fields. JavaScript syntax checks pass too. The audit fix is on GitHub.

The next planned steps were the logo and responsive dashboard, followed by the real Google and Redis setup. The integration milestone I care about is a real heartbeat from my Mini, an intentional reporting interruption, and recovery, with the page describing each state correctly.

October 4, 2026: keeping the bird and Node.js

The first visual direction was black, technical, a little terminal-like, but also polished and friendly. We explored two generated concept images, one with a more precise terminal feel and another with softer surfaces and typography. I especially like the bird. It gives the identity some personality at a scale that could eventually work as a favicon.

Those images were design studies, not screenshots of working software. At this stage, the bird still needed a finished vector treatment, and the interface needed to be built against the real API. The technical styling also doesn't change the scope: Perch will show status, not offer a terminal in the browser.

I also asked whether we should switch to Rust. The decision for this version is to keep Node.js. The backend mostly verifies identity and moves small snapshots into Redis, and we haven't established a performance problem that a rewrite would solve. Vercel supports both Node.js and Rust, so hosting alone doesn't settle the choice. Keeping the existing implementation lets us spend the next round on the dashboard and real heartbeat verification.

Rust remains an option for a future Mac agent distributed as a standalone executable, which could remove the requirement to install Node on the monitored machine. That is a possible packaging improvement, not work already underway.

October 4, 2026: white canvas, bird on a Mini, and a working interface

I changed my mind about the dark terminal styling. I wanted something closer to an Apple product: a white background, bold centered text, and room for the interface to breathe. The bird stayed. A generated image of a small green bird perched on a silver desktop computer became the landing page's main image. It is concept artwork, not a photograph of my machine.

The implementation now includes a public landing page, a responsive dashboard at /app, and reusable SVG bird, wordmark, and favicon assets. The dashboard reads the existing API, switches between machines, shows resource metrics and CPU history, and lists tmux metadata. A snapshot ages between polls, history lines break across reporting gaps, and a failed refresh keeps the previous snapshot visible with an explicit warning. An expired session clears the private data from the page.

The local checks now pass 19 unit/API tests and 10 browser tests. Browser coverage includes 390px, 768px, and 1440px layouts, fresh and stale data, empty states, unavailable metrics, API failures, HTML injection handling, and automated accessibility checks. The Google button flow is stubbed in those tests. Real account authentication remains unverified.

The landing page is deployed on Vercel. I verified that the page, artwork, and /app route load. The hosted dashboard currently explains the remaining setup; its unconfigured APIs return errors without exposing machine data. The next milestone is still a real Mini heartbeat and an interruption/recovery test.

I also want to explore a Mac menu bar companion: the little bird would make a useful quick-glance entry point. That remains an idea. The hosted web portal still matters because it can be reached from another device when the monitored Mac stops reporting.

October 4, 2026: checking the privacy boundary

I wanted to be explicit about the basic promise: another person's Perch installation should not expose my Mini, and mine should not expose theirs. There is no shared signup service. Each installation accepts one configured Google owner, and its agent tokens can only write the machine they belong to.

The review found two gaps worth fixing before connecting a real machine. Redis keys previously included the machine ID but no installation or owner scope. Reusing a database with another installation's machine IDs could therefore mix snapshots. Keys now include a scope derived from the configured origin, Google client, and owner; sessions and login nonces are bound to that same identity. Old unscoped records are deliberately not imported. This protects against a configuration mistake, not someone who possesses the database credentials.

The browser issue was a race: a refresh started before sign-out could finish afterward and render its private response. A regression test reproduced that behavior. The fix clears private data immediately, ignores responses from the previous session lifecycle, and notifies other same-origin tabs after successful sign-out. Restored pages clear their old view before checking the session again.

The suite now passes 28 unit/API/storage tests and 14 browser tests. These include rejection of another owner's identity, cross-installation cookies, machine impersonation, overlapping machine IDs, and delayed responses after sign-out. The tests use a fake Redis transport and stubbed Google authentication; they do not replace the next integration check with two real accounts. The hosted APIs still fail closed because Google and Redis are not configured.

There are limits I want visible before calling this ready for private monitoring. A stolen stateless session remains valid until expiry or a deployed secret rotation; ordinary sign-out removes the browser's cookie. Database and hosting credentials remain privileged. Hostnames and tmux session names may themselves be sensitive. I have documented these limits alongside a production acceptance checklist rather than treating passing tests as a security certification.

October 4, 2026: Tailscale in the snapshot

I want Perch to work naturally alongside Tailscale. The first integration uses the installed local client: with PERCH_TAILSCALE=1, the agent runs tailscale status --json --peers=false and retains only its own state, MagicDNS name, and IP addresses. On macOS it can fall back to the executable bundled in the Tailscale app. The server validates those fields again before storage.

The dashboard now has a Tailscale section with copy controls and explicit unavailable states. “Running at last heartbeat” describes the evidence better than “Connected,” which could imply that the browser has tested a connection. When the snapshot ages or fetching fails, the section says the current state is unknown.

The local suite passes 35 unit/API/collector tests and 15 browser checks. A read-only check against the installed Tailscale client also worked, without uploading or printing its addresses. This does not verify a hosted heartbeat or connectivity from another device; that still awaits the real deployment setup.

This integration leaves the hosted portal's owner authentication in place. It does not put Vercel on my tailnet or make the public URL private at the network layer. A future tailnet-only mode could use Tailscale Serve on a separate always-on host. Putting the portal on the Mini itself would lose the outage visibility that motivated Perch.

October 4, 2026: a download that matches what exists

I wanted a download link on the landing page. The working Mac component is still a Node.js agent, so the page now calls it “Download Mac agent” and states the requirements before offering the ZIP. It includes the collector, Tailscale support, optional user LaunchAgent installer, example configuration, setup instructions, and MIT license. It needs no npm packages, but it does need Node.js 22 and the owner's configured portal.

The archive is built from a fixed source-file allowlist, with a manifest of file hashes and a separate SHA-256 checksum. CI checks that the published ZIP matches the source and imports its runtime from a fresh extraction. A browser test downloads the file and verifies the checksum. These checks establish that the package is complete and consistent; they do not make it an Apple-signed application or verify a real LaunchAgent installation.

All 16 browser checks pass, including phone, tablet, and desktop layouts and the new download flow. A native bird-in-the-menu-bar app remains a separate build. The page makes that distinction explicit.

Search articles

Type a title to find an article.