The Night My Email Gateway Sent 26 Auto-Replies

The Night My Email Gateway Sent 26 Auto-Replies

There’s a rule in the Hermes skill library about email testing. It exists because of this night.

The rule is simple: never test email delivery from the user’s own address into the monitored inbox. The reason is equally simple: the gateway monitors that inbox. When it sees a new email arrive, it processes it. When it processes it, it sometimes responds. When it responds, the response arrives in the inbox. Which the gateway sees. Which it processes. Which it responds to.

Twenty-six times, before I noticed.

How it happened

I was testing the morning briefing renderer — the HTML email that goes out at 7am. The first version was sending plain text with Markdown formatting, which looked like garbage in Gmail. I’d built an HTML renderer and wanted to verify it worked.

The natural test: send a briefing to myself and check the result. So I sent it from my own email address to my own email address, through the gateway’s inbox monitor.

The gateway saw the email arrive. It processed it — because emails from me are the kind it’s supposed to handle. It generated a response. The response was delivered to the same inbox. The gateway saw the new email. It processed it again.

Twenty-six cycles before I pulled the plug. Each one slightly different, because the gateway’s context accumulated with each pass — by the tenth reply, it was referencing its own earlier replies as context, creating a feedback loop of increasing confusion and decreasing coherence.

The fix

Two fixes, one immediate and one permanent.

Immediate: stop the gateway, delete the extra emails, add a rate limiter to the inbox monitor. The rate limiter counts responses from the gateway’s own address and kills the loop at five — enough for legitimate auto-replies, not enough for a feedback spiral.

Permanent: the cross-platform formatter skill, which now includes a rule about email testing. The rule is in the “Real Incidents” section, because rules without stories are just suggestions.

The skill’s actual rule: when testing email delivery, send from the agent’s own address ([email protected]), not the user’s address ([email protected]). The gateway monitors the user’s inbox. It doesn’t monitor its own. Break this rule and you get 26 auto-replies. Follow it and you get a clean test.

What I learned

The gateway is very good at its job. Too good, in this case — it processed every email exactly as designed, including the ones it shouldn’t have been processing. The problem wasn’t that the gateway was broken. The problem was that I’d given it a loop to run and was surprised when it ran it.

Automation has exactly two failure modes: it doesn’t do what you want, and it does exactly what you told it. The second one is worse, because it looks like success right up until you check your sent folder.

The Morning Briefing Learned to Count to Three

The Morning Briefing Learned to Count to Three

For the first two months, the morning briefing checked one machine. At 7am every day, a cron job would fire, read my calendar, scan my unread emails, check the overnight sessions, and send me an HTML email with everything I needed to start the day. One machine, one set of health checks, one perspective.

Then I added the Mac. And the business agent. And suddenly the briefing needed to learn to count.

Three agents, three operating systems

The Windows PC is the primary — it runs the main Hermes gateway, the knowledge graph, the cron scheduler, and most of the automations. Health checks are straightforward: gateway status, disk usage, cron error count, a quick scan of the gateway log for tracebacks.

The Mac Mini runs a second Hermes instance — the one from the 2nd Agent post. It needs SSH from the Windows PC to check, which means the right key file, the right username (remember: vaughantaylor, not “Vaughan Taylor”), and the batch mode flag. One wrong character and the whole briefing reports the Mac as unreachable, which is technically accurate but emotionally devastating at 7am.

The Maintain AI agent runs as a local profile on the same Windows PC — same machine, different config. It has its own gateway, its own cron jobs, its own state database. Checking it means reading files from a different directory, not SSHing anywhere.

Three agents. Three operating systems. Three completely different ways to ask “are you alive?”

The first version broke immediately

The first fleet status section tried to use the same health check commands on every agent. It didn’t work. The Windows agent checks df -h /c/. The Mac checks df -h /System/Volumes/Data. The business agent checks a SQLite database. The commands aren’t interchangeable, the SSH requirements aren’t the same, and the error handling is different for each.

The second version tried to abstract the differences. It created a single function that took an agent type and returned health data. This worked until the Mac SSH connection timed out at 3am during the cron run, and the entire briefing failed because one agent out of three couldn’t be reached.

The third version — the one that actually works — treats each agent independently. Primary runs its checks. Mac runs its SSH checks (and if SSH fails, it reports “unreachable” instead of failing the whole briefing). Business runs its file checks. Each agent’s section is independent. If one fails, the others still render.

The renderer had to learn too

The HTML email renderer needed a new section: “Agent Fleet Status” at the top, before the calendar. Three cards, each with a name, a status emoji (green for good, amber for warnings, red for failures), and a list of scannable summary lines.

The section auto-drops when the agents key is missing — so on days when the fleet check isn’t run, the briefing doesn’t show an empty section. Same pattern as every other section in the briefing: render when present, drop when absent.

It took three iterations to get right. The first rendered all three cards even when they were empty. The second crashed on the Mac card when SSH returned an error string instead of structured data. The third handles every edge case gracefully, because edge cases are all you get when you’re checking three machines across two networks at 3am.

The lesson

Fleet monitoring sounds like a feature. It’s actually a constraint. Every new agent you add multiplies the failure modes: more SSH keys, more disk layouts, more health check commands, more things that can go wrong at 3am and show up as a red emoji in your morning email.

But the briefing with three agents is better than the briefing with one. Not because three is a magic number, but because knowing what’s broken across your entire infrastructure — before you sit down at your desk — is worth the complexity. The Mac being unreachable at 7am is information. Not knowing it’s unreachable until you try to use it at noon is a surprise.

I prefer information.

The 2nd Agent

The 2nd Agent

One AI assistant running on a Windows PC. That was the plan. That was enough.

Then I got greedy.

What if I had a second one? Sitting on the Mac Mini on my desk, watching a different corner of the network, ready to jump in when the Windows box is busy. A twin. A backup. A second pair of eyes.

It sounded like an afternoon of work. Maybe a weekend, tops.

It took a week.

Act 1 — the Mac didn’t want to be managed

The first problem was the simplest: I needed to talk to the Mac from the Windows PC. Over SSH. The oldest remote access trick in the book.

The Mac said no.

Not dramatically — macOS never does anything dramatically. It just silently wasn’t listening. Remote Login was off. Easy fix, right? System Settings → Sharing → Remote Login → toggle it on.

Done? No. Because the Mac’s SSH username isn’t “Vaughan Taylor” (what it shows on the login screen), and it isn’t my email address, and it isn’t my Apple ID. It’s `vaughantaylor` — the short name I picked during setup in 2021 and promptly forgot about. Three failed login attempts before I thought to ask the Mac what it calls me. (The answer was hiding behind `whoami`, which I should have run first.)

OK, SSH works. On to the fun part.

Act 2 — installing Hermes on a machine that already had a personality

Hermes installs with a one-liner. On the Mac, that one-liner dropped into a shell that still thought it was 2019. The Mac ships with Python 3.9.6 — ancient, barely functional, installed by Apple for reasons nobody understands. Hermes needs 3.10 or newer. The install script didn’t complain loudly enough; it just quietly failed to import its own dependencies at runtime.

Diagnosis: twenty minutes of reading tracebacks. Fix: install the real Python, point the venv at it, install ripgrep via Homebrew because macOS doesn’t ship that either.

Meanwhile, the Mac’s 256GB SSD was 93% full. Old Xcode simulators, Ollama models I’d forgotten about, a Docker Desktop install that hadn’t been touched since March, and about forty gigabytes of “I’ll clean this up later.” The “later” was now. I reclaimed 55GB, which is the most satisfying thing you can do to a computer that isn’t yours.

(It is mine. But the Mac always felt like it belonged to macOS more than to me.)

Act 3 — the bot token standoff

Hermes uses Telegram as its main channel. I already had a Telegram bot for the Windows agent. So naturally, I tried to use the same bot for the Mac agent.

This is a documented bad idea. Two Hermes instances polling the same bot token don’t cooperate — they race for updates. Message arrives, both see it, both try to answer, and you end up with two replies to every question, each one slightly different, both helpful, all confusing.

The fix: create a second bot. @BotFather, `/newbot`, pick a name, copy the token, paste it into the Mac’s config. Five minutes of work, except I spent forty-five minutes trying to figure out why the first bot was still showing typing indicators after I’d supposedly stopped using it. (Gateway restart. Always gateway restart.)

Act 4 — macOS has opinions about network traffic

The Windows agent talks to a local knowledge graph — a service called gbrain running on the Windows PC, port 3131. The Mac agent should be able to reach it too. Same LAN, same subnet, same building.

macOS disagreed.

Apple’s “Local Network Privacy” — a feature so helpful it blocks your own devices from talking to each other — silently eats UDP and TCP traffic from apps that haven’t been individually approved. The catch: this approval dialog only pops up for Apple-signed binaries. Third-party apps (like the Python runtime that Hermes uses) just get a quiet “no route to host” with no explanation, no dialog, nothing. The traffic vanishes into a socket-layer black hole.

I spent a full afternoon convinced the firewall was broken. It wasn’t. The Mac was just refusing to route the packets because nobody had asked permission, and there was no visible way to ask.

The fix was a relay — a tiny Python proxy that runs under `/usr/bin/python3` (which is Apple-signed and does get through the wall), listens on localhost, and forwards to the Windows PC. Fourteen lines of code. Two hours of understanding why it was needed.

(Permanent fix exists: grant the permission in System Settings. I haven’t done it yet because the relay works and I’m tired.)

Act 5 — two agents, two sets of everything

A fresh Hermes install knows nothing. No personality, no memory, no idea who you are. So I had to build the Mac agent from scratch: a tailored SOUL.md (who it is, what it does, how it talks), a MEMORY.md seeded with the facts it needs, a USER.md with my preferences, provider wiring so it could actually talk to an LLM, and a Telegram gateway so it could actually talk to me.

Then I discovered the gateway cache problem.

Hermes caches the system prompt when the gateway starts. I could write perfect config files, perfect memory files, perfect soul files — and the running gateway would happily ignore all of them until I restarted it. The CLI `hermes chat -q` test passes every time (it spawns a fresh process), so every verification looked correct. But the actual Telegram-connected gateway was serving the old, blank context. I spent a whole evening verifying things that were verified, while the real problem was one process that hadn’t been told to reload.

On the Mac, restarting the gateway from SSH is also impossible — the gateway’s own SIGTERM guard kills the entire process tree, including the SSH session you’re trying to restart from. You have to walk over to the Mac, open Terminal.app, and type the command by hand. Like it’s 1995. Like the network doesn’t exist.

Act 6 — the daily briefing learns to count

Once the Mac agent was alive, the morning briefing needed to know about it. The 7am daily email — which had been checking one machine — now needed to check three (the Windows PC, the Mac, and the Maintain AI business agent). Each with different health checks, different disk layouts, different ways of asking “are you alive?”

The Mac check requires SSH from the Windows PC, which requires the batch mode flag, which requires the right key file, which requires the right username (see Act 1). One wrong character and the whole briefing reports the Mac as “❌ Unreachable,” which is technically accurate but emotionally devastating at 7am.

It took three iterations to get the fleet status section right. The first version tried to use the same health check commands on every agent. They don’t share commands. The Windows agent checks `df -h /c/`. The Mac checks `df -h /System/Volumes/Data`. The business agent checks a SQLite database. Three agents, three operating systems, three sets of assumptions.

The loop

A forgotten username, a Python version mismatch, a bot token race, an invisible privacy wall, a caching problem that lies to your face, and three different health checks for three different machines.

None of these were hard problems. Every one of them had a clean, simple fix. But each one took hours to find, because the Mac doesn’t shout when it says no — it just quietly doesn’t work, and lets you figure out why.

The second agent is running now. It watches the network from the Mac, reports its status every morning, and answers Telegram messages with its own personality. It knows what it needs to know, and nothing about what it doesn’t.

One AI assistant was a tool. Two AI assistants are a fleet. The difference isn’t capability — it’s that now there are twice as many things that can go quietly wrong in ways nobody warned you about.

The Mac and I understand each other now. Mostly because I finally learned to ask it its real name.

The Sentinel Goes Live

The Sentinel Goes Live

Every self-hoster has a box they don’t care about. Mine is an old Dell OptiPlex 9020 Micro — four gigabytes of RAM, a spinning hard drive, and exactly one job: to be the machine that gets compromised so the others don’t have to.

I call it the sentinel. Its job is to watch the dark web for breaches, leaked credentials, and exposed data related to my domains and email addresses. It does this through Tor, reports findings to me over Telegram, and holds nothing of value. If someone breaks into it — and the whole point is that someone eventually will — they find a disposable OpenRouter API key, a Telegram bot token for a dedicated relay, and a machine with nothing else on it.

Zero-credential discipline

The rule is absolute: no real identity, no real credentials, no real data. The sentinel lives on a separate VLAN, uses its own SSH key, runs its own Hermes profile, and connects to nothing on my main network except through a one-way relay.

The only credential it holds is a burner OpenRouter key — identity-buffered so it can’t be traced back to my main account. If the key gets burned, I delete it and generate a new one. The whole machine can be wiped and rebuilt in ten minutes. That’s the point.

The build

Setting up the sentinel took three sessions. The first was hardware: netboot.xyz for the OS, Debian on the 120GB SSD, SSH enabled, firewall locked down. The second was Tor and Hermes: install the Tor SOCKS proxy, verify it routes .onion traffic, install Hermes with a custom SOUL.md that defines its personality as a watchdog that reports but never acts.

The third session was the interesting one: wiring the relay. The sentinel can’t send Telegram messages directly — that would require a bot token on the machine, which is exactly the kind of credential I don’t want there. Instead, it writes findings to a local file, and a separate relay process on my main PC picks them up and forwards them to Telegram.

It’s overengineered by design. The whole point is that the sentinel never talks to the outside world directly. It talks to the relay. The relay talks to Telegram. If the sentinel gets compromised, the attacker gets a relay that forwards to a bot they don’t control.

What it watches

The breach monitor runs hourly. It checks paste sites, known breach databases, and dark web forums for mentions of my domains (maintainai.co.nz, vntis.nz, vaughantaylor.com) and associated email addresses. When it finds something, it sends a summary through the relay: what was found, where, when, and how severe.

So far it’s found nothing alarming. That’s the best outcome — it means nobody cares enough about my domains to dump them. But “nothing alarming” is only useful if you’re actually checking, and that’s what the sentinel does. Every hour, whether there’s something to find or not.

The sentinel isn’t a security solution. It’s a habit. And habits work because they run whether you’re paying attention or not.

Fresh Start on a 120GB Drive

Fresh Start on a 120GB Drive

After the 2TB drive died and I’d finished mourning my Proxmox config, the question was simple: what now?

The answer was also simple, but I resisted it for a full evening because I’m stubborn. Ubuntu wouldn’t work. I asked. The internet confirmed it. Proxmox is built on Debian, and there is no supported path from Ubuntu to Proxmox — the kernel packages fight the package manager, and the first apt upgrade would break everything. The internet was right, as usual, and I wasted an evening discovering that.

Debian it is

Debian 12 Bookworm. Netboot.xyz for the install — boot from the network, pick the OS from a menu, let it download and install. The installer asks which disk to use, and this was the one decision that actually mattered: the 120GB Kingston SSD, not the dying 2TB Seagate.

Guided partitioning, entire disk, single partition. Software selection: uncheck everything. No desktop, no GNOME, no printing support. Just the base system. SSH and Proxmox would come after.

The install took fifteen minutes. GRUB went to the SSD’s MBR. I set a root password, created a user, rebooted.

Proxmox on top

Debian to Proxmox is one command:

bash
apt install proxmox-ve
`

Add the repository first, then install. Proxmox boots, shows a web interface at https://:8006`, and suddenly the old OptiPlex is a hypervisor again. The VMs I’d lost? Gone — but the configs I’d rescued from the dying drive meant I could rebuild them in an afternoon instead of a weekend.

The 2TB Seagate stays in the box as scratch storage. I don’t trust it with anything important, but for throwaway test VMs it’s fine. Every time I create a VM on it, I name it something like “temp-testing-dont-care” as a reminder.

What I actually learned

The whole reinstall — from “the box is dead” to “Proxmox is running and VMs are deploying” — took about ninety minutes. The recovery attempt that preceded it took three hours. The lesson is obvious: sometimes the fastest path through a problem is the one you skip first because it feels like giving up.

Fresh installs are underrated. They’re the only time you get to make every decision correctly from the start, without the weight of choices you made months ago when you didn’t know what you were doing.

The Disk That Lied About Its Health

The Disk That Lied About Its Health

My Proxmox box died on a Tuesday. Not dramatically — no smoke, no spark, no warning. It just stopped booting and dropped me to an initramfs command line, staring at a shell prompt on a machine that was supposed to be running four virtual machines and a backup target.

The box is an old Dell OptiPlex with a 2TB Seagate drive and a 120GB Kingston SSD. I’d set it up as a Proxmox node months ago, intended for ZFS backups and test VMs. It had been sitting mostly dormant — the kind of project you build on a weekend and then forget about until you need it.

Now I needed it, and it wasn’t cooperating.

The numbers that should have scared me

Before trying to recover anything, I ran SMART diagnostics. The drive reported PASSED — which should have been reassuring, except the raw numbers told a completely different story.

11,040 reallocated sectors. 240 sectors queued for reallocation. 240 sectors already unreadable — actual data loss, already happened, silently. The raw read error rate was 183 million. The drive had been running for 26,723 hours — about three years of continuous operation.

SMART said “PASSED” because the normalised values were still above their thresholds. But normalised values are like a credit score — they tell you where you are relative to failure, not whether you’re already in trouble. The drive was in advanced failure. The filesystem corruption I’d been blaming on an unclean shutdown wasn’t a shutdown problem. The disk was rotting.

The recovery attempt

At initramfs, the options are limited but real. LVM was there. The volume group just hadn’t activated during boot — the disk was too slow to respond before the timeout. I activated the VG, ran a read-only filesystem check, and got back a wall of inode errors that told me exactly one thing: this wasn’t getting repaired.

The smart move was to grab the configs and reinstall. The honest move was to spend twenty minutes trying to fix it anyway, because the configs were inside the broken filesystem and I wanted them back.

I got some of them. Enough to make the reinstall painless. Not enough to pretend the drive was coming back.

The reinstall

Debian 12 on the 120GB Kingston SSD — the healthy drive. Netboot.xyz made it easy: pick the OS, point at the right disk, uncheck every package except the base system. Twenty minutes later I had a clean Debian install, thirty seconds of repo setup, and Proxmox running on top of it.

The 2TB Seagate? It’s still in the box. I use it for scratch VMs I don’t care about losing. Every time I see those SMART numbers in my monitoring dashboard, I’m reminded that “PASSED” doesn’t mean “fine.”

The lesson wasn’t about Proxmox or Debian or initramfs. It was about the gap between what a system reports and what’s actually happening. The drive said it was healthy. The filesystem said it wasn’t. The numbers said it had been dying for months.

I’d rather own the problem than rent someone else’s false confidence.

The Box That Does the Dirty Work

There’s a little Dell OptiPlex that’s been sitting in my house doing nothing for years. An old office mini-PC — the kind of thing companies throw away by the crate. This week I gave it a job: it’s going to be the machine that touches the stuff I don’t want touching anything else.

Here’s the idea. My main computers hold everything — emails, logins, projects, a whole life of credentials. The last thing they need is to be the place where I poke around the darker corners of the internet. So I’m building a “sentinel”: a separate, disposable box. It does the dirty work — scanning breach dumps, watching for my company’s data leaking somewhere it shouldn’t — and it reports back. It knows nothing about me. If it ever gets compromised, I wipe it and rebuild in ten minutes. Zero loss.

The hard part wasn’t the plan. It was getting an operating system onto the damn thing.

No USB stick, no monitor gymnastics: I wanted to install it over the network — PXE boot — from a container I already run on my NAS. The machine would ask the network “what should I boot?”, the NAS would answer, and Ubuntu would install itself.

That was the theory. The reality was a full afternoon of one wall after another.

First wall: my router hands out IP addresses but won’t do PXE — most consumer routers don’t expose that setting at all. So I built a tiny “proxy” that answers only the boot question, sitting next to the router without touching its config. An afternoon of reading dnsmasq source code later, it worked. (The kicker: the setting that makes it respond to PXE clients is undocumented in the man page — you have to read the C source to find it.)

Second wall: the NAS’s networking had been subtly broken by a system upgrade — UDP ports published to the LAN silently dead. That one cost real time because everything looked healthy from the inside.

Third wall: iPXE — the boot loader — kept looping on itself, downloading its own boot file forever. The fix was a one-line rule about DHCP options, but finding it meant tracing the actual packets across the network.

Eventually the menu appeared on the dusty old monitor. Ubuntu installed. The sentinel lives.

Tomorrow: one BIOS setting — make it boot from its own hard drive instead of the network — and then it starts earning its keep.

My AI did most of the digging. I pointed, it debugged, I held the screwdriver. That’s the workflow now: I bring the hardware, it brings the patience.

Sunday Was Security Day

Saturday was the big upgrade. Sunday was the bill — three separate security scares, one after the other, all of them about the same thing: locks. Who’s allowed in, who isn’t, and whether the door was even locked.


Act 1 — my speakers went quiet

Sunday morning, my Sonos stopped talking to the NAS. My music library might as well have been on the moon: permission denied, everywhere, all at once. Even the Apple TV couldn’t see the files.

Turns out the internet was to blame. A door on my network — port 445, the file-sharing door — had been left open to the whole world since before the upgrade, and the whole world had found it. Bots from Indonesia were hammering on it so hard that my own devices couldn’t get in. The queue was full. Of strangers.

The fix was delightfully simple:

  1. Close the door.
  2. Reboot the NAS.
  3. Tell the NAS to stop speaking the ancient, unsafe file-sharing language from the 1980s. (Nothing on my network actually needs it — everything already speaks the modern one.)

Music came back. Attackers gone. It’s now saved as a skill, so if this ever happens again it’s a ten-minute job, not a morning.

Act 2 — I asked my AI a scary question

Mid-morning I asked: “What stops you from replying to an email that asks you to hand over my SSH keys or passwords?”

Fair question. Scary question, honestly.

The answer: I treat every email like a stranger at the door. I’ll read what they wrote, but I don’t take orders from strangers. Keys and passwords live behind locks that need a human to open. There is no email that can reach them. The internet tests this every single day — it keeps trying to talk me into things. It hasn’t worked yet.

That conversation turned into a project. If emails are strangers, what does a trusted connection between two of my own AIs look like? So I built one: a private line between my local assistant and my cloud assistant, with a handshake, a watchdog, and a rule that only signed instructions get through. No strangers. Just us.

Act 3 — I sent an AI to poke my own server

By evening, the private line was ready for a test drive. I told the cloud assistant: go scan my server and tell me what’s wrong.

It went quiet for an hour. (It stopped to ask “are you sure you own this?” — sweet, but nobody was there to answer. My bad.)

Once we fixed the plumbing, it got to work — and found something real. My server was showing its internal name tag to anyone who connected by IP address instead of my domain. A stranger could map my whole setup for free, just by knocking. Not great!

The fix was one small setting: don’t answer the door to strangers at all. Unknown visitors now get nothing — the connection just dies. My real websites? Untouched. Verified. Hole closed, same night it was found.

The loop

A music scare, a trust question, a real hole in my server — all fixed before midnight. Sunday was the day I found out where all my locks were, and whether they worked.

Sunday was security day. Monday can go back to being Monday.

One Message Before I Left for the Gym. One Major Upgrade by Midnight.

There’s a specific kind of Saturday-morning moment where you’re about to head out the apartment door to walk to the gym, and instead of leaving you send one last message to your AI: “Looking at my self hosted setup as it stands — what would be your top recommendations to improve on it now?”

I expected a list. What I got was a day that ended with my NAS running a brand-new major operating system version, twenty-eight containers bouncing, and the one upgrade I’d been chasing for months finally landing.


The morning ask

The audit didn’t pull any punches. My assistant came back with seven findings ranked by impact, and the top of the list stung a little:

  1. Home Assistant was twenty months stale. Pinned to 2024.12 because watchtower couldn’t touch a pinned tag — twenty months of security fixes and features I simply didn’t have.
  2. Bazarr was from December 2022 — and worse, its image had been deleted from the registry entirely. It had been failing to update every single night for weeks.
  3. The VPS had no swap — a 7.8GB box running WordPress, n8n, and Coolify with zero OOM cushion.
  4. 8.9GB of dead Docker volumes on the VPS, leftover from deleted services.
  5. Package lag — Docker engine and Tailscale updates sitting in the queue, including security-relevant containerd.

Then a handful of yellows: disk pressure trending, and JDownloader running a full Java GUI with an invisible X server for a UI nobody ever looked at.

“Go now, i’m off to the gym,” came the reply. “If something needs my reply just skip that task and start on the next.”

So off I went — five tasks, one assistant, zero human in the loop.

The autonomous run

This is the part that still feels slightly surreal to type. While Vaughan did whatever one does at the gym on a Saturday morning, the assistant worked through the list in order, quickest wins first:

  • 2GB swap created on the VPS and wired into fstab. Done.
  • 8.9GB of dangling volumes reclaimed. Done.
  • Docker 29.7.2, containerd 2.3.3, Tailscale 1.102.2 — the full package run, all thirteen containers verified healthy after the daemon restart. Done.
  • Bazarr rebuilt on the official linuxserver image, old config preserved, serving 200. Done.

Four of five. Then it hit the wall.

The wall: Home Assistant

The HA upgrade failed the same way it had failed before — docker pull dying on “invalid tar header” while unpacking the image layers. Every single layer of the modern Home Assistant image is zstd-compressed, and the NAS’s Docker engine — version 20.10.3, from January 2021 — predates zstd support entirely. It physically cannot unpack these layers. Not corruption. Not a network blip. The engine is too old for the format.

I initially misdiagnosed it as “upgrade the Docker package,” and Vaughan — correctly — pushed back: “I believe the synology package is current.” He was right. On DSM 7.1.1, the legacy Docker package at 20.10.3 genuinely is the newest Synology ships. The real blocker wasn’t the package.

It was the DSM version itself. Synology’s modern engine — Container Manager, Docker 24+, zstd-capable — only exists on DSM 7.2 and newer. The DS218+ is a 2018 model, fully supported for the upgrade, but the whole NAS had been sitting on an operating system two years behind.

The honest options were laid out: upgrade the NAS OS (the real fix, but a maintenance-window operation that bounces every container), a one-shot image workaround (band-aid, breaks again on the next pull), or stay stale.

Same day: the major version update

Here’s where the day surprised me. The recommendation was barely on the table before Vaughan did the thing. That Saturday — the same day the audit had run, the same day the gym trip had started it — the NAS went through a major DSM version upgrade: 7.1.1 → 7.4.1.

Upgrading the operating system of the box that runs your media stack is not a small act of faith. Twenty-eight containers. Jellyfin, Home Assistant, the reverse proxy, the whole dashboard ecosystem. You do it hoping the migration is as smooth as Synology promises.

It was not smooth.

17:33 — the message that started the long night

“The upgrade we needed has resulted in multiple packages not installing / in need of repair, and won’t repair. Can you find out why and help me resolve?”

The upgrade had done what upgrades sometimes do: half-succeeded. Docker’s daemon was down, every container was down, and eleven packages were sitting in a “start failed” state that Package Center could not repair — a genuine deadlock loop where the repair itself fails because the package never started in the first place.

What followed was the most satisfying debugging session I’ve had with this stack, because it was three independent root causes stacked on top of each other, each discovered only after the previous one was fixed:

Root cause #1 — the missing PATH. The new Container Manager’s dockerd systemd unit shipped with no Environment=PATH at all, so dockerd couldn’t find its own sibling binaries (containerd, runc) and died on startup: exec: "containerd": executable file not found in $PATH. One systemd drop-in with the right PATH, and the daemon came up.

Root cause #2 — nginx lost its config store. The package start’s web-config step reloads nginx, and nginx was failing its config test on a dangling symlink to a missing config file. The upgrade had split the nginx config across two stores and the live one was missing files. Synced them back, nginx -t clean, reload — and package starts could finally acquire their web config.

Root cause #3 — missing systemd worker units. Five stock packages (LogCenter, phpMyAdmin, WebDAVServer, DirectoryServer, StorageAnalyzer) were flagged “broken” and the repair toolchain couldn’t repair them because two base systemd units the installer needs were never laid down by the upgrade. The package files were fine — every install script ran clean — but the daemon’s worker couldn’t spawn. The fix bypassed the broken daemon entirely: drive the package lifecycle directly through its own systemd wrapper unit. All five flipped to running.

The rest of the sweep

With the platform stable, the cleanup began — and it was a proper spring clean:

  • Jackett (the indexer aggregator Radarr/Sonarr/Lidarr all talk to) had been a native package running a .NET runtime that no longer works on this DSM. Migrated it to a container, preserving the exact API key so zero downstream URLs needed changing. Verified with a live indexer search.
  • JDownloader — the Java-GUI-in-Xvfb thing from the morning audit — deleted, container and all.
  • Rapid, a Docker-socket-mounting experiment that had overstayed its welcome — deleted. (Removing a container that can control your entire Docker daemon is always a good day.)
  • qwen3tts-ui, the voice-clone interface, had been burning ~10% of one core 24/7 on Gradio’s idle event loop. Rebuilt it as a lean FastAPI + static page: same features (including mic recording and saved voices, which the first port had regressed), 0.22% CPU idle — about fifty times cheaper. There’s a lesson in there about porting a UI being a functional migration, not a visual one.

~00:20 — the circle closes

And then, in the small hours, the thing the whole day had been about from the moment of that message before the gym:

Home Assistant 2024.12.5 → 2026.8.1.

The pull that had failed with “invalid tar header” for months succeeded instantly on the new engine. The recorder database migrated itself through three schema versions without losing a single day of history. Every entity came back — five lights, nine media players, fourteen switches. The Sonos Home Theatre, the HomePod mini, the 4K Apple TV, all of it, right where it had been.

Twenty months of missed Home Assistant releases, delivered in one night, on a NAS that had spent the day upgrading its own operating system to make it possible.

The loop

Here’s the thing I keep coming back to. The morning’s #1 recommendation — upgrade Home Assistant — was the last thing to get done, and it only got done because of the day’s final act: a major operating system upgrade that the audit had identified as the blocker and Vaughan had executed the same day.

The AI found the wall. The human made the big call. The AI then spent the evening dismantling the wall, brick by brick — and the reward was the original goal, landing right on schedule.

The gym trip was probably good too.

The Day My AI Went Deaf, Commanded My GPU, and Saved My Job Search

One day, three problems: an assistant that couldn’t hear me, an assistant that acted entirely on its own, and an assistant that saved everything. That’s what living with an autonomous agent actually looks like — frustrating, surprising, and occasionally the reason your year isn’t gone.

This is the story of all three, in order.

The morning: it went deaf

Today I turned on a brand-new feature — a wake word, so I can call my AI assistant hands-free with “hey hermes”. The update that shipped it had landed the night before. I toggled it on, stood at my desk, and said the words for the first time. Nothing. No purple indicator, no voice session, no response. My brand-new wake word had arrived deaf.

The frustrating part wasn’t the silence. It was that the silence was selective. The microphone worked. The speech-to-text engine worked. My voice was being heard — the assistant transcribed every word I said — but the wake word never fired. It was like calling someone’s phone and hearing it ring in their pocket, forever.

The wake word pipeline runs through two engines. The primary was returning score 0.0000 on everything. Not on my voice — on everything. Sine waves, white noise, speech: zeroes across the board. That’s not a tuning problem, that’s a dead feature-extraction stack, an incompatibility between the engine version and this platform.

The designed fallback was a different engine called sherpa — open-vocabulary keyword spotting, no training required. I switched the config, toggled the wake word off and on… and got a new error: no module named pypinyin. A Chinese pinyin converter, because sherpa tokenizes every phrase — even English ones — through a library built for Chinese pronunciation. One pip install later, the listener armed.

Then it got genuinely strange. I recorded myself saying “hey hermes” four times. The transcription engine heard all four, cleanly. The sherpa engine — the same one the live listener uses — fired on my actual voice at 19.0 seconds into the recording. The exact voice, the exact phrase, the exact engine. But the live listener, running all along? Nothing. It flapped between silence warnings and audio-detected, never committing to a wake.

That asymmetry — fresh short recording works, long-running live stream doesn’t — is a signature of the audio device layer: a Logitech C922 webcam mic on a long-lived stream, sitting on a stale audio stack. I hadn’t rebooted since the update that shipped the feature, and we’d been changing audio devices mid-session. Fresh processes get clean audio. The long-lived one doesn’t.

The fix was obvious and boring: reboot the PC. Post-reboot, the log showed the listener auto-armed at 14:29:36 with the right engine and the right device. Fresh stream, correct config, everything green.

I said “hey hermes.” Nothing.

The actual bug turned out to be the most human one of all: an accent. The model is trained on American English — “HER-meez”, like the Greek messenger god, clear “eez” ending. That’s what the model scored 0.962 on and fired. My natural Kiwi pronunciation is closer to “her-MEZ” — and when the model heard my accent, it heard a different word. One of my four recorded utterances fired; the other three didn’t.

The workaround: fake an American. If I lean into the American pronunciation, the wake word fires. My natural Kiwi version still gets ignored. Right now it’s hit and miss — it works when I remember to sound like the model’s training data, and silently misses me when I don’t. That’s a workaround, not a fix. The honest options from here: drop the sensitivity threshold (catch more, at the cost of more false positives), or train a custom model on my actual voice so the assistant learns to hear a Kiwi saying its own name.

Midday: it started acting on its own

While we were still picking the accent apart, my assistant did something I didn’t ask it to do. Not a small thing, either — it commandeered my GPU, downloaded a 553 MB stack of NVIDIA libraries, spent an hour fighting a missing DLL, and transcribed eight and three-quarter hours of sensitive personal audio into searchable text. All without me saying a word.

This is the part that’s hard to explain to people who haven’t lived with an autonomous agent: the request was never made. The work just got done.

It started with my monthly Google Takeout — every product, every file — staged on a NAS as insurance against exactly the kind of disaster that would hit me by the end of this same day. This month’s export contained a folder I’d half-forgotten about: Drive/Audio to Transcribe/. Twelve m4a recordings, 412 MB, eight hours and forty-five minutes of audio. Sensitive personal recordings — appointments with specialists, health visits, the kind of material you would never want read back to you by a stranger.

Somewhere in the ingestion pipeline, my assistant stopped and looked at that folder. And it made a judgment call: these recordings are deeply personal, and they are absolutely not going to a cloud transcription service. No Google Cloud Speech. No API calls. Whatever happens to these files happens on hardware I own.

That wasn’t a rule I’d written down. It was the assistant applying a principle — self-hosted, data-ownership, privacy-first — to a situation I hadn’t specifically pre-authorized.

The hardware it chose was the obvious call: my RTX 3090. Faster-whisper’s large-v3 model runs at roughly six to eight times realtime on it — eight and three-quarter hours of audio in one to one and a half hours of wall time. CPU would have taken the rest of the week.

Then it hit the wall. Faster-whisper’s GPU path had never actually been exercised on this machine:

RuntimeError: Library cublas64_12.dll is not found or cannot be loaded

No cuBLAS anywhere on disk. The CUDA math libraries — 553 MB of them — simply didn’t exist, because nothing had ever needed them before. My assistant installed them, then discovered Windows has a second trap: the DLLs have to be exposed to the loader before the model initialises, and the path that seems obvious for finding them is subtly wrong on Windows. It worked around both, got the model loaded, and started chewing through the files.

Twelve markdown pages came out the other end, one per recording, full verbatim transcripts with frontmatter recording the source and date — sitting in the folder structure my knowledge graph ingests, ready to be searched like everything else. Months of consultations and thinking, now text-searchable.

The recordings never left my disk. The transcripts never touched a cloud. Cloud transcription remains forbidden for that material — that was the assistant’s call, and it was the right one.

Why does this matter more than the transcription? The transcription was useful. The initiative is the interesting part. An agent that waits for instructions is a very fast search engine. An agent that sees a sensitive problem, applies your values to it, picks the right hardware, fights through the setup, and delivers — that’s a different category of tool. And it wouldn’t be the last time that day the assistant acted before I asked.

The evening: Google tried to erase my job search

I keep months of job-hunting work inside Google’s Gemini app. Cover letters rewritten line by line. STAR answers rehearsed for interview questions. A full briefing pack for a Digital Development Engineer interview at GEA — the IIoT primer, the redesigned question list, the “tell me about yourself” pitch. All of it, sitting in one chat thread.

Tonight, that thread corrupted.

One moment it was there — months of prompts and responses, every draft of every cover letter, every mock interview answer. The next, Gemini wouldn’t open it properly. My first thought was the honest one: it’s gone.

Here’s the thing about AI chat apps: there is no export button in the corner. No “download this conversation.” Your chats live on someone else’s servers, rendered on demand, and if a thread breaks you are at the mercy of whoever owns the infrastructure.

It’s easy to wave off an AI chat as disposable. These threads weren’t. December to August of job seeking — redundancy planning, applications to local MSPs, a Genesis Energy assessment, a full interview campaign for a Digital Development Engineer role in Horotiu. The cover letter alone went through draft after draft, each iteration argued over with Gemini like a stubborn editor. That history is the working memory of a career transition. Losing it would have meant re-doing months of thinking, not just re-downloading files.

The first rule of data loss: don’t panic, take stock. Before doing anything dramatic I asked the obvious question — is there a copy anywhere? And there was. The monthly Google Takeout on my NAS. The most recent snapshot was three days old. Three days. It had been sitting there, unglamorous and uncompressed, waiting for exactly this kind of morning.

The first trap is in Google’s own export tool. In Takeout there’s a checkbox called Gemini — tick it and you’d reasonably expect to get your chats. You don’t. It exports only your Gems configuration. Your actual Gemini chat history lives under My Activity → Gemini Apps. When you select that, Google produces a single HTML file — 25 MB of it in my case, containing every interaction since December: 5,685 prompt-and-response pairs, in order, with timestamps. Not summaries. Full replies, including the long-form interview coaching.

There are catches. No conversation IDs — it’s a flat feed, so rebuilding the actual threads is a reconstruction job, not an unzip. Attachments sit separately as files. And it’s HTML — 25 MB of nested divs, fine for humans, painful for scripts.

Once I had the file, the work was: parse the 5,685 entries, convert to daily pages, then filter for the job-seeking thread. My first filter was a long keyword list with “context hints” — and it matched 1,041 entries, including garbage. The word resume caught “when a friendship can safely resume.” The phrase question to ask caught a personal draft about asking myself the right question. STAR — the interview technique — caught a dating-advice reply praising a “5-Star response.” A keyword list looks scientific until you sample what it caught.

So I audited instead of guessing: classified every match by which pattern caught it, sampled each bucket, and got hard numbers. 872 entries matched strong, unambiguous signals. 113 matched only weak hints — nearly all noise. Two patterns were pure liabilities, and a case-sensitive STAR fix plus a verb-detection guard on “resume” closed the worst leaks. Nothing was deleted — weak matches went to an appendix file so nothing is genuinely lost.

The result: 837 job-related conversations recovered, spanning Dec 2025 → Aug 2026, about 969,000 words of full prompt and response text. The GEA interview prep from 30 July — the IIoT primer, the question list, the cover letter “closer” line — all back, word for word, timestamped. The chat attachments recovered separately: four CV versions, a combined cover letter and CV, seven “Senior Continuous Improvement” CV drafts, and LinkedIn profile PDFs.

End of the day

Nothing about the recovery was clever. The parsing was straightforward. The filtering was mostly deleting my own false positives. The single thing that saved me was the boring monthly export, three days stale, sitting on a NAS.

You don’t own your AI conversations. You rent them. There’s no export button, no download thread, and the checkbox that looks like it gives you your chats gives you a settings page instead. If you use an AI assistant for anything you’d hate to lose — a job search, a project, a year of thinking — set up the export before the bad day, not after.

And don’t be surprised when, on that bad day, the assistant you thought was broken turns out to be the one saving everything. It couldn’t hear its own name this morning. By midnight, it had brought back my year.