Skip to content

Local AI Agent Inventory

Inventories local AI-agent tooling across managed devices — a modern-workspace signal most fleets can't see yet.

Download the script View on GitHub

1 · The script

Read-only by construction — it authenticates with a Managed Identity, calls Microsoft Graph with GET only, and writes a sanitized CSV snapshot. Set the three CONFIG values at the top, then run.

View the full script (27 KB)
#requires -Version 7.0
<#
.SYNOPSIS
    Collect-LocalAIAgentInventory.ps1 — shadow-AI / local-AI-tooling inventory collector for
    the Zero-Access Pattern. Read-only, Managed-Identity.

.DESCRIPTION
    Reads the per-device output of a Proactive Remediations DETECTION script (deployed
    separately in Intune) that reports which local AI tools are installed/running on each
    device, parses it into structured agents (name / category / signals / sanctioned-or-not),
    enriches with the primary user's department, location, and account status, and writes a
    governance report plus pre-computed stats. Read-only throughout — nothing is written to
    the tenant; the detection runs on the device, this collector only reads the reported
    result.

    Writes:
      root/       AIAgentInventory.csv         (full, identifiable, for Power BI)
      agent-data/ AIAgentInventory_Agent.csv   (slim; user identifiers optional)
      agent-data/ AIAgentInventory_Stats.csv   (pre-computed counts + coverage gaps)

.NOTES
    DEPENDENCY: a companion Intune Proactive Remediation DETECTION script that emits, per
    device, a line describing detected AI tooling (this collector parses that line — see
    ConvertFrom-DetectionOutput for the expected format). That detection script is not part of
    this repo; point $DetectionScriptName / $DetectionScriptId at yours.

    GENERIC / PARAMETERIZED: no resource group, storage account, container, or detection-script
    ID is hardcoded. Run against a personal lab tenant only.

    BETA ENDPOINT: deviceHealthScripts run states use /beta; devices/users use /v1.0.

    Read-only Graph app roles on the Managed Identity:
      DeviceManagementConfiguration.Read.All    (deviceHealthScripts + deviceRunStates)
      DeviceManagementManagedDevices.Read.All    (managed devices fallback)
      User.Read.All, Directory.Read.All          (user department/location/account status)
    Plus "Storage Blob Data Contributor" on the storage account.

    MIT licensed. Microsoft, Intune, Entra, Microsoft Graph and Azure are trademarks of the
    Microsoft group of companies; other product names are trademarks of their owners.
    Independent content; not endorsed by Microsoft.
#>

# ===========================================================================
#  CONFIGURE ME  ->  set these to your own values, then run.
#  These three lines are the only thing you MUST change.
# ===========================================================================
$ResourceGroup  = "<your-resource-group-name>"     # resource group that holds your storage account
$StorageAccount = "<your-storage-account-name>"    # storage account name (lowercase, globally unique)
$Container      = "<your-container-name>"           # blob container, e.g. "intune-report"

# -- detection script + optional settings -----------------------------------
$DetectionScriptName = "Detect-LocalAIAgents"   # display name of your Proactive Remediation detection script
$DetectionScriptId   = ""                       # leave blank to resolve by name above, or paste your script's ID
$PageSize            = 200                       # drop to 50 if Graph rejects the page size
$ActiveDayThreshold  = 14                        # a device synced within N days counts as "active"
$IncludeUserIdentifiersInAgentCopy = $true       # $false = drop PII from the agent-data copy
$IncludeCleanDevicesInAgentCopy    = $false      # $false = agent copy carries findings + unevaluated only
# ===========================================================================

# Safety net - stop if the placeholders above weren't replaced.
if ("$ResourceGroup $StorageAccount $Container" -match '<your-') {
    throw "Please set ResourceGroup, StorageAccount, and Container at the top of the script before running."
}

#-- Step 1 - Initialize Variables
$ExportLocation     = "$env:TEMP"
$Today              = Get-Date -Format 'yyyy-MM-dd'
$ProgressPreference = 'SilentlyContinue'
$VerbosePreference  = 'Continue'

#-- Step 2 - Graph GET with pagination
Function Invoke-MyGraphGetRequest {
    Param ($URL)
    Write-Verbose "Sending request to $URL"
    $AllResults = @()
    try {
        Do {
            $WebRequest   = Invoke-WebRequest -Uri $URL -Method GET -Headers $script:Headers -UseBasicParsing
            $ResponseData = ($WebRequest.Content | ConvertFrom-Json)
            $AllResults  += $ResponseData.value
            $URL          = $ResponseData.'@odata.nextLink'
        } While ($URL)
        Return $AllResults
    } catch {
        Write-Error "Failed to fetch data from ${URL}: $_"
        return $null
    }
}

#-- Step 3 - Authenticate with Managed Identity
$url = $env:IDENTITY_ENDPOINT
$headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
$headers.Add("X-IDENTITY-HEADER", $env:IDENTITY_HEADER)
$headers.Add("Metadata", "True")
$body = @{ resource = 'https://graph.microsoft.com/' }

Write-Verbose "Obtaining access token"
$accessToken    = (Invoke-RestMethod $url -Method 'POST' -Headers $headers -ContentType 'application/x-www-form-urlencoded' -Body $body).access_token
$script:Headers = @{
    'Authorization'    = "Bearer $accessToken"
    'ConsistencyLevel' = 'eventual'
}
Write-Verbose "Access token obtained successfully"

#-- Step 4 - Storage context
try {
    $StorageAccountContext = (Get-AzStorageAccount -ResourceGroupName $ResourceGroup -Name $StorageAccount).Context
    Write-Verbose "Storage context obtained"
} catch { Write-Error "Failed to get storage context: $_"; return }

#-- Step 5 - Resolve the remediation script ID by display name
if (-not $DetectionScriptId) {
    Write-Verbose "Resolving remediation script '$DetectionScriptName'..."
    $Scripts = Invoke-MyGraphGetRequest -URL "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts?`$select=id,displayName"
    $Match   = $Scripts | Where-Object { $_.displayName -eq $DetectionScriptName } | Select-Object -First 1
    if (!$Match) {
        Write-Error "Remediation script '$DetectionScriptName' not found. Check the display name or set `$DetectionScriptId."
        return
    }
    $DetectionScriptId = $Match.id
}
Write-Verbose "Using remediation script ID: $DetectionScriptId"

#-- Step 6 - Fetch per-device detection run states
$RunStateSelect = "detectionState,lastStateUpdateDateTime,preRemediationDetectionScriptOutput,preRemediationDetectionScriptError,remediationState,remediationScriptError"
$NestedFull     = "managedDevice(`$select=id,deviceName,osVersion,operatingSystem,lastSyncDateTime,userPrincipalName)"
$NestedMinimal  = "managedDevice(`$select=id,deviceName,osVersion,userPrincipalName)"

Write-Verbose "Fetching detection run states..."
$NeedBulkDeviceData = $false

$RunStateURI = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/$DetectionScriptId/deviceRunStates?`$select=$RunStateSelect&`$expand=$NestedFull&`$top=$PageSize"
$RunStates   = Invoke-MyGraphGetRequest -URL $RunStateURI

if (!$RunStates) {
    Write-Warning "Extended projection rejected - retrying with the minimal verified expand."
    $NeedBulkDeviceData = $true
    $RunStateURI = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/$DetectionScriptId/deviceRunStates?`$select=$RunStateSelect&`$expand=$NestedMinimal&`$top=$PageSize"
    $RunStates   = Invoke-MyGraphGetRequest -URL $RunStateURI
}

if (!$RunStates) { Write-Error "No detection run states returned. Check the script ID and that DeviceManagementConfiguration.Read.All is granted."; return }
Write-Verbose "Run states returned: $($RunStates.Count)"

#-- Step 7 - Bulk managed devices, ONLY if the expand could not supply sync data
$ManagedDeviceMap = @{}
if ($NeedBulkDeviceData) {
    Write-Verbose "Fetching all managed devices (bulk fallback)..."
    $ManagedDevices = Invoke-MyGraphGetRequest -URL "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$select=id,deviceName,userPrincipalName,lastSyncDateTime,operatingSystem,osVersion"
    foreach ($md in $ManagedDevices) {
        if ($md.id) { $ManagedDeviceMap[$md.id] = $md }
    }
    Write-Verbose "Managed device map built: $($ManagedDeviceMap.Count) entries"
}
else {
    Write-Verbose "Expand supplied device detail - bulk managedDevices sweep skipped."
}

#-- Step 8 - Bulk-fetch ALL users ONCE -> hashtable
#   accountEnabled and department are the two fields the governance report needs
#   beyond the standard location set.
Write-Verbose "Fetching all users (bulk)..."
$AllUsers = Invoke-MyGraphGetRequest -URL "https://graph.microsoft.com/v1.0/users?`$select=displayName,userPrincipalName,city,country,officeLocation,department,jobTitle,accountEnabled,usageLocation"

$UserMap = @{}
foreach ($u in $AllUsers) {
    if ($u.userPrincipalName) { $UserMap[$u.userPrincipalName.ToUpper()] = $u }
}
Write-Verbose "User map built: $($UserMap.Count) entries"

#-- Step 9 - Parse the detection output line into individual agents
function ConvertFrom-DetectionOutput {
    param([string]$Output)

    $agents = @()
    if ([string]::IsNullOrWhiteSpace($Output)) { return $agents }

    $clean = $Output.Replace('...[truncated]', '').Trim()

    # Split the sanctioned tail off the unsanctioned head
    $unsanctionedPart = $clean
    $sanctionedPart   = ''
    $idx = $clean.IndexOf('| Sanctioned:')
    if ($idx -ge 0) {
        $unsanctionedPart = $clean.Substring(0, $idx)
        $sanctionedPart   = $clean.Substring($idx + '| Sanctioned:'.Length)
    }

    # Strip the leading label from the unsanctioned segment
    if ($unsanctionedPart -match '^Unsanctioned AI tooling \(\d+\):(.*)$') {
        $unsanctionedPart = $Matches[1]
    } else {
        $unsanctionedPart = ''   # "No unsanctioned AI tooling detected" / OOBE skip
    }

    foreach ($segment in @(
            [pscustomobject]@{ Text = $unsanctionedPart; Sanctioned = $false },
            [pscustomobject]@{ Text = $sanctionedPart;   Sanctioned = $true }
        )) {

        if ([string]::IsNullOrWhiteSpace($segment.Text)) { continue }

        foreach ($entry in ($segment.Text -split ';')) {
            $e = $entry.Trim()
            if (-not $e) { continue }

            # Name [Category|Signal/Signal]
            if ($e -match '^(.*?)\s*\[([^\|\]]+)\|([^\]]+)\]$') {
                $agents += [pscustomobject]@{
                    Name       = $Matches[1].Trim()
                    Category   = $Matches[2].Trim()
                    Signals    = $Matches[3].Trim()
                    Sanctioned = $segment.Sanctioned
                }
            }
            elseif ($e -match '^(.*?)\s*\[([^\]]+)\]$') {
                # Tolerate the older Name [Signals] format
                $agents += [pscustomobject]@{
                    Name       = $Matches[1].Trim()
                    Category   = 'Unknown'
                    Signals    = $Matches[2].Trim()
                    Sanctioned = $segment.Sanctioned
                }
            }
        }
    }
    return $agents
}

#-- Step 10 - Build the enriched record set (one row per device per agent)
Write-Verbose "Building report..."

function Get-FirstValue($v) {
    if ($v -is [array]) { if ($v.Count -gt 0) { return $v[0] } else { return "Unknown" } }
    if ($v)             { return $v }
    return "Unknown"
}

$Enriched = New-Object System.Collections.Generic.List[object]

foreach ($state in $RunStates) {

    # Device identity comes from the expanded managedDevice. When the extended
    # projection was rejected, supplement sync/OS detail from the bulk map by id.
    $md = $state.managedDevice
    if (-not $md -or -not $md.deviceName) { continue }

    $mdExtra = $null
    if ($NeedBulkDeviceData -and $md.id -and $ManagedDeviceMap.ContainsKey($md.id)) {
        $mdExtra = $ManagedDeviceMap[$md.id]
    }

    $deviceName = $md.deviceName
    $upn  = if ($md.userPrincipalName) { [string]$md.userPrincipalName } else { "Unknown" }
    $user = if ($upn -ne "Unknown" -and $UserMap.ContainsKey($upn.ToUpper())) { $UserMap[$upn.ToUpper()] } else { $null }

    $osName    = if ($md.operatingSystem) { $md.operatingSystem } elseif ($mdExtra) { $mdExtra.operatingSystem } else { "Unknown" }
    $osVersion = if ($md.osVersion) { $md.osVersion } elseif ($mdExtra) { $mdExtra.osVersion } else { "Unknown" }
    $syncRaw   = if ($md.lastSyncDateTime) { $md.lastSyncDateTime } elseif ($mdExtra) { $mdExtra.lastSyncDateTime } else { $null }

    # Account status - the governance question is "does a leaver still have local AI tooling installed"
    $AccountStatus = "Unknown"
    if ($user -and $null -ne $user.accountEnabled) {
        $AccountStatus = if ($user.accountEnabled) { "Enabled" } else { "Disabled" }
    }

    # Last sync + activity window
    $LastSyncDate = "Unknown"; $DaysAgo = ""; $IsActive = ""
    if ($syncRaw) {
        $syncDt       = [datetime]$syncRaw
        $LastSyncDate = $syncDt.ToString("yyyy-MM-dd")
        $DaysAgo      = [int](New-TimeSpan -Start $syncDt -End (Get-Date)).TotalDays
        $IsActive     = if ($DaysAgo -le $ActiveDayThreshold) { "Yes" } else { "No" }
    }

    $LastDetection = "Unknown"
    if ($state.lastStateUpdateDateTime) {
        $LastDetection = ([datetime]$state.lastStateUpdateDateTime).ToString("yyyy-MM-dd")
    }

    $rawOutput = [string]$state.preRemediationDetectionScriptOutput
    $agents    = ConvertFrom-DetectionOutput -Output $rawOutput

    # Devices that errored or were skipped in OOBE are visible, not silently dropped
    $DetectionState = if ($state.detectionState) { [string]$state.detectionState } else { "unknown" }

    $scanStatus = "Scanned"
    if ($state.preRemediationDetectionScriptError -or $DetectionState -eq 'scriptError') { $scanStatus = "ScriptError" }
    elseif ($DetectionState -in @('pending', 'unknown'))                                  { $scanStatus = "NotYetReported" }
    elseif ($rawOutput -like 'Skipped*')                                                  { $scanStatus = "SkippedOOBE" }
    elseif (-not $rawOutput)                                                              { $scanStatus = "NoOutput" }

    $hasUnsanctioned = if ($agents | Where-Object { -not $_.Sanctioned }) { "Yes" } else { "No" }

    # No agents detected -> emit a single 'None' row so estate denominators work
    if ($agents.Count -eq 0) {
        $agents = @([pscustomobject]@{ Name = 'None'; Category = 'None'; Signals = ''; Sanctioned = $false })
    }

    # Country resolution. Entra 'country' is free text and often blank; 'usageLocation'
    # is an ISO 3166-1 alpha-2 code and is reliably populated on licensed users.
    $CountryName = Get-FirstValue $user.country
    $CountryCode = Get-FirstValue $user.usageLocation
    if ($CountryName -eq "Unknown" -and $CountryCode -ne "Unknown") { $CountryName = $CountryCode }
    if ($CountryCode -eq "Unknown" -and $CountryName -ne "Unknown" -and $CountryName.Length -eq 2) { $CountryCode = $CountryName.ToUpper() }

    $agentCount = $agents.Count
    $rowIndex   = 0

    foreach ($agent in $agents) {
        $rowIndex++
        $Enriched.Add([PSCustomObject]@{
            DeviceName          = $deviceName
            PrimaryUser         = $upn
            UserDisplayName     = Get-FirstValue $user.displayName
            Department          = Get-FirstValue $user.department
            JobTitle            = Get-FirstValue $user.jobTitle
            OfficeLocation      = Get-FirstValue $user.officeLocation
            City                = Get-FirstValue $user.city
            Country             = $CountryName
            CountryCode         = $CountryCode
            AccountStatus       = $AccountStatus

            # --- the Power BI filter columns ---
            AIAgentName         = $agent.Name
            AIAgentCategory     = $agent.Category
            AIAgentSanctioned   = if ($agent.Sanctioned) { "Sanctioned" } else { "Unsanctioned" }
            DetectionSignals    = $agent.Signals

            # --- counting guards ---
            # This file is TALL: one row per device per agent. Counting rows
            # overstates the device count. IsPrimaryDeviceRow is 'Yes' on exactly
            # one row per device, so device counts = rows where it equals 'Yes'.
            IsPrimaryDeviceRow  = if ($rowIndex -eq 1) { "Yes" } else { "No" }
            AgentCountOnDevice  = $agentCount

            # --- device context ---
            HasUnsanctionedAI   = $hasUnsanctioned
            ScanStatus          = $scanStatus
            DetectionState      = $DetectionState
            OperatingSystem     = $osName
            OSVersion           = $osVersion
            LastSyncDate        = $LastSyncDate
            LastSyncDaysAgo     = $DaysAgo
            IsActiveLast14Days  = $IsActive
            LastDetectionDate   = $LastDetection
            SnapshotDate        = $Today
        })
    }
}

if (!$Enriched) { Write-Error "No report rows produced."; return }
Write-Verbose "Report rows (device x agent): $($Enriched.Count)"

#-- Step 11 - Pre-computed stats
Write-Verbose "Building summary statistics..."
$Stats = [System.Collections.Generic.List[object]]::new()

function Add-Stat {
    param([string]$Category, [string]$Key, [int]$Count, [string]$Label)
    # StatLabel is a plain-English sentence. Azure AI Search (standard.lucene)
    # tokenises PascalCase categories like 'DevicesByAgent' as a single opaque
    # token, so natural-language questions never reach them. The label is what
    # the agent actually matches on.
    if (-not $Label) { $Label = "$Category - $Key" }
    $Stats.Add([pscustomobject]@{
        ReportType   = 'AIAgentInventory'
        Category     = $Category
        Key          = $Key
        Count        = $Count
        StatLabel    = $Label
        SnapshotDate = $Today
    })
}

$DeviceLevel  = $Enriched | Group-Object DeviceName
$DeviceCount  = $DeviceLevel.Count

Add-Stat -Category 'Total' -Key 'DevicesScanned' -Count $DeviceCount `
    -Label "Total number of distinct devices with an AI agent detection result"

$withUnsanctioned = @($DeviceLevel | Where-Object { $_.Group[0].HasUnsanctionedAI -eq 'Yes' })
Add-Stat -Category 'Total' -Key 'DevicesWithUnsanctionedAI' -Count $withUnsanctioned.Count `
    -Label "Number of distinct devices with unsanctioned AI tooling installed or running"

$cleanDevices = @($DeviceLevel | Where-Object { $_.Group[0].AIAgentName -eq 'None' })
Add-Stat -Category 'Total' -Key 'DevicesWithNoAIDetected' -Count $cleanDevices.Count `
    -Label "Number of distinct devices where no AI tooling of any kind was detected"

# Honest gap: devices the detection could not evaluate. Absent is not the same as clean.
$notEvaluated = @($DeviceLevel | Where-Object { $_.Group[0].ScanStatus -ne 'Scanned' })
Add-Stat -Category 'Coverage' -Key 'DevicesNotSuccessfullyScanned' -Count $notEvaluated.Count `
    -Label "Number of distinct devices where the AI detection did not complete, so their AI tooling status is unknown"

# Agent popularity - the headline chart
$Enriched | Where-Object { $_.AIAgentName -ne 'None' } |
    Group-Object AIAgentName | ForEach-Object {
        $n = ($_.Group | Select-Object -ExpandProperty DeviceName -Unique).Count
        Add-Stat -Category 'DevicesByAgent' -Key $_.Name -Count $n `
            -Label "Number of distinct devices with $($_.Name) detected"
    }

$Enriched | Where-Object { $_.AIAgentName -ne 'None' } |
    Group-Object AIAgentCategory | ForEach-Object {
        $n = ($_.Group | Select-Object -ExpandProperty DeviceName -Unique).Count
        Add-Stat -Category 'DevicesByAgentCategory' -Key $_.Name -Count $n `
            -Label "Number of distinct devices with AI tooling of type $($_.Name)"
    }

# Sanctioned vs unsanctioned split - the adoption-vs-exception story
$Enriched | Where-Object { $_.AIAgentName -ne 'None' } |
    Group-Object AIAgentSanctioned | ForEach-Object {
        $n = ($_.Group | Select-Object -ExpandProperty DeviceName -Unique).Count
        Add-Stat -Category 'DevicesBySanctionState' -Key $_.Name -Count $n `
            -Label "Number of distinct devices with $($_.Name) AI tooling"
    }

# Departments carrying the most unsanctioned tooling - who to talk to first
$Enriched | Where-Object { $_.AIAgentSanctioned -eq 'Unsanctioned' -and $_.AIAgentName -ne 'None' -and $_.Department -ne 'Unknown' } |
    Group-Object Department | ForEach-Object {
        $n = ($_.Group | Select-Object -ExpandProperty DeviceName -Unique).Count
        Add-Stat -Category 'UnsanctionedByDepartment' -Key $_.Name -Count $n `
            -Label "Number of distinct devices with unsanctioned AI tooling in the $($_.Name) department"
    }

$Enriched | Where-Object { $_.AIAgentSanctioned -eq 'Unsanctioned' -and $_.AIAgentName -ne 'None' -and $_.Country -ne 'Unknown' } |
    Group-Object Country | ForEach-Object {
        $n = ($_.Group | Select-Object -ExpandProperty DeviceName -Unique).Count
        Add-Stat -Category 'UnsanctionedByCountry' -Key $_.Name -Count $n `
            -Label "Number of distinct devices with unsanctioned AI tooling in country $($_.Name)"
    }

# Total devices per country and per office, so a RATE can be calculated rather than
# reading raw counts (a large country will always top an unsanctioned-count chart).
$Enriched | Where-Object { $_.Country -ne 'Unknown' } |
    Group-Object Country | ForEach-Object {
        $n = ($_.Group | Select-Object -ExpandProperty DeviceName -Unique).Count
        Add-Stat -Category 'DevicesByCountry' -Key $_.Name -Count $n `
            -Label "Total number of distinct devices scanned in country $($_.Name)"
    }

$Enriched | Where-Object { $_.OfficeLocation -ne 'Unknown' } |
    Group-Object OfficeLocation | ForEach-Object {
        $n = ($_.Group | Select-Object -ExpandProperty DeviceName -Unique).Count
        Add-Stat -Category 'DevicesByOfficeLocation' -Key $_.Name -Count $n `
            -Label "Total number of distinct devices scanned at office location $($_.Name)"
    }

# Honest gap: devices that cannot be placed on the map at all.
$noCountry = @($DeviceLevel | Where-Object { $_.Group[0].Country -eq 'Unknown' })
Add-Stat -Category 'Coverage' -Key 'DevicesWithNoCountry' -Count $noCountry.Count `
    -Label "Number of distinct devices with no country recorded on the primary user, excluded from all country breakdowns"

$noOffice = @($DeviceLevel | Where-Object { $_.Group[0].OfficeLocation -eq 'Unknown' })
Add-Stat -Category 'Coverage' -Key 'DevicesWithNoOfficeLocation' -Count $noOffice.Count `
    -Label "Number of distinct devices with no office location recorded on the primary user, excluded from all office location breakdowns"

$Enriched | Where-Object { $_.AIAgentSanctioned -eq 'Unsanctioned' -and $_.AIAgentName -ne 'None' -and $_.OfficeLocation -ne 'Unknown' } |
    Group-Object OfficeLocation | ForEach-Object {
        $n = ($_.Group | Select-Object -ExpandProperty DeviceName -Unique).Count
        Add-Stat -Category 'UnsanctionedByOfficeLocation' -Key $_.Name -Count $n `
            -Label "Number of distinct devices with unsanctioned AI tooling at office location $($_.Name)"
    }

# Disabled accounts still carrying AI tooling - a leaver-risk signal
$Enriched | Where-Object { $_.AccountStatus -eq 'Disabled' -and $_.AIAgentName -ne 'None' } |
    Group-Object AIAgentSanctioned | ForEach-Object {
        $n = ($_.Group | Select-Object -ExpandProperty DeviceName -Unique).Count
        Add-Stat -Category 'DisabledAccountsWithAI' -Key $_.Name -Count $n `
            -Label "Number of distinct devices with $($_.Name) AI tooling whose primary user account is disabled"
    }

# Scan health - how much of the estate the detection actually covered
$DeviceLevel | Group-Object { $_.Group[0].ScanStatus } | ForEach-Object {
    Add-Stat -Category 'ScanStatus' -Key $_.Name -Count $_.Count `
        -Label "Number of distinct devices whose AI detection scan status was $($_.Name)"
}

Write-Verbose "Summary stat rows: $($Stats.Count)"

#-- Step 12 - Publish helper (12 MB agent gate)
function Publish-Csv {
    param($Data, [string]$Name, [switch]$RootOnly, [switch]$AgentOnly)
    if (-not $Data) { Write-Warning "No rows for $Name"; return }

    $path = Join-Path $ExportLocation $Name
    $Data | Export-Csv -Path $path -NoTypeInformation -Encoding UTF8 -Force
    $mb = [math]::Round((Get-Item $path).Length / 1MB, 2)

    if (-not $AgentOnly) {
        Set-AzStorageBlobContent -File $path -Container $Container -Blob $Name `
            -Context $StorageAccountContext -Force | Out-Null
        Write-Output "$Name -> root ($mb MB)"
    }
    if (-not $RootOnly) {
        if ($mb -le 12) {
            Set-AzStorageBlobContent -File $path -Container $Container -Blob "agent-data/$Name" `
                -Context $StorageAccountContext -Force | Out-Null
            Write-Output "$Name -> agent-data ($mb MB)"
        } else {
            Write-Warning "$Name is $mb MB - over 12MB gate; agent copy skipped."
        }
    }
}

#-- Step 13 - Publish
# ROOT: full identifiable detail for Power BI. Never goes to agent-data.
$PowerBIRows = $Enriched | Select-Object `
    DeviceName, PrimaryUser, UserDisplayName, Department, JobTitle,
    OfficeLocation, City, Country, CountryCode, AccountStatus,
    AIAgentName, AIAgentCategory, AIAgentSanctioned, DetectionSignals,
    IsPrimaryDeviceRow, AgentCountOnDevice,
    HasUnsanctionedAI, ScanStatus, DetectionState, OperatingSystem, OSVersion,
    LastSyncDate, LastSyncDaysAgo, IsActiveLast14Days, LastDetectionDate, SnapshotDate

# AGENT: slim copy. RowKey is a deterministic document key for Azure AI Search.
# Without it the blob indexer keys by row position, so a device gaining or losing an
# agent shifts every following row and leaves stale documents behind. Base64-encode
# it in the indexer field mapping (keys must be URL-safe).
$Keyed = $Enriched | ForEach-Object {
    $_ | Add-Member -NotePropertyName 'RowKey' -NotePropertyValue ("{0}|{1}" -f $_.DeviceName, $_.AIAgentName) -Force -PassThru
}

# Scope the agent copy. A confirmed-clean device is dropped; a device the scan could
# NOT evaluate is kept, because absent must never be readable as clean.
$AgentScope = $Keyed
if (-not $IncludeCleanDevicesInAgentCopy) {
    $AgentScope = $Keyed | Where-Object { $_.AIAgentName -ne 'None' -or $_.ScanStatus -ne 'Scanned' }
    Write-Verbose "Agent copy scoped to findings + unevaluated: $(@($AgentScope).Count) of $($Keyed.Count) rows"
}

if ($IncludeUserIdentifiersInAgentCopy) {
    $AgentRows = $AgentScope | Select-Object `
        RowKey, DeviceName, PrimaryUser, UserDisplayName, Department, JobTitle,
        OfficeLocation, City, Country, CountryCode, AccountStatus,
        AIAgentName, AIAgentCategory, AIAgentSanctioned, DetectionSignals,
        IsPrimaryDeviceRow, AgentCountOnDevice, HasUnsanctionedAI, ScanStatus, DetectionState,
        OperatingSystem, IsActiveLast14Days, LastDetectionDate, SnapshotDate
}
else {
    $AgentRows = $AgentScope | Select-Object `
        RowKey, DeviceName, Department, OfficeLocation, City, Country, CountryCode, AccountStatus,
        AIAgentName, AIAgentCategory, AIAgentSanctioned, DetectionSignals,
        IsPrimaryDeviceRow, AgentCountOnDevice, HasUnsanctionedAI, ScanStatus, DetectionState,
        OperatingSystem, IsActiveLast14Days, LastDetectionDate, SnapshotDate
}

$StatRows = $Stats | ForEach-Object {
    $_ | Add-Member -NotePropertyName 'RowKey' -NotePropertyValue ("{0}|{1}|{2}" -f $_.ReportType, $_.Category, $_.Key) -Force -PassThru
} | Select-Object RowKey, ReportType, Category, Key, Count, StatLabel, SnapshotDate

Publish-Csv -Data $PowerBIRows -Name "AIAgentInventory.csv"       -RootOnly
Publish-Csv -Data $AgentRows   -Name "AIAgentInventory_Agent.csv" -AgentOnly
Publish-Csv -Data $StatRows    -Name "AIAgentInventory_Stats.csv" -AgentOnly

Write-Verbose "Script completed successfully at $(Get-Date)"

2 · The Power BI template

.pbit goes here

Drop local-ai-agent-inventory.pbit into docs/assets/pbit/ and swap this note for a download button: [:material-download: Local AI Agent Inventory template](../assets/pbit/local-ai-agent-inventory.pbit)

3 · Example report

Local AI Agent Inventory — example Power BI report

Replace report-placeholder.svg with a screenshot of your own Local AI Agent Inventory report.