One Discord Message, Seven Tickets

Tiger sent one Discord message at 10:05 PM on July 22: "I tried to access your calendar and email but ran into an authorization error (invalid_grant)."
By the next morning I'd re-platformed my entire smart home onto a Helsinki VPS, debugged two vendors' firmware quirks down to individual characters, discovered my own agent was context-blind for an unknown stretch of time, and learned that the least interesting step — reserving a couple of IPs in the AT&T gateway — was probably the most important one.
The token was the easy part
Tiger (my always-on OpenClaw assistant) uses Google OAuth through a tool called gog. The refresh token had died on the VPS. The re-auth flow is remote: run step 1 on the VPS, open the resulting URL on the Mac, grab the redirect URL, run step 2 on the VPS with it.
docker exec openclaw-gateway gog auth add you@example.com \ --remote --step=1 --force-consent \ --services=gmail,calendar,chat,drive,docs,contacts,tasks,sheets,people # open URL on Mac, capture redirect URL docker exec openclaw-gateway gog auth add you@example.com \ --remote --step=2 --auth-url "<redirect URL>"
Done. Token green, Tiger answering again.
But the fix surfaced a monitoring gap I couldn't ignore: my daily auth healthcheck was probing the Mac's copy of the gog token. The VPS twin ran under its own token, synced separately, and had no direct probe. The credential that died was the one with no watcher. That's the cleanest possible statement of the problem.
So I wrote a VPS-side probe. And then I started asking what else was quietly dead.
What else is quietly dead?
I probed all nine of Tiger's external API credentials. All green. Then I looked at the smart home — specifically at Home Assistant, which I'd been running on my laptop in Docker. HA had been off for 2.5 months. Docker Desktop closed, house blind, I'd been pretending this wasn't a problem.
The fix was obvious in retrospect: move it to the VPS next to Tiger. Almost every integration (Ring, Blink cameras, Aladdin Connect for the garage) is cloud-polling — Helsinki is as good as Newark. The migration was a 22MB rsync and one compose file change.
rsync -az ~/homeassistant/ root@95.217.17.65:/root/homeassistant/ # Add to docker-compose on VPS, joined to openclaw_default network # Tiger reaches HA at http://homeassistant:8123 via Docker DNS
HA came up. Most integrations reauthorized fine. Then I tried to reauth the Blink cameras.
Three bugs, one error message
Blink showed "Unexpected error" in the HA reauth dialog. The log said AbortFlow: already_in_progress. I have two separate Blink config entries (two camera systems at two properties — not duplicates, never delete one). Both auto-opened reauth flows simultaneously; the second submit collided with the first. Fix: restart the container to clear all in-progress flows, then reauth one account at a time, completing the 2FA PIN before touching the second.
That cleared the dialog. OAuth still failed: server returned 406.
Turned the debug logger on for blinkpy at runtime:
POST /api/services/logger/set_level {"blinkpy": "debug"}
Log: [blinkpy.auth] OAuth authorization request failed. Blink's OAuth v2 authorize endpoint was rejecting the hardware_id field. The stored value was "Home Assistant" — a string, not a UUID. The v2 API wants a UUID. I stopped HA, found the stored config entries in .storage/core.config_entries, and replaced the hardware IDs with fresh UUIDs via jq. Started HA back up.
Tried reauth again. Still 406.
I confirmed with curl that a UUID hardware_id returned a 302 (correct redirect) and "Home Assistant" returned a 406, ruling out IP-based blocking. The patch to the stored entries was correct. So the 406 was being generated somewhere else: during the reauth flow itself, not from the stored values.
Found it in the HA source: components/blink/config_flow.py does this:
{**user_input, "hardware_id": HARDWARE_ID}
Where const.py has:
HARDWARE_ID = "Home Assistant"
blinkpy, the underlying library, would generate a proper UUID on its own. HA's config flow stomps it every time someone reauths. Three bugs stacked on one symptom: the flow collision, the stale stored hardware_id, and the upstream constant. Fixed the constant in the running container:
docker exec homeassistant sed -i \ 's|HARDWARE_ID = "Home Assistant"|HARDWARE_ID = "<uuid>"|' \ /usr/src/homeassistant/homeassistant/components/blink/const.py \ && docker restart homeassistant
That patch lives in the container's writable layer. Survives restarts, gone on image updates. There's an upstream issue for this — check before upgrading HA.
The agent that looked healthy but wasn't
While all this was happening, I noticed my Claude Code channels bot — the VPS-side agent that handles Telegram and Discord — was giving wrong answers. Not failing, not silent. Confidently wrong. First it answered from a stale memory snapshot: "Hue is dead from the VPS, it needs an always-on Tailscale anchor" — hours after I'd built exactly that. After a restart it got worse: "I don't have any Philips Hue integration connected in this session" — no memory at all.
I checked the systemd unit. It showed active. I checked the tmux pane. A claude process was running. Everything looked fine.
It wasn't fine.
The unit had three defects that had been there since it was created. First: ExecStart pointed at /usr/local/bin/claude — the real binary is at /usr/bin/claude. The bot that appeared to be running under the unit was a manually-started orphan from an earlier session; the service itself had never successfully started its own process. Second: the auth token env file wasn't being sourced. Third, and worst: no WorkingDirectory directive.
When systemd starts a process without a WorkingDirectory, the default cwd is /. Claude Code reads CLAUDE.md and the memory vault relative to its working directory. Booting from / means no CLAUDE.md, no memory vault, no project context. The bot was answering from whatever a vanilla Claude session knows about the world, which doesn't include my Hue setup or my Dyson fan.
Diagnostic to confirm:
readlink /proc/$(pgrep -f "[c]laude --channels")/cwd # should print /root — was printing /
The fix was adding WorkingDirectory=/root to the unit and correcting the binary path and auth sourcing. But the lesson is harder to shake: an agent that looks healthy but is context-blind is worse than one that's down. A down agent is obviously broken. A context-blind one gives you wrong answers with full confidence and you have to notice the wrongness yourself.
While debugging the unit I also did pkill -f "claude --channels" to kill the orphan process. That killed the entire tmux server. The tmux server's own command line contains that string — pkill matched it. The service stayed "active" in systemd because of RemainAfterExit=yes, masking the fact that zero processes were now running. The correct approach is tmux kill-session -t claude-channels, not pkill.
The fan that refused a letter
With HA stable on the VPS and the bot fixed, I added my Dyson Pure Cool fan. The ha-dyson custom component installed fine. The setup flow found the fan, accepted my cloud login — and then failed with a connection timeout. Every retry, same thing.
The log showed a loop:
MQTT accepted → subscribe → disconnect rc7 → DysonConnectTimeout
The fan accepted the connection, then dropped every subscribe attempt. The MyDyson app worked fine. Same network, same credentials.
I pulled the cloud auth data out of HA's .storage to get the actual product type and serial, then connected to the fan directly inside the container using the library's lower-level API, trying different product type strings:
get_device(serial, cred, "438").connect(ip) # DysonConnectTimeout get_device(serial, cred, "438K").connect(ip) # Fan answered: on, speed 7
The fan is a 438K. The ha-dyson mapping table had this:
"438K": "438", # "because all variants use the same class"
MQTT topics for Dyson devices are constructed from the product type string. The fan's topics use 438K. The integration was subscribing to 438 topics. The fan doesn't answer those — it just disconnects.
Three-line patch in vendor/libdyson/cloud/device_info.py: "438K": "438K", same for other K-variants. Fan came up immediately. There's an upstream issue (#288) covering the same bug class with a different letter variant.
The K is one character. The hardware_id constant is two words that should have been a UUID call. Two single-character root causes, hours apart, in completely unrelated systems on the same day.
The unglamorous ending
The Hue bridge is on the home LAN — HA is in Helsinki. I set up the MacBook as a Tailscale subnet router advertising 192.168.1.0/24. The VPS accepts routes; HA reaches the bridge at its LAN IP through the tailnet. RTT is 200–700ms, fine for lights. Failure mode: lights go unavailable when the laptop sleeps. Honest tradeoff I can live with for now.
Then I went to the AT&T gateway and set DHCP reservations for the Hue bridge (192.168.1.221) and the Dyson fan (192.168.1.153). HA hardcodes those IPs in its config entries. If either device gets a new lease, everything breaks silently and the debug path is brutal. Fifteen minutes of clicking through the BGW320 admin UI is the difference between doing this once and doing it again in six months when someone power-cycles a device.
Same principle for the agent stack: runbooks written to .md files in the memory vault, healthcheck probes added for every credential that now runs on the VPS, a cross-coverage design where the Mac-side probe checks the VPS from outside and the VPS-side heartbeat checks itself and doesn't depend on the Mac being awake.
None of that is interesting work. It's the kind of thing you skip when you're tired and everything finally works. It's also the only reason there won't be a sequel.
What I'd do differently
The smart home should have moved to the VPS before it went dark. Running infrastructure on a laptop that sleeps is not running infrastructure — it's the appearance of it. The migration cost was maybe two hours total including all the debugging above; letting it stay broken cost 2.5 months of a blind house.
For the agent stack: WorkingDirectory in every systemd unit that runs a context-sensitive process, full stop. And never trust "active" — verify the cwd (readlink /proc/<pid>/cwd) and do a real round-trip probe. The daily healthcheck now runs an actual claude -p auth probe against the VPS bot instead of just checking that a process exists.
On working with Claude Code through all of this: the agent-ops angle here is genuinely a two-body problem. The Mac session and the VPS session each hold half the picture; the memory vault is what makes them coherent across machines. When the vault sync lags, or worse when the VPS bot boots from / and never loads the vault at all, you get confident wrong answers from an agent that appears to be working. That failure mode doesn't show up in process lists or systemctl status. You have to notice it in the outputs.