Learn how to query, start, stop, and monitor Windows Services and Scheduled Tasks directly from Power Automate Desktop flows. Build a self-healing pipeline monitor that detects failures, attempts auto-remediation, logs results, and surfaces alerts — all without manual intervention.

Picture this: it's 2 AM, and a critical nightly ETL job silently failed because the Windows service it depends on crashed forty minutes before the scheduled run. No one notices until morning when the data warehouse reports are empty and someone from finance is already calling. You've patched this manually a dozen times — restarting the service, re-triggering the task, sending a notification email — but you're building automation for other people, so why not build some for yourself?
This is exactly the kind of problem Power Automate Desktop was built to solve. Windows Task Scheduler and the Services Control Manager (SCM) are two of the most powerful levers you have over background processing on a Windows machine, and both are fully accessible from PAD — through a combination of built-in actions, PowerShell scripting, and system-level commands. By the end of this lesson, you'll know how to check service health, restart failed services conditionally, trigger and query scheduled tasks programmatically, and weave all of that into an unattended monitoring bot that runs without anyone touching a keyboard.
What you'll learn:
You should already be comfortable with the basics of Power Automate Desktop — if you're new to the platform, start with Getting Started with Power Automate Desktop: Installing, Recording, and Running Your First Desktop Flow. You should also understand how to run PowerShell from within a desktop flow, which is covered thoroughly in Scripting Inside Desktop Flows: Running PowerShell, Python, and VBScript Actions. Basic familiarity with variables, conditions, and loops in PAD is assumed — if you need a refresher, see Variables, Lists, and Data Tables in Power Automate Desktop: A Complete Practitioner's Guide.
The machine running this flow needs to be either an attended or unattended bot with local administrator rights, because both service management and task scheduler manipulation require elevated privileges.
Before we write a single action, it's worth being precise about what Windows Task Scheduler and the Service Control Manager actually are, because they're often confused.
Windows Services are long-running background processes managed by the SCM. They can start automatically at boot, run as specific user accounts, and be configured to restart on failure. Think: SQL Server Agent, Windows Update, a custom Python data ingestion daemon, or an ETL orchestrator. Services have distinct states: Running, Stopped, Paused, StartPending, StopPending.
Scheduled Tasks are one-shot or recurring executions of a program, script, or command, fired at a specified time or in response to a trigger (logon, event log entry, system idle, etc.). They live in the Task Scheduler library and are managed through schtasks.exe, the TaskService COM object, or PowerShell's ScheduledTasks module.
PAD interacts with both of these through three channels:
sc.exe and schtasks.exeGet-Service and Get-ScheduledTask cmdletsEach channel has trade-offs. The native Services actions are readable and simple but limited. PowerShell gives you full control but requires you to parse output. DOS commands are portable but clunky for anything conditional. You'll use all three depending on the job.
Note
Running this automation unattended requires the PAD machine account to have the "Log on as a service" right and, for service management, membership in the local Administrators group. You can test attended first, then move to unattended once the logic is validated. See Attended vs Unattended RPA: Choosing a Run Mode and Configuring Machines in Power Automate for a detailed breakdown of that transition.
PAD's System action library includes a dedicated Services group with three core actions:
Let's build a concrete pattern around these. Suppose you manage a Windows service called DataPipelineAgent — a custom Python process that polls an SFTP server and dumps files into a staging folder. It occasionally crashes, and your job is to detect and remediate that.
The native PAD actions can start and stop services, but they don't expose a "Get Service Status" query action. For that, you'll use a PowerShell snippet.
In your flow, add a Run PowerShell Script action and use this script:
$serviceName = "DataPipelineAgent"
$svc = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
if ($null -eq $svc) {
Write-Output "NOT_FOUND"
} else {
Write-Output $svc.Status
}
Configure the action to capture Script output into a variable — call it %ServiceStatus%. The output will be one of: Running, Stopped, Paused, StartPending, StopPending, or NOT_FOUND.
Now add a condition:
IF %ServiceStatus% = "Stopped" THEN
# Attempt restart
ELSE IF %ServiceStatus% = "NOT_FOUND" THEN
# Log error — service doesn't exist, don't try to start it
ELSE IF %ServiceStatus% = "Running" THEN
# All good — log health check passed
END
Tip
PowerShell's Get-Service outputs the .Status property as a ServiceControllerStatus enum, which Write-Output serializes as the string name. In PAD, you'll compare against the string "Running" or "Stopped" — not an integer. This trips up people coming from VBScript or batch scripts where you'd compare numeric codes.
Inside the Stopped branch, use the native Start Service action:
DataPipelineAgentIf the service takes longer than the timeout to start (e.g., it's initializing a database connection pool), PAD will throw an exception. Wrap this in an On Block Error handler so a slow-starting service doesn't crash your entire monitoring flow. The Error Handling in Desktop Flows: On Block Error, Retry Policies, and Recovery Screenshots article covers the On Block Error pattern in depth — use the Continue flow run option and capture the error message to your log.
After calling Start Service, re-query the status with your PowerShell snippet again and check whether the service actually reached Running state. Don't assume the action succeeding means the service is healthy — some services start and then immediately crash (a "crash loop"). A second status check 5–10 seconds later catches this.
Run PowerShell Script (check status again)
Wait 5 seconds
Run PowerShell Script (check status again)
IF %ServiceStatus% = "Running" THEN
SET %RestartSuccessful% = True
SET %LogMessage% = "DataPipelineAgent restarted successfully at %CurrentDateTime%"
ELSE
SET %RestartSuccessful% = False
SET %LogMessage% = "DataPipelineAgent failed to start. Status: %ServiceStatus%"
END
Restarting a service (which Windows doesn't expose as a single atomic operation for all services) means stop, then start. Here's the pattern:
# PowerShell approach — handles dependent services too
$serviceName = "DataPipelineAgent"
try {
Stop-Service -Name $serviceName -Force -ErrorAction Stop
Start-Sleep -Seconds 3
Start-Service -Name $serviceName -ErrorAction Stop
Write-Output "RESTART_SUCCESS"
} catch {
Write-Output "RESTART_FAILED: $($_.Exception.Message)"
}
Using PowerShell for the full restart cycle is cleaner than chaining PAD's Stop Service and Start Service actions, because -Force also stops dependent services gracefully — something the native actions don't handle.
Warning
Never call Stop-Service on services like wuauserv (Windows Update), Spooler, or any service your PAD agent itself depends on without understanding the downstream impact. Stopping the wrong service in an unattended flow on a production machine can lock yourself out of remote management entirely.
Scheduled tasks are the other half of this picture. Many data operations — nightly exports, weekly reconciliations, hourly health checks — run as scheduled tasks rather than persistent services. You need to be able to run them on demand, check their last run result, disable them temporarily, and enable them again.
For quick queries, schtasks.exe is available through PAD's Run DOS Command action. Here's how to get the last run result for a task:
schtasks /Query /TN "\DataOps\NightlyWarehouseLoad" /FO CSV /NH
The /FO CSV flag returns comma-separated output, /NH suppresses the header row, and /TN specifies the task name including its folder path in the Task Scheduler library.
Capture the output in a variable (e.g., %TaskQueryOutput%) and then parse it. The CSV columns are: TaskName, Next Run Time, Status. The Status field will be Ready, Running, Disabled, or blank.
For more detail — especially the Last Run Result (an exit code) and Last Run Time — you need PowerShell:
$taskPath = "\DataOps\"
$taskName = "NightlyWarehouseLoad"
$task = Get-ScheduledTask -TaskPath $taskPath -TaskName $taskName -ErrorAction SilentlyContinue
if ($null -eq $task) {
Write-Output "NOT_FOUND|N/A|N/A"
exit
}
$taskInfo = Get-ScheduledTaskInfo -TaskPath $taskPath -TaskName $taskName
$state = $task.State # Ready, Running, Disabled, Queued
$lastRunTime = $taskInfo.LastRunTime
$lastResult = $taskInfo.LastTaskResult # 0 = success, non-zero = error code
Write-Output "$state|$lastRunTime|$lastResult"
Capture this output in %TaskInfo%, then split it in PAD:
Use the Split Text action:
%TaskInfo%|%TaskInfoParts%Now %TaskInfoParts[0]% is the state, %TaskInfoParts[1]% is the last run time, and %TaskInfoParts[2]% is the last result code.
Key insight
The Last Task Result code 0x0 (zero) means success. 0x1 means the task's process exited with code 1 — usually a script error. 0x41301 is a particularly important one: it means the task is currently running. 0x41303 means the task has not run yet. Always check this code rather than inferring success from the Last Run Time alone.
To fire a task immediately without waiting for its next scheduled time, use the Run DOS Command action:
schtasks /Run /TN "\DataOps\NightlyWarehouseLoad"
Or in PowerShell, which gives you error handling:
try {
Start-ScheduledTask -TaskPath "\DataOps\" -TaskName "NightlyWarehouseLoad" -ErrorAction Stop
Write-Output "TRIGGERED"
} catch {
Write-Output "ERROR: $($_.Exception.Message)"
}
Capturing the output lets you detect permission errors (common when PAD runs as a service account without the right to trigger tasks owned by a different user) versus genuine task execution errors.
This is where many automations get sloppy. Triggering a task is fire-and-forget by default — your flow continues immediately after. But if your next step depends on the task's output (say, loading a file the task created), you need to poll for completion:
$taskPath = "\DataOps\"
$taskName = "NightlyWarehouseLoad"
$maxWaitSeconds = 600 # 10 minutes
$pollInterval = 15
$elapsed = 0
do {
Start-Sleep -Seconds $pollInterval
$elapsed += $pollInterval
$info = Get-ScheduledTaskInfo -TaskPath $taskPath -TaskName $taskName
$task = Get-ScheduledTask -TaskPath $taskPath -TaskName $taskName
$state = $task.State
} while ($state -eq "Running" -and $elapsed -lt $maxWaitSeconds)
$finalResult = $info.LastTaskResult
Write-Output "$state|$finalResult|$elapsed"
Run this as a single PowerShell block. It loops internally until the task leaves the Running state or a timeout is hit. This is more reliable than building the poll loop in PAD itself because PowerShell's Start-Sleep is more precise than PAD's Wait action, and it avoids context-switching overhead on each poll.
For maintenance windows — patching nights, database failovers — you may need to disable a scheduled task temporarily and re-enable it afterwards:
# Disable
Disable-ScheduledTask -TaskPath "\DataOps\" -TaskName "NightlyWarehouseLoad"
# Enable
Enable-ScheduledTask -TaskPath "\DataOps\" -TaskName "NightlyWarehouseLoad"
Wrap each in a try/catch and write the result to a variable so your flow knows whether the disable/enable worked before proceeding.
Now let's assemble these pieces into a real monitoring bot. The scenario: you have three background processes critical to your data pipeline:
| Component | Type | Expected State |
|---|---|---|
DataPipelineAgent |
Windows Service | Always Running |
\DataOps\HourlyExtract |
Scheduled Task | Last result = 0, runs hourly |
\DataOps\NightlyWarehouseLoad |
Scheduled Task | Last result = 0, runs at 2 AM |
Your flow will run every 15 minutes (triggered by Task Scheduler itself, or by a cloud flow — see Triggering Desktop Flows from Cloud Flows: Passing Inputs and Returning Outputs), check each component, attempt auto-remediation, and write a status log.
Main Flow
│
├── [Subflow] CheckAndRestartService
│ ├── PowerShell: Get service status
│ ├── IF Stopped → Start Service
│ ├── Wait 5 seconds
│ ├── PowerShell: Re-verify status
│ └── Return: ServiceOK (True/False), LogEntry
│
├── [Subflow] CheckScheduledTask
│ ├── PowerShell: Get task state + last result
│ ├── IF last result <> 0 → Log warning
│ ├── IF state = Running AND runtime > threshold → Log warning
│ └── Return: TaskOK (True/False), LogEntry
│
├── [Subflow] WriteStatusLog
│ ├── Append row to CSV log file
│ └── IF any component failed → Trigger alert
│
└── Main
├── Call CheckAndRestartService("DataPipelineAgent")
├── Call CheckScheduledTask("\DataOps\", "HourlyExtract")
├── Call CheckScheduledTask("\DataOps\", "NightlyWarehouseLoad")
└── Call WriteStatusLog(all results)
Using Subflows and Reusable Logic in Power Automate Desktop keeps this maintainable. Each subflow takes input parameters and returns a result — if you need to add a fourth component to monitor, you call the same subflow with new arguments.
This subflow takes %InputServiceName% as an input parameter and returns %ServiceHealthy% (Boolean) and %ServiceLogEntry% (text).
Action sequence:
%StatusRaw%%StatusRaw% to remove trailing newlines → %ServiceStatus%%ServiceStatus% = "Running":%ServiceHealthy% = True%ServiceLogEntry% = "[OK] %InputServiceName% is Running"%ServiceStatus% = "NOT_FOUND":%ServiceHealthy% = False%ServiceLogEntry% = "[ERROR] %InputServiceName% not found on this machine"%RestartFailed% = True, continue%InputServiceName% with 30s timeout%StatusRecheck%%StatusRecheck% = "Running":%ServiceHealthy% = True%ServiceLogEntry% = "[RECOVERED] %InputServiceName% was Stopped, restarted successfully"%ServiceHealthy% = False%ServiceLogEntry% = "[CRITICAL] %InputServiceName% failed to restart. Status: %StatusRecheck%"PowerShell for both checks:
param([string]$ServiceName)
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($null -eq $svc) { Write-Output "NOT_FOUND"; exit }
Write-Output $svc.Status.ToString()
Tip
PAD's Run PowerShell Script action doesn't natively support parameters, but you can embed the service name directly in the script using a PAD variable inside the script text field: $svc = Get-Service -Name "%InputServiceName%" -ErrorAction SilentlyContinue. PAD resolves the variable before handing the script to PowerShell. Just be careful with service names containing special characters.
This takes %InputTaskPath% and %InputTaskName% as inputs.
$task = Get-ScheduledTask -TaskPath "%InputTaskPath%" -TaskName "%InputTaskName%" -ErrorAction SilentlyContinue
if ($null -eq $task) { Write-Output "NOT_FOUND|0|0"; exit }
$info = Get-ScheduledTaskInfo -TaskPath "%InputTaskPath%" -TaskName "%InputTaskName%"
$state = $task.State.ToString()
$lastResult = $info.LastTaskResult
$lastRunTime = $info.LastRunTime.ToString("yyyy-MM-dd HH:mm:ss")
Write-Output "$state|$lastResult|$lastRunTime"
Parse the output by splitting on |. Then apply this logic:
TaskInfoParts[0] = "Disabled": log [WARNING] Task is disabledTaskInfoParts[1] ≠ "0": log [WARNING] Last run failed with code %TaskInfoParts[1]%TaskInfoParts[0] = "Running": log [INFO] Task is currently executing[OK]This aggregates all log entries and appends them to a CSV. Use PAD's Write Text to File action with Append mode:
%CurrentDateTime%,%ServiceLogEntry%,%HourlyExtractLogEntry%,%NightlyLoadLogEntry%
Get %CurrentDateTime% using the Get Current Date and Time action (format: yyyy-MM-dd HH:mm:ss).
For alerting when something is actually broken, you have two good options:
Key insight
The most valuable thing your monitoring log can do is tell you trends, not just current state. A service that restarts successfully every 4 hours looks fine in a point-in-time check but is clearly destabilizing. When you analyze your CSV log with Excel or Power BI, look for recovery frequency, not just failure count.
Real pipelines have dependencies. The NightlyWarehouseLoad task only makes sense to run after the DataPipelineAgent service has been running for at least 30 minutes and the HourlyExtract task has completed successfully at least once in the last two hours. Let's encode that logic.
$taskPath = "\DataOps\"
$taskName = "HourlyExtract"
$windowMinutes = 120
$info = Get-ScheduledTaskInfo -TaskPath $taskPath -TaskName $taskName
$lastSuccess = if ($info.LastTaskResult -eq 0) { $info.LastRunTime } else { [datetime]::MinValue }
$minutesSinceSuccess = [int]((Get-Date) - $lastSuccess).TotalMinutes
if ($minutesSinceSuccess -le $windowMinutes -and $info.LastTaskResult -eq 0) {
Write-Output "RECENT_SUCCESS|$minutesSinceSuccess"
} else {
Write-Output "STALE_OR_FAILED|$minutesSinceSuccess"
}
In your flow, check this before triggering the nightly load:
IF %DependencyCheckResult% starts with "STALE_OR_FAILED"
SET %LogEntry% = "[SKIPPED] NightlyWarehouseLoad not triggered: HourlyExtract hasn't succeeded in 2 hours"
SET %TriggerNightly% = False
ELSE
SET %TriggerNightly% = True
END
This kind of orchestration — deciding whether to start a process based on the state of upstream processes — is something Task Scheduler's native dependency features handle poorly. PAD fills that gap elegantly.
Sometimes your flow needs to create or modify a scheduled task as part of a larger workflow — for example, after deploying an updated script, you want to register a new task that points to it:
$action = New-ScheduledTaskAction -Execute "python.exe" `
-Argument "C:\DataOps\extract_pipeline.py" `
-WorkingDirectory "C:\DataOps"
$trigger = New-ScheduledTaskTrigger -Daily -At "02:30AM"
$settings = New-ScheduledTaskSettingsSet `
-ExecutionTimeLimit (New-TimeSpan -Hours 2) `
-RestartCount 3 `
-RestartInterval (New-TimeSpan -Minutes 5)
$principal = New-ScheduledTaskPrincipal `
-UserId "DOMAIN\svc_dataops" `
-LogonType Password `
-RunLevel Highest
Register-ScheduledTask `
-TaskPath "\DataOps\" `
-TaskName "NightlyWarehouseLoad" `
-Action $action `
-Trigger $trigger `
-Settings $settings `
-Principal $principal `
-Force
Write-Output "REGISTERED"
Warning
Register-ScheduledTask with -Force will overwrite an existing task of the same name without prompting. If you're using this in an automated deployment flow, make sure you've backed up the existing task definition first (export it with Export-ScheduledTask), especially for production tasks. A botched task registration can mean your critical nightly process simply never runs.
Build a complete desktop flow that monitors a simulated pipeline environment. You'll need a Windows machine with administrator access.
Setup (5 minutes):
services.msc) and find any service you can safely stop and start — Spooler (Print Spooler) works for testing on a machine with no active print jobs, or use W32Time (Windows Time).cmd.exe /c echo test, Trigger = daily at midnight, under a \TestOps\ folder.Exercise steps:
Step 1 — Create the CheckService subflow:
ServiceNameInputServiceStatusResult (text), ServiceRestartAttempted (Boolean)Step 2 — Create the CheckTask subflow:
TaskPathInput, TaskNameInputTaskStateResult, TaskLastResult, TaskLastRunTimeStep 3 — Create the LogResults subflow:
C:\DataOps\Logs\pipeline_health.csvStep 4 — Wire up the main flow:
Validation:
[OK][WARNING] Task is disabled"Access Denied" when starting or stopping services
This is the most common failure. The PAD machine service runs as NT AUTHORITY\LOCAL SERVICE by default, which doesn't have permission to touch most services. Fix: configure the PAD service or the desktop flow's run-as account to use a local administrator account. For unattended flows, this is configured in the machine settings in the Power Automate portal. See Managing Machines and Machine Groups for Scalable Unattended Automation in Power Automate for the machine account configuration steps.
PowerShell output includes trailing whitespace or newlines
When you capture Write-Output $svc.Status.ToString(), the captured text in PAD often has a trailing newline character. Comparing %ServiceStatus% = "Running" fails because the actual value is "Running\n". Always trim the variable using the Trim Text action immediately after the PowerShell step.
Task Scheduler path format mismatch
PowerShell cmdlets expect the task path to end with a backslash: "\DataOps\". schtasks.exe uses the full path including task name: "\DataOps\NightlyWarehouseLoad". Mixing these formats between your PowerShell and DOS command actions causes silent failures (task not found). Be consistent: use PowerShell for everything that needs the split path format.
Service starts but immediately crashes
Your Start Service action succeeds, your re-check returns "Running," but five minutes later the service is Stopped again. This is a crash loop caused by application errors in the service itself — not a PAD problem. Your flow correctly reported recovery. The fix is in the service's application code or configuration. To detect crash loops in your monitoring flow, store the restart count and timestamp in a persistent variable (write it to a file), and escalate if restart count > 3 in an hour.
schtasks /Run returns success but the task doesn't execute
This happens when the task is set to run only when the user is logged on, but no matching user session exists (common in unattended scenarios). Check the task's General settings in Task Scheduler — it should say "Run whether user is logged on or not." You can fix this in PowerShell:
$task = Get-ScheduledTask -TaskPath "\DataOps\" -TaskName "NightlyWarehouseLoad"
$task.Principal.LogonType = "S4U"
$task | Set-ScheduledTask
Flow times out waiting for a task to complete
If your polling loop runs inside a single PowerShell block (the recommended pattern), the PAD action itself has a default timeout. For long-running tasks, increase the PowerShell action's timeout in its Advanced settings — or better, break the wait into a PAD-level loop with shorter PowerShell checks, so PAD's own run timeout doesn't interfere.
Tip
For monitoring flows running unattended at scale, consider writing your health check results to a shared location (SharePoint list, Azure Blob Storage, SQL table) rather than a local CSV. A local file is invisible to anything outside that one machine. Deploying Unattended Desktop Flows at Enterprise Scale: Machine Group Load Balancing, Queue Management, and Run Concurrency Strategies in Power Automate covers the infrastructure choices that make centralized logging practical.
You've built a complete pattern for Windows process management from Power Automate Desktop: querying service health with PowerShell, starting and stopping services with both native PAD actions and script-based approaches, triggering and monitoring scheduled tasks, and assembling all of it into a self-healing monitoring bot with structured logging.
The core principles to carry forward:
For your next steps, explore integrating these monitoring results with cloud-side alerting — a cloud flow that polls your SharePoint log list and sends Teams adaptive card notifications when LastResult is non-zero is a natural extension. Also consider expanding this pattern to cover application event logs: Get-WinEvent lets you query Windows Event Viewer programmatically and surface application errors before they cause service failures, giving you predictive rather than reactive monitoring.
Power Automate Desktop & RPA
Automating Windows Registry and Environment Variable Management in Power Automate Desktop: Reading, Writing, and Applying System-Level Settings in RPA Workflows
Automating Database Queries and Record Updates from Power Automate Desktop: Connecting to SQL Server, Executing Queries, and Writing Results to Windows Applications