Patch one named server on a schedule by starting a background Windows Update install, and check its progress later

Patch a specific device, typically a server outside your normal patch tool, on a schedule: every N days the action starts installing its missing Windows updates without restarting it, and you can check on the install afterwards. For MSPs and IT teams with a few servers that must be patched the same way every week.

Runs in On the device (PowerShell, as SYSTEM)
Uses None
Suggested approval Specific groups (your engineers) → No approval needed, so scheduled runs don't wait for an approver
Category Devices

Test it with SuperIT before you rely on it

This is an example, not a ready-made action, and it hasn't been tested end to end. Before you use it, work through it with SuperIT: ask SuperIT to review the script against your environment and requirements, and adjust it together. Then run it on a test device, as in Test it. Device actions can't use Test, so every run is real.

Why use an action instead of asking SuperIT

SuperIT can't repeat a chat command on a timer, and creating a scheduled task on the device is denied by the default command policies. An action can run on a schedule, for example every 7 days on SRV-APP02, with the same reviewed script each time. It runs as SYSTEM and isn't checked against command policies; its authorization policy decides who can run it.

For one device, now, just ask SuperIT in chat: it can list missing updates and run the install, and a chat command can run for up to 30 minutes, longer than a device action's 5.

When to use it

  • "Install updates on SRV-APP02 every Saturday night."
  • A line-of-business server that your patch tool doesn't cover must be patched weekly.
  • "Did last night's scheduled update install on SRV-APP02 finish?"

What the action does

The mode input picks one of three jobs. Every mode first stops, without changing anything, if expected_hostname isn't this device's computer name.

  • Report
    1. Asks the Windows Update Agent for software updates that aren't installed, hidden or optional (BrowseOnly). Driver updates and feature upgrades (the Upgrades category) aren't included.
    2. Prints each update's title, KB number, severity and size, and whether a restart is already pending.
  • Install
    1. Refuses to start if a previous install is still running.
    2. Creates (or replaces) a scheduled task called SuperIT action - Install Windows updates, which runs as SYSTEM, and starts it.
    3. Returns straight away. The task searches the same way, accepts licence terms, downloads and installs every update that doesn't need user input, and writes a log to C:\Windows\Logs\WindowsUpdateAction.log.
  • Status
    1. Prints the task's state, the last 25 lines of the log, and whether a restart is pending.

The background task exists only because device actions time out after 5 minutes by default and installing updates often takes longer: Install hands the work to a scheduled task on the device instead of waiting for it.

What it doesn't do:

  • It never restarts the device. Use Schedule a reboot with a warning when Status says a restart is needed.
  • It doesn't choose individual KBs, install drivers, optional preview updates or feature upgrades, or unhide updates.
  • It doesn't remove its scheduled task. The task stays on the device, with no trigger, after the install. See Remove the scheduled task.
  • It doesn't override WSUS, Windows Update for Business or Intune. The device searches whichever source it's configured to use, and policies such as deferrals still apply.

Before you begin

  • A Windows device with the SuperIT RMM agent, online. Actions run on Windows devices only; the macOS and Linux agents are in Beta and can't run actions. See Requirements.
  • Your role. Creating an action in a chat needs the Admin role. Engineers and Admins can build it under Workflows → Actions.
  • No credential. See Credentials.
  • An authorization policy that doesn't need approval for the schedule's owner. For example: Requester Specific groups superit:engineers, Approver No approval needed. Don't add an Anyone rule. A scheduled run uses the current policy, so a run that needs approval waits, and expires after 7 days if nobody approves it. See Authorization policies and approvals.

Build it with SuperIT

Create an action called "Check and install Windows updates".
Runtime: PowerShell, running on the device as SYSTEM (execution target: device). Timeout 5 minutes (the default).
Inputs: expected_hostname (string, required), mode (select: Report, Install, Status; required; default Report).
Stop before doing anything unless expected_hostname matches $env:COMPUTERNAME.
Report: Microsoft.Update.Session search "IsInstalled=0 and IsHidden=0 and BrowseOnly=0 and Type='Software'", skip updates in the Upgrades category (match CategoryID 3689bdc8-b205-4af4-8d4a-a63924c5e9d5, not the localized name), print title, KBs, severity, size; print pending restart.
Install: refuse if the task "SuperIT action - Install Windows updates" is running; otherwise register it (SYSTEM, highest, -EncodedCommand, no trigger) and start it. The task uses the same search and skip, downloads and installs all updates that can't request user input, accepts EULAs, logs to C:\Windows\Logs\WindowsUpdateAction.log, ends with a "DONE" or "FAILED" line, and never restarts.
Status: task state, last 25 log lines, pending restart.
Windows PowerShell 5.1 compatible. Authorization policy: superit:engineers, no approval needed.

SuperIT confirms the brief, drafts the script, validates it and creates the action as a draft. Review the script, then publish it under Workflows → Actions.

Or build it yourself

Create the action under Workflows → Actions with the PowerShell runtime and these inputs, then set it to run on a device.

Name Type Required Example
expected_hostname string Yes SRV-APP02
mode select (Report, Install, Status) Yes Report

On a device, the script runs in Windows PowerShell as SYSTEM with $ErrorActionPreference = "Stop" already set. Inputs arrive as strings in $ctx.inputs. Invoke-WindowsUpdateAction at the top lists the steps in order; each step is a small function below it, and the last line runs it. $result is set as $script:result, because a plain $result = inside a function stays local and never reaches the Result tab.

# Report: lists missing software updates. Install: starts a SYSTEM scheduled task that installs
# them without restarting. Status: shows that task's state and log. Prints whether a restart is pending.
function Invoke-WindowsUpdateAction {
    param($ExpectedHostname, $Mode)

    Assert-TargetDevice -ExpectedHostname $ExpectedHostname
    $m = Assert-Inputs -Mode $Mode
    $taskName = 'SuperIT action - Install Windows updates'
    $logPath = "$env:SystemRoot\Logs\WindowsUpdateAction.log"
    $restartPending = Test-RestartPending

    Write-Output "=== WINDOWS UPDATES: $($m.ToUpper()) ==="
    switch ($m) {
        'Report'  { Write-UpdateReport -RestartPending $restartPending }
        'Install' { Start-UpdateInstall -TaskName $taskName -LogPath $logPath }
        'Status'  { Write-InstallStatus -TaskName $taskName -LogPath $logPath -RestartPending $restartPending }
    }
}

function Assert-TargetDevice {
    param($ExpectedHostname)
    $expected = ("$ExpectedHostname".Trim() -split '\.')[0]
    if (-not $expected) { throw "expected_hostname is required: enter this device's computer name." }
    if ($expected -ne $env:COMPUTERNAME) { throw "This run reached '$env:COMPUTERNAME', not '$expected'. Nothing was changed." }
}

function Assert-Inputs {
    param($Mode)
    if ("$Mode" -notin @('Report', 'Install', 'Status')) { throw "mode must be Report, Install or Status." }
    "$Mode"
}

function Test-RestartPending {
    $cv = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion'
    (Test-Path "$cv\WindowsUpdate\Auto Update\RebootRequired") -or (Test-Path "$cv\Component Based Servicing\RebootPending")
}

function Write-UpdateReport {
    param([bool]$RestartPending)
    Write-Output "Searching for updates on $env:COMPUTERNAME..."
    $updates = @(Find-MissingUpdate)
    foreach ($u in $updates) { Write-Output ("  {0} [{1}] {2} ({3} MB)" -f $u.kb, $u.severity, $u.title, $u.size_mb) }
    Write-Output "Missing updates: $($updates.Count). Restart pending: $RestartPending."
    # $result is read after the script ends, so set it at script scope, not in this function.
    $script:result = @{
        missing         = @($updates | ForEach-Object { @{ title = $_.title; kb = $_.kb; severity = $_.severity } })
        restart_pending = $RestartPending
    }
}

function Find-MissingUpdate {
    try {
        $searcher = (New-Object -ComObject Microsoft.Update.Session).CreateUpdateSearcher()
        # BrowseOnly=0 leaves out optional updates, such as preview cumulative updates.
        $search = $searcher.Search("IsInstalled=0 and IsHidden=0 and BrowseOnly=0 and Type='Software'")
    } catch {
        throw "Failed to search for updates on $($env:COMPUTERNAME): $_"
    }
    foreach ($u in $search.Updates) {
        if (@($u.Categories | Where-Object { $_.CategoryID -eq '3689bdc8-b205-4af4-8d4a-a63924c5e9d5' }).Count -gt 0) { continue }
        @{
            title    = $u.Title
            kb       = (@($u.KBArticleIDs) | ForEach-Object { "KB$_" }) -join ','
            severity = "$($u.MsrcSeverity)"
            size_mb  = [math]::Round($u.MaxDownloadSize / 1MB)
        }
    }
}

function Start-UpdateInstall {
    param([string]$TaskName, [string]$LogPath)
    $existing = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
    if ($existing -and $existing.State -eq 'Running') { throw "An update install is already running. Use Status to follow it." }

    Register-UpdateTask -TaskName $TaskName
    Add-Content -Path $LogPath -Value ("{0:yyyy-MM-dd HH:mm:ss}  Install started from SuperIT" -f (Get-Date))
    Start-ScheduledTask -TaskName $TaskName
    Write-Output "Started the update install in the background. Run this action with mode Status to follow it."
    $script:result = @{ started = $true; log = $LogPath }
}

function Register-UpdateTask {
    param([string]$TaskName)
    # A task, not this script, does the install: it outlives the action's timeout.
    $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes((Get-UpdateJobScript)))
    $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -NonInteractive -EncodedCommand $encoded"
    $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
    Register-ScheduledTask -TaskName $TaskName -Action $action -Principal $principal -Force | Out-Null
}

function Get-UpdateJobScript {
    @'
$log = "$env:SystemRoot\Logs\WindowsUpdateAction.log"
function Log($m) { Add-Content -Path $log -Value ("{0:yyyy-MM-dd HH:mm:ss}  {1}" -f (Get-Date), $m) }
try {
    Log 'Searching for updates'
    $s = New-Object -ComObject Microsoft.Update.Session
    $r = $s.CreateUpdateSearcher().Search("IsInstalled=0 and IsHidden=0 and BrowseOnly=0 and Type='Software'")
    $c = New-Object -ComObject Microsoft.Update.UpdateColl
    foreach ($u in $r.Updates) {
        if (@($u.Categories | Where-Object { $_.CategoryID -eq '3689bdc8-b205-4af4-8d4a-a63924c5e9d5' }).Count -gt 0) { Log "Skipped (feature upgrade): $($u.Title)"; continue }
        if ($u.InstallationBehavior.CanRequestUserInput) { Log "Skipped (needs user input): $($u.Title)"; continue }
        if (-not $u.EulaAccepted) { $u.AcceptEula() }
        [void]$c.Add($u); Log "Queued: $($u.Title)"
    }
    if ($c.Count -eq 0) { Log 'DONE: nothing to install' } else {
        $d = $s.CreateUpdateDownloader(); $d.Updates = $c; [void]$d.Download(); Log 'Downloaded'
        $i = $s.CreateUpdateInstaller(); $i.Updates = $c; $res = $i.Install()
        for ($n = 0; $n -lt $c.Count; $n++) { Log ("Result code {0}: {1}" -f $res.GetUpdateResult($n).ResultCode, $c.Item($n).Title) }
        Log ("DONE: result code {0}, restart needed {1}" -f $res.ResultCode, $res.RebootRequired)
    }
} catch { Log "FAILED: $($_.Exception.Message)" }
'@
}

function Write-InstallStatus {
    param([string]$TaskName, [string]$LogPath, [bool]$RestartPending)
    $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
    $state = 'never run'
    if ($task) { $state = "$($task.State)" }
    Write-Output "Install task: $state. Restart pending: $RestartPending."
    $tail = @()
    if (Test-Path $LogPath) { $tail = @(Get-Content -Path $LogPath -Tail 25) }
    foreach ($line in $tail) { Write-Output "  $line" }
    $script:result = @{ task_state = $state; restart_pending = $RestartPending; log_tail = $tail }
}

Invoke-WindowsUpdateAction -ExpectedHostname $ctx.inputs.expected_hostname -Mode $ctx.inputs.mode

In the log, result code 2 means succeeded, 3 succeeded with errors, 4 failed and 5 aborted.

Remove the scheduled task

The task SuperIT action - Install Windows updates runs as SYSTEM and stays on the device after the install, with no trigger, so it runs only when this action starts it. When you stop patching the device with this action, remove it in an elevated PowerShell on the device: Unregister-ScheduledTask -TaskName 'SuperIT action - Install Windows updates' -Confirm:$false.

Test it

You can't use Test on an action that runs on a device. (On a cloud-sandbox action, Test isn't a dry run either: it runs the script for real and skips approvals.) Every device run is real, so use a test device.

  1. Publish the action under Workflows → Actions.
  2. Select Run, enter the test device's computer name in expected_hostname, choose Report, pick that device under Run target, and select Run action. Check Stdout. A search can take a minute or two.
  3. Run it with Install. The run finishes in seconds.
  4. Run it with Status every few minutes until the log ends with a DONE or FAILED line.
  5. Run it with a different name in expected_hostname and confirm it fails with "Nothing was changed".

Use it

  • On a schedule. Ask SuperIT "Run Check and install Windows updates in Install mode on SRV-APP02 every 7 days, starting Saturday at 10pm, with expected_hostname SRV-APP02". A schedule is tied to one device, so create one per server. Pair it with a later scheduled restart on the same device.
  • Check the result. Scheduled runs don't send their output anywhere. Ask SuperIT "Run Check and install Windows updates in Status mode on SRV-APP02" the next morning, or see the run under the action's Executions tab.
  • From the app. Open the action, select Run, enter the device's name, choose the mode, pick the same device under Run target, and select Run action.
  • Not from tickets automatically. Don't switch on Run automatically for this action. A run started from a ticket's Recommended actions panel goes to the action's default device or your team's designated device, not the device named in the ticket.
  • For clients. MSPs can publish it to managed teams.

Always pick the device. If no device is chosen, SuperIT runs a device action on the action's default device or your team's designated device, and expected_hostname stops the run if that's the wrong one.

Variations

  • Post the result to your team's channel: schedule a Status run a few hours after Install, and add the webhook call from Post a message to a Teams or Slack channel to Write-InstallStatus. Actions can't call each other, so the call goes in this script.
  • Install only named KBs: add a kb_numbers input and skip updates whose KBArticleIDs aren't in it.
  • Include driver updates by removing Type='Software' from both searches.