This is part one of a two-part series on Windows 365 Cloud Apps. In this post, we’ll walk through what Cloud Apps are and how to create the provisioning policy with PowerShell. In part two, we’ll publish the apps themselves. I’ll also include the PowerShell script that uses Azure/Graph REST APIs.
What is Windows 365 Cloud Apps?
Windows 365 Cloud Apps let you give users access to specific apps streamed from a Cloud PC—without handing out a full desktop to everyone. Under the hood, Cloud Apps run on Windows 365 Frontline Cloud PCs in Shared mode. That licensing model is designed for shift or part-time staff: many users can be assigned, but only one active session per license at a time.
Think of it as “just-the-apps” VDI: Outlook, Word, your line-of-business app—delivered from the cloud—with the management simplicity of Windows 365 and Intune.
Why customers care: You streamline app delivery, lower overhead, and modernize VDI without building and babysitting a big remote desktop estate.
Cloud Apps vs AVD Published Apps vs “Traditional” VDI Published Apps
| Topic | Windows 365 Cloud Apps | Azure Virtual Desktop Published Apps | Traditional VDI Published Apps |
|---|---|---|---|
| What users see | Individual apps streamed from a Cloud PC; no full desktop | Individual apps from session hosts in Azure Virtual Desktop | Individual apps from on-prem or hosted RDS/Horizon/Citrix farms |
| Infra you manage | Cloud PC lifecycle via Intune; Microsoft operates the fabric | You design & operate host pools, scaling, FSLogix, images | You run the farm: brokers, gateways, hypervisors, storage |
| Licensing / sessions | Frontline: many users per license, 1 active session per license | Per-user/per-device or CALs + Azure consumption; multiple sessions per host | Per-user/device + on-prem infra costs |
| Admin plane | Intune + Windows 365 | Azure Portal + ARM + Host pool automation | Vendor consoles + on-prem change management |
| App packaging | Start-menu discovered apps from the image (MSIX/Appx discovery expanding) | MSI/MSIX; MSIX App Attach; image-based | MSI/MST/App-V/Citrix packages, etc. |
| Who it’s great for | Task/shift workers; predictable, lightweight app access | Broad use cases; granular scale & control | Heavily customized legacy estates, on-prem constraints |
Mental model:
If you need elastic host pools or platform primitives, choose AVD.
- If you’re tied to on-prem or specific vendor features, you might keep traditional VDI, but expect more ops work.
- If you like the “managed Cloud PC” experience and want app-only access, choose Cloud Apps.
- If you like the “managed Cloud PC” experience and want app-only access, choose Cloud Apps.
PowerShell: create the policy via REST/Graph
- Auth: Provide
$TenantId,$ClientId,$ClientSecretfrom your app registration. Grant/admin-consent the scopes listed above. If you are not aware of how to create the app registration, you can follow here – How to register an app in Microsoft Entra ID – Microsoft identity platform | Microsoft Learn - Image: Set
$ImageType(e.g.,"gallery") and$ImageIdfor your chosen image. - Region:
$RegionName(e.g.,australiaeastor"automatic"). - Assignment:
$GroupId: Entra group whose members should see the Cloud Apps.$ServicePlanId: the Frontline size (e.g., FL 2vCPU/8GB/128GB in the example).$AllotmentCount: how many concurrent sessions you want available for this policy.$AllotmentDisplayName: a friendly label that shows up with the assignment.
- Verification/Polling: The script dumps the policy with assignments and can optionally poll for provisioned Cloud PCs tied to the policy.
- Get-or-Create a Cloud Apps provisioning policy (
userExperienceType = cloudApp,provisioningType = sharedByEntraGroup, Azure AD Join in a specified region). - Assigns the policy to an Entra group with service plan, capacity (allotment), and a friendly label
Required permissions (app registration – admin consent):
CloudPC.ReadWrite.All(andCloudPC.Read.All)DeviceManagementServiceConfig.ReadWrite.All(for policy config)
<#
Create (or reuse) a Windows 365 "Cloud Apps" provisioning policy, assign an Entra group
with size + capacity + label, then verify assignment (via $expand=assignments) and optionally
poll for provisioned Cloud PCs. Uses Microsoft Graph beta.
Key note: /assignments endpoint returns 404 by design; use $expand=assignments. See MS docs.
#>
# ==========================
# 0) CONFIG — EDIT THESE
# ==========================
$TenantId = "<Copy/Paste Tenant ID>"
$ClientId = "<Copy/Paste Client ID>"
$ClientSecret = "<Copy/Paste ClientSecret ID>"
# Policy
$DisplayName = "Cloud-Apps-Prov-4"
$Description = "Cloud Apps Prov Policy - Frontline"
$EnableSSO = $true
$RegionName = "australiaeast" # or "automatic"
$Locale = "en-AU"
$Language = "en-AU"
# Image (gallery)
$ImageType = "gallery"
$ImageId = "microsoftwindowsdesktop_windows-ent-cpc_win11-24H2-ent-cpc-m365"
# Assignment
$GroupId = "b582705d-48be-4e4b-baac-90e5b50ebdf2" # Entra ID Group
$ServicePlanId = "057efbfe-a95d-4263-acb0-12b4a31fed8d" # FL 2vCPU/8GB/128GB
$AllotmentCount = 1
$AllotmentDisplayName = "CP-FL-Shared-CloudApp-1"
# Optional provisioning poll
$VerifyDesiredCount = $AllotmentCount
$VerifyMaxTries = 30
$VerifyDelaySec = 30
# ==========================
# Helpers
# ==========================
function New-GraphUri {
param(
[Parameter(Mandatory)][string]$Path, # e.g. "/beta/deviceManagement/virtualEndpoint/provisioningPolicies"
[hashtable]$Query
)
$cleanPath = '/' + ($Path -replace '^\s*/+','' -replace '\s+$','')
$b = [System.UriBuilder]::new("https","graph.microsoft.com")
$b.Path = $cleanPath
if ($Query -and $Query.Count -gt 0) {
Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue | Out-Null
$nvc = [System.Web.HttpUtility]::ParseQueryString([string]::Empty)
foreach ($k in $Query.Keys) { $nvc.Add($k, [string]$Query[$k]) }
$b.Query = $nvc.ToString()
} else { $b.Query = "" }
return $b.Uri.AbsoluteUri
}
function Invoke-Graph {
param(
[Parameter(Mandatory)][ValidateSet('GET','POST','PATCH','DELETE','PUT')][string]$Method,
[Parameter(Mandatory)][string]$Uri,
[hashtable]$Headers,
$Body
)
try {
if ($PSBoundParameters.ContainsKey('Body')) {
return Invoke-RestMethod -Method $Method -Uri $Uri -Headers $Headers -Body ($Body | ConvertTo-Json -Depth 20)
} else {
return Invoke-RestMethod -Method $Method -Uri $Uri -Headers $Headers
}
} catch {
Write-Warning "HTTP $Method $Uri failed: $($_.Exception.Message)"
try {
$resp = $_.Exception.Response
if ($resp -and $resp.GetResponseStream()) {
$reader = New-Object IO.StreamReader($resp.GetResponseStream())
$text = $reader.ReadToEnd()
Write-Host "Response body:" -ForegroundColor DarkYellow
Write-Host $text
}
} catch {}
throw
}
}
# ==========================
# 1) AUTH — Client Credentials
# ==========================
$TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
$tokenForm = @{
client_id = $ClientId
client_secret = $ClientSecret
scope = "https://graph.microsoft.com/.default"
grant_type = "client_credentials"
}
$auth = Invoke-RestMethod -Method Post -Uri $TokenEndpoint -Body $tokenForm -ContentType 'application/x-www-form-urlencoded'
if (-not $auth.access_token) { throw "No access_token returned. Ensure CloudPC.ReadWrite.All (+ CloudPC.Read.All) are admin-consented." }
$Headers = @{
Authorization = "Bearer $($auth.access_token)"
"Content-Type" = "application/json"
"Prefer" = "include-unknown-enum-members"
}
# Base paths
$PoliciesPath = "/beta/deviceManagement/virtualEndpoint/provisioningPolicies"
$CloudPcsPath = "/beta/deviceManagement/virtualEndpoint/cloudPCs"
# ==========================
# 2) Ensure (Get-or-Create) the policy
# ==========================
$escapedName = $DisplayName.Replace("'","''")
$findUri = New-GraphUri -Path $PoliciesPath -Query @{ '$filter' = "displayName eq '$escapedName'" }
Write-Host "Finding policy by name: $DisplayName" -ForegroundColor Cyan
try { $existing = Invoke-Graph -Method GET -Uri $findUri -Headers $Headers }
catch { Write-Warning "Name lookup failed; proceeding to create."; $existing = $null }
if ($existing -and $existing.value -and $existing.value.Count -gt 0) {
$policy = $existing.value | Select-Object -First 1
$policyId = $policy.id
Write-Host "Using existing policy '$DisplayName' (id: $policyId)" -ForegroundColor Yellow
} else {
$createBody = @{
"@odata.type" = "#microsoft.graph.cloudPcProvisioningPolicy"
displayName = $DisplayName
description = $Description
enableSingleSignOn = $EnableSSO
imageType = $ImageType
imageId = $ImageId
managedBy = "windows365"
microsoftManagedDesktop = @{
"@odata.type" = "microsoft.graph.microsoftManagedDesktop"
managedType = "notManaged"
}
windowsSetting = @{
"@odata.type" = "microsoft.graph.cloudPcWindowsSetting"
locale = $Locale
}
windowsSettings = @{
"@odata.type" = "microsoft.graph.cloudPcWindowsSettings"
language = $Language
}
userExperienceType = "cloudApp"
provisioningType = "sharedByEntraGroup"
domainJoinConfigurations = @(
@{
"@odata.type" = "microsoft.graph.cloudPcDomainJoinConfiguration"
domainJoinType = "azureADJoin"
type = "azureADJoin"
regionName = $RegionName
}
)
}
$createUri = New-GraphUri -Path $PoliciesPath
Write-Host "Creating policy '$DisplayName'..." -ForegroundColor Cyan
$policy = Invoke-Graph -Method POST -Uri $createUri -Headers $Headers -Body $createBody
$policyId = $policy.id
if (-not $policyId) { throw "Create succeeded but no policy id returned." }
Write-Host "Created policy id: $policyId" -ForegroundColor Green
}
# ==========================
# 3) Assign — group + size + capacity + label
# ==========================
$assignUri = New-GraphUri -Path ($PoliciesPath + "/$policyId/assign")
Write-Host "Clearing existing assignments..." -ForegroundColor DarkGray
Invoke-Graph -Method POST -Uri $assignUri -Headers $Headers -Body @{ assignments = @() } | Out-Null
$assignBody = @{
assignments = @(
@{
target = @{
"@odata.type" = "#microsoft.graph.cloudPcManagementGroupAssignmentTarget"
groupId = $GroupId
servicePlanId = $ServicePlanId
allotmentLicensesCount = $AllotmentCount
allotmentDisplayName = $AllotmentDisplayName
}
}
)
}
Write-Host "Assigning policy to group $GroupId (plan $ServicePlanId, count $AllotmentCount)..." -ForegroundColor Cyan
Invoke-Graph -Method POST -Uri $assignUri -Headers $Headers -Body $assignBody | Out-Null
Write-Host "Assignment submitted." -ForegroundColor Green
# ==========================
# 4) Verify — read policy with $expand=assignments (RETRY)
# ==========================
$expandUri = New-GraphUri -Path ($PoliciesPath + "/$policyId") -Query @{ '$expand' = 'assignments' }
Write-Host "Reading back policy + assignments via $expand..." -ForegroundColor Cyan
$verify = $null
for ($try=1; $try -le 12; $try++) {
try {
$verify = Invoke-Graph -Method GET -Uri $expandUri -Headers $Headers
if ($verify.assignments -and $verify.assignments.Count -gt 0) { break }
Write-Host "Assignments not materialized yet (attempt $try). Waiting 3s..." -ForegroundColor Yellow
} catch {
Write-Warning "Expand read attempt $try failed; retrying in 3s..."
}
Start-Sleep -Seconds 3
}
if (-not $verify) { throw "Failed to read policy with assignments after retries." }
$verify | ConvertTo-Json -Depth 20 | Write-Output
# Sanity: confirm the expected assignment
$expected = $verify.assignments | Where-Object {
$_.target.groupId -eq $GroupId -and
$_.target.servicePlanId -eq $ServicePlanId -and
$_.target.allotmentLicensesCount -eq $AllotmentCount -and
($_.target.allotmentDisplayName -eq $AllotmentDisplayName -or -not $AllotmentDisplayName)
}
if ($expected) {
Write-Host "✅ Assignment present with expected group, plan, and count." -ForegroundColor Green
} else {
Write-Warning "Assignment not found with expected fields. See dump above."
}
# ==========================
# 5) (Optional) Poll for provisioned Cloud PCs for this policy
# ==========================
if ($VerifyDesiredCount -gt 0) {
$cloudPcsUri = New-GraphUri -Path $CloudPcsPath -Query @{ '$filter' = "provisioningPolicyId eq '$policyId'" }
for ($i = 1; $i -le $VerifyMaxTries; $i++) {
try {
$cloudPcs = Invoke-Graph -Method GET -Uri $cloudPcsUri -Headers $Headers
$pcsForPlan = $cloudPcs.value | Where-Object { $_.servicePlanId -eq $ServicePlanId -or -not $_.psobject.Properties.Name.Contains('servicePlanId') }
$count = ($pcsForPlan | Measure-Object).Count
if ($count -ge $VerifyDesiredCount) {
Write-Host "Provisioned Cloud PCs for policy: $count (target $VerifyDesiredCount) ✅" -ForegroundColor Green
$pcsForPlan | ConvertTo-Json -Depth 10 | Write-Output
break
} else {
Write-Host "Provisioned Cloud PCs for policy: $count (waiting for $VerifyDesiredCount) … attempt $i/$VerifyMaxTries" -ForegroundColor Yellow
}
} catch {
Write-Warning "Provisioning check failed on attempt ${i}: $($_.Exception.Message)"
}
Start-Sleep -Seconds $VerifyDelaySec
}
}
GitHub Link – avdwin365mem/W365-CloudApp-Prov-Policy at main · askaresh/avdwin365mem
Provisioning Policy Details – UI
Policy

Overview

Tips, gotchas, and troubleshooting
- App discovery: Ensure the app has a Start menu shortcut on the image. That’s how Cloud Apps gets its list.
- Security baselines: If your tenant enforces restrictions on PowerShell in the image at discovery time, discovery can fail.
- MSIX/Appx: Discovery is expanding—classic installers show up first; some Appx/MSIX apps (e.g., newer Teams) may not appear yet.
- Concurrency math: Active sessions for the policy are capped by assigned Frontline license count on that policy.
- Schema drift: These are beta endpoints. If you hit a property/enum change, the script’s warnings will surface the response body—update the field names accordingly.
What’s next (Part 2)
We’ll move to All Cloud Apps to publish the discovered apps, tweak display name/description/command line/icon index, confirm they appear in Windows App, and cover unpublish/reset workflows—with your screenshots.
I hope you find this helpful information for creating a Cloud App using PowerShell. If I have missed any steps or details, I will be happy to update the post.
Thanks,
Aresh Sarkari












































Recent Comments