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.

  • namewindows-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

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.

~/.claude/skills/windows-automation/SKILL.md

--- name: windows-automation description: Automates Windows machines end to end - unattended PowerShell jobs with transcripts and exit codes, Task Scheduler via the ScheduledTasks… license: MIT ---

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/Bypass machine-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\System32 with no profile, no mapped drives, and no user environment unless configured.

Pick the mechanism

NeedDefaultNotes
Run on a schedule, at logon, at startup, on an eventTask Scheduler (Register-ScheduledTask)Built in, logs history, retries
Long-running process that must restart on crashScheduled 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 softwarewinget (install, upgrade, configure)Chocolatey/Scoop only where already standard
Whole-machine desired state (apps + settings)winget configure with a .winget / configuration.dsc.yaml fileReview third-party config files before running
Change a setting with no cmdletRegistry, with backup and verificationPrefer Group Policy/Intune in managed fleets
Drive a GUI-only appAutoHotkey v2Runs in the interactive session only
Use Linux tools from Windows or the reverseWSL interop (wsl.exe, *.exe from Linux)Mind paths and encodings
Fleet of domain machinesGroup 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 1 becomes 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 *-EventLog cmdlets 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

AccountUse forCaveat
Interactive user, “only when logged on”Anything touching the desktop, AHK, user appsDoes not run when logged off
User with stored passwordUser-context job when logged offBreaks when the password changes
S4U (-LogonType S4U)Local-only work as a user without storing a passwordNo network credentials: shares and authenticated web calls fail
SYSTEM (-UserId 'SYSTEM' -LogonType ServiceAccount)Local machine maintenance needing adminFull control of the box; network access as the computer account
gMSA (-UserId 'CONTOSO\svc-backup$' -LogonType Password)Domain jobs needing network accessRequires 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
  • -StartWhenAvailable catches up runs missed while the machine was off or asleep.
  • Without -ExecutionTimeLimit the 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-ScheduledTaskInfo shows LastRunTime and LastTaskResult (0 = success; 267009 = still running; 267011 = never run). Start on demand with Start-ScheduledTask.
  • Keep Export-ScheduledTask XML 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 machine vs user changes 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 winget in that context first, or use the Microsoft.WinGet.Client PowerShell 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
  • -PropertyType matters: 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 always reg 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_exe or ahk_class, not titles that change with language.
  • Use WinWait* with timeouts and check the result; never Sleep and 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 --list writes UTF-16; set WSL_UTF8=1 before parsing its output.
  • Share variables across the boundary with WSLENV (/p translates paths).
  • A scheduled task calling wsl.exe must 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 CurrentUser for workstations, or pass -ExecutionPolicy RemoteSigned on the task command line.
  • Downloaded scripts carry Mark of the Web; Unblock-File after 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 with Get-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.
  • LastTaskResult 0 while the script failed: the action launched pwsh -Command or a .bat that swallowed the exit code. Use -File and exit $LASTEXITCODE in 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 -DontStopIfGoingOnBatteries are set.
  • Long paths over 260 characters fail in older tools; enable long paths policy or keep roots short.

Verification

  1. Start-ScheduledTask, then Get-ScheduledTaskInfo shows LastTaskResult 0 and the log file has a fresh entry from the task’s principal.
  2. Force a failure (bad path): LastTaskResult is non-zero and the log shows the error.
  3. Run twice in a row: second run changes nothing (idempotent) or is skipped by the lock.
  4. Reboot, stay logged off past the trigger time: the task ran (or caught up via StartWhenAvailable).
  5. winget configure test reports the machine in the desired state; registry values read back as written.
  6. 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.

esc