| name | microsoft-todo-tasks |
| compatibility | Requires PowerShell 5.1+, outbound network access to Microsoft Graph, and a browser for the OAuth2 device code flow. |
| description | Create, list, and manage Microsoft To Do tasks via the Graph REST API using raw OAuth2 device code flow in PowerShell. Bypasses the buggy Microsoft.Graph SDK (WAM broker issues, System.Text.Json conflicts, empty context after auth). Handles personal Microsoft accounts (live.com identity provider) that cannot use tenant-specific auth. Includes clean Edge profile launch to avoid cached localhost redirects from MSAL. USE FOR: create To Do task, Microsoft To Do, Graph API tasks, device code auth, OAuth2 device flow, To Do reminder, task list, create task with reminder, To Do API, personal Microsoft account Graph auth, live.com Graph auth, bypass WAM, bypass MSAL, raw OAuth2 PowerShell, device code flow PowerShell, Graph REST API PowerShell, To Do tasks from script, bulk create To Do tasks. DO NOT USE FOR: Outlook COM tasks (use Outlook COM directly), Exchange tasks, calendar events (use calendar APIs), sending email (use send-outlook-email skill). |
Microsoft To Do Tasks via Graph REST API
Create and manage Microsoft To Do tasks using raw OAuth2 device code flow and
Invoke-RestMethod — no Microsoft.Graph SDK required.
Preferred pattern: persistent token cache (one device login per ~90 days)
Device code flow is painful when repeated. The OAuth2 offline_access scope returns a
refresh token valid ~90 days (sliding), which can be stored on disk (DPAPI-encrypted,
current-user only) and used to silently mint fresh access tokens. Every subsequent script
run gets a valid token with no browser, no code entry.
Package the cache + refresh logic as a small reusable PowerShell module so no script has
to reimplement it:
- Module:
scripts/GraphTokenCache.psm1 (suggested path — adjust to your project's script folder)
- Cache file:
%LOCALAPPDATA%\GraphTokenCache\token.xml (DPAPI-encrypted via ConvertFrom-SecureString)
- Scope:
Tasks.ReadWrite offline_access
- Authority:
common
- Client ID:
14d82eec-204b-4c2f-b7e8-296a70dab67e (Microsoft Graph CLI public client)
Usage in scripts
Import-Module "$PSScriptRoot\GraphTokenCache.psm1" -Force
$accessToken = Get-GraphToken # silent on first run if cache is fresh; silent refresh if expired
$headers = @{ Authorization = "Bearer $accessToken"; 'Content-Type' = 'application/json' }
# ... now call Graph as usual
Get-GraphToken logic:
- If cache file is missing → run device code flow, persist tokens, return access token.
- If cache has unexpired access token → return it.
- Otherwise refresh silently via
grant_type=refresh_token → persist new tokens → return access token.
- If refresh fails (token revoked, consent changed, 90+ days idle) → fall back to device code flow.
Minimal module (drop into any project)
# scripts/GraphTokenCache.psm1
$script:TokenFile = Join-Path $env:LOCALAPPDATA 'GraphTokenCache\token.xml'
$script:ClientId = '14d82eec-204b-4c2f-b7e8-296a70dab67e'
$script:Scope = 'Tasks.ReadWrite offline_access'
function Unprotect-String { param([string]$E) [System.Net.NetworkCredential]::new('', (ConvertTo-SecureString $E)).Password }
function Protect-String { param([string]$P) ConvertTo-SecureString $P -AsPlainText -Force | ConvertFrom-SecureString }
function Save-GraphToken {
param([pscustomobject]$Token)
New-Item (Split-Path $script:TokenFile) -ItemType Directory -Force | Out-Null
@{
refresh_token = Protect-String $Token.refresh_token
access_token = Protect-String $Token.access_token
expires_at = (Get-Date).AddSeconds([int]$Token.expires_in - 60).ToString('o')
client_id = $script:ClientId
scope = $script:Scope
} | Export-Clixml $script:TokenFile
}
function Invoke-GraphDeviceLogin {
$dc = Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/common/oauth2/v2.0/devicecode" -Body @{client_id=$script:ClientId; scope=$script:Scope}
Set-Clipboard -Value $dc.user_code
Write-Host "CODE: $($dc.user_code) (on clipboard) URL: https://login.microsoftonline.com/common/oauth2/deviceauth"
Start-Process "https://login.microsoftonline.com/common/oauth2/deviceauth"
$body = @{client_id=$script:ClientId; grant_type='urn:ietf:params:oauth:grant-type:device_code'; device_code=$dc.device_code}
for ($i=0; $i -lt 180; $i++) {
try { $t = Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/common/oauth2/v2.0/token" -Body $body -EA Stop; Save-GraphToken $t; return $t.access_token }
catch {
$e = ($_.ErrorDetails.Message | ConvertFrom-Json -EA SilentlyContinue).error
if ($e -eq 'authorization_pending') { Start-Sleep 5 } else { throw "Device login failed: $e" }
}
}
throw 'Device login timed out'
}
function Get-GraphToken {
if (Test-Path $script:TokenFile) {
$d = Import-Clixml $script:TokenFile
if ([datetime]::Parse($d.expires_at) -gt (Get-Date)) { return (Unprotect-String $d.access_token) }
try {
$body = @{client_id=$script:ClientId; grant_type='refresh_token'; refresh_token=(Unprotect-String $d.refresh_token); scope=$script:Scope}
$t = Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/common/oauth2/v2.0/token" -Body $body -EA Stop
Save-GraphToken $t
return $t.access_token
} catch { Write-Host 'Refresh failed; falling back to device login...' }
}
Invoke-GraphDeviceLogin
}
Export-ModuleMember -Function Get-GraphToken, Invoke-GraphDeviceLogin
Security notes
ConvertFrom-SecureString uses Windows DPAPI. The encrypted blob is bound to the current Windows user profile on the current machine — it cannot be decrypted by another user or if copied to another machine.
- Do not commit
token.xml to source control. The default location (%LOCALAPPDATA%\GraphTokenCache\) sits outside any repo.
- Revoke access any time at https://microsoft.com/consent → "Microsoft Graph Command Line Tools".
- To force re-auth: delete the cache file (
Remove-Item $env:LOCALAPPDATA\GraphTokenCache\token.xml).
The one-shot device-code recipe below is still valid for first-time setup or as a fallback.
New scripts should default to the persistent pattern.
When to Use
- Create tasks in the Microsoft To Do app (not classic Outlook Tasks)
- Need reminders that show in To Do on mobile, web, and desktop
- Outlook is configured with a local data file (OST) where COM tasks don't sync to To Do
- The Microsoft.Graph PowerShell SDK fails due to WAM, assembly conflicts, or empty context
- The account is a personal Microsoft account (live.com) that can't use tenant-specific auth
Why Not the Microsoft.Graph SDK?
The Microsoft.Graph.Authentication module (v2.x) has multiple issues on Windows:
-
WAM broker hidden popups: Connect-MgGraph uses Windows Account Manager (WAM) by
default. In embedded terminals (VS Code), the auth popup hides behind other windows.
Setting $env:AZURE_IDENTITY_DISABLE_WAMBROKER = 'true' is ignored by the module.
-
Device code returns empty context: Connect-MgGraph -UseDeviceCode completes without
error but Get-MgContext returns empty Account, Scopes, and TenantId.
-
Assembly version conflicts: System.Text.Json and Microsoft.IdentityModel.Abstractions
version mismatches cause Could not load file or assembly errors in terminals where
other .NET types were loaded.
-
Personal account (live.com) tenant issues: Using -TenantId with the actual tenant ID
produces AADSTS50020 because the account is a live.com identity provider, not a native
Azure AD account.
Solution: Use raw Invoke-RestMethod against the OAuth2 device code endpoint directly.
Prerequisites
- PowerShell 7+ (or Windows PowerShell 5.1)
- Internet access to
login.microsoftonline.com and graph.microsoft.com
- Microsoft Edge browser installed (for clean-profile auth window)
- A Microsoft account (personal or work/school) with To Do enabled
Recipe: Complete Device Code Auth + Task Creation
Step 1: Request Device Code
$clientId = '14d82eec-204b-4c2f-b7e8-296a70dab67e' # Microsoft Graph CLI (public client)
$scope = 'Tasks.ReadWrite offline_access'
$dc = Invoke-RestMethod -Method POST `
-Uri "https://login.microsoftonline.com/common/oauth2/v2.0/devicecode" `
-Body @{client_id = $clientId; scope = $scope}
Write-Host "CODE: $($dc.user_code)"
Write-Host "URL: $($dc.verification_uri)"
Write-Host "Expires in: $($dc.expires_in) seconds"
# Copy code to clipboard for easy pasting
Set-Clipboard -Value $dc.user_code
Key details:
client_id: Use 14d82eec-204b-4c2f-b7e8-296a70dab67e (Microsoft Graph CLI) — this is
a well-known public client ID that supports device code flow for both personal and
work/school accounts.
- Use
common authority (not consumers or a specific tenant ID) — works for both account types.
- The code expires in ~900 seconds (15 minutes). The user must enter it within this window.
Step 2: Open Browser with Clean Profile
Critical: Open Edge with a completely fresh user profile to avoid cached state from
previous MSAL browser auth attempts (which leave localhost redirect cookies).
$cleanProfile = "$env:TEMP\edge_clean_$(Get-Random)"
New-Item $cleanProfile -ItemType Directory -Force | Out-Null
Start-Process 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe' `
-ArgumentList @(
"--user-data-dir=`"$cleanProfile`""
'--no-first-run'
'--no-default-browser-check'
'https://login.microsoftonline.com/common/oauth2/deviceauth'
)
Why clean profile?
- Previous MSAL
AcquireTokenInteractive attempts set a http://localhost redirect URI
- The browser caches cookies/redirects that cause subsequent visits to
microsoft.com/devicelogin
to redirect to http://localhost/?error=invalid_request&error_description=...response_type...
- InPrivate mode alone is NOT sufficient — the redirect can still trigger
- A fresh
--user-data-dir guarantees zero cached state
Correct URL: Always use https://login.microsoftonline.com/common/oauth2/deviceauth
(NOT https://microsoft.com/devicelogin or https://login.microsoft.com/device — these
redirect through intermediate URLs that can hit the localhost cache issue).
Step 3: Poll for Token
$tokenBody = @{
client_id = $clientId
grant_type = 'urn:ietf:params:oauth:grant-type:device_code'
device_code = $dc.device_code
}
$token = $null
for ($i = 0; $i -lt 180; $i++) {
try {
$token = Invoke-RestMethod -Method POST `
-Uri "https://login.microsoftonline.com/common/oauth2/v2.0/token" `
-Body $tokenBody -ErrorAction Stop
Write-Host "TOKEN OBTAINED!"
break
} catch {
$err = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue
if ($err.error -eq 'authorization_pending') {
[System.Threading.Thread]::Sleep(5000) # Poll every 5 seconds
} elseif ($err.error -eq 'expired_token') {
Write-Host "Code expired — user didn't sign in within 15 minutes"
break
} else {
Write-Host "Error: $($err.error) — $($err.error_description)"
break
}
}
}
Error codes:
authorization_pending — normal, keep polling
expired_token — the 15-minute window expired
authorization_declined — user clicked "Cancel" in the browser
bad_verification_code — wrong device_code (code reuse or mismatch)
Step 4: Create Tasks
if ($token) {
$headers = @{
Authorization = "Bearer $($token.access_token)"
'Content-Type' = 'application/json'
}
# Get the default task list
$lists = (Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/me/todo/lists" `
-Headers $headers).value
$list = $lists | Where-Object { $_.wellknownListName -eq 'defaultList' }
if (-not $list) { $list = $lists[0] }
$listId = $list.id
# Create a task
$taskBody = @{
title = '[Client] Task Title'
body = @{
content = 'Task description with action items'
contentType = 'text'
}
dueDateTime = @{
dateTime = '2026-05-08T00:00:00'
timeZone = 'Europe/Berlin'
}
reminderDateTime = @{
dateTime = '2026-05-05T07:00:00'
timeZone = 'Europe/Berlin'
}
isReminderOn = $true
importance = 'high' # 'low', 'normal', or 'high'
} | ConvertTo-Json -Depth 5
$result = Invoke-RestMethod -Method POST `
-Uri "https://graph.microsoft.com/v1.0/me/todo/lists/$listId/tasks" `
-Headers $headers -Body $taskBody -ContentType 'application/json'
Write-Host "Created: $($result.title) (ID: $($result.id))"
}
Step 5: List Existing Tasks
$tasks = (Invoke-RestMethod `
-Uri "https://graph.microsoft.com/v1.0/me/todo/lists/$listId/tasks?`$top=50&`$orderby=dueDateTime/dateTime" `
-Headers $headers).value
$tasks | ForEach-Object {
Write-Host "$($_.dueDateTime.dateTime.Substring(0,10)) | $($_.importance) | $($_.title)"
}
Complete One-Block Recipe (Copy-Paste Ready)
This does everything in one block — get code, open clean browser, poll, create tasks:
# === AUTH ===
$clientId = '14d82eec-204b-4c2f-b7e8-296a70dab67e'
$dc = Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/common/oauth2/v2.0/devicecode" -Body @{client_id=$clientId; scope='Tasks.ReadWrite offline_access'}
Set-Clipboard -Value $dc.user_code
Write-Host "CODE: $($dc.user_code) (on clipboard)"
$cleanProfile = "$env:TEMP\edge_clean_$(Get-Random)"
New-Item $cleanProfile -ItemType Directory -Force | Out-Null
Start-Process 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe' -ArgumentList "--user-data-dir=`"$cleanProfile`"", '--no-first-run', '--no-default-browser-check', 'https://login.microsoftonline.com/common/oauth2/deviceauth'
$tokenBody = @{client_id=$clientId; grant_type='urn:ietf:params:oauth:grant-type:device_code'; device_code=$dc.device_code}
$token = $null
for ($i = 0; $i -lt 180; $i++) {
try { $token = Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/common/oauth2/v2.0/token" -Body $tokenBody -EA Stop; Write-Host "TOKEN OK!"; break }
catch { $e = ($_.ErrorDetails.Message | ConvertFrom-Json -EA SilentlyContinue).error; if ($e -eq 'authorization_pending') { [System.Threading.Thread]::Sleep(5000) } else { Write-Host "ERR: $e"; break } }
}
# === CREATE TASKS ===
if ($token) {
$h = @{Authorization="Bearer $($token.access_token)";'Content-Type'='application/json'}
$lists = (Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/me/todo/lists" -Headers $h).value
$list = $lists | Where-Object { $_.wellknownListName -eq 'defaultList' }; if (!$list) { $list = $lists[0] }
$lid = $list.id
# Define tasks as an array of hashtables
$tasks = @(
@{t='Task Title';b='Description';d='2026-05-08';r='2026-05-05T07:00:00';i='high'}
# Add more tasks here...
)
$ok = 0
foreach ($t in $tasks) {
$body = @{title=$t.t;body=@{content=$t.b;contentType='text'};dueDateTime=@{dateTime="$($t.d)T00:00:00";timeZone='Europe/Berlin'};reminderDateTime=@{dateTime=$t.r;timeZone='Europe/Berlin'};isReminderOn=$true;importance=$t.i} | ConvertTo-Json -Depth 5
try { Invoke-RestMethod -Method POST -Uri "https://graph.microsoft.com/v1.0/me/todo/lists/$lid/tasks" -Headers $h -Body $body -ContentType 'application/json' | Out-Null; $ok++; Write-Host "OK $ok`: $($t.t)" }
catch { Write-Host "FAIL: $($t.t) - $($_.ErrorDetails.Message)" }
}
Write-Host "=== $ok / $($tasks.Count) TASKS CREATED ==="
}
Pitfalls and Lessons Learned
Personal Microsoft Account (live.com) Auth
- Your account may show as
user@company.com but be a personal Microsoft
account (identity provider = live.com), not a native Azure AD account.
- Using
-TenantId 'tenant-guid' produces AADSTS50020: User account from identity provider 'live.com' does not exist in tenant.
- Always use
common authority — works for both personal and work/school accounts.
- Do NOT use
consumers — the Tasks.ReadWrite scope may not be supported there.
Outlook COM Tasks vs Microsoft To Do
- Outlook COM
CreateItem(3) creates classic Exchange tasks in the Aufgaben folder.
- If
Store.ExchangeStoreType = 0 and Store.IsDataFileStore = True, the account uses a
local data file — COM tasks will NOT sync to Microsoft To Do.
- To get tasks in To Do, you MUST use the Graph API (
/me/todo/lists/{id}/tasks).
- Classic COM tasks still work for Outlook reminders (popup notifications).
Cached Localhost Redirects
- MSAL
AcquireTokenInteractive with http://localhost redirect URI creates browser state
that interferes with microsoft.com/devicelogin.
- Symptom: redirected to
http://localhost/?error=invalid_request&error_description=...response_type...
- Fix: Use
--user-data-dir with a fresh temp directory for Edge.
- InPrivate/Incognito mode alone is NOT sufficient.
Microsoft.Graph SDK Issues (v2.36+)
Connect-MgGraph -UseDeviceCode in a fresh process returns empty context (no Account, no Scopes).
- WAM broker (
WamEnabled: True) cannot be disabled via environment variables.
System.Text.Json version 10.0.0.0 assembly binding conflict in terminals with mixed .NET types.
- Workaround: Skip the SDK entirely. Use
Invoke-RestMethod with raw OAuth2 endpoints.
Edge Path
- Edge is at
C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe on most systems.
- Verify with:
(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe').'(default)'
- The
msedge command may not be in PATH — always use the full path.
Token Lifetime
- Access tokens are valid for ~60 minutes.
- For long operations, include
offline_access in scopes to get a refresh token.
- Refresh with:
Invoke-RestMethod -Method POST -Uri ".../token" -Body @{client_id=$clientId; grant_type='refresh_token'; refresh_token=$token.refresh_token; scope=$scope}
Graph API Task Properties
importance: 'low', 'normal', or 'high'
dueDateTime.timeZone: Use IANA format like 'Europe/Berlin', not 'W. Europe Standard Time'
reminderDateTime: Must include time component (e.g., 2026-05-05T07:00:00)
isReminderOn: Must be $true for the reminder to fire
body.contentType: 'text' or 'html'
categories: Array of strings (may not sync to all To Do clients)