Eight phases. Phases 1-5 are agentic (this conversation runs them). Phase 6 is the user (~60 sec hands-on). Phases 7-8 are agentic again (resume after target boots).
Total wall-clock: ~30 min on a fast connection + USB 3.0 stick. Add ~30 min if USB 2.0 stick (slow DISM split).
Confirm preconditions before doing anything destructive.
- USB plugged in?
Get-Disk | Where-Object BusType -eq 'USB'. Confirm size ≥ 8 GB. Note theNumber(typically1). - ISO present?
Test-Path "$ProjectRoot\iso\Win11_*.iso". If not, queue Phase 3 (download). - C: drive headroom? Need ≥ 10 GB free during ISO download + USB write.
Get-PSDrive C. If tight, suggest cleanmgr but don't force it. - Internet? Implicit — quick
Test-Connection 1.1.1.1. Needed for ISO + first-boot Tailscale install. - CDP Chrome on :9333 alive?
Invoke-WebRequest http://127.0.0.1:9333/json/version. Needed for headless Tailscale auth-key generation. If down, fall back to interactive paste-in. - Hostname not already in tailnet?
tailscale status | Select-String <proposed-name>. Collisions auto-suffix to-1,-2etc., creating noise.
If any check fails, surface the gap to the user before proceeding.
Collect inputs. Required before rendering any template.
| Field | Source | Example |
|---|---|---|
{{HOSTNAME}} |
user | DevTower |
{{USERNAME}} |
user (often = hostname) | DevTower |
{{PASSWORD_ENCODED}} |
computed from plain password — see render note below | RABlAHYAVABvAHcAZQByADIAMAAyADYAUABhAHMAcwB3AG8AcgBkAA== |
{{LICENSE_KEY}} |
user (Win 10/11 Pro) | XXXXX-XXXXX-XXXXX-XXXXX-XXXXX |
{{TIMEZONE}} |
default Pacific Standard Time |
— |
{{TARGET_HW_MODEL}} |
user (informational, drives boot-key + bypass decision) | Dell XPS 15 9550/9560 (P82G) |
{{HW_BYPASS}} |
inferred from CPU gen | true for 7th gen Intel |
{{USB_DISK_NUMBER}} |
from Phase 1 | 1 |
{{USB_MODEL_HINT}} |
from Phase 1 (substring of FriendlyName) | Kingston |
{{ISO_PATH}} |
absolute path under iso/ |
...\iso\Win11_25H2_Pro_x64.iso |
{{TAILSCALE_AUTH_KEY}} |
Phase 4 output | tskey-auth-... |
{{TAILNET_DOMAIN}} |
for tailscale ssh smoke test |
inferred from tailscale status |
Boot key by vendor:
| Vendor | Boot menu | BIOS setup |
|---|---|---|
| Dell | F12 | F2 |
| HP | F9 (or Esc → F9) | F10 |
| Lenovo | F12 (ThinkPads: Enter then F12) | F1 |
| ASUS | F8 (desktop) / Esc (laptop) | Del / F2 |
| MSI | F11 | Del |
| Surface | hold Volume Down + power | hold Volume Up + power |
Capture this in the build's BUILD-COMPLETE.md so the user knows what to press.
Pull a fresh Win 11 25H2 Pro x64 ISO if not present.
- Pull
Fido.ps1(Microsoft's PowerShell-only ISO downloader, by Rufus's author):Invoke-WebRequest -Uri "https://github.com/pbatard/Fido/raw/master/Fido.ps1" -OutFile "$ProjectRoot\scripts\Fido.ps1" -UseBasicParsing
- Get the signed download URL (it expires in a few hours, so use it immediately):
$url = & powershell -ExecutionPolicy Bypass -File "$ProjectRoot\scripts\Fido.ps1" -Win 11 -Rel Latest -Ed Pro -Lang English -Arch x64 -GetUrl
- Download in background (run_in_background — typically 3-15 min depending on connection):
$ProgressPreference = 'SilentlyContinue' Invoke-WebRequest -Uri $url -OutFile "$ProjectRoot\iso\Win11_25H2_Pro_x64.iso" -UseBasicParsing
- Verify size (Win 11 25H2 is ~7-8 GB; if < 5 GB, download truncated).
Skip this phase if ISO is already on disk.
Generate (or accept) a Tailscale pre-auth key.
Headless path (preferred):
- Render
templates/generate-tailscale-key.py.tmplto$ProjectRoot/scripts/generate-tailscale-key.py. AdjustKEYS_URLonly if Tailscale UI moves. - Run it. The script auto-detects whether Chrome :9333 is logged into Tailscale admin (via Google SSO usually) and drives the "Generate auth key" flow.
- Output is
SUCCESS tskey-auth-...on stdout. Capture into$TailscaleAuthKeyfor Phase 5. - If it prints
NEEDS_LOGIN→ the user needs to sign into Tailscale admin in their CDP-instrumented Chrome once, then re-run. - If toggle ticks fail (Pre-approved), the resulting key may require manual approval in Tailscale admin after first node registration. Acceptable. Note in
BUILD-COMPLETE.md.
Interactive fallback:
Leave {{TAILSCALE_AUTH_KEY}} as the literal __TAILSCALE_AUTH_KEY__ placeholder. The rendered post-install.ps1 will detect the placeholder and run tailscale up --ssh interactively, printing the login URL to setup-complete.log. The user reads it post-boot, clicks Approve.
Render templates, wipe USB, write install media. Destructive — gate behind explicit approval.
-
Render templates with placeholders substituted:
templates/autounattend.xml.tmpl→$ProjectRoot/autounattend.xmltemplates/post-install.ps1.tmpl→$ProjectRoot/scripts/post-install.ps1templates/SetupComplete.cmd.tmpl→$ProjectRoot/scripts/SetupComplete.cmdtemplates/build-usb.ps1.tmpl→$ProjectRoot/scripts/build-usb.ps1templates/build-usb-runner.ps1.tmpl→$ProjectRoot/scripts/build-usb-runner.ps1- Also copy
~/.claude/skills/windows-bootstrap/recover.ps1→$ProjectRoot/recover.ps1(no template needed -- it reads$env:TS_AUTH_KEYat runtime). The build script picks it up and lays bothrecover.ps1and a per-buildRECOVER.cmdat the USB root as a safety net for any post-install failure.
Substitute
{{HOSTNAME}},{{USERNAME}},{{PASSWORD_ENCODED}},{{LICENSE_KEY}},{{TIMEZONE}},{{ISO_PATH}},{{USB_DISK_NUMBER}},{{USB_MODEL_HINT}},{{TAILSCALE_AUTH_KEY}}literally.Render note for password placeholders — autounattend has TWO encoded password slots, each with a different Microsoft-mandated suffix:
$plain = 'DevTower2026' # what the user types at the lock screen # For LocalAccount + AutoLogon (suffix: "Password") $passwordEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($plain + 'Password')) # For built-in Administrator backdoor (suffix: "AdministratorPassword") $adminEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($plain + 'AdministratorPassword')) # Substitute: # {{PASSWORD_ENCODED}} -> $passwordEncoded (LocalAccount + AutoLogon) # {{ADMIN_PASSWORD_ENCODED}} -> $adminEncoded (AdministratorPassword)
Both
<PlainText>false</PlainText>. The Administrator slot is a v4 fallback: if LocalAccount creation fails silently (PITFALLS #15), Administrator is still enabled with a known password. PITFALLS #12 documents why PlainText=true is unreliable. -
Run
build-usb.ps1in dry-run mode first (no-Execute) — confirms ISO + USB + paths. -
Spawn
build-usb-runner.ps1elevated viaStart-Process -Verb RunAs:Start-Process powershell.exe -ArgumentList @( '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "$ProjectRoot\scripts\build-usb-runner.ps1", '-TailscaleAuthKey', $key ) -Verb RunAs
-
UAC prompt is the ONE click the user must do during the build phase. Tell them explicitly: "UAC incoming, click Yes." Don't try to auto-click — Win 11's secure desktop blocks UI Automation by default (PITFALLS #9).
-
Poll for
$ProjectRoot/build-done.flag. Read its JSON when present:{ "result": "SUCCESS"|"FAILED-RC"|"FAILED-EXC: ...", "log_file": "...", "finished_utc": "..." } -
On
SUCCESS: skip to Phase 6. -
On
FAILED-EXCrelated toappraiserres.dll: the slow DISM-split has already completed. Don't re-run the whole script. Manually finalize (autounattend +sources\$OEM$\) — see PITFALLS #6. -
On
FAILED-RC: read the log, fix the bug, re-run.
Time estimate Phase 5: ~5 min on USB 3.0, ~25 min on USB 2.0 (DISM split-image dominates).
Tell the user to physically move the USB. The only ~60 sec of hands-on.
1. Right-click D: in Explorer → Eject. Wait for safe-to-remove toast.
2. Plug USB into <target-host>.
3. Power on, immediately tap <BOOT_KEY> repeatedly until boot menu appears.
4. Select "UEFI: <USB_MODEL_HINT>" — pick the UEFI variant, not Legacy.
5. Walk away. Setup wipes Disk 0, runs unattended, ~25-30 min.
Provide this verbatim in the chat. Don't try to be clever — the user reads it once and moves.
Wait for the target to announce on tailnet. Resume agentic flow.
- Poll
tailscale statusevery 60s for ~45 min. Target appears as100.x.x.x <HOSTNAME> <user>@ windows. - If after 45 min still absent: pull the install log via the target's local console (the user must be at the keyboard if Tailscale didn't join).
Get-Content C:\Windows\Setup\Scripts\setup-complete.log. - If
(needs approval)appears next to the new node intailscale status: that's the Pre-approved toggle didn't get set in Phase 4. Openhttps://login.tailscale.com/admin/machinesand click approve. PITFALLS #11. - Smoke-test:
Expect:
tailscale ssh <USERNAME>@<HOSTNAME> 'whoami; hostname; (Get-CimInstance Win32_OperatingSystem).Caption'
<HOSTNAME>\<USERNAME>,<HOSTNAME>,Microsoft Windows 11 Pro.
Capture the new node in the registry + ssh config + memory.
-
Append a stanza to your fleet credentials registry (e.g.
~/.config/<fleet-name>/credentials.yaml):nodes: DevTower: role: workstation bootstrapped: 2026-05-07 hardware: model: Dell XPS 15 9550/9560 (P82G) cpu: <fill from tailscale ssh> ram: <fill> storage: <fill> os: Microsoft Windows 11 Pro 25H2 network: tailscale_ip: 100.x.x.x tailscale_hostname: DevTower auth: local_user: DevTower local_password_initial: DevTower2026 local_password_rotated: <yyyy-mm-dd if rotated> license_key_source: G2A 2026-05-01 windows_activation: <ok | failed> ssh_via: tailscale ssh DevTower@DevTower preferred_access: tailscale ssh bootstrap_steps_applied: - Win 11 25H2 Pro via build-usb.ps1 - autounattend.xml: HW bypass, no MS account, local user, autologin once - post-install.ps1: Tailscale install, OpenSSH, beacon
-
Add an alias to
~/.ssh/config(under the existingHostblocks):Host devtower dt HostName DevTower User DevTower # Tailnet only — no public-IP fallback ProxyCommand tailscale ssh -W %h:%p
-
(Optional) Update memory: append a line to
MEMORY.mdreferencing the project + bootstrap date. -
Smoke-test final aliases:
ssh dt 'hostname'Should land you in PowerShell as
DevTowerwithout prompting.
After all 8 phases:
Documents/2026/windows-bootstrap/(or per-build folder) holds rendered scripts + ISO + logs +BUILD-COMPLETE.md.- New stanza in credentials registry.
- New
Hostblock in ssh config. - Target node live on tailnet, reachable as
tailscale ssh <HOSTNAME>@<HOSTNAME>andssh <alias>. - This skill's PITFALLS.md gets a new entry if anything new went wrong (always re-validate after each bring-up).
- Driver pre-stage: Win 11 25H2 fetches most drivers via Windows Update on first boot. For older HW (XPS 9560: GTX 960M, Killer wireless), you may need to manually install vendor drivers post-boot. Future iteration could pre-stage drivers in
sources\$OEM$\$1\Drivers\for offline install. - Domain join: skill assumes workgroup. Domain-joined fleets need different OOBE flow.
- BitLocker: explicitly disabled in autounattend (
<HideOnlineAccountScreens>+ no<BitLocker>block). Turn it on deliberately post-install if needed. - Custom branding / start menu: out of scope. autounattend has
<RegisteredOwner>only.