# Straylight

One remote for my desk and my living room. A HUD for a volume knob became a control platform whose core runs on every computer in the house.

- Author: [Max Vyshniakov](https://maxvyshniakov.com/about/)
- Role: Product, interaction and architecture decisions
- Period: 14 September – 20 October 2026
- Status: Private, in active development, likely to go open source
- Models: Claude Opus 5.5 (42.1M output tokens, 51%), GPT-6 Astra (30.6M output tokens, 37%), GPT-6.1 Sol (9.2M output tokens, 11%), GPT-5.6 Sol (1.4M output tokens, 2%), Other models (0.1M output tokens, <1%)
- Stack: Rust, Tokio, Swift, AppKit, Electron, React, React Flow, Home Assistant, MQTT, MCP, rustls, hyper, SQLite, ICU, Opus, SPAKE2, ChaCha20-Poly1305, Objective-C, SwiftUI, IOKit, Core Audio, Core Bluetooth, launchd, D-Bus, mDNS, TypeScript, Vite, TanStack Router, ELK, libavoid, Web Workers, dnd-kit, Sid-Kit, Data Grid, Tailwind CSS, WebSocket, Storybook, Playwright, Python
- Category: Interaction design & system architecture
- Topics: Interaction design, System architecture, Hardware, AI agents
- Web page: https://maxvyshniakov.com/work/straylight/

macOS won't read the touch surface of Apple's own Siri Remote, so I paired the remote with a small Linux machine at home instead. Eleven days later that workaround became the architecture. Every computer in the house is now an equal Host running the same Rust core, and the house keeps working when my laptop is closed. Straylight is the one remote for my desk and living room I've wanted since school: a Siri Remote, a knob and a keyboard drive volume, displays, lights, a projector and a TV, and every button runs a scenario I draw in a flow editor. I built it with AI agents, holding the devices, deciding how everything should feel and drawing the boundaries between the parts.

![The Straylight editor window in dark mode. The sidebar lists scenarios, HUDs and each remote with its layouts and battery level; the Ulanzi Dial's Display layout is open, with its Left button highlighted on a picture of the Dial and a list of inputs set to Default, Off or On, and two cards on the canvas, an orange Change remote layout card set to the Media layout and a green Controls tab card selecting the Media tab.](https://maxvyshniakov.com/media/dc306f419dff49bac284c0736e24a43ab6add0fc8fb72b2b047c4f58a5a2409d.webp)

The Ulanzi Dial's Display layout in Straylight 3.0.3. Dial photo by Ulanzi.

## At a glance

* 5 weeks: from 14 September to 20 October, after a five-day prototype in July
* 2,552: commits since 14 September, with no revert commits
* 219 → 4 ms: for a HUD press, once the slowdown after the Rust day was measured
* ~157k: lines of Rust in the one core every Host runs

- Scenarios needed a real editor, and the research favoured a native shell and the smallest possible list for reordering actions. → [Build on what I already had](https://maxvyshniakov.com/work/straylight/#built-on-what-i-already-had)
- Every feature brought its own little model, and a global control mode decided three things at once. → [Everything is a scenario](https://maxvyshniakov.com/work/straylight/#everything-is-a-scenario)
- Straylight had to run on any computer, and the plan kept the engine in Swift with a separate one for Linux. → [Rust for the Mac too, not a few shared codecs](https://maxvyshniakov.com/work/straylight/#rust-in-a-day-then-a-knob-that-felt-slow)
- The helper VM could run only one action per button, because of a leader-and-helper model we had chosen ourselves. → [Every computer is an equal Host, with no leader](https://maxvyshniakov.com/work/straylight/#every-computer-is-a-host)
- Names and formats drifted as fast as the code, and an audit advised keeping the old ones readable. → [No legacy while the app is private](https://maxvyshniakov.com/work/straylight/#working-with-agents)

## One remote for the whole room

I've wanted one remote for the whole room since school. On Windows 98 I soldered an infrared receiver to a COM port and got WinLIRC to read my TV remote. I could see every button I pressed, but I couldn't do much with it: the computer offered few ways to act on a command, and there was nobody to write the glue. More than twenty years later the shops still don't sell what I had in mind. Logitech stopped making Harmony remotes in 2021, and the rest are huge, ugly and covered in buttons.

![Seen from above on a grey felt mat: a NuPhy keyboard with a blue backlight and a black Magic Trackpad, and below them a row of seven remotes, a white Google TV remote, the silver Siri Remote, black remotes for the miniDSP, a Topping DAC and a Zeemr projector, the glossy Air Mouse and the square Ulanzi Dial.](https://maxvyshniakov.com/media/f524efa527ba94b270804a1a02f7129e71ac8c68ad8ff32d496404eea9443bd0.webp)

The remotes in this story, photographed for this page.

By this summer my MacBook was already a device in Home Assistant, held together by mac2mqtt, BetterTouchTool and BetterDisplay scripts. In May I described the setup to Codex as "brutal crutches", and in June it simply stopped working. In July I started replacing it with something of my own, built by AI agents. Everything in that first commit has since been replaced by native code.

## It started with a volume knob

On Monday 27 July a Ulanzi Dial arrived: a palm-sized knob with three buttons, meant for the volume, the chandelier and the display. The first problem showed up within minutes: macOS queued a series of "volume up" events and played them back with lag. Thirty-nine minutes later the project had its first commit.

The rest of that night went to one sound. A real volume knob tells you the level by how loud its click is, and I wanted that. At one point the agent reported a 382 ms sample. "That's long for such a short sound. Are you sure about that length?" A minute later I guessed: "Maybe it's 38 ms." It was. The file ran for 382 ms, but the click itself ended at 38, and the rest was silence the agent had been waiting out.

Then the part I'd come for: an on-screen indicator, a HUD, like BetterDisplay's glass one. My first verdict was "Very doll-like. Not Liquid Glass." A day later, after a redesign: "Yours is very far off now. It was better before these reworks."

The HUD's first two days, rebuilt from each commit's code.

1) **29 Jul, 00:43**: The first HUD. “Very doll-like. Not Liquid Glass.”
   ![A light pink glass HUD over a starry sky and the glowing edge of the Earth, reading DELL U4025QW and 92%, with a brightness bar between two sun icons.](https://maxvyshniakov.com/media/dc15438bab3e9e5d08505ef958f09c820b16895e46d46e7e0228b6ebdb1226b5.webp)
2) **29 Jul, 17:04**: Apple's segmented control, one tab per Dial profile.
   ![A dark glass HUD with Apple's segmented control: Volume, Lights and Display, with Display selected.](https://maxvyshniakov.com/media/0f9b4320c4c9584468b469880e4a1b7aa14a3a24d511021f111b6d64da6e9572.webp)
3) **29 Jul, 18:34**: Custom tabs that show every level at once.
   ![A dark glass HUD with custom tabs reading 68%, 92% and 52%, the volume tab selected, above a volume bar.](https://maxvyshniakov.com/media/e31e916924b4dc30c62a812cdb9fc45571349730ec7afc519c23db1b054c4859.webp)
4) **30 Jul**: The chandelier's colour temperature on the Dial.
   ![The same HUD with the lights tab selected, showing a thermometer icon, 2.67K and a colour-temperature bar.](https://maxvyshniakov.com/media/cab8b0f99b962837f0020255d6d8dd16d4cabfa0131cfe08e83d7ed6829dd115.webp)

When the HUD's scale fell behind fast turns and the agent couldn't see why, we split the job in a way that stuck for the whole project:

> I'll turn, you debug. I'll do a screen recording, and you'll hear the real wheel's clicks on it.
>
> — Me to Codex, 29 July at 16:11

My hands and ears on the hardware, the agent on the logs. The same week the keyboard joined in. I flashed my NuPhy with my own fork of a community firmware, calibrated its white by eye against the MacBook's, and made Caps Lock and the side lights show the input language in colour: white for English, orange for Russian, turquoise for Ukrainian. On 31 July I asked: "So is it an app now, or not?" It was, just about: a HUD with three tabs and a keyboard that knew which language I was typing in. Then I left it alone for six weeks.

![A dark glass HUD over a starry sky with three segments, RU, EN and UK, with UK highlighted in turquoise.](https://maxvyshniakov.com/media/30291cf2489aacb0a3108c6b0ddd74cf06a6dfb895dda5f7767689354e6cd684.webp)

The language HUD, Ukrainian in turquoise.

## Built on what I already had

When I came back in September, I didn't start from what a remote app needs. I started from what I already had: my design system [Sid-Kit](https://maxvyshniakov.com/work/sid-kit/), my table engine [Data Grid](https://maxvyshniakov.com/work/data-grid/), and a long-standing wish to have a flow builder of my own. Straylight was where all three could be pushed at once. It's how I approach most of what I do: look at the tools around me, and at where the effort on one project can move several others forward.

The brief grew on 21 September, when I picked up an Air Mouse that had been lying around:

> There are a number of apps I want to be able to launch from the remote. I see it as a kind of media-centre module, a HUD.
>
> — Me to Codex, 21 September at 11:03

![A glossy black Air Mouse remote lying on its printed manual on a black and white patterned rug.](https://maxvyshniakov.com/media/11d266b25fcf38fac00b1677e9d9bdc96796658cb3a63d4df52f9d384567c2cc.webp)

The Air Mouse, the first Straylight remote.

The same message brought in the DAC, the audio processor, the projector and an infrared blaster. One detail shaped the whole interface: when I watch the projector, my monitor and laptop are folded away. So the on-screen menu, the Launcher, had to work without a screen in front of me, with clicks, optional speech and pulses of keyboard light.

The settings window became an Electron app the same day. The agent's research leaned towards Tauri or native shells, without having built or benchmarked anything. I picked Electron because it let me build the editor in React on Sid-Kit. And instead of the smallest way to reorder a list of action cards, which is what the research recommended, I asked for a real flow builder:

> Let it be a bit of overkill for the task at hand. I want us to gain experience with flow builders like this.
>
> — Me to Codex, 21 September at 16:12

Two prototypes from 21 September. The graph won.

* **A. Connected cards**
  ![A light prototype with a list of control modes, a projector mode open, and a column of connected action cards with a sequence outline.](https://maxvyshniakov.com/media/ff8614f686f0682a58aa2ae3547197742dadd8793d16cffeb00d9aa248a125aa.webp)
* **B. Graph and inspector**
  ![A light prototype with a vertical graph of green, orange and purple step cards and an Add an action palette on the right.](https://maxvyshniakov.com/media/d0ec916b73171d9e06ae9f9641a897f8b5810695489a13a5786736492fe97b23.webp)

Forcing my own tools into a new kind of app paid off for all of them. Sid-Kit went from 0.23 to 0.35 while Straylight was built, and the editor exposed limits it hadn't met before: controls now inherit their card's intent, and since 0.33 every field sits one step above the card it lies on, in the card's hue. Data Grid got a consumer far from product catalogs: the States page is one table of every value on every computer. Since 21 October one guided script releases Sid-Kit, Data Grid and Straylight together.

The flow builder is close to becoming a library of its own. Before settling on React Flow, the agent read the source of Dify, Flowise, Langflow, n8n and Coze. A card takes its colour from its name, and every control inside inherits it, so a scenario reads as a set of coloured blocks and nobody picks a colour by hand. Connectors route around the cards in a Web Worker running libavoid, re-route live as you drag and cross only where they must. Cards that can be dragged wobble gently instead of explaining themselves in a tooltip: "the metaphor is the hint: you can drag me." The wobble layers four motions whose periods never line up, and it stops when the system asks for reduced motion.

Problem: Scenarios needed a real editor, and the research favoured a native shell and the smallest possible list for reordering actions. **Solution: Build the editor on my own design system and table engine, as an Electron app with a full flow builder.** Cost: A heavier app than a native settings window, and more work than the research recommended for reordering a list. Result: Straylight runs on a mature UI kit, Sid-Kit and Data Grid grew from the pressure, and a flow builder with routed connectors and coloured cards is nearly ready to stand on its own.

**Under the hood: The flow editor**

- **Colour from a name.** In Sid-Kit a card takes `intent={{ key }}`. The key is hashed and multiplied by the golden angle, 137.508°, so names that differ by a letter land far apart on the OKLCH colour wheel. Controls default to `intent="inherit"` and pick up the card's hue.
- **Connectors.** Routing runs in a Web Worker with libavoid; _Tidy up_ lays the graph out with ELK. Insertion points react within 48 screen pixels at any zoom, connections snap at 28.
- **Speed.** Dragging one card in a 25-card scenario re-rendered every card 148 times. After the fix, the 95th-percentile drag frame went from 116.7 to 16.8 ms (headless Chrome, development React, 1440 × 1000). In October an idle canvas stopped animating its dashes after two seconds; before, an open editor kept the GPU at about 40% doing nothing.
- **Launcher tiles.** A tile's tint comes from the OKLCH hue of the saturated pixels in its app icon.
- **Phone.** The same editor runs in a phone browser, served by the background agent over its own HTTPS with a one-time sign-in code.

## Everything is a scenario

Every input on every remote ends in the same kind of scenario, and each special case that looked different turned out not to be. At first every feature brought its own little model, and a global "control mode" decided three things at once: what was running, what the buttons did and what the menu showed. On 23 September the mode was taken apart. Then, one by one, everything else turned out to be a scenario too:

> The Launcher basically presses some button. It's just that the button is a kind of widget in the Launcher.
>
> — Me to Claude, 24 September at 23:13

Remote buttons got scenarios of their own, and a short press and a hold became two inputs: "If I press it once, I want +5%. If I hold it, +1% on repeat." Launcher items became scenarios, and the Launcher's own remote layout stopped being special: "Let's make the Launcher layout an ordinary layout. 'Set as launcher layout' and that's it."

Diagram: One path for every input.

1) **Input**: A press, a hold, a swipe or a ring turn on any remote
2) **Layout**: The remote's active layout picks the binding
3) **Scenario**: The binding's own flow of actions, with branches
4) **Actions**: States, volume, displays, apps, Home Assistant, other Hosts
5) **Feedback**: A HUD, a click on the right output, speech, a key lighting up

↺ The editor only changes drafts. Apply hands them to the Host, which keeps running when every window is closed.

The code went through the same tidying. Close to midnight on 24 September I asked: "Is it normal that we have such an enormous file? I thought the module boundaries were already clear." The agent's own status line had mentioned a Swift file of about 4,700 lines. By the next afternoon, after 175 commits, it was down to 389, and a check in the build now fails any file that grows past its limit. I know where each boundary runs: rules that make sense everywhere belong in a shared core that could also run on Linux, and things that only make sense on one system stay native.

## The Siri Remote talks to Linux

macOS can't read the touch surface of Apple's own Siri Remote, so the remote talks to a Linux machine instead, and that workaround later became the architecture. I'd asked about it at 02:44 on 23 September: the latest Siri Remote is inexpensive, solid and pleasant to hold, and its clickpad can work as a touchpad. On macOS, the touch surface needs a private framework, and the hardened runtime kills the process as soon as the touch callback runs. The Power button can't be intercepted at all.

So the remote pairs with a small Linux VM on my home server, through its own Bluetooth adapter. On 4 October I wrote: "I bought a BT adapter and plugged it into the mini. And I bought the remote too. What next?" When the agent answered with a list of Home Assistant settings for me to click through, I sent it back: it had access to Home Assistant and the server, so it could do that itself. By evening the remote worked, and I started finding where the model had cut corners:

> We've got a completely different remote, with a completely different transport and different buttons. But we keep pretending we have one remote.
>
> — Me to Claude, 4 October at 19:05

Layouts became the property of each remote. A newly found device waits until you choose to use it, because that's "a deliberate user action". When a press seemed to do nothing ("it looks like set dressing"), each remote got its own page showing every link in its path and a live line with the last press. In the editor each remote is a picture with clickable controls at its real size; I measured them with a ruler, and when a hotspot sat slightly off, my note was: "Look at these ridges, the notches." And once the remote could move the pointer, its input left Home Assistant's MQTT broker, where any login can do anything in plain text, for its own encrypted channel: "this thing controls input: it can type and control everything that happens on the screen."

![The editor with the Siri Remote's YouTube layout open, while Streamer is the active one in the sidebar: a picture of the remote with its clickpad highlighted, a list of inputs each set to Default, Off or On, and on the canvas a pink Keyboard shortcut card that sends C to the YouTube app.](https://maxvyshniakov.com/media/25942308d9404805b1ef6ddb5a0a194e9f61efcba1101d5f6df9e8b8ca21de75.webp)

The Siri Remote's YouTube layout. Remote photo by Apple.

![The Air Mouse, the Siri Remote and the Ulanzi Dial side by side at their relative real sizes on a dark dotted background, labelled 44 by 152, 36 by 136 and 83 by 97 millimetres.](https://maxvyshniakov.com/media/50e13c42a44b8b38c7bb7b94be716ff91247ff616dabea86607fd0395c8c2661.webp)

The remotes at their measured sizes, from the makers' photos.

The Dial went the same way. Its vendor app had lost my trust, and I put it plainly: "The Ulanzi Dial is basically a remote. We already have a remote abstraction." The agent decoded the Dial's own Bluetooth protocol, and five minutes after I quit the vendor app: "everything works". One position of its hardware switch now talks to the Linux machine and dims the chandelier while the MacBook sleeps. "One remote" stopped meaning one remote for the Mac and started meaning one remote for the room.

## Movie night belongs to Home Assistant

The feature I use most is a single Launcher item: Projector. My projector is about as smart as a brick. It only listens to infrared, and the only sign that it's on is that it slowly appears on the network. So the scene sends Power, waits 27 seconds, presses Right four times 600 ms apart and then OK to reach the HDMI input. It switches the audio processor's preset, moves the remotes to the Google TV Streamer and turns the Mac's displays off last.

Each of those numbers came from a failure. Sent 100 ms apart, four Rights read as one held key that auto-repeated five steps. The projector ignores pings for four seconds or more about once a minute, so it can't be asked whether it's on. And a touch on the trackpad woke the displays in the middle of the scene, so they now go off last.

The scene started inside Straylight, as a scenario that guessed the projector's state. That evening I asked whether it wouldn't be more rational in Home Assistant, which can see the projector on the network, and it moved the same day. That set the line between the two: Home Assistant owns the household; Straylight owns the computers, and publishes what Home Assistant can't see for itself.

![The editor with the Launcher's Apps section open: a panel listing its rows, Main with Plexamp, OpAmp, YouTube, Plex and Projector, MiniDSP SHD Studio with USB, TOSLINK and two presets, and Remote layouts; Plexamp is selected, and its scenario on the canvas is a green Open application card for Plexamp, a blue Window action card entering fullscreen and an orange Change remote layout card switching the Siri Remote to its Default layout.](https://maxvyshniakov.com/media/caff3f772326a7537a65eaf542b818f3c7e60ab1c7c6956be040c39ed5c4d592.webp)

The Launcher's Apps section. Every item is a scenario.

**Field notes: The physical world argues back**

- **The monitor.** My Dell loses brightness writes sent 10 ms apart, so they go at least 20 ms apart. A confirmed write takes about 150 ms, and 130 of those are the monitor's own delay.
- **The wrong player.** A Play command addressed to Plex was accepted by macOS and delivered to Plexamp instead. Since then, a sent shortcut and confirmed playback count as different kinds of evidence.
- **The swallowed press.** The first Back in the menu disappeared: the capture helper waited for a "neutral" report that the Air Mouse never sends while idle.
- **Silent clicks.** Menu clicks went quiet during fast navigation, because the system sound player needed 123–242 ms to start. A running audio stream fixed it.
- **The display that went dark.** The audio processor's "keep the display on" value held for three minutes in testing, and later the display turned off anyway. Straylight now resends a two-minute timeout every minute.
- **A hidden pause.** Home Assistant's Android TV integration waits 0.4 s after every command unless told not to.
- **Low Power Mode.** I turned it on to quiet the fans, and macOS capped the built-in display, so Dim's 10% became almost black. "From a product point of view it would be right to adapt even to this, at the Straylight level."
- **The 435× knob.** Turning faster makes the Dial take bigger steps. Two milliseconds of Bluetooth jitter between two clicks once read as a 435-fold acceleration, so the speed is now capped at 48 clicks a second.

## Rust in a day, then a knob that felt slow

On 8 October I wrote down what the app is for:

> The point of the app: turn your computer into a controllable device that Home Assistant can reach, in particular a media device, a media centre.
>
> — Me to Codex, 8 October at 07:26

Any computer, not just my Mac. The agent's research was careful: keep the engine in Swift, share a few small pieces in Rust and write a separate engine for Linux. Two days later I set a different destination: one engine for every platform, the Mac included. Rust was new to me, and learning what it's good for was part of the point.

Before anything moved, hundreds of small capture programs recorded what the Swift code actually did, so each piece moved to Rust could be checked against it. Then, on 11 October, the core moved owner by owner: configuration, scenarios, the Dial, states, storage, HUDs, MQTT. That day has 457 commits, and the Rust code went from 822 lines to 70,935. Workers ran on GPT-6.1 Sol and GPT-6 Astra reviewed their code, because Sol "loves to overcomplicate things endlessly". If the reviewer had nothing substantial to say, the instruction was "don't burn tokens".

The next day an audit gave the honest verdict: "language ownership is ahead of architectural separation". The day after that, I felt it. I can't read a profiler, but I can feel 100 ms in a volume knob, and it was "bloody obvious how much less responsive the volume control has become". An audit measured why: since the Rust day, every press copied the whole 2.7 MB configuration into Rust, 219 ms of work for one HUD press. After the fix it took 4.

> I keep noticing that Straylight's responsiveness to key presses sort of floats. Maybe the critical things should move to some reserved thread. What class should we extract from this observation and intention of mine?
>
> — Me to Claude, 16 October at 19:05

The class was the main thread doing everything; under the editor's load it was stalled for more than 30 ms about 30% of the time. Since 18 October rules, HUD feedback and input each have their own thread, the main thread only draws, and a build check fails any new hop onto it.

Problem: Straylight had to run on any computer, and the plan kept the engine in Swift with a separate one for Linux. **Solution: One Rust engine for every platform, the Mac included, instead of a few shared codecs around a Swift engine.** Cost: A day of 457 commits that moved code faster than it drew boundaries, and a knob that felt slow two days later. Result: The same core runs on the Mac and on Linux. The slowdown was measured and fixed: a HUD press went from 219 ms to 4 ms.

**Measurements: Before and after**

- **A HUD press**, with my 2.6 MB configuration: 219 ms of work before, 4 ms after (13–14 October, a synthetic benchmark, 200 presses).
- **Opening the Launcher:** 158 ms before, 2.9 ms after (same benchmark).
- **Saving the configuration:** the main thread stalled for about 1.25 s before, at most about 45 ms after (18 October).
- **A Dial turn into a Home Assistant script:** 22.6 ms before, 9.5 ms after, with no new connection per click (19 October, against a simulated Home Assistant).
- **The Launcher's first spoken word:** about a second before, 9.5 ms after (16 October, a fake audio output).
- **Idle:** half a CPU core spent listing voices, fixed on 14 October; another quarter of a core in polling loops, replaced by events on 20 October. Neither has a final figure yet, and the new threads haven't been timed end to end since they landed.

## Every computer is a Host

On the evening of 15 October I opened the Dial's page and couldn't understand what I was looking at. One of its layouts ran "through Straylight Link", the helper VM, and I could edit the action on a button but not add a second one. "Why is there only one action on Link? What stops it running the same scenarios as on the Mac?" Nothing did: the limit was our own decision, and the Rust engine could run there too. Five minutes later I went further:

> What if we take an even wider view and treat Straylight Link as just another host? There's a host on macOS, there's a host on Linux, and some interface manages them. Although here I start to drift; this is probably where concepts like a leading host appear.
>
> — Me to Codex, 15 October at 20:29

GPT-6 Astra argued against the leading host, then grilled me with 23 numbered questions, each with a recommended answer. Between 20:40 and 21:55 I answered "1", the recommendation, 21 times and "OK" twice. Now there is one Straylight running on several Hosts, each a named computer that owns its configuration and runs it with what it has. My MacBook is one; the Linux VM, named homelab, is another. They run the same Rust core, about 157,000 lines by 20 October, each with its own platform adapters.

Problem: The helper VM could run only one action per button, because of a leader-and-helper model we had chosen ourselves. **Solution: Every computer is an equal Host with its own configuration, the same Rust core and no leader.** Cost: No automatic failover, runs interrupted on one Host don't resume on another, and every call between Hosts needs its own authority, permission and failure contract. Result: The Linux machine stopped being a smaller second product. A Mac can switch on a light through homelab's Home Assistant without a token of its own, and homelab runs the Dial while the Mac is off.

When one Host needs another, it asks explicitly over an encrypted connection: run your scenario, read or set your State, use your Home Assistant. Sunday 19 October has 202 commits of exactly that: pairing with one-time codes, scenarios calling other Hosts, Scenes holding values from two computers. The old Link channel was deleted the same day. My answer to keeping it as a fallback: "Let's do without these backup channels and other mush."

Diagram: The Siri Remote's path since 19 October.

1) **Siri Remote** (Bluetooth): Presses, touches, the ring and the microphone
2) **homelab** (Linux Host): Captures the remote on its own adapter and picks one Mac
3) **TLS, pinned key** (Host connection): Late presses are dropped; a release always arrives
4) **wintermute** (Mac Host): Its layouts and scenarios run the press

↺ The Dial can do the same, or run its own layouts on homelab while the Mac is off.

![A demo scenario called Movie night on the editor canvas at 100%: four cards in a column joined by dashed connectors, a violet Capture snapshot card saving the Siri Remote layout, an orange Change remote layout card switching the Siri Remote to its Streamer layout, a cyan Home Assistant card whose Host is homelab running the Projector scene, and a green Caffeinate card set to Enable.](https://maxvyshniakov.com/media/b0f040c144f340a52298072b417fbc4270f2ae11f953c1f5debc5a2251c4d34d.webp)

A demo scenario made for this page.

This is the pattern the whole project keeps repeating. A special case turns out to be an ordinary thing in disguise. The Dial was "basically a remote". The Launcher layout became an ordinary layout. The Launcher's items, the remote's buttons and the Dial's turns all became scenarios. And the helper VM became the same Straylight on a smaller computer. Each time, a rule disappears and something reusable takes its place.

## Everything is a State

The other half of the model is State: what something is right now, and what it should become. It started on 24 September with the projector. To leave movie mode I'd have needed a separate "Exit Projector" item, "and that's a crutch". Within an hour the vocabulary was settled: a Scene is "like a preset", and a custom state is for "a stateless device where we somehow need to pretend it has a state".

The biggest change came on 18 October, out of the HUD. Its third tab was hard-coded for "the primary light", and a second lamp had a role of its own:

> If, say, I have both a chandelier and a lamp in the room that I want to control separately, I could get a fourth tab and adjust that extra lamp there. And it might not be a lamp but, say, the curtains. See where I'm going?
>
> — Me to Claude, 18 October at 19:01

The tabs became views of States, up to eight, each added straight from a Home Assistant entity. An hour and a half later I asked whether the fast value I turn and the real value that follows were separate, "I thought that was the whole point", and they became so: the Dial moves a target level that the HUD shows at once, and the device follows. From that conversation to working code took about nineteen hours; from my July remark that the HUDs "will essentially be states, tabs, of a single HUD", eighty-one days.

The HUDs of 3.0.3. The track title is sample text.

1) **Controls**: Three tabs over States, the chandelier selected.
   ![A dark glass HUD over the Earth's glowing edge with three tabs reading 64%, 52% and 40%, the lights tab selected with a yellow bulb, above a brightness bar.](https://maxvyshniakov.com/media/a52c138fda6bcebb53d9c02d27013331b833c4e6372bf88260b83fd344ce1e5c.webp)
2) **Now Playing**: The track that started, with a play badge.
   ![A square glass card with a music note, a play badge, the title Teardrop and the artist Massive Attack.](https://maxvyshniakov.com/media/ce57e5b04e3ff4bfe966f740538624aead0b23d6e7cb848cc4bd57d04e949c35.webp)
3) **Power**: A switch flipped by a button that has no tab.
   ![A square glass card with a table lamp icon, a power badge and the label Living room lamps.](https://maxvyshniakov.com/media/36f12d30c38629033cd3697458107a3236b9668d8166c1d650a8573a8baf1238.webp)
4) **Home**: A notification sent by Home Assistant.
   ![A glass card with a house icon, the title Home and the text Lights turned on in three rooms.](https://maxvyshniakov.com/media/92bb76334de656f94395e34e64dae75804bdfa0d6683b9d6de0c27ca15f0ba4c.webp)

The next day Home Assistant's lights, covers, fans and media players became States too, kept current by one subscription per Host instead of a request on every read. On 20 October I asked to "redo this as a data grid… we split everything the way Home Assistant does: device, then state", and the States page became one table on Data Grid.

![The States page in the editor: one table with Device, Name and Value columns grouped by Host, with wintermute's 32 States, among them Air Mouse layout, audio output volume and mute, the chandelier's power, brightness and colour temperature, custom demo states, display brightness, living room lamps and the SHD Studio's input.](https://maxvyshniakov.com/media/1fa20063c5dacf2271232b764c39e1672a3dc6222a7c7c515ecc0ae539437007.webp)

Every State of every Host in one table.

## Built to be trusted

An app that captures input and controls a computer should be readable, and everything in it that asks for trust can be switched off. The idea came at a quarter to three one morning, when the Dial's Link connection annoyed me by sitting in the device list. Twenty minutes later I saw the real reason for it:

> I'm also thinking about these modules in terms of people not being afraid to use the app, knowing they can switch a module off. When an app asks for the Accessibility permission, that's serious business. And so that in the open-source code you can see that the module really cuts that functionality off completely.
>
> — Me to Codex, 10 October at 03:04

A module that's switched off stops taking work, cancels its timers and ignores late callbacks. Today there are four: media keys, the Home Assistant bridge, the connection to the Linux Host and an MCP server for AI agents. The design document is honest about the limit: switching a module off stops it, but doesn't narrow the system permissions the app already has.

The MCP module came from a question on 14 October: "Someone downloads Straylight, installs it, and these days it's just not the done thing to walk around setting up a scenario by hand. So they should ask an agent what they can do." It's off by default. Claude Code, Codex or Cursor can read the configuration and propose changes, shown on the canvas as drafts marked with the client's name. An agent can't see credentials, pair devices or delete anything, and it can apply only its own drafts, with my consent.

![The Modules settings page with four switches: Media keys, Home Assistant MQTT bridge and Linux Host connection on, MCP server off, with tabs for Claude Code, Codex, Claude Desktop and Cursor and a command to add Straylight to Claude Code.](https://maxvyshniakov.com/media/dd678abcc0a5e70445019a34607e1935e774ffca071f253f49150e20e5cacdda.webp)

The four built-in modules in 3.0.3.

**Under the hood: Security, in the order it happened**

- **25 September.** The unauthenticated local HTTP API, 52 endpoints, shrinks to two Dial routes; on 6 October it's gone.
- **1 October.** Secrets move out of the Keychain, which refused every rebuild of a self-signed app, into owner-only files. The decision record says plainly that any process running as me can read them.
- **5 October.** Remote input leaves the MQTT broker for its own channel: keys derived per session, every packet sealed with ChaCha20-Poly1305 and counted, so a replayed one is dropped.
- **16 October.** Each of the 77 commands a browser or an app can send declares its access level: view, control or administration. By 19 October there are 102.
- **17–19 October.** Hosts and browsers connect over TLS 1.3 and pair with one-time codes through SPAKE2, bound to the TLS session so a relay in the middle fails. Clients pin the Host's key rather than its certificate. Plain HTTP is served only on the Mac itself, and even a local browser has to sign in.
- **Locked Mac.** While the screen is locked, keyboard, window and pointer actions are refused, and nothing refused runs after unlocking.
- **Review.** A Codex review of 494 commits from 15–18 October found four defects, among them a revoked browser that could finish a delayed upgrade. All four were fixed on 18 October.

## Working with agents

> I never look at the code. I can only skim very high-level architectural decisions and find logical inconsistencies in them.
>
> — Me to Codex, 21 September at 22:07

Most of my more than 2,000 messages to the agents are short: commit, push, merge, "pressed it, works". The rest are long dictated monologues about the model, which I asked the agents not to take literally: "It's my stream of consciousness. Pick the optimal solution." Around that, a working loop grew:

- **Grill.** Before a big piece of work, the agent interviews me with numbered questions, each with a recommended answer.
- **Tickets.** Decisions go into GitHub issues: 403 of them by 20 October, and not one pull request.
- **Lanes.** An orchestrating agent runs tickets in parallel, each worker in its own copy of the repository.
- **Review.** Two independent reviews, one against the project's standards and one against the spec, before anything reaches the main branch.
- **Release notes.** One file per day, written for a user, shown in the app under _What's new_.

Codex and Claude checked each other's work. On 6 October Claude ran out of its usage limit with about 25 parallel lanes in flight, and Codex merged its branches, including 19 files a worker hadn't committed. At six the next morning:

> Let's not rely on your memory for orchestration. Maybe we need a skill, “orchestrate”, that describes what the orchestrator needs.
>
> — Me to Claude, 7 October at 06:11

The skill appeared that morning, with a budget of four parallel lanes and a procedure for recovering from a usage-limit stop. That's the habit I'm proudest of: fixing the class of a mistake rather than the instance. When an agent took several tries to build a dialog properly, I asked it why, and what minimal steps would make it work for other agents. The answer usually became a lint rule, a skill or a better name.

I also kept the app free of legacy. Names had drifted as fast as the code: "Home" meant Home Assistant, a Dial position and the Linux VM, and an audit of 111 glossary terms found real bugs hiding in the words. The audit advised keeping old formats readable. I disagreed: "While the app isn't public, this shouldn't multiply at all." The decision went into the repository as one sentence, "The application is not public." My configuration was converted once and the old readers were deleted. In 2,583 commits there isn't a single revert commit: the direction changed constantly, but always forward.

Plenty of dead ends were mine: navigating the Launcher by keyboard colours, an editor without a save button, a single shared memory of State I defended for two weeks, and the projector logic in the wrong place.

## Where it stands

Version 3.0.3 runs every day on my Mac and on homelab. What's still open: my own acceptance of the first two-Host milestone on real hardware, the full core on Linux, smaller Hosts such as a Raspberry Pi, and voice from the Siri Remote's microphone. Windows isn't started. Straylight is built around one home, my devices and my habits, and several numbers on this page are single runs or synthetic benchmarks, as their notes say.

It's still a personal tool and a favourite toy, but it's built to a standard where opening it makes sense, and I probably will: "So people can see this thing isn't malware, that it doesn't steal data, doesn't intercept input and send it off somewhere."

**The prototype and the five weeks at a glance**

* 27–31 Jul (2026-07-27): **The prototype, Wintermute Control.** A HUD like BetterDisplay's for a new volume knob, and my own keyboard firmware that shows the input language. Then it sits untouched for six weeks.
* Mon 14 Sep (2026-09-14): **Back to it.** Straylight's month starts here, with a newer model reviewing the July code.
* Mon 21 Sep (2026-09-21): **From a utility to a media centre.** An old Air Mouse, a Launcher, Electron and a flow editor built on my design system.
* Fri 25 Sep (2026-09-25): **One night, 175 commits.** A 4,697-line file is taken apart into modules. Claude suggests a new name.
* Sun 4 Oct (2026-10-04): **The Siri Remote.** It pairs with a Linux VM on my home server, because macOS won't share its clickpad.
* Sun 11 Oct (2026-10-11): **Rust in a day.** 457 commits move the engine out of Swift, owner by owner.
* Thu 15 Oct (2026-10-15): **One core, many hosts.** The Linux VM stops being a helper and becomes a Straylight of its own.
* Mon 19 Oct (2026-10-19): **202 commits.** Hosts pair, borrow each other's States and Home Assistant, and edit each other.
* Tue 20 Oct (2026-10-20): **Version 3.0.3.** The Linux Host gets its own Launcher, browser editing and the Dial's dimmer.

* 5 weeks — from 14 September to 20 October, after a five-day prototype in July
* 2,000+ — messages from me to the agents, the prototype included
* 2,552 — commits since 14 September, and no revert commits
* 403 — issues, and no pull requests
* 224k — lines of source, plus 139k lines of tests

Claude Opus 5.5 co-signed 1,055 of the commits, about half of them in the last six days. Most of the rest, including the whole Rust day, came from Codex sessions on GPT-5.6 Sol, GPT-6 Astra and GPT-6.1 Sol.

## One intent away

Writing my own tools with agents had stopped surprising me. What I didn't expect was hardware. A device I like no longer comes bound to the software it shipped with, or to the limits of the system it plugs into.

> You can't use the Siri Remote properly on macOS? Now that's not a problem: you take it and use it. You don't like Ulanzi Studio? Write your own software. You want a smart home where you don't spend months reading forums to fix something? Tell the agent; it knows Home Assistant very well. Home Assistant is missing some capability? You just get it, and connect any hardware to whatever you need.
>
> — Me to Claude, 14 October

Every one of those happened this month. The real distance now is whether I can hold the big picture. At school, with a receiver soldered to a COM port, I could read every button on my remote, and what stood between me and the rest was everything I couldn't build. Now it's mostly how clearly I can say what I mean.
