The Workstation Triage Script Your RMM Won't Give You
Most RMMs give you device info but not the full diagnostic picture. We built a comprehensive PowerShell triage script that collects CPU, RAM, disk, process, and system data — and paired it with AI to make the results actionable.
9 min read
A technician gets a ticket: “My computer is slow.” They remote in, open Task Manager, check a few things, maybe look at the event log, restart a service, and move on. That process takes 10-15 minutes and varies wildly depending on who’s working the ticket.
The problem isn’t the troubleshooting — it’s the inconsistency. Your senior tech checks disk space, RAM pressure, CPU hogs, pending reboots, and stopped services. Your junior tech checks Task Manager and restarts the machine. Same ticket, completely different depth of investigation.
We needed a way to standardize that first diagnostic pass. And we needed it to run inside NinjaOne, where our RMM agent already sits on every managed device.
The gap in your RMM
NinjaOne is excellent at what it does — patching, remote access, scripting, alerting. But it doesn’t expose real-time RAM utilization, CPU load by process, or a comprehensive “what’s going on with this machine right now” diagnostic out of the box.
When a tech gets a “slow computer” ticket, they’re piecing together information from multiple sources:
- NinjaOne for the device overview and installed software
- Remote session to check Task Manager
- Maybe a quick
systeminfoin the console - Hopefully a glance at the event log
That’s four context switches before they even start fixing anything. And none of it is captured in the ticket for the next tech who might see the same device.
The script: one command, full diagnostic
We wrote a PowerShell script that collects everything a technician needs for a “slow/acting weird” complaint in a single execution. Deploy it through NinjaOne’s scripting engine, and the output lands in the script results — searchable, timestamped, and attached to the device.
Here’s what it collects:
System info and uptime
$os = Get-CimInstance Win32_OperatingSystem
$cs = Get-CimInstance Win32_ComputerSystem
$bios = Get-CimInstance Win32_BIOS
$uptime = (Get-Date) - $os.LastBootUpTime
Write-Output "Hostname : $($env:COMPUTERNAME)"
Write-Output "Last Boot : $($os.LastBootUpTime)"
Write-Output "Uptime : $($uptime.Days)d $($uptime.Hours)h $($uptime.Minutes)m"
Write-Output "OS : $($os.Caption) $($os.OSArchitecture)"
Write-Output "Manufacturer : $($cs.Manufacturer)"
Write-Output "Model : $($cs.Model)"
Write-Output "Serial Number : $($bios.SerialNumber)"
Uptime is the first thing to check. A machine that hasn’t rebooted in 45 days is a different conversation than one that was just restarted yesterday. The script surfaces this immediately so the tech doesn’t have to dig.
RAM — the metric your RMM hides
$totalRAMBytes = $os.TotalVisibleMemorySize * 1KB
$freeRAMBytes = $os.FreePhysicalMemory * 1KB
$usedRAMBytes = $totalRAMBytes - $freeRAMBytes
$totalRAM_GB = [math]::Round($totalRAMBytes / 1GB, 2)
$usedPct = [math]::Round(($usedRAMBytes / $totalRAMBytes) * 100, 1)
This is the big one. NinjaOne doesn’t natively expose current RAM utilization in a way that’s useful for troubleshooting. You can see total installed RAM, but not how much is actually in use right now. This script gives you the exact numbers plus automatic warnings at 75% and 90% thresholds.
CPU load
$cpus = @(Get-CimInstance Win32_Processor)
$cpuLoad = ($cpus | Measure-Object -Property LoadPercentage -Average).Average
Point-in-time CPU load. Not a historical average — what’s happening right now. Combined with the process tables below, this tells the tech exactly where the compute is going.
Disk space with automatic flagging
Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object {
$usedPct = [math]::Round(($usedBytes / $totalBytes) * 100, 1)
$flag = if ($usedPct -ge 90) { " *** CRITICAL" }
elseif ($usedPct -ge 80) { " *** LOW" }
else { "" }
}
Every drive, with automatic critical/low flags. No mental math required — the script tells the tech if disk is a problem.
Top processes by CPU time and RAM
The script outputs two sorted tables: the top 15 processes by cumulative CPU time, and the top 15 by current RAM usage. This is where the tech finds the culprit.
An important distinction the script makes clear: CPU time is cumulative seconds since the process started, not current utilization percentage. A browser with 2,000 seconds of CPU time has been running for a while and using CPU along the way. That’s different from a process that’s spiking to 100% right now.
The RAM table shows each process as a percentage of total system memory. When a tech sees Chrome using 4.2 GB (35% of total RAM), the conversation with the end user changes immediately.
Pending reboot check
if (Test-Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired") {
Write-Output "[!] Windows Update reboot pending"
}
The script checks three registry keys that indicate a pending reboot: Windows Update, Component Based Servicing, and Pending File Rename Operations. A machine that needs a reboot and hasn’t gotten one is a different diagnostic than one that’s freshly rebooted and still slow.
Stopped auto-start services
This is subtle but important. The script finds services that are set to start automatically but are currently stopped — and it filters out trigger-started services so you don’t get false positives.
$triggerStarted = @{}
Get-ChildItem "HKLM:\SYSTEM\CurrentControlSet\Services" -ErrorAction SilentlyContinue | ForEach-Object {
if (Test-Path (Join-Path $_.PSPath "TriggerInfo")) {
$triggerStarted[$_.PSChildName] = $true
}
}
Trigger-started services legitimately sit in “Stopped” state until a hardware or network event activates them. Without this filter, you’d get dozens of false alarms. With it, you get a clean list of services that should be running and aren’t.
Local admins and antivirus
The script rounds out the diagnostic with a local administrators check (who has admin on this box?) and antivirus status via SecurityCenter2 — including whether real-time protection is enabled and definitions are up to date.
The full script
Here’s the complete script, ready to deploy through NinjaOne:
#==============================================================
# Workstation Triage Diagnostic Script
# Purpose: Collect CPU, RAM, Disk, Process, and System info
# for slow/acting weird complaints
#==============================================================
Write-Output "============================================"
Write-Output " WORKSTATION TRIAGE DIAGNOSTIC REPORT"
Write-Output " $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Output "============================================"
#--------------------------------------------------------------
# SYSTEM INFO & UPTIME
#--------------------------------------------------------------
Write-Output ""
Write-Output "--- SYSTEM INFO ---"
$os = Get-CimInstance Win32_OperatingSystem
$cs = Get-CimInstance Win32_ComputerSystem
$bios = Get-CimInstance Win32_BIOS
$uptime = (Get-Date) - $os.LastBootUpTime
Write-Output "Hostname : $($env:COMPUTERNAME)"
Write-Output "Last Boot : $($os.LastBootUpTime)"
Write-Output "Uptime : $($uptime.Days)d $($uptime.Hours)h $($uptime.Minutes)m"
Write-Output "OS : $($os.Caption) $($os.OSArchitecture)"
Write-Output "Build : $($os.BuildNumber)"
Write-Output "Manufacturer : $($cs.Manufacturer)"
Write-Output "Model : $($cs.Model)"
Write-Output "Serial Number : $($bios.SerialNumber)"
Write-Output "Domain : $($cs.Domain)"
Write-Output "Logged In User : $($cs.UserName)"
#--------------------------------------------------------------
# MEMORY (RAM) SUMMARY
#--------------------------------------------------------------
Write-Output ""
Write-Output "--- MEMORY (RAM) ---"
$totalRAMBytes = $os.TotalVisibleMemorySize * 1KB
$freeRAMBytes = $os.FreePhysicalMemory * 1KB
$usedRAMBytes = $totalRAMBytes - $freeRAMBytes
$totalRAM_GB = [math]::Round($totalRAMBytes / 1GB, 2)
$freeRAM_GB = [math]::Round($freeRAMBytes / 1GB, 2)
$usedRAM_GB = [math]::Round($usedRAMBytes / 1GB, 2)
$usedPct = if ($totalRAMBytes -gt 0) { [math]::Round(($usedRAMBytes / $totalRAMBytes) * 100, 1) } else { 0 }
Write-Output "Total RAM : $totalRAM_GB GB"
Write-Output "Used RAM : $usedRAM_GB GB ($usedPct%)"
Write-Output "Free RAM : $freeRAM_GB GB"
if ($usedPct -ge 90) {
Write-Output "WARNING: RAM usage critical (>90%)"
} elseif ($usedPct -ge 75) {
Write-Output "NOTICE: RAM usage elevated (>75%)"
}
#--------------------------------------------------------------
# CPU SUMMARY
#--------------------------------------------------------------
Write-Output ""
Write-Output "--- CPU ---"
$cpus = @(Get-CimInstance Win32_Processor)
$cpuLoad = ($cpus | Measure-Object -Property LoadPercentage -Average).Average
$totalCores = ($cpus | Measure-Object -Property NumberOfCores -Sum).Sum
$totalLogical = ($cpus | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
$cpuName = if ($cpus.Count -gt 1) { "$($cpus[0].Name) (x$($cpus.Count) sockets)" } else { $cpus[0].Name }
Write-Output "CPU : $cpuName"
Write-Output "Cores : $totalCores cores / $totalLogical logical"
Write-Output "Current Load : $cpuLoad%"
if ($cpuLoad -ge 90) {
Write-Output "WARNING: CPU usage critical (>90%)"
} elseif ($cpuLoad -ge 75) {
Write-Output "NOTICE: CPU usage elevated (>75%)"
}
#--------------------------------------------------------------
# DISK SPACE
#--------------------------------------------------------------
Write-Output ""
Write-Output "--- DISK SPACE ---"
Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object {
$totalBytes = $_.Size
$freeBytes = $_.FreeSpace
$usedBytes = $totalBytes - $freeBytes
if ($totalBytes -gt 0) {
$total = [math]::Round($totalBytes / 1GB, 1)
$free = [math]::Round($freeBytes / 1GB, 1)
$used = [math]::Round($usedBytes / 1GB, 1)
$usedPct = [math]::Round(($usedBytes / $totalBytes) * 100, 1)
$flag = if ($usedPct -ge 90) { " *** CRITICAL" } elseif ($usedPct -ge 80) { " *** LOW" } else { "" }
Write-Output "Drive $($_.DeviceID) Total: $total GB | Used: $used GB ($usedPct%) | Free: $free GB$flag"
} else {
Write-Output "Drive $($_.DeviceID) Total: N/A (empty or unreadable volume)"
}
}
#--------------------------------------------------------------
# TOP 15 PROCESSES BY CPU TIME
#--------------------------------------------------------------
Write-Output ""
Write-Output "--- TOP 15 PROCESSES BY CPU TIME (cumulative seconds, not current %) ---"
Write-Output ("{0,-35} {1,14} {2,12}" -f "Process", "CPU-Time(sec)", "RAM(MB)")
Get-Process | Sort-Object CPU -Descending | Select-Object -First 15 | ForEach-Object {
$ramMB = [math]::Round($_.WorkingSet64 / 1MB, 1)
$cpuSec = if ($null -ne $_.CPU) { [math]::Round($_.CPU, 1) } else { "N/A" }
Write-Output ("{0,-35} {1,14} {2,12}" -f $_.Name, $cpuSec, $ramMB)
}
#--------------------------------------------------------------
# TOP 15 PROCESSES BY RAM
#--------------------------------------------------------------
Write-Output ""
Write-Output "--- TOP 15 PROCESSES BY RAM ---"
Write-Output ("{0,-35} {1,12} {2,8}" -f "Process", "RAM(MB)", "RAM(%)")
Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 15 | ForEach-Object {
$ramMB = [math]::Round($_.WorkingSet64 / 1MB, 1)
$ramPct = if ($totalRAMBytes -gt 0) { [math]::Round(($_.WorkingSet64 / $totalRAMBytes) * 100, 1) } else { 0 }
Write-Output ("{0,-35} {1,12} {2,8}" -f $_.Name, $ramMB, "$ramPct%")
}
#--------------------------------------------------------------
# PENDING REBOOT CHECK
#--------------------------------------------------------------
Write-Output ""
Write-Output "--- PENDING REBOOT CHECK ---"
$rebootPending = $false
if (Test-Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired") {
Write-Output "[!] Windows Update reboot pending"
$rebootPending = $true
}
if (Test-Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending") {
Write-Output "[!] Component servicing reboot pending"
$rebootPending = $true
}
$pendingRename = Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager" `
-Name PendingFileRenameOperations -ErrorAction SilentlyContinue
if ($pendingRename -and $pendingRename.PendingFileRenameOperations) {
Write-Output "[!] Pending file rename operations (reboot needed)"
$rebootPending = $true
}
if (-not $rebootPending) { Write-Output "No reboot pending." }
#--------------------------------------------------------------
# STOPPED AUTO-START SERVICES
#--------------------------------------------------------------
Write-Output ""
Write-Output "--- STOPPED AUTO-START SERVICES ---"
Write-Output "(Trigger-started services excluded to reduce false positives)"
$triggerStarted = @{}
Get-ChildItem "HKLM:\SYSTEM\CurrentControlSet\Services" -ErrorAction SilentlyContinue | ForEach-Object {
if (Test-Path (Join-Path $_.PSPath "TriggerInfo")) {
$triggerStarted[$_.PSChildName] = $true
}
}
$stoppedServices = Get-Service | Where-Object {
$_.StartType -eq 'Automatic' -and
$_.Status -eq 'Stopped' -and
-not $triggerStarted.ContainsKey($_.Name)
} | Select-Object Name, DisplayName, Status
if ($stoppedServices) {
$stoppedServices | ForEach-Object {
Write-Output "[!] $($_.DisplayName) ($($_.Name)) - STOPPED"
}
} else {
Write-Output "All automatic services are running (or are trigger-started)."
}
#--------------------------------------------------------------
# LOCAL ADMINISTRATORS
#--------------------------------------------------------------
Write-Output ""
Write-Output "--- LOCAL ADMINISTRATORS ---"
try {
$localAdmins = Get-LocalGroupMember -Group "Administrators" -ErrorAction Stop
if ($localAdmins) {
$localAdmins | ForEach-Object {
Write-Output "$($_.ObjectClass): $($_.Name)"
}
} else {
Write-Output "Administrators group is empty."
}
} catch {
Write-Output "Unable to retrieve local admins: $_"
}
#--------------------------------------------------------------
# ANTIVIRUS STATUS
#--------------------------------------------------------------
Write-Output ""
Write-Output "--- ANTIVIRUS STATUS ---"
$av = Get-CimInstance -Namespace "root\SecurityCenter2" -ClassName AntiVirusProduct -ErrorAction SilentlyContinue
if ($av) {
$av | ForEach-Object {
$stateInt = [int]$_.productState
$rtByte = ($stateInt -shr 8) -band 0xFF
$defByte = $stateInt -band 0xFF
$enabled = if ($rtByte -band 0x10) { "Enabled" } else { "Disabled" }
$upToDate = if ($defByte -eq 0x00) { "Up to date" } else { "Out of date" }
Write-Output "$($_.displayName) | $enabled | $upToDate"
}
} else {
Write-Output "No AV products found via SecurityCenter2."
}
Write-Output ""
Write-Output "============================================"
Write-Output " END OF REPORT"
Write-Output "============================================"
How this fits into AI-assisted triage
The script on its own is useful. But where it gets powerful is when the output feeds into AI triage.
When a “slow computer” ticket comes in and Junto’s triage pipeline runs, the AI can trigger this script via NinjaOne’s API — through a NinjaOne MCP server or direct integration — capture the output, and include the relevant findings in the internal note it posts to the ticket. The tech opens the ticket and sees:
Device diagnostic summary: RAM at 92% (WARNING). Top process: Chrome at 4.2GB (35% of RAM). Disk C: at 84% (LOW). Uptime: 47 days. Pending reboot: Windows Update. 3 stopped auto-start services.
That’s 10 minutes of diagnostic work done before the tech even looks at the ticket. They skip straight to the conversation: “You’ve got 47 days of uptime and Chrome is eating your RAM. Let’s reboot and see if that clears it.”
Why we open-sourced it
This script solves a specific gap that every NinjaOne MSP hits. We’re sharing it because the more MSPs standardize their diagnostic process, the more value AI triage can add on top. A consistent, structured diagnostic output is exactly what AI needs to provide useful recommendations.
Copy it. Modify it. Deploy it in your NinjaOne instance. And if you want the AI layer that turns this output into actionable triage notes automatically, that’s what Junto does.