Entra / Microsoft 365 · Licensing
Find underused Copilot for Microsoft 365 licenses
Check users with Copilot for Microsoft 365 licenses who may not be using the features as expected, and optionally reclaim licenses.
Connect & set up
Run these once per session. All scopes are read-only unless the script makes changes.
Connect-MgGraph -NoWelcome -Scopes Reports.Read.All, ReportSettings.ReadWrite.All, User.ReadWrite.All# Reports.Read.All is needed to fetch usage data# ReportSettings.ReadWrite.All is needed to change the tenant settings to allow access to unobfuscated usage data# User.ReadWrite.All is needed to read license data for user accounts and to remove licenses from accounts. Also to read sign-in data for users.
Run it
The main script. Copy it, or download the .ps1 and run it from your console.
If (!(Get-MgContext).Account) {Write-Host "Connecting to Microsoft Graph..."Connect-MgGraph -NoWelcome -Scopes Reports.Read.All, ReportSettings.ReadWrite.All, User.ReadWrite.All# Reports.Read.All is needed to fetch usage data# ReportSettings.ReadWrite.All is needed to change the tenant settings to allow access to unobfuscated usage data# User.ReadWrite.All is needed to read license data for user accounts and to remove licenses from accounts. Also to read sign-in data for users.}# Define the score that marks a user as underusing Microsoft 365 Copilot[double]$MicrosoftCopilotScore = 30# Sku Id for the Microsoft 365 Copilot license[guid]$CopilotSKUId = "639dec6b-bb19-468b-871c-c5c441c4b0cb"Write-Host "Scanning for user accounts with Microsoft 365 Copilot licenses..."[array]$Users = Get-MgUser -Filter "usertype eq 'Member' and assignedLicenses/any(s:s/skuId eq $CopilotSkuId)" `-ConsistencyLevel Eventual -CountVariable Licenses -All -Sort 'displayName' `-Property Id, displayName, signInActivity, userPrincipalName -PageSize 999If (!$Users) {Write-Host "No users with Microsoft 365 Copilot licenses found"Break} Else {Write-Host ("{0} users with Microsoft 365 Copilot licenses found" -f $Users.Count)}$ConcealedNames = $false# Make sure that we can fetch usage data that isn't obfuscatedWrite-Host "Checking tenant settings for usage data obfuscation..."If ((Get-MgAdminReportSetting).DisplayConcealedNames -eq $true) {$Parameters = @{ displayConcealedNames = $false }Write-Host "Switching tenant settings to allow access to unobfuscated usage data..."Update-MgAdminReportSetting -BodyParameter $Parameters$ConcealedNames = $true}# Fetch usage data for CopilotWrite-Host "Fetching Microsoft 365 Copilot usage data..."$Uri = "https://graph.microsoft.com/beta/reports/getMicrosoft365CopilotUsageUserDetail(period='D90')"[array]$SearchRecords = Invoke-GraphRequest -Uri $Uri -Method GetIf (!($SearchRecords)) {Write-Host "No usage data found for Microsoft 365 Copilot"Break}# Store the fetched usage data in an array[array]$UsageData = $SearchRecords.value# Check do we have more usage data records to fetch and fetch more if a nextlink is available$NextLink = $SearchRecords.'@Odata.NextLink'While ($null -ne $NextLink) {$SearchRecords = $null[array]$SearchRecords = Invoke-MgGraphRequest -Uri $NextLink -Method GET$UsageData += $SearchRecords.valueWrite-Host ("{0} usage data records fetched so far..." -f $UsageData.count)$NextLink = $SearchRecords.'@odata.NextLink'}$CopilotReport = [System.Collections.Generic.List[Object]]::new()ForEach ($User in $Users) {$LastSignIn = $null; $ScoreApps = 7[array]$UserData = $UsageData | Where-Object {$_.UserPrincipalName -eq $User.UserPrincipalName}If (!($UserData)) {# can't assess a user if we don't have usage dataWrite-Host ("No Microsoft 365 Copilot usage data found for {0}" -f $User.DisplayName)Continue}If ($User.SignInActivity.LastSuccessfulSignInDateTime) {$LastSignIn = $User.SignInActivity.LastSuccessfulSignInDateTime} Else {$LastSignIn = $User.SignInactivity.LastSignInDateTime}If ($null -eq $LastSignIn) {$LastSignIn = "Never"$DaysSinceSignIn = "N/A"} Else {# Is it more than 30 days since a sign-in?$LastSignIn = Get-Date $LastSignIn -format 'dd-MMM-yyyy HH:mm:ss'$DaysSinceSignIn = (New-TimeSpan ($LastSignIn)).Days}# Check dates of use for the various Copilot features# OneNoteIf (-not ([string]::IsNullOrEmpty($UserData.oneNoteCopilotLastActivityDate))) {$OneNoteDate = Get-Date $UserData.oneNoteCopilotLastActivityDate -format 'dd-MMM-yyyy'$OneNoteDays = (New-TimeSpan $OneNoteDate).Days} Else {$OneNoteDate = 'Not used'$OneNoteDays = 0$ScoreApps = $ScoreApps -1}#TeamsIf (-not ([string]::IsNullOrEmpty($UserData.microsoftTeamsCopilotLastActivityDate))) {$TeamsDate = Get-Date $UserData.microsoftTeamsCopilotLastActivityDate -format 'dd-MMM-yyyy'$TeamsDays = (New-TimeSpan $TeamsDate).Days} Else {$TeamsDate = 'Not used'$TeamsDays = 0$ScoreApps = $ScoreApps -1}#OutlookIf (-not ([string]::IsNullOrEmpty($UserData.outlookCopilotLastActivityDate))) {$OutlookDate = Get-Date $UserData.outlookCopilotLastActivityDate -format 'dd-MMM-yyyy'$OutlookDays = (New-TimeSpan $OutlookDate).Days} Else {$OutlookDate = 'Not used'$OutlookDays = 0$ScoreApps = $ScoreApps -1}# WordIf (-not ([string]::IsNullOrEmpty($UserData.wordCopilotLastActivityDate))) {$WordDate = Get-Date $UserData.wordCopilotLastActivityDate -format 'dd-MMM-yyyy'$WordDays = (New-TimeSpan $WordDate).Days} Else {$WordDate = 'Not used'$WordDays = 0$ScoreApps = $ScoreApps -1}# Microsoft 365 ChatIf (-not ([string]::IsNullOrEmpty($UserData.copilotChatLastActivityDate))) {$ChatDate = Get-Date $UserData.copilotChatLastActivityDate -format 'dd-MMM-yyyy'$ChatDays = (New-TimeSpan $ChatDate).Days} Else {$ChatDate = 'Not used'$ChatDays = 0$ScoreApps = $ScoreApps -1}# ExcelIf (-not ([string]::IsNullOrEmpty($UserData.excelCopilotLastActivityDate))) {$ExcelDate = Get-Date $UserData.excelCopilotLastActivityDate -format 'dd-MMM-yyyy'$ExcelDays = (New-TimeSpan $ExcelDate).Days} Else {$ExcelDate = 'Not used'$ExcelDays = 0$ScoreApps = $ScoreApps -1}# PowerPointIf (-not ([string]::IsNullOrEmpty($UserData.powerPointCopilotLastActivityDate))) {$PowerPointDate = Get-Date $UserData.powerPointCopilotLastActivityDate -format 'dd-MMM-yyyy'$PowerPointDays = (New-TimeSpan $PowerPointDate).Days} Else {$PowerPointDate = 'Not used'$PowerPointDays = 0$ScoreApps = $ScoreApps -1}# Compute a score for the user$Score = $OutlookDays + $TeamsDays + $OneNoteDays + $ExcelDays + $WordDays + $ChatDays + $PowerPointDaysIf ($ScoreApps -gt 0) {[double]$UserScore = ($Score / $ScoreApps)} Else {[double]$UserScore = 0}$ReportLine = [PSCustomObject][Ordered]@{UserPrincipalName = $User.UserPrincipalNameUser = $User.DisplayName'Last sign in' = $LastSignIn'Days since sign in' = $DaysSinceSignIn'Copilot data from' = Get-Date $UserData.reportRefreshDate -format 'dd-MMM-yyyy''Copilot in Teams' = $TeamsDate'Days since Teams' = $TeamsDays'Copilot in Outlook' = $OutlookDate'Days since Outlook' = $OutlookDays'Copilot in Word' = $WordDate'Days since Word' = $WordDays'Copilot in Chat' = $ChatDate'Days since Chat' = $ChatDays'Copilot in Excel' = $ExcelDate'Days since Excel' = $ExcelDays'Copilot in PowerPoint' = $PowerPointDate'Days since PowerPoint' = $PowerPointDays'Copilot in OneNote' = $OneNoteDate'Days since OneNote' = $OneNoteDays'Number active apps' = $ScoreApps'Overall Score' = $UserScore}$CopilotReport.Add($ReportLine)}# Extract the set of users who should be considered as underusing Copilot[array]$UnderusedCopilot = $CopilotReport | Where-Object {$_.'Overall Score' -gt $MicrosoftCopilotScore -or $_.'Overall Score' -eq 0}# If there are no underused Copilot users, say so - and if we have, give the administrator the chance to remove the licensesIf (!($UnderusedCopilot)) {Write-Host "No users found to be underusing an assigned Microsoft 365 Copilot license"} Else {Clear-Host$LicenseReport = [System.Collections.Generic.List[Object]]::new()Write-Host ("The following {0} users are underusing their assigned Microsoft 365 Copilot license" -f $UnderusedCopilot.Count)$UnderusedCopilot | Sort-Object {$_.'Overall Score' -as [double]} | Select-Object User, UserPrincipalName, 'Number active apps', 'Overall Score' | Format-Table -AutoSize[string]$Decision = Read-Host "Do you want to remove the Microsoft 365 Copilot licenses from these users"If ($Decision.Substring(0,1).toUpper() -eq "Y") {ForEach ($User in $UnderusedCopilot) {# Check that the user still has a Copilot license...$UserLicenseData = $User = Get-MgUser -Userid $User.UserPrincipalName -Property Id, displayName, userPrincipalName, assignedLicenses, licenseAssignmentStatesIf ($CopilotSKUId -notin $UserLicenseData.assignedLicenses.skuId) {Write-Host ("The {0} account does not have a Microsoft 365 Copilot license" -f $UserLicenseData.displayName)Continue}# Direct assigned license or group-assigned license?[array]$CopilotLicense = $User.LicenseAssignmentStates | Where-Object {$_.skuId -eq $CopilotSkuId}If ($null -eq $CopilotLicense[0].assignedByGroup) {# Process the removal of a direct-assigned licenseTry {Write-Host ("Removing direct-assigned Microsoft 365 Copilot license from {0}" -f $UserLicenseData.displayName) -ForegroundColor YellowSet-MgUserLicense -UserId $UserLicenseData.Id -AddLicenses @{} -RemoveLicenses @($CopilotSKUId) -ErrorAction Stop | Out-Null$LicenseReportLine = = [PSCustomObject][Ordered]@{UserPrincipalName = $UserLicenseData.UserPrincipalNameUser = $UserLicenseData.displayNameAction = "Removed direct assigned Copilot license"SkuId = $CopilotSKUIdTimestamp = Get-Date -format s}$LicenseReport.Add($LicenseReportLine)} Catch {Write-Host ("Failed to remove Microsoft 365 Copilot license from {0}: {1}" -f $UserLicenseData.displayName, $_.Exception.Message) -ForegroundColor Red}} Else {# Process the removal of a group-assigned licenseWrite-Host ("Removing group-assigned Microsoft 365 Copilot license from {0}" -f $UserLicenseData.displayName) -ForegroundColor Yellow$GroupId = $CopilotLicense[0].assignedByGroupTry {Remove-MgGroupMemberDirectoryObjectByRef -DirectoryObjectId $UserLicenseData.Id -GroupId $GroupId -ErrorAction Stop$LicenseReportLine = [PSCustomObject][Ordered]@{UserPrincipalName = $UserLicenseData.UserPrincipalNameUser = $UserLicenseData.displayNameAction = ("Removed group assigned Copilot license from {0}" -f $GroupId)SkuId = $CopilotSKUIdTimestamp = Get-Date -format s}$LicenseReport.Add($LicenseReportLine)} Catch {Write-Host ("Failed to remove Microsoft 365 Copilot license for {0} from group {1}: {2}" -f $UserLicenseData.displayName, $GroupId, $_.Exception.Message) -ForegroundColor Red}}}Write-Host ("{0} Microsoft 365 Copilot licenses removed" -f $LicenseReport.Count)} Else {Write-Host "No Microsoft 365 Copilot licenses removed"}}If ($LicenseReport) {Write-Host ""Write-Host "License removal report"$LicenseReport | Select-Object Timestamp, User, UserPrincipalName, Action | Sort-Object Timestamp | Format-Table -AutoSize}Write-Host "Generating report..."If (Get-Module ImportExcel -ListAvailable) {$ExcelGenerated = $TrueImport-Module ImportExcel -ErrorAction SilentlyContinue$ExcelOutputFile = ((New-Object -ComObject Shell.Application).Namespace('shell:Downloads').Self.Path) + "\Underused Copilot Licenses.xlsx"If (Test-Path $ExcelOutputFile) {Remove-Item $ExcelOutputFile -ErrorAction SilentlyContinue}$UnderusedCopilot | Export-Excel -Path $ExcelOutputFile -WorksheetName "Copilot License Report" -Title ("Underused Copilot License Report {0}" -f (Get-Date -format 'dd-MMM-yyyy')) -TitleBold -TableName "UnderusedCopilot"} Else {$CSVOutputFile = ((New-Object -ComObject Shell.Application).Namespace('shell:Downloads').Self.Path) + "\Underused Copilot License.CSV"$UnderusedCopilot | Export-Csv -Path $CSVOutputFile -NoTypeInformation -Encoding Utf8}If ($ExcelGenerated) {Write-Host ("An Excel report of underused Microsoft 365 Copilot licenses is available in {0}" -f $ExcelOutputFile)} Else {Write-Host ("A CSV report of underused Microsoft 365 Copilot licenses is available in {0}" -f $CSVOutputFile)}# Reset tenant obfuscation settings to True if we switched the setting earlierIf ((Get-MgAdminReportSetting).DisplayConcealedNames -eq $false -and $ConcealedNames -eq $true) {Write-Host "Resetting tenant settings to obfuscate usage data..."$Parameters = @{ displayConcealedNames = $True }Update-MgAdminReportSetting -BodyParameter $Parameters}
Attribution
Author
Office365itpros