Windows Automation skill for Claude Code
Scheduled tasks and installs that run with nobody logged in. Claude wires scripts into Windows the way an admin would: tasks registered from code with an explicit principal, time limit, catch-up and restart settings, transcripts under ProgramData, and exit codes Task Scheduler actually records. Installs go through winget with exact IDs, registry changes are backed up and read back, and AutoHotkey v2 is used only when an app has no API.
- name
windows-automation - size255 lines · ≈3,200 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Automates Windows machines end to end - unattended PowerShell jobs with transcripts and exit codes, Task Scheduler via the ScheduledTasks module or schtasks with the right principal and settings, winget install, upgrade and configure for reproducible setups, safe registry edits with backups, AutoHotkey v2 for GUI-only apps, WSL interop, execution policy and Authenticode signing, and long-running work as a service. Use when scheduling a script on Windows, provisioning a dev or kiosk machine, scripting software installs, changing registry settings at scale, automating a GUI with no API, calling Linux tools from Windows, or a task works by hand but fails when scheduled. Try asking
- Run this cleanup script every night at 3am on our Windows server, even when nobody is logged in
- Write a winget configuration that sets up a new developer laptop
- My scheduled task says it succeeded but the backup never ran. Figure out why
Use it when
- Scheduling scripts or long-running jobs on Windows
- Provisioning machines with winget installs and settings
- Changing registry settings safely across machines
- Automating a GUI-only Windows app or bridging Windows and WSL
Skip it when
- You need help with PowerShell language features or Pester tests — use powershell-scripting
- The machine is a Mac — use macos-automation
- The job runs on Linux with cron or systemd timers — use scheduled-jobs
How to check the result
Start-ScheduledTask followed by Get-ScheduledTaskInfo shows LastTaskResult 0 with a fresh log entry, a forced failure shows a non-zero result, and the task still runs after a reboot while logged off.
The common mistake it prevents
Launching the script with pwsh -Command or through a batch wrapper, so failures exit 0 and Task Scheduler reports success for a job that never worked.
Done well means: the job runs with nobody logged in (or deliberately only when someone is), under the least-privileged account that works; it writes a log you can find and returns an exit code Task Scheduler records; every machine change is idempotent and backed up first; and the whole setup can be recreated from files in a repo.
PowerShell language details (advanced functions, error streams, Pester) belong to powershell-scripting; this skill is about wiring scripts into Windows.
Hard rules
- Prefer an API, CLI, or COM object over GUI automation. Use AutoHotkey or UI Automation only when nothing else exists, and say why.
- Never weaken system security to make automation work: no disabling UAC, Defender,
SmartScreen, or setting
ExecutionPolicy Unrestricted/Bypassmachine-wide. Sign scripts or scope the policy to the process. - Back up before writing to the registry (
reg export) and before changing services or tasks (Export-ScheduledTask). Changes to HKLM, services, firewall, or other users’ profiles need explicit user confirmation before they run. - Secrets never go in scripts, task arguments, or AHK files. Use SecretManagement, Windows Credential Manager, or a gMSA.
- Absolute paths everywhere. Scheduled tasks start in
C:\Windows\System32with no profile, no mapped drives, and no user environment unless configured.
Pick the mechanism
| Need | Default | Notes |
|---|---|---|
| Run on a schedule, at logon, at startup, on an event | Task Scheduler (Register-ScheduledTask) | Built in, logs history, retries |
| Long-running process that must restart on crash | Scheduled task at startup with restart settings, or a real service (.NET Worker Service, or a wrapper such as WinSW) | New-Service needs a binary that speaks the service protocol; a .ps1 cannot be one directly |
| Install and update software | winget (install, upgrade, configure) | Chocolatey/Scoop only where already standard |
| Whole-machine desired state (apps + settings) | winget configure with a .winget / configuration.dsc.yaml file | Review third-party config files before running |
| Change a setting with no cmdlet | Registry, with backup and verification | Prefer Group Policy/Intune in managed fleets |
| Drive a GUI-only app | AutoHotkey v2 | Runs in the interactive session only |
| Use Linux tools from Windows or the reverse | WSL interop (wsl.exe, *.exe from Linux) | Mind paths and encodings |
| Fleet of domain machines | Group Policy, Intune, or remoting (Invoke-Command) | Not per-machine tasks by hand |
Workflow
- [ ] 1. Write the job as a script that runs non-interactively from a clean shell
- [ ] 2. Add logging (transcript or log file), exit codes, and a lock if overlap is harmful
- [ ] 3. Choose the principal: user, SYSTEM, gMSA, or S4U; least privilege that works
- [ ] 4. Register the task/service from code, not by clicking
- [ ] 5. Run it once on demand under the real principal; read the log and LastTaskResult
- [ ] 6. Test reboot, logged-off, laptop-on-battery, and second-run idempotence
- [ ] 7. Commit the registration script, config files, and an uninstall script
1-2. Script shape for unattended runs
#Requires -Version 7.4
[CmdletBinding()]
param([string] $LogDir = 'C:\ProgramData\Contoso\Backup\logs')
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
$log = Join-Path $LogDir ("run-{0:yyyyMMdd-HHmmss}.log" -f (Get-Date))
Start-Transcript -Path $log | Out-Null
try {
# ... work ...
Get-ChildItem $LogDir -Filter 'run-*.log' |
Where-Object LastWriteTime -lt (Get-Date).AddDays(-30) | Remove-Item
exit 0
}
catch {
Write-Error $_ -ErrorAction Continue
exit 1
}
finally {
Stop-Transcript | Out-Null
}
- Launch with
pwsh.exe -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -File "C:\Scripts\backup.ps1". Use-File, not-Command: with-File,exit 1becomes the process exit code Task Scheduler records. - Logs go under
C:\ProgramData\<Org>\<Job>\(machine jobs) or$env:LOCALAPPDATA(per-user jobs). Rotate them. - For Event Log entries from PowerShell 7 (the
*-EventLogcmdlets exist only in 5.1), create a source once as admin with[System.Diagnostics.EventLog]::CreateEventSource('ContosoBackup','Application'), then[System.Diagnostics.EventLog]::WriteEntry('ContosoBackup', $msg, 'Error', 1001). - Elevation check when required:
#Requires -RunAsAdministrator.
3. Principal
| Account | Use for | Caveat |
|---|---|---|
| Interactive user, “only when logged on” | Anything touching the desktop, AHK, user apps | Does not run when logged off |
| User with stored password | User-context job when logged off | Breaks when the password changes |
S4U (-LogonType S4U) | Local-only work as a user without storing a password | No network credentials: shares and authenticated web calls fail |
SYSTEM (-UserId 'SYSTEM' -LogonType ServiceAccount) | Local machine maintenance needing admin | Full control of the box; network access as the computer account |
gMSA (-UserId 'CONTOSO\svc-backup$' -LogonType Password) | Domain jobs needing network access | Requires AD setup; the best choice for servers |
Use -RunLevel Highest only when the job needs admin rights.
4. Register the task from code
$action = New-ScheduledTaskAction -Execute 'C:\Program Files\PowerShell\7\pwsh.exe' `
-Argument '-NoProfile -NonInteractive -File "C:\Scripts\backup.ps1"' `
-WorkingDirectory 'C:\Scripts'
$trigger = New-ScheduledTaskTrigger -Daily -At '02:30'
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew `
-ExecutionTimeLimit (New-TimeSpan -Hours 2) `
-RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 10) `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
Register-ScheduledTask -TaskPath '\Contoso\' -TaskName 'NightlyBackup' -Action $action `
-Trigger $trigger -Principal $principal -Settings $settings -Force
-StartWhenAvailablecatches up runs missed while the machine was off or asleep.- Without
-ExecutionTimeLimitthe default limit is 3 days; set a real one. - Put tasks in your own folder (
\Contoso\) so they are easy to list and remove. - cmd/batch equivalent:
schtasks /Create /TN "\Contoso\NightlyBackup" /TR "\"C:\Program Files\PowerShell\7\pwsh.exe\" -NoProfile -File C:\Scripts\backup.ps1" /SC DAILY /ST 02:30 /RU SYSTEM /RL HIGHEST /F - Inspect:
Get-ScheduledTask -TaskPath '\Contoso\' | Get-ScheduledTaskInfoshowsLastRunTimeandLastTaskResult(0 = success; 267009 = still running; 267011 = never run). Start on demand withStart-ScheduledTask. - Keep
Export-ScheduledTaskXML in the repo as the reviewed definition.
winget
winget install --id Git.Git -e --source winget --silent `
--accept-package-agreements --accept-source-agreements --disable-interactivity
winget upgrade --all --silent --accept-package-agreements --accept-source-agreements
winget export -o apps.json # package list from this machine
winget import -i apps.json --accept-package-agreements --accept-source-agreements
winget configure test -f .\configuration.dsc.yaml # does the machine match?
winget configure -f .\configuration.dsc.yaml --accept-configuration-agreements
- Always
--id ... -e(exact) plus--source; name queries match substrings and can pick the wrong package. --scope machinevsuserchanges install location and who sees the app; machine scope needs elevation.- winget runs per user and may be missing for SYSTEM on older builds. For SYSTEM tasks,
check
Get-Command wingetin that context first, or use theMicrosoft.WinGet.ClientPowerShell module.
Registry, safely
$key = 'HKCU:\Software\Contoso\App'
reg export 'HKCU\Software\Contoso\App' "$env:TEMP\contoso-app-backup.reg" /y | Out-Null
if (-not (Test-Path $key)) { New-Item -Path $key -Force | Out-Null }
New-ItemProperty -Path $key -Name 'TelemetryLevel' -PropertyType DWord -Value 0 -Force | Out-Null
(Get-ItemProperty -Path $key -Name 'TelemetryLevel').TelemetryLevel # verify: 0
-PropertyTypematters: DWord vs String values are read differently by applications.- 32-bit apps on 64-bit Windows read
HKLM:\SOFTWARE\WOW6432Node\.... Write where the consuming app looks. - Per-user settings for other users require loading their hive (
reg load); do it only with confirmation and alwaysreg unload. - Many settings need an Explorer restart, sign-out, or reboot. Say which.
AutoHotkey v2 (GUI only)
#Requires AutoHotkey v2.0
#SingleInstance Force
SetTitleMatchMode 2
^!e:: { ; Ctrl+Alt+E
if !WinExist("ahk_exe legacyapp.exe")
Run "C:\Program Files\Legacy\legacyapp.exe"
if !WinWaitActive("ahk_exe legacyapp.exe", , 10) {
MsgBox "Legacy app did not appear within 10 s"
return
}
ControlClick "Button3" ; prefer controls over screen coordinates
Send "{Text}" A_YYYY "-" A_MM "-" A_DD
Send "{Enter}"
}
- Target windows with
ahk_exeorahk_class, not titles that change with language. - Use
WinWait*with timeouts and check the result; neverSleepand hope. - A non-elevated script cannot send input to an elevated window (UIPI). Run the script at the same integrity level as the target, or avoid elevated targets.
- GUI automation needs a signed-in desktop session; it fails in a scheduled task that runs while logged off or while the screen is locked.
- Use v2 syntax only; v1 scripts (
MsgBox, text) do not run under v2.
WSL interop
wsl.exe -d Ubuntu -- bash -lc 'cd ~/proj && make test' # Linux command from Windows
wsl.exe -d Ubuntu -- wslpath -a 'C:\data\in.csv' # -> /mnt/c/data/in.csv
explorer.exe . # Windows tool from Linux; include .exe
cmd.exe /C dir # cmd built-ins need cmd.exe /C
powershell.exe -NoProfile -Command 'Get-Date'
- Keep project files on the side whose tools do the work: Linux tools on the Linux filesystem, Windows tools on NTFS. Cross-filesystem access is much slower.
wsl.exe --listwrites UTF-16; setWSL_UTF8=1before parsing its output.- Share variables across the boundary with
WSLENV(/ptranslates paths). - A scheduled task calling
wsl.exemust run as the user who owns the distro.
Execution policy and signing
- Execution policy is a safety guard, not a security boundary. Use
Set-ExecutionPolicy RemoteSigned -Scope CurrentUserfor workstations, or pass-ExecutionPolicy RemoteSignedon the task command line. - Downloaded scripts carry Mark of the Web;
Unblock-Fileafter review. - For AllSigned environments, sign with a code-signing certificate and a timestamp so
signatures outlive the certificate:
Set-AuthenticodeSignature -FilePath .\backup.ps1 -Certificate $cert -TimestampServer 'http://timestamp.digicert.com'. Re-sign after every edit; verify withGet-AuthenticodeSignature.
Gotchas
- Task works by hand, fails scheduled: wrong working directory, mapped drive letters (use UNC paths), missing user profile/environment, or S4U without network access.
LastTaskResult0 while the script failed: the action launchedpwsh -Commandor a.batthat swallowed the exit code. Use-Fileandexit $LASTEXITCODEin wrappers.- Tasks created by clicking “Run with highest privileges” under a user who later leaves the company stop running. Register under SYSTEM or a gMSA.
- Daylight saving: a 02:30 daily trigger may skip or repeat on DST change days; pick a time outside 01:00-03:00 or make the job idempotent.
- Battery defaults: tasks do not start on battery and stop when unplugged unless
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteriesare set. - Long paths over 260 characters fail in older tools; enable long paths policy or keep roots short.
Verification
Start-ScheduledTask, thenGet-ScheduledTaskInfoshowsLastTaskResult0 and the log file has a fresh entry from the task’s principal.- Force a failure (bad path):
LastTaskResultis non-zero and the log shows the error. - Run twice in a row: second run changes nothing (idempotent) or is skipped by the lock.
- Reboot, stay logged off past the trigger time: the task ran (or caught up via StartWhenAvailable).
winget configure testreports the machine in the desired state; registry values read back as written.- An uninstall script removes the task, files, and registry keys it created.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.