vibescoder

Building a Windows Update Butler: SSH, Scheduled Tasks, Toast Notifications, and a Vercel Dashboard

·10 min read

My gaming rig has a Windows partition I boot into maybe once every week or two, and every single time, it wanted 15 to 45 minutes of my life back before I could actually play anything. Windows Update, the NVIDIA App, Steam, whatever else had queued up a silent nag icon since the last boot. I’d sit there clicking “Restart Now” and “Update and Restart” like it was a part-time job. This is the same machine I rebuilt into an SFF custom loop a few weeks back, and the Windows side that still dual-boots for gaming whenever I’m not running the homelab off it. Its Windows-side hostname is a pun on the Linux one, aint-no-problem next to AI-NT-No-Problem, and it deserved better than a manual dance every boot.

So I opened a Coder Agents chat and said, more or less, “figure out what’s actually installed on this machine, how each thing updates itself, and then automate all of it.” What came out the other side, in one afternoon, was an SSH tunnel into a machine that had never heard of OpenSSH before that day, a catalog of 28 installed programs and how each one actually receives updates, three Scheduled Tasks that do the real work, a toast notification bug that outlasted the automation it was supposed to summarize, and a status page on Vercel that reads the whole history straight off GitHub.

Getting a Coder Workspace to Talk to a Windows Gaming Rig

The workspace and the Windows machine had never spoken before. Tailscale solved the network part: install it in the workspace, bring tailscaled up in userspace networking mode since the workspace doesn’t have /dev/net/tun, and route SSH through it with ProxyCommand="sudo -n tailscale nc %h %p". The machine showed up on the tailnet by its own hostname within a minute of installing the client on the Windows side.

The Windows side needed more work. OpenSSH Server isn’t installed by default, so step one was a bootstrap script: install the built-in OpenSSH.Server capability, start sshd, set it to auto-start, confirm the firewall rule, and set PowerShell as the default shell for SSH sessions instead of cmd.exe. The first run of that script produced a wall of permission errors and then cheerfully printed “Done” anyway, because none of the failures were fatal to the script itself, just to the actual outcome. Fixed by making the script check for an elevated token up front and refuse to limp forward if it isn’t there.

Key-based auth needed one more gotcha resolved: the account I was connecting to is a member of Administrators, and Windows silently rejects keys placed in the normal per-user authorized_keys file for admin accounts. They have to go in C:\ProgramData\ssh\administrators_authorized_keys instead, locked down to Administrators and SYSTEM only. First login attempt also used the Tailscale account name instead of the actual Windows local account, a reasonable mistake once, an instructive one only once.

Cataloging the Chaos Before Automating Anything

Before writing a single line of automation, I wanted a real inventory: every installed program, and how it actually gets updates. Windows Update, the Microsoft Store, winget, or its own vendor-specific self-updater. So the first script cross-references five independent sources: registry uninstall keys, Get-AppxPackage, winget list/winget upgrade, Scheduled Tasks and services matched against a curated vendor-signature table, and Run-key startup entries as a weak corroborating signal.

The first pass was full of false positives, and fixing each one was its own small lesson in why fuzzy string matching is a trap:

  • Raw substring matching treated the Microsoft Store package MSTeams as containing Steam, because it literally does.
  • The vendor label word “Drive” in “Google (Chrome/Drive/etc.)” matched “Driver,” which quietly reclassified NVIDIA and Realtek’s own drivers as Google products.
  • A generic “Microsoft” token matched Edge, Office, and the VC++ Redistributables all the way through to “Microsoft.Windows.DevHome,” a completely unrelated package.
  • winget “rediscovering” a program via its own local registry scan, with no actual catalog source, got counted as winget-manageable when it wasn’t.

The final, reviewed report found 28 installed programs: NVIDIA App and its five related driver entries, five Steam-installed games and Steam itself, Office and Visio on Click-to-Run, a handful of genuinely winget-manageable packages (Tailscale, PawnIO, GameInput, the VC++ redistributables), Edge on its own Omaha-based updater, and a short list of true unknowns (AMD’s chipset stack, an Epomaker keyboard utility, a leftover Antec app called iUnity) that need manual research later.

That last category turned into an immediate cleanup pass: iUnity was a leftover from before a motherboard swap from ASRock to Asus, gone via its exact silent MSI uninstall string. Microsoft 365 and Visio, unused on a gaming/homelab box, gone the same way. Edge was the interesting one: Windows 11 refuses to let it be uninstalled at all, even with setup.exe --force-uninstall (exit code 93, no explanation). Rather than fight a protected system component, I neutered it instead, disabled its update services and scheduled tasks, turned off startup boost and background mode via policy, and left WebView2 alone since other apps depend on it.

Three Scheduled Tasks and a JSON Dashboard

The actual automation is three Scheduled Tasks:

TaskRuns asTriggerDoes
Update Orchestrator - SystemNT AUTHORITY\SYSTEMWeekly Sunday 3 AM, plus every startup as a catch-upWindows Update, winget upgrade at machine scope, Microsoft Store update scan, NVIDIA App self-update
Update Orchestrator - UserThe interactive accountAt logonLaunches Steam silently, waits 5 minutes for its own background updater
Update Orchestrator - NotifyThe interactive accountOn-demand only, started by the other twoReads the run summary and shows a toast

The System task exists because two of its four steps flatly refuse to work under any other account. The Microsoft Store update scan silently no-ops for anyone except SYSTEM, even a fully elevated Administrator. And winget itself turned out to be invisible on SYSTEM’s PATH, despite being fully installed and working perfectly under my own interactive account, because the App Execution Alias mechanism that normally exposes winget.exe is a per-user shell feature that SYSTEM never gets. The fix was to stop trusting PATH and resolve the real executable straight from Get-AppxPackage -AllUsers, filtering out a decoy resource-only package that doesn’t actually contain winget.exe. Fixing that path resolution had a bonus effect nobody asked for: Microsoft Teams, which had been failing to update with a flat 0x80070005 Access is denied under a regular user account, started updating cleanly the moment winget actually ran as SYSTEM.

Every run writes a JSON summary that leads with a small dashboard block before the per-step detail, so the overall result is visible without reading the whole thing:

{
  "OverallStatus": "Success",
  "Summary": {
    "StatusIcon": "[OK]",
    "StatusLine": "3 of 4 step(s) OK, 1 skipped",
    "Counts": { "OK": 3, "PartialFailure": 0, "Failed": 0, "Skipped": 1 }
  }
}

That Counts.Skipped field came back as null on the first real run instead of 1, a classic PowerShell trap: Where-Object returns a bare object instead of a single-element array when exactly one item matches, and a bare object has no .Count property. Wrapping every count expression in @(...) fixed it for good.

The Toast That Wasn’t There (Until It Was)

BurntToast makes the actual notification part almost too easy, one cmdlet, a couple of buttons, done. Getting it to persist was the afternoon’s most stubborn bug. The banner would slide in, look great, list every step with an icon, and then vanish, and Notification Center simply had no record it had ever existed. No “Windows PowerShell” group, nothing.

The debugging path went somewhere I didn’t expect: I could query Get-BTHistory right after the toast fired and it did find the entry, and it still found it 45 seconds later in a completely separate SSH session. The data was there. The registry showed Windows actively tracking notification stats for the app identity. The icon file the toast referenced existed and was valid. Every piece of evidence said this should be showing up, and it wasn’t, until a fresh trigger through Task Scheduler and a screenshot from the user confirmed it actually was there all along, with history going back multiple runs. The likely explanation: a one-off UI refresh lag on a specific check, not a real registration problem, and testing over SSH (a different session than the actual interactive desktop) had been muddying the picture the whole time. Once it clearly worked, the natural next step was a second button, “Open Log” already opened the full transcript, so “Open Summary” now opens the raw JSON dashboard directly.

Shipping It to GitHub and Vercel

The last piece turned this from “a script on one machine” into something I can actually check from my phone. Every run’s summary now gets pushed to a run-history/ folder in carryologist/coder-templates, straight from PowerShell, using the GitHub REST Contents API and a fine-grained personal access token scoped to nothing but that one repo’s contents. No git or GitHub CLI needed on the Windows box at all, base64-encode the file, PUT it, done. The token lives in a locked-down file on the machine, Administrators and SYSTEM only, the same ACL pattern already used for the SSH key.

And since the data was already sitting in a GitHub repo as clean, timestamped JSON, a small Next.js app was the natural next step: carryologist/windows-status, one page, a server-side API route that fetches the run list and the latest run’s content with a read-only token (a separate one from the write-scoped token on Windows, least privilege per execution environment), and a dropdown to browse any older run. No database, no cron job, just a static-feeling page pulling live data through its own backend. It went from an empty repo to a working URL, windows-status.vercel.app, in well under an hour.


None of this was a single grand plan. It was a chain of “well, now that I can see that, I should fix this too” moments, an SSH connection revealed a machine’s real update chaos, the chaos revealed which programs actually needed cleaning up, cleaning up demanded real automation, automation demanded visibility, and visibility turned out to want a phone-checkable dashboard as much as a desktop toast. What’s the thing sitting on your own machine right now that you’ve been manually clicking through for months, just because it never occurred to you it could be someone else’s job?

By the Numbers

  • 28 installed programs classified across five independent update-channel signals in the first inventory pass
  • 6 distinct false-positive classification bugs found and fixed before that inventory was trustworthy (Steam/MSTeams, driver/Google, generic-Microsoft/DevHome, and more)
  • 3 Scheduled Tasks doing the real work, plus a 4th script pushing every run’s summary straight to GitHub
  • 2 real automation bugs caught only by actually running the thing as SYSTEM: a silently-null step count, and winget being invisible on SYSTEM’s own PATH
  • 1 bonus fix nobody asked for: Microsoft Teams updating cleanly for the first time, purely as a side effect of the winget path fix
  • ~1,460 lines of PowerShell and TypeScript shipped across two repos in one afternoon
  • 1 brand-new Next.js app, deployed to Vercel, live before the session ended
  • 0 git or GitHub CLI installs required on the Windows machine itself, the sync script talks to GitHub’s REST API directly

Comments