<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Anthony Buhnerkemper</title>
    <description>The latest articles on DEV Community by Anthony Buhnerkemper (@anthonybuhnerkemper).</description>
    <link>https://dev.to/anthonybuhnerkemper</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4176081%2F5d0fd606-e5cf-412a-8fe4-6c2d1963e763.png</url>
      <title>DEV Community: Anthony Buhnerkemper</title>
      <link>https://dev.to/anthonybuhnerkemper</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anthonybuhnerkemper"/>
    <language>en</language>
    <item>
      <title>Automating Configuration Manager (SCCM) client actions with PowerShell</title>
      <dc:creator>Anthony Buhnerkemper</dc:creator>
      <pubDate>Sat, 10 Oct 2026 23:09:11 +0000</pubDate>
      <link>https://dev.to/anthonybuhnerkemper/automating-configuration-manager-sccm-client-actions-with-powershell-1c1f</link>
      <guid>https://dev.to/anthonybuhnerkemper/automating-configuration-manager-sccm-client-actions-with-powershell-1c1f</guid>
      <description>&lt;p&gt;If you've supported Configuration Manager for any length of time, you know the routine: someone deploys something, a user can't see it in Software Center, and the first answer is "run Machine Policy Retrieval and wait a few minutes." Doing that by remoting into each machine and clicking through the Configuration Manager control panel applet doesn't scale past a handful of devices.&lt;/p&gt;

&lt;p&gt;Every one of those buttons is just a WMI method call, so PowerShell can do it for one machine or five thousand. The scripts in this post are in my open-source &lt;a href="https://github.com/anthonybuhnerkemper/configmgr-powershell-toolkit" rel="noopener noreferrer"&gt;configmgr-powershell-toolkit on GitHub&lt;/a&gt;, also available as a module on the &lt;a href="https://www.powershellgallery.com/packages/ConfigMgrAdminToolkit" rel="noopener noreferrer"&gt;PowerShell Gallery&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Install-Module&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ConfigMgrAdminToolkit&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Scope&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;CurrentUser&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How client actions work
&lt;/h2&gt;

&lt;p&gt;Each action in the client's Actions tab maps to a &lt;strong&gt;schedule ID&lt;/strong&gt;, a GUID. You fire it with the static &lt;code&gt;TriggerSchedule&lt;/code&gt; method on the &lt;code&gt;SMS_Client&lt;/code&gt; class in the client's &lt;code&gt;root\ccm&lt;/code&gt; namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Invoke-CimMethod&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ComputerName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;PC01&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Namespace&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;root\ccm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ClassName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SMS_Client&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;-MethodName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;TriggerSchedule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Arguments&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;sScheduleID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'{00000000-0000-0000-0000-000000000021}'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole trick. The rest is knowing the IDs and handling many machines safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The schedule IDs worth memorizing
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Schedule ID&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hardware Inventory&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{00000000-0000-0000-0000-000000000001}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Software Inventory&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{00000000-0000-0000-0000-000000000002}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discovery Data Record (heartbeat)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{00000000-0000-0000-0000-000000000003}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Machine Policy Retrieval&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{00000000-0000-0000-0000-000000000021}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Machine Policy Evaluation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{00000000-0000-0000-0000-000000000022}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Software Updates Assignments Evaluation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{00000000-0000-0000-0000-000000000108}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;State Message Refresh&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{00000000-0000-0000-0000-000000000111}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Software Update Scan&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{00000000-0000-0000-0000-000000000113}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Software Update Deployment Evaluation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{00000000-0000-0000-0000-000000000114}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application Deployment Evaluation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{00000000-0000-0000-0000-000000000121}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few gotchas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;User policy&lt;/strong&gt; (&lt;code&gt;...026&lt;/code&gt; / &lt;code&gt;...027&lt;/code&gt;) is tied to the logged-on user's SID. Triggering it remotely as an admin refreshes &lt;em&gt;your&lt;/em&gt; policy, not the user's, so I generally skip it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full hardware inventory&lt;/strong&gt; requires deleting the &lt;code&gt;InventoryActionStatus&lt;/code&gt; instance for &lt;code&gt;...001&lt;/code&gt; in &lt;code&gt;root\ccm\invagt&lt;/code&gt; first. Do it sparingly; full inventories from a lot of machines at once can flood the site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configuration baselines&lt;/strong&gt; aren't schedule IDs. Those use &lt;code&gt;TriggerEvaluation&lt;/code&gt; on &lt;code&gt;SMS_DesiredConfiguration&lt;/code&gt; in &lt;code&gt;root\ccm\dcm&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Friendly names instead of GUIDs: &lt;code&gt;Invoke-CMClientAction&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Nobody wants to type GUIDs, so &lt;code&gt;Invoke-CMClientAction&lt;/code&gt; maps friendly names to IDs, takes pipeline input, and supports &lt;code&gt;-WhatIf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Invoke-CMClientAction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ComputerName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;PC01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;PC02&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Action&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;MachinePolicyRetrieval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;MachinePolicyEvaluation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-WhatIf&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Raw ID still works for anything not in the list&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Invoke-CMClientAction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ComputerName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;PC01&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ScheduleId&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'{00000000-0000-0000-0000-000000000113}'&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Pipeline from a file&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Get-Content&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;\pcs.txt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Invoke-CMClientAction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Action&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ApplicationDeploymentEvaluation&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It uses a CIM session over WSMan by default. Add &lt;code&gt;-Protocol Dcom&lt;/code&gt; for older machines where WinRM isn't enabled. You need local admin on the target either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check before you trigger: &lt;code&gt;Test-CMClientConnectivity&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Half the "it didn't work" reports I get are machines that are off, unreachable, or have a broken client. Before a big run I check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-Content&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;\pcs.txt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Test-CMClientConnectivity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Format-Table&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It tests ping, WinRM, the &lt;code&gt;CcmExec&lt;/code&gt; service state, and the client version. Two PowerShell 7 differences shaped how it's written:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Get-Service -ComputerName&lt;/code&gt; was &lt;strong&gt;removed&lt;/strong&gt; in PowerShell 7, so the script queries &lt;code&gt;Win32_Service&lt;/code&gt; over CIM, which works in both editions.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Test-Connection&lt;/code&gt; uses &lt;code&gt;-TargetName&lt;/code&gt; in 7 (with &lt;code&gt;-ComputerName&lt;/code&gt; kept as an alias) and returns different objects.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also remember that "Active" in the console's client activity view only means the client did &lt;em&gt;something&lt;/em&gt; within the activity threshold (seven days by default). It doesn't mean the machine is online right now. That's why site-side health (&lt;code&gt;Get-CMClientHealthReport&lt;/code&gt;) and live connectivity are separate tools in the kit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaling to a collection: &lt;code&gt;Invoke-CMClientPolicyRefreshBulk&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;For a whole collection, I use &lt;code&gt;Invoke-CMClientPolicyRefreshBulk&lt;/code&gt;. It accepts names, a text file, or a collection ID resolved through the SMS Provider, and it has two modes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Direct: CIM TriggerSchedule on each client, run in parallel on PowerShell 7&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Invoke-CMClientPolicyRefreshBulk&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-SiteCode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;HOU&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ProviderMachineName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cm01&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-CollectionId&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;HOU00042&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Mode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Direct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ThrottleLimit&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;32&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-WhatIf&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Notification: ask the site to push it over the client notification channel&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Invoke-CMClientPolicyRefreshBulk&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-SiteCode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;HOU&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ProviderMachineName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cm01&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-CollectionId&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;HOU00042&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Mode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Notification&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Direct&lt;/strong&gt; mode is immediate and gives a per-machine result, but needs remoting and local admin on every endpoint. On PowerShell 7 it fans out with &lt;code&gt;ForEach-Object -Parallel&lt;/code&gt;. On 5.1, &lt;code&gt;Invoke-Command&lt;/code&gt; against a list or an array of CIM sessions gets you similar parallelism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notification&lt;/strong&gt; mode uses the site's own cmdlet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Invoke-CMClientNotification&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-DeviceCollectionId&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'HOU00042'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ActionType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;DownloadComputerPolicy&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Invoke-CMClientNotification&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-DeviceName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'PC01'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ActionType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;RequestScanForUpdate&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This goes over the client notification channel through the management point, so you don't need firewall rules for remote management to every endpoint, and the site throttles the work. For large batches, this is what I reach for first. The available &lt;code&gt;-ActionType&lt;/code&gt; values depend on your version; check &lt;code&gt;Get-Help Invoke-CMClientNotification -Parameter ActionType&lt;/code&gt;. The older &lt;code&gt;Invoke-CMClientAction&lt;/code&gt; &lt;em&gt;site&lt;/em&gt; cmdlet is deprecated in favor of it (not to be confused with my script of the same name, which talks to clients directly).&lt;/p&gt;

&lt;h2&gt;
  
  
  Related client-side actions
&lt;/h2&gt;

&lt;p&gt;The same pattern of calling a client WMI class works for a few other jobs I automate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Install an app from Software Center remotely.&lt;/strong&gt; &lt;code&gt;Invoke-CMAppInstallOnClient -ComputerName PC01 -ApplicationName '7-Zip 24.08 x64' -WhatIf&lt;/code&gt; calls &lt;code&gt;CCM_Application&lt;/code&gt; in &lt;code&gt;root\ccm\ClientSDK&lt;/code&gt;, exactly like clicking Install. The app must already be deployed to the device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pending reboot report.&lt;/strong&gt; &lt;code&gt;Get-CMPendingRebootReport -ComputerName PC01, PC02&lt;/code&gt; calls &lt;code&gt;CCM_ClientUtilities.DetermineIfRebootPending&lt;/code&gt; and checks the usual Windows registry indicators. Point it at a collection with &lt;code&gt;-SiteCode&lt;/code&gt; / &lt;code&gt;-ProviderMachineName&lt;/code&gt; / &lt;code&gt;-CollectionId&lt;/code&gt; and it reads the client state from the site instead of contacting each machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client repair.&lt;/strong&gt; &lt;code&gt;Repair-CMClient&lt;/code&gt; escalates from the least invasive option (&lt;code&gt;SMS_Client.RepairClient&lt;/code&gt;) to restarting &lt;code&gt;CcmExec&lt;/code&gt; to a full &lt;code&gt;ccmsetup&lt;/code&gt; reinstall. Every method is gated by &lt;code&gt;ShouldProcess&lt;/code&gt; with high confirm impact.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Safe practice
&lt;/h2&gt;

&lt;p&gt;Client actions feel harmless, and a single policy refresh is. But a full inventory, a software update evaluation, or an app install across a large collection can put real load on your management points, distribution points, and network. The rules I follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-WhatIf&lt;/code&gt; first&lt;/strong&gt;, and count your targets before anything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pilot collections first&lt;/strong&gt;, then broader rings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never target All Systems&lt;/strong&gt; on a whim. Build a collection that means what you intend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer client notification&lt;/strong&gt; for large batches, so the site throttles for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the logs&lt;/strong&gt; on the client (&lt;code&gt;C:\Windows\CCM\Logs&lt;/code&gt;). &lt;code&gt;PolicyAgent.log&lt;/code&gt; and &lt;code&gt;PolicyEvaluator.log&lt;/code&gt; show whether policy actually arrived, and &lt;code&gt;AppDiscovery.log&lt;/code&gt; / &lt;code&gt;AppEnforce.log&lt;/code&gt; cover apps.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Get the code
&lt;/h2&gt;

&lt;p&gt;Everything here is MIT-licensed, with a longer guide in the repo covering the ConfigurationManager module, SMS Provider queries, applications vs. packages, content distribution, software updates, Run Scripts, CMPivot and RBAC:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/anthonybuhnerkemper/configmgr-powershell-toolkit" rel="noopener noreferrer"&gt;https://github.com/anthonybuhnerkemper/configmgr-powershell-toolkit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PowerShell Gallery: &lt;a href="https://www.powershellgallery.com/packages/ConfigMgrAdminToolkit" rel="noopener noreferrer"&gt;https://www.powershellgallery.com/packages/ConfigMgrAdminToolkit&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>powershell</category>
      <category>sccm</category>
      <category>sysadmin</category>
      <category>devops</category>
    </item>
    <item>
      <title>Managing Citrix Virtual Apps and Desktops with PowerShell: a practical guide</title>
      <dc:creator>Anthony Buhnerkemper</dc:creator>
      <pubDate>Sat, 10 Oct 2026 23:02:58 +0000</pubDate>
      <link>https://dev.to/anthonybuhnerkemper/managing-citrix-virtual-apps-and-desktops-with-powershell-a-practical-guide-159e</link>
      <guid>https://dev.to/anthonybuhnerkemper/managing-citrix-virtual-apps-and-desktops-with-powershell-a-practical-guide-159e</guid>
      <description>&lt;p&gt;Citrix Studio is a client of the Citrix PowerShell SDK. Everything you click in Studio turns into SDK calls you could run yourself. Older Studio versions even showed you the PowerShell they ran. So anything Studio can do, PowerShell can do too, plus a lot that Studio can't: bulk changes, scheduled reporting, and changes you can audit and repeat.&lt;/p&gt;

&lt;p&gt;This is a condensed version of a longer guide I keep alongside my open-source toolkit. The full guide and all the scripts referenced here are in &lt;a href="https://github.com/anthonybuhnerkemper/citrix-powershell-toolkit" rel="noopener noreferrer"&gt;citrix-powershell-toolkit on GitHub&lt;/a&gt;, and the scripts are also packaged as a module on the &lt;a href="https://www.powershellgallery.com/packages/CitrixAdminToolkit" rel="noopener noreferrer"&gt;PowerShell Gallery&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Cmdlet names come from the published SDK, but properties and parameters change between releases. Always check &lt;code&gt;Get-Help &amp;lt;cmdlet&amp;gt; -Full&lt;/code&gt; on your own controller and test in a lab.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What the SDK actually talks to
&lt;/h2&gt;

&lt;p&gt;A Citrix Virtual Apps and Desktops (CVAD) site is a set of services on each Delivery Controller, and each service has its own cmdlet prefix:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Prefix&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Broker&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Broker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Get-BrokerMachine&lt;/code&gt;, &lt;code&gt;Get-BrokerSession&lt;/code&gt;, &lt;code&gt;Get-BrokerDesktopGroup&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Machine Creation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Prov&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Get-ProvScheme&lt;/code&gt;, &lt;code&gt;Get-ProvTask&lt;/code&gt;, &lt;code&gt;Publish-ProvMasterVMImage&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Hyp&lt;/code&gt; / &lt;code&gt;XDHyp:&lt;/code&gt; drive&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Get-ChildItem XDHyp:\Connections&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Configuration Logging&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Log&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Get-LogHighLevelOperation&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delegated Admin&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Admin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Get-AdminAdministrator&lt;/code&gt;, &lt;code&gt;Get-AdminRole&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cmdlets never touch the site database directly. They make remote calls to a controller, which is why every cmdlet takes &lt;code&gt;-AdminAddress&lt;/code&gt; and why you can run the SDK from any domain-joined admin box that has it installed. Licensing is separate: the License Server has its own module (&lt;code&gt;Citrix.Licensing.Admin.V1&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Loading the SDK: snap-ins vs. modules
&lt;/h2&gt;

&lt;p&gt;For years every Citrix service shipped as a Windows PowerShell &lt;strong&gt;snap-in&lt;/strong&gt;, so community scripts are full of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Add-PSSnapin&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Citrix&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Snap-ins only work in Windows PowerShell 5.1. From the 2203 release on, the SDK also ships as &lt;strong&gt;modules&lt;/strong&gt; (&lt;code&gt;Citrix.Broker.Admin.V2&lt;/code&gt; and friends), so &lt;code&gt;Import-Module&lt;/code&gt; and auto-loading work. Whether PowerShell 7 is supported depends on your release, so test it before you rely on it. In practice, a lot of Citrix automation still runs in 5.1.&lt;/p&gt;

&lt;p&gt;My scripts load the SDK defensively, so the same code runs on old and new controllers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="kr"&gt;function&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Import-CitrixSdk&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Get-Command&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Get-BrokerSite&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ErrorAction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SilentlyContinue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$mod&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Get-Module&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ListAvailable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Citrix.Broker.Admin.V2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Select-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-First&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$mod&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Import-Module&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$mod&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ErrorAction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Stop&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Get-Command&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Add-PSSnapin&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ErrorAction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SilentlyContinue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Add-PSSnapin&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Citrix&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ErrorAction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SilentlyContinue&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Get-Command&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Get-BrokerSite&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ErrorAction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SilentlyContinue&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;throw&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'Citrix SDK not found.'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read &lt;code&gt;Get-Help about_Broker_Filtering&lt;/code&gt; at least once. It documents the filter language every &lt;code&gt;Get-Broker*&lt;/code&gt; cmdlet shares.&lt;/p&gt;

&lt;h2&gt;
  
  
  Citrix DaaS: the Remote PowerShell SDK
&lt;/h2&gt;

&lt;p&gt;With Citrix DaaS, Citrix runs the controllers, so you install the &lt;strong&gt;Remote PowerShell SDK&lt;/strong&gt; on a machine you control. The cmdlet names are the same, and the calls go through Citrix Cloud. For automation, create a secure (API) client in the Citrix Cloud console and register it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Set-XDCredentials&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-CustomerId&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'yourCustomerId'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;                  &lt;/span&gt;&lt;span class="nt"&gt;-SecureClientFile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'C:\Secure\secureclient.csv'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;                  &lt;/span&gt;&lt;span class="nt"&gt;-ProfileType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;CloudApi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-StoreAs&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'DaaSAutomation'&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Later sessions&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Get-XDCredentials&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ProfileName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'DaaSAutomation'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Out-Null&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nx"&gt;Get-XDAuthentication&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ProfileName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'DaaSAutomation'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Treat that secure client file like a password. On DaaS you don't pass &lt;code&gt;-AdminAddress&lt;/code&gt;, which is one reason I splat it (see below).&lt;/p&gt;

&lt;h2&gt;
  
  
  Always say which controller
&lt;/h2&gt;

&lt;p&gt;On-premises, pass &lt;code&gt;-AdminAddress&lt;/code&gt; explicitly in scripts. It makes the target obvious and lets the script run from a jump host. I splat it so the same code works with DaaS, where it's omitted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ap&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$AdminAddress&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$ap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AdminAddress&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$AdminAddress&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Get-BrokerDesktopGroup&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nx"&gt;ap&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For scheduled jobs, pick the first healthy controller from a list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ddc&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'ddc01'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'ddc02'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Where-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;try&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Get-BrokerServiceStatus&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-AdminAddress&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ErrorAction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Stop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ServiceStatus&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-eq&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'OK'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;catch&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Select-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-First&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Three conventions that bite everyone
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The 250-record trap.&lt;/strong&gt; &lt;code&gt;Get-Broker*&lt;/code&gt; cmdlets return at most &lt;strong&gt;250 records by default&lt;/strong&gt;. When there are more, you get a warning and quietly truncated data. This is the most common bug in Citrix reporting scripts. Always set it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-BrokerSession&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-MaxRecordCount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;100000&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Filter on the server.&lt;/strong&gt; Typed parameters and &lt;code&gt;-Filter&lt;/code&gt; are evaluated by the Broker, so much less data crosses the wire than with &lt;code&gt;Where-Object&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-BrokerMachine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-RegistrationState&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Unregistered&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-InMaintenanceMode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-MaxRecordCount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;5000&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Get-BrokerSession&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Filter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SessionState&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-eq&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'Disconnected'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;DesktopGroupName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-like&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'Finance*'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-MaxRecordCount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save &lt;code&gt;Where-Object&lt;/code&gt; for computed values like "disconnected for more than four hours".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Names vs. Uids, and three kinds of machine name.&lt;/strong&gt; Relationships are often exposed as integer Uids (&lt;code&gt;AssociatedDesktopGroupUids&lt;/code&gt; on applications), so build a lookup hashtable once instead of querying in a loop. For machines, &lt;code&gt;MachineName&lt;/code&gt; is &lt;code&gt;DOMAIN\HOST&lt;/code&gt;, &lt;code&gt;DNSName&lt;/code&gt; is the FQDN, and &lt;code&gt;HostedMachineName&lt;/code&gt; is the hypervisor VM name. Use the right one when you join data.&lt;/p&gt;

&lt;p&gt;And the pipeline caution: Set cmdlets accept Get output, which is powerful and easy to over-apply. Run the Get half alone and count the results before adding &lt;code&gt;| Set-BrokerMachine&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cmdlets I use most
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Machines&lt;/strong&gt; â€” &lt;code&gt;Get-BrokerMachine&lt;/code&gt; gives you &lt;code&gt;RegistrationState&lt;/code&gt;, &lt;code&gt;PowerState&lt;/code&gt;, &lt;code&gt;InMaintenanceMode&lt;/code&gt;, &lt;code&gt;SessionCount&lt;/code&gt;, &lt;code&gt;FaultState&lt;/code&gt;, &lt;code&gt;LastDeregistrationReason&lt;/code&gt;, &lt;code&gt;AgentVersion&lt;/code&gt; and &lt;code&gt;Tags&lt;/code&gt;. Maintenance mode (&lt;code&gt;Set-BrokerMachine -InMaintenanceMode $true&lt;/code&gt;) blocks &lt;em&gt;new&lt;/em&gt; connections; existing sessions continue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sessions&lt;/strong&gt; â€” &lt;code&gt;Get-BrokerSession&lt;/code&gt; with &lt;code&gt;SessionState&lt;/code&gt; and &lt;code&gt;SessionStateChangeTime&lt;/code&gt; reliably gives you "disconnected since". &lt;code&gt;Stop-BrokerSession&lt;/code&gt; logs off, &lt;code&gt;Disconnect-BrokerSession&lt;/code&gt; disconnects, and &lt;code&gt;Send-BrokerSessionMessage&lt;/code&gt; warns users before maintenance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delivery groups&lt;/strong&gt; â€” &lt;code&gt;Get-BrokerDesktopGroup&lt;/code&gt; exposes &lt;code&gt;DesktopsAvailable&lt;/code&gt;, &lt;code&gt;DesktopsInUse&lt;/code&gt;, &lt;code&gt;DesktopsUnregistered&lt;/code&gt; and more, which is enough for a quick capacity dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Power&lt;/strong&gt; â€” don't call the hypervisor yourself. Queue &lt;code&gt;New-BrokerHostingPowerAction -Action Restart&lt;/code&gt; and let the hosting connection's throttling protect your storage from boot storms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCS&lt;/strong&gt; â€” &lt;code&gt;Get-ProvScheme&lt;/code&gt;, &lt;code&gt;Get-ProvTask&lt;/code&gt;, and &lt;code&gt;Publish-ProvMasterVMImage&lt;/code&gt; for image updates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configuration Logging&lt;/strong&gt; â€” &lt;code&gt;Get-LogHighLevelOperation&lt;/code&gt; tells you who changed what and when, whether from Studio or the SDK.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tags (&lt;code&gt;Add-BrokerTag&lt;/code&gt;) deserve special mention. They're the cleanest way to define patch rings or reboot waves without restructuring delivery groups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workflows I automate
&lt;/h2&gt;

&lt;p&gt;These map to scripts in the toolkit. Every script that changes something supports &lt;code&gt;-WhatIf&lt;/code&gt; and &lt;code&gt;-Confirm&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Morning health check.&lt;/strong&gt; &lt;code&gt;Get-CitrixMachineHealth&lt;/code&gt; (registration, maintenance, power), &lt;code&gt;Get-CitrixDeliveryGroupSummary&lt;/code&gt; (capacity), and &lt;code&gt;Get-CitrixSessionReport&lt;/code&gt;. Unregistered machines that aren't in maintenance mode are almost always the first thing worth looking at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Drain a server for patching.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;Set-CitrixMaintenanceMode -MachineName ... -Enable $true -WhatIf&lt;/code&gt;, then for real.&lt;/li&gt;
&lt;li&gt;Optionally warn users with &lt;code&gt;Send-BrokerSessionMessage&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Wait for sessions to drain.&lt;/li&gt;
&lt;li&gt;Patch and restart.&lt;/li&gt;
&lt;li&gt;Confirm &lt;code&gt;RegistrationState -eq 'Registered'&lt;/code&gt;, then turn maintenance mode off.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Clean up stale disconnected sessions.&lt;/strong&gt; Report first with &lt;code&gt;Get-CitrixDisconnectedSessions -MinimumMinutes 480&lt;/code&gt;, then act with &lt;code&gt;Invoke-CitrixLogoffIdleSessions -DisconnectedMinutes 480 -WhatIf&lt;/code&gt;. Long term, set session-limit timers through policy, so the script handles exceptions rather than doing the work policy should do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Staggered reboots by tag.&lt;/strong&gt; &lt;code&gt;Restart-CitrixMachinesByTag&lt;/code&gt; skips machines with sessions by default and queues restarts in batches with a delay. For multi-session groups, consider the built-in reboot schedules too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCS image update.&lt;/strong&gt; Update and seal the master, snapshot it, &lt;code&gt;Publish-ProvMasterVMImage&lt;/code&gt;, watch &lt;code&gt;Get-ProvTask&lt;/code&gt;, then roll machines onto the new image with a staged reboot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Documentation and audit.&lt;/strong&gt; &lt;code&gt;Export-CitrixSiteDocumentation&lt;/code&gt; writes a point-in-time HTML/CSV snapshot of the site. &lt;code&gt;Get-CitrixConfigLogReport&lt;/code&gt; pulls Configuration Logging entries. &lt;code&gt;Get-CitrixCatalogReport&lt;/code&gt;, &lt;code&gt;Get-CitrixApplicationInventory&lt;/code&gt; and &lt;code&gt;Get-CitrixLicenseUsage&lt;/code&gt; round out the inventory.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on reporting output
&lt;/h2&gt;

&lt;p&gt;Every report in the toolkit emits objects first and files second. That sounds minor, but it means you can pipe a report into &lt;code&gt;Where-Object&lt;/code&gt;, &lt;code&gt;Group-Object&lt;/code&gt; or &lt;code&gt;Export-Csv&lt;/code&gt; the same way you would any other cmdlet, and only write HTML or CSV when you actually want an artifact. For example, a quick count of unregistered machines per delivery group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-CitrixMachineHealth&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-AdminAddress&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ddc01&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;Where-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;RegistrationState&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-ne&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'Registered'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;Group-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;DesktopGroupName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Sort-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Count&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Descending&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Objects also make it easy to diff today's run against yesterday's, which is often where the useful signal is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safe change practice
&lt;/h2&gt;

&lt;p&gt;The habits that have saved me the most pain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-WhatIf&lt;/code&gt; first, every time.&lt;/strong&gt; If a script that changes state doesn't support &lt;code&gt;ShouldProcess&lt;/code&gt;, don't schedule it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get, count, then Set.&lt;/strong&gt; &lt;code&gt;(Get-BrokerMachine -Tag 'Wave1' -MaxRecordCount 5000).Count&lt;/code&gt; before you pipe anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small batches.&lt;/strong&gt; Pilot on one machine, then one delivery group, then everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave a trail.&lt;/strong&gt; SDK changes appear in Configuration Logging; add your own transcript or log file for scheduled jobs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Scheduling and least privilege
&lt;/h2&gt;

&lt;p&gt;For scheduled tasks, run under a dedicated service account with a &lt;strong&gt;custom Citrix delegated administration role&lt;/strong&gt; scoped to only what the job needs. A read-only reporting job should never run as Full Administrator. On DaaS, use an API client per automation, so you can rotate or revoke one without breaking the others. Run Windows PowerShell 5.1 unless you've confirmed your SDK release supports 7.&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting quick hits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"The term Get-BrokerMachine is not recognized"&lt;/strong&gt; â€” SDK not installed or not loaded. Check &lt;code&gt;Get-Module -ListAvailable Citrix*&lt;/code&gt; and &lt;code&gt;Get-PSSnapin -Registered Citrix*&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exactly 250 results&lt;/strong&gt; â€” you forgot &lt;code&gt;-MaxRecordCount&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access denied on some objects&lt;/strong&gt; â€” your delegated admin scope doesn't cover them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New property missing&lt;/strong&gt; â€” your SDK is older than your controllers. Upgrade the SDK on the admin box.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Get the code
&lt;/h2&gt;

&lt;p&gt;Everything here, plus the full long-form guide, is MIT-licensed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/anthonybuhnerkemper/citrix-powershell-toolkit" rel="noopener noreferrer"&gt;https://github.com/anthonybuhnerkemper/citrix-powershell-toolkit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PowerShell Gallery: &lt;a href="https://www.powershellgallery.com/packages/CitrixAdminToolkit" rel="noopener noreferrer"&gt;https://www.powershellgallery.com/packages/CitrixAdminToolkit&lt;/a&gt; (&lt;code&gt;Install-Module CitrixAdminToolkit&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you find a property that changed in your release, open an issue. Version drift is the hardest part of keeping Citrix automation accurate.&lt;/p&gt;

</description>
      <category>powershell</category>
      <category>citrix</category>
      <category>sysadmin</category>
      <category>devops</category>
    </item>
    <item>
      <title>Three read-only PowerShell reports every Microsoft 365 admin should run</title>
      <dc:creator>Anthony Buhnerkemper</dc:creator>
      <pubDate>Sat, 10 Oct 2026 23:02:45 +0000</pubDate>
      <link>https://dev.to/anthonybuhnerkemper/three-read-only-powershell-reports-every-microsoft-365-admin-should-run-2eem</link>
      <guid>https://dev.to/anthonybuhnerkemper/three-read-only-powershell-reports-every-microsoft-365-admin-should-run-2eem</guid>
      <description>&lt;p&gt;When I pick up a Microsoft 365 tenant, whether it's new to me or one I haven't looked at closely in a while, I don't start by changing anything. I start by asking it questions. Three questions in particular have given me the most value for the least risk:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Where are the licenses going?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Which accounts nobody is using anymore?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Is any mail quietly leaving the organization?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I packaged the scripts I use for these into a small open-source toolkit: &lt;a href="https://github.com/anthonybuhnerkemper/m365-powershell-toolkit" rel="noopener noreferrer"&gt;m365-powershell-toolkit on GitHub&lt;/a&gt;, also available as a module on the &lt;a href="https://www.powershellgallery.com/packages/M365AdminToolkit" rel="noopener noreferrer"&gt;PowerShell Gallery&lt;/a&gt;. All three are &lt;strong&gt;read-only&lt;/strong&gt;. They report, they don't change anything, and they ask for the least-privileged scopes I could get away with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting set up
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Install-Module&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Microsoft.Graph&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Scope&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;CurrentUser&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Install-Module&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ExchangeOnlineManagement&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Scope&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;CurrentUser&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Either clone the repo and run the .ps1 files, or install the module:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Install-Module&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;M365AdminToolkit&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Scope&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;CurrentUser&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PowerShell 7.2+ is what I use day to day, but Windows PowerShell 5.1 works for most of it. An account with &lt;strong&gt;Global Reader&lt;/strong&gt; or &lt;strong&gt;Reports Reader&lt;/strong&gt; is enough; you don't need Global Admin to run any of these. Every function has comment-based help, so &lt;code&gt;Get-Help Get-StaleEntraUsers -Full&lt;/code&gt; is the fastest way to see the options.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. License usage: &lt;code&gt;Get-M365LicenseReport&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Licenses are usually the biggest line item a tenant has, and the admin center makes it surprisingly awkward to answer "who has what?" across every SKU at once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-M365LicenseReport&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-OutputPath&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;\reports&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It connects to Microsoft Graph with just &lt;code&gt;Organization.Read.All&lt;/code&gt; and &lt;code&gt;User.Read.All&lt;/code&gt;, then produces two CSVs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A SKU summary&lt;/strong&gt;: enabled, consumed and available units for each subscribed SKU, so you can spot both shelfware and SKUs that are about to run out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-user assignments&lt;/strong&gt;: one row per user per assigned SKU, with the SKU part number resolved from its GUID.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I look for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SKUs with a large gap between purchased and consumed (money on the table at renewal).&lt;/li&gt;
&lt;li&gt;Disabled accounts still holding paid licenses. Sort the per-user CSV by &lt;code&gt;AccountEnabled&lt;/code&gt; and this jumps out immediately.&lt;/li&gt;
&lt;li&gt;Users with overlapping SKUs (for example a standalone add-on that's already included in a suite they have).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Stale accounts: &lt;code&gt;Get-StaleEntraUsers&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Every tenant collects dead accounts: people who left, test accounts, "temporary" vendor logins. Each one is an attack surface and often a license too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-StaleEntraUsers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-DaysInactive&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;90&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-IncludeNeverSignedIn&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This uses the &lt;code&gt;signInActivity&lt;/code&gt; property on the user object, which carries both the last interactive and last non-interactive sign-in. The script treats a user as stale only when &lt;strong&gt;both&lt;/strong&gt; are older than the threshold. That matters: a mailbox used only by a sync client or a service can look dormant if you only check interactive sign-ins.&lt;/p&gt;

&lt;p&gt;A few practical notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;signInActivity&lt;/code&gt; needs &lt;strong&gt;Entra ID P1 or P2&lt;/strong&gt; and the &lt;code&gt;AuditLog.Read.All&lt;/code&gt; scope in addition to &lt;code&gt;User.Read.All&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;By default only Member accounts are evaluated. Add &lt;code&gt;-IncludeGuests&lt;/code&gt; to include B2B guests, which is often where the real clutter is.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-IncludeNeverSignedIn&lt;/code&gt; adds accounts with no recorded sign-in that were created before the cutoff. Those are frequently provisioning leftovers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I hand the CSV to whoever owns access reviews rather than disabling accounts from the script. Keeping the report and the action separate means nobody gets locked out by a bad assumption.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. External forwarding: &lt;code&gt;Get-MailboxForwardingAudit&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This one has caught real problems for me. Auto-forwarding to an outside address is a classic sign of a compromised mailbox, and it's also how well-meaning users leak data to personal accounts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-MailboxForwardingAudit&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-InternalDomains&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;contoso.com&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;contoso.onmicrosoft.com&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It connects to Exchange Online and checks two places forwarding can hide (use &lt;code&gt;-SkipInboxRules&lt;/code&gt; for a faster mailbox-only pass on large tenants):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mailbox-level forwarding&lt;/strong&gt; (&lt;code&gt;ForwardingAddress&lt;/code&gt; / &lt;code&gt;ForwardingSmtpAddress&lt;/code&gt;), which admins or users can set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inbox rules&lt;/strong&gt; that forward, redirect, or forward-as-attachment, which is where attackers prefer to put it, since users rarely look.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anything that targets a domain not in &lt;code&gt;-InternalDomains&lt;/code&gt; gets flagged. Pass every accepted domain you own, or you'll get noise from internal forwards.&lt;/p&gt;

&lt;p&gt;When it finds something, I check the account's sign-in logs before touching the rule. If the forwarding was set by an attacker, the evidence matters more than a quick cleanup. Separately, it's worth confirming that your outbound spam policy blocks automatic external forwarding by default and only allows it by exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why read-only first
&lt;/h2&gt;

&lt;p&gt;None of these scripts will fix anything for you, and that's on purpose. Reports are safe to schedule, safe to hand to a junior admin, and safe to run in a tenant you don't fully understand yet. They turn "I think we have a lot of stale accounts" into a CSV you can put in front of the people who decide.&lt;/p&gt;

&lt;p&gt;Run them on a regular cadence and compare each run against the last one. The changes are often more interesting than the totals.&lt;/p&gt;

&lt;p&gt;The code is MIT-licensed. Issues and pull requests are welcome:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/anthonybuhnerkemper/m365-powershell-toolkit" rel="noopener noreferrer"&gt;https://github.com/anthonybuhnerkemper/m365-powershell-toolkit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PowerShell Gallery: &lt;a href="https://www.powershellgallery.com/packages/M365AdminToolkit" rel="noopener noreferrer"&gt;https://www.powershellgallery.com/packages/M365AdminToolkit&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>powershell</category>
      <category>microsoft365</category>
      <category>sysadmin</category>
      <category>security</category>
    </item>
  </channel>
</rss>
