PC Health Built-in Catalog: Checks & Remediations
This is the reference list of every built-in PC Health check and remediation K12Panel ships with, including the exact PowerShell each one runs. Use it to see precisely what a built-in measures or does before you bind it to a policy — and as a starting point for your own custom checks and fixes. For the concepts see About PC Health and Self-Healing; for the workflow see Using PC Health in K12Panel; to write your own see Writing Custom Health Checks and Writing Custom Remediations.
This is the reference list of every built-in PC Health check and remediation K12Panel ships with, including the exact PowerShell each one runs. Use it to see precisely what a built-in measures or does before you bind it to a policy — and as a starting point for your own custom checks and fixes. For the concepts see About PC Health and Self-Healing; for the workflow see Using PC Health in K12Panel; to write your own see Writing Custom Health Checks and Writing Custom Remediations.
A few things that apply to everything below:
- Windows only. All built-ins target Windows devices running the K12Panel agent, and ride the agent's normal check-in — nothing to install, no agent update.
- Built-ins are locked. You can't edit a built-in's script. To tweak one, copy its PowerShell into a new custom check or remediation and adjust that. (Authoring definitions needs the Architect role.)
- Thresholds live in the policy, not the check. The default bands listed here are just sensible starting points; each policy can override them per check. One built-in — CPU utilization (average) — ships with no bands on purpose: it's a measurement, not an alert.
- Checks output one number or throw (throwing → Unknown). Remediations return on success or
throwon failure — neverexit. (The one exception is CPU utilization (average), a built-in that reports a pair of raw counters for the server to convert; custom checks always output a single number.)
Built-in checks
Storage & disk
Disk free (system drive) — slug disk_free · unit % · lower is worse · gauge · default bands critical < 5 (clears at 8), warning < 15 (clears at 18) · timeout 15s.
$disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$env:SystemDrive'" -ErrorAction Stop
if (-not $disk -or [int64]$disk.Size -le 0) { throw "system drive $env:SystemDrive not found or zero size" }
[math]::Round(([double]$disk.FreeSpace / [double]$disk.Size) * 100, 2)
Data drive free space — slug data_drive_free · unit % · lower is worse · gauge · default bands critical < 5 (clears at 8), warning < 15 (clears at 18) · timeout 15s. Lowest free % across internal fixed data drives (D:, E:, …), excluding the system drive and USB/network drives; returns 100 on single-drive machines so they stay quiet.
$sys = $env:SystemDrive.TrimEnd(':')[0]
$vals = Get-Volume | Where-Object {
$_.DriveType -eq 'Fixed' -and $_.DriveLetter -and $_.DriveLetter -ne $sys -and $_.Size -gt 0
} | ForEach-Object { [math]::Round($_.SizeRemaining / $_.Size * 100, 2) }
if (-not $vals) { 100 } else { ($vals | Measure-Object -Minimum).Minimum }
Predictive disk health (SMART) — slug disk_smart_health · 1 healthy / 0 failing · lower is worse · stoplight · default band critical < 1 · timeout 15s. Returns 0 only when a disk reports Warning/Unhealthy; Unknown (VMs, USB enclosures, Storage Spaces) is treated as healthy to avoid false alarms.
if ((Get-PhysicalDisk | Where-Object { $_.HealthStatus -in @('Warning','Unhealthy') }).Count -gt 0) { 0 } else { 1 }
Windows servicing
Uptime since reboot — slug uptime_days · unit days · higher is worse · default bands critical > 30 (clears at 28), warning > 14 (clears at 12) · timeout 15s.
$os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
if (-not $os.LastBootUpTime) { throw "LastBootUpTime unavailable" }
[math]::Round((New-TimeSpan -Start $os.LastBootUpTime -End (Get-Date)).TotalDays, 3)
Pending-reboot age — slug pending_reboot_age · unit days (0 = none) · higher is worse · default bands critical > 7, warning > 3 · timeout 20s. Reads the age of a pending reboot from the registry key's last-write timestamp (via a small P/Invoke, since PowerShell doesn't expose key write times).
$keys = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'
)
$pending = @($keys | Where-Object { Test-Path -LiteralPath $_ })
if ($pending.Count -eq 0) { return 0.0 }
if (-not ('K12.Reg' -as [type])) {
Add-Type -Namespace K12 -Name Reg -MemberDefinition @'
[DllImport("advapi32.dll", CharSet=CharSet.Unicode)] public static extern int RegOpenKeyEx(System.UIntPtr hKey, string subKey, int options, int samDesired, out System.UIntPtr result);
[DllImport("advapi32.dll")] public static extern int RegQueryInfoKey(System.UIntPtr hKey, System.IntPtr a, System.IntPtr b, System.IntPtr c, System.IntPtr d, System.IntPtr e, System.IntPtr f, System.IntPtr g, System.IntPtr h, System.IntPtr i, System.IntPtr j, out long lastWriteFiletime);
[DllImport("advapi32.dll")] public static extern int RegCloseKey(System.UIntPtr hKey);
'@
}
$HKLM = [System.UIntPtr]::new([uint64]0x80000002)
$KEY_READ = 0x20019
$oldest = $null
foreach ($p in $pending) {
$sub = $p -replace '^HKLM:\\', ''
$h = [System.UIntPtr]::Zero
if ([K12.Reg]::RegOpenKeyEx($HKLM, $sub, 0, $KEY_READ, [ref]$h) -eq 0) {
$ft = [long]0
$ok = [K12.Reg]::RegQueryInfoKey($h,
[System.IntPtr]::Zero,[System.IntPtr]::Zero,[System.IntPtr]::Zero,[System.IntPtr]::Zero,[System.IntPtr]::Zero,
[System.IntPtr]::Zero,[System.IntPtr]::Zero,[System.IntPtr]::Zero,[System.IntPtr]::Zero,[System.IntPtr]::Zero,
[ref]$ft)
if ($ok -eq 0 -and $ft -gt 0) {
$dt = [DateTime]::FromFileTimeUtc($ft)
if ($null -eq $oldest -or $dt -lt $oldest) { $oldest = $dt }
}
[void][K12.Reg]::RegCloseKey($h)
}
}
if ($null -eq $oldest) { throw "pending reboot detected but key timestamp unavailable" }
[math]::Round(((Get-Date).ToUniversalTime() - $oldest).TotalDays, 3)
Windows Update age — slug windows_update_age · unit days · higher is worse · default bands critical > 60, warning > 30 · timeout 20s · pairs with Reset Windows Update cache. Days since the newest installed hotfix (Win32_QuickFixEngineering), with the legacy registry key as a fallback (that key is absent on modern Win10/11, so a registry-only check would read Unknown fleet-wide).
$dates = @(Get-CimInstance Win32_QuickFixEngineering -ErrorAction SilentlyContinue |
Where-Object { $_.InstalledOn } | ForEach-Object { [datetime]$_.InstalledOn })
$k = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\Results\Install'
$reg = (Get-ItemProperty -Path $k -Name LastSuccessTime -ErrorAction SilentlyContinue).LastSuccessTime
if ($reg) { $dates += [datetime]$reg }
if (-not $dates) { throw 'no update-install timestamp from QFE or registry' }
$latest = ($dates | Measure-Object -Maximum).Maximum
[math]::Round(((Get-Date) - $latest).TotalDays, 1)
Security & compliance
Windows Firewall on (all profiles) — slug firewall_enabled · 1 on / 0 off · lower is worse · default band critical < 1 · timeout 15s · pairs with Enable Windows Firewall. Returns 0 if any of Domain/Private/Public is disabled.
if ((Get-NetFirewallProfile | Where-Object { -not $_.Enabled }).Count -gt 0) { 0 } else { 1 }
System drive BitLocker on — slug bitlocker_on · 1 protected / 0 not · lower is worse · default band warning < 1 · timeout 15s. Throws → Unknown on Windows Home (no BitLocker) and when not elevated (the agent runs as SYSTEM, so it's fine in the field).
$v = Get-BitLockerVolume -MountPoint $env:SystemDrive -ErrorAction Stop
if ($v.ProtectionStatus -eq 'On') { 1 } else { 0 }
Hardware & time
Battery health (capacity vs design) — slug battery_health · unit % · lower is worse · gauge · default bands critical < 40 (clears at 45), warning < 60 (clears at 65) · timeout 20s. Full-charge capacity as a share of the original design capacity (100 = new). Returns 100 on devices with no battery (desktops) so they stay quiet.
$design = Get-CimInstance -Namespace root\wmi -ClassName BatteryStaticData -ErrorAction SilentlyContinue |
Select-Object -First 1 -ExpandProperty DesignedCapacity
$full = Get-CimInstance -Namespace root\wmi -ClassName BatteryFullChargedCapacity -ErrorAction SilentlyContinue |
Select-Object -First 1 -ExpandProperty FullChargedCapacity
if (-not $design -or -not $full) { 100 } else { [math]::Round($full / $design * 100, 1) }
System clock drift — slug time_drift · unit seconds · higher is worse · default bands critical > 300, warning > 60 · timeout 30s · pairs with Resync system time. Absolute clock offset in seconds vs an external source; works on non-domain and Entra-ID-joined devices. Measures via NTP against a first-responder list of vendor-neutral servers (pool.ntp.org, then time.cloudflare.com), falling back to an HTTPS Date-header read for networks that block UDP/123.
$off = $null
foreach ($srv in @('pool.ntp.org','time.cloudflare.com')) {
$raw = w32tm /stripchart /computer:$srv /samples:1 /dataonly 2>$null
foreach ($line in $raw) {
if ($line -match '([+-]?\d+\.\d+)s') { $off = [math]::Abs([double]$Matches[1]); break }
}
if ($null -ne $off) { break }
}
if ($null -eq $off) {
try {
$r = Invoke-WebRequest -Uri 'https://www.google.com' -Method Head -UseBasicParsing -TimeoutSec 8
$server = [datetime]::Parse($r.Headers['Date']).ToUniversalTime()
$off = [math]::Abs(((Get-Date).ToUniversalTime() - $server).TotalSeconds)
} catch { }
}
if ($null -eq $off) { throw 'could not measure time drift (NTP and HTTPS both unreachable)' }
[math]::Round($off, 2)
Print Spooler running — slug service_spooler · 1 running / 0 stopped · lower is worse · default band critical < 1 · timeout 15s · pairs with Restart Print Spooler + clear queue.
if ((Get-Service -Name 'Spooler' -ErrorAction Stop).Status -eq 'Running') { 1 } else { 0 }
Performance
CPU utilization (average) — slug cpu_avg · unit % · higher is worse · gauge · no default bands · timeout 15s. Average CPU utilization over the device's most recent sample window (an hour on the default policy), matching the number Task Manager reports.
This one behaves differently from every other built-in, in two ways worth understanding before you enable it.
It is a measurement, not an alert. It ships with no bands, so it records a value and always reads OK — it will never raise a PC Health alert, and it never counts toward a device's health issues. That's deliberate: a PC legitimately pegs at 100% during a Windows update, a big spreadsheet, or a video export, so alerting on high CPU mostly cries wolf. The value is in seeing it across the fleet:
- Turn on the CPU % column on the Assets table (Columns → CPU %).
- Ask AI Search for it in plain language — "PCs above 85% CPU", "slow computers", "which machines are maxed out".
That's how you find the machines that are genuinely undersized — the evidence behind a replacement request, and the answer to the most common ticket in any school: "my computer is slow." If you do want it to alert, add a band to the check in your policy (e.g. warning > 90) — you're opting in with a threshold you chose.
Two samples are needed before a value appears. CPU utilization can't be read as a single instant value the way disk space can — Windows only exposes counters that accumulate. So K12Panel reads the counters each cycle and works out the average between the last two readings. The practical consequences:
- After you enable the check, the first check-in shows Unknown; a number appears on the second. On the default hourly policy, that's about an hour.
- A reboot resets the counters, so the cycle spanning a restart reads Unknown, then recovers on the next one.
- A device that's been off for days reads Unknown rather than reporting a stale multi-day average.
None of these are faults — they're the check telling you honestly that it doesn't have two readings to compare yet.
$p = Get-CimInstance Win32_PerfRawData_Counters_ProcessorInformation -Filter "Name='_Total'" -ErrorAction Stop
if (-not $p) { throw "ProcessorInformation counters unavailable" }
[double]$p.PercentProcessorUtility
[double]$p.PercentProcessorUtility_Base
This is the one built-in you can't copy into a custom check. Notice it outputs two numbers, not one — a pair of raw counters that the server turns into a percentage. Custom checks must output a single number (see Writing Custom Health Checks), so pasting this script into one won't work; it would read as Unknown. Bind the built-in to your policy instead.
Delivery (server-derived)
Agent offline — slug agent_offline · unit hours · higher is worse · default band warning > 4. This one has no PowerShell — K12Panel computes it on the server from the device's last check-in time, so it still works when the agent is dead or offline.
Built-in check templates
A template is a check with a placeholder you fill in to stamp out a concrete, editable check. Find them in the Templates section of PC Health → Checks and click Use template.
Service running — slug service_running · parameter Service name () · 1 running / 0 stopped · default band critical < 1. Produces an "is that service running" check for any service you name (e.g. wuauserv, W32Time). When you stamp one, you can optionally create the matching Restart a service remediation in the same step.
if ((Get-Service -Name '' -ErrorAction Stop).Status -eq 'Running') { 1 } else { 0 }
Built-in remediations
All non-disruptive fixes run whenever they're needed; disruptive fixes auto-run only inside the policy's maintenance window.
Disk Cleanup — slug disk_cleanup · non-disruptive · timeout 300s. Clears user and Windows temp folders, the Windows Update download cache, and the recycle bin.
$ErrorActionPreference = 'SilentlyContinue'
$paths = @("$env:TEMP\*", "$env:WINDIR\Temp\*", "$env:WINDIR\SoftwareDistribution\Download\*")
foreach ($p in $paths) { Remove-Item -Path $p -Recurse -Force -ErrorAction SilentlyContinue }
Clear-RecycleBin -Force -ErrorAction SilentlyContinue
Reset Windows Update cache — slug wu_reset · non-disruptive · timeout 300s · pairs with Windows Update age. Stops Windows Update, clears the download cache so stuck/partial updates re-fetch, and restarts it. No reboot.
Stop-Service -Name wuauserv -Force -ErrorAction Stop
Remove-Item -Path "$env:SystemRoot\SoftwareDistribution\Download\*" -Recurse -Force -ErrorAction SilentlyContinue
Start-Service -Name wuauserv -ErrorAction Stop
Restart Print Spooler + clear queue — slug spooler_restart · non-disruptive · timeout 120s · pairs with Print Spooler running. Restarts the spooler and clears stuck print jobs, self-verifying it comes back running. (Clears queued jobs — users may need to reprint.)
Stop-Service -Name Spooler -Force -ErrorAction Stop
Remove-Item -Path "$env:SystemRoot\System32\spool\PRINTERS\*" -Force -ErrorAction SilentlyContinue
Start-Service -Name Spooler -ErrorAction Stop
Start-Sleep -Seconds 2
if ((Get-Service -Name Spooler).Status -ne 'Running') { throw 'spooler not running after restart' }
Resync system time — slug time_resync · non-disruptive · timeout 120s · pairs with System clock drift. Ensures the Windows Time service is running and forces a resync against its source.
Start-Service -Name w32time -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
& w32tm /resync /force | Out-Null
if ((Get-Service -Name w32time).Status -ne 'Running') { throw 'w32time not running after resync' }
Enable Windows Firewall (all profiles) — slug firewall_enable · non-disruptive · timeout 60s · pairs with Windows Firewall on. Turns the firewall back on for Domain/Public/Private and self-verifies none remain disabled. (Enforces the secure default — may block traffic that was flowing with the firewall off.)
Set-NetFirewallProfile -Profile Domain,Public,Private -Enabled True -ErrorAction Stop
Start-Sleep -Seconds 1
if ((Get-NetFirewallProfile | Where-Object { -not $_.Enabled }).Count -gt 0) { throw 'a firewall profile is still disabled' }
Reboot Device — slug reboot_device · disruptive · timeout 60s. A built-in command (not a script) — K12Panel dispatches its native Reboot action.
Restart Agent — slug restart_agent · disruptive · timeout 60s. A built-in command (not a script) — K12Panel restarts its own agent service.
Built-in remediation templates
Restart a service — slug restart_service · parameter Service name () · non-disruptive · timeout 120s. The partner to the Service running check template: restarts the named service and verifies it came back running. Find it under PC Health → Remediations → Templates → Use template.
Restart-Service -Name '' -Force -ErrorAction Stop
Start-Sleep -Seconds 2
if ((Get-Service -Name '').Status -ne 'Running') { throw "'' not running after restart" }
Frequently Asked Questions
Can I edit a built-in's script? No — built-ins are locked. To change one, copy its PowerShell above into a New Custom Check (or New Custom Remediation), edit it, and bind that instead. Authoring definitions needs the Architect role.
Why doesn't "Agent offline" have a script? It's server-derived. K12Panel calculates it from the device's last check-in time on the server, so it can flag a device precisely when the agent isn't running to answer for itself.
These bands don't match what I want. The bands listed are the built-in defaults. Thresholds live in the policy, so open the policy, find the check's row, and set the bands you want there — the same check can be strict for one group and relaxed for another.
Do I need to update the agent to get these? No. Checks and remediations are delivered on the agent's normal check-in. Nothing to install, no agent update.
Which checks pair with a fix out of the box? Windows Update age ↔ Reset Windows Update cache; Print Spooler running ↔ Restart Print Spooler; System clock drift ↔ Resync system time; Windows Firewall on ↔ Enable Windows Firewall; and any Service running instance ↔ its Restart a service partner. Wire them together on the check's row in the policy editor. The rest are notify-only by default.