• Skip to primary navigation
  • Skip to main content
  • Skip to primary sidebar

Clatent

Technology | Fitness | Food

  • About
  • Resources
  • Contact

Reporting

Teams Chat and PowerShell – How to add value!

March 23, 2026 by ClaytonT Leave a Comment

It’s been a bit for a technical blog post, but I’ve missed it and finally found some time to do one. What we are learning today is how to send messages, photos, urls, and information from APIs to Teams’ chat using PowerShell. This only covers for Teams chat’s as using Teams’ channels use slightly different authentication and cmdlets, but the concepts are the same. I know it’s not as exciting as Agents, Identity, or zero trust, but this will hopefully make your work day easier and bring more value to your and your company.

To start this off, we will first need a chat ID. I recommend at first using your chat with yourself, or use a chat that only you and people that won’t mind being bombed with test messages will mind!

Best way to get your chat ID

  1. Go to the web version of Teams and go to the chat you want to Read and/or write to
    1. We use to be able to copy the url and pull the ChatID out of it, but I’m not seeing that option anymore.
  2. Then click on the 3 dots in the top right and click “Copy Link”
  3. Here we will need to use everything after “chat/” or “channel” and before “/conversations” or “/General”
    1. It usually starts with “19:” or “19%” right after the “chat/”
  4. Once we have that I’d save it to a variable so you don’t have to keep remembering it and or copying and pasting that large link. Here I saved it to the variable $MyTestChatID so it was easy to remember. If this was prod, I’d use something on the lines of $TeamsChatId
    $MyTestChatID = "19:6h632y7d-nmx8-1yno-325h-77en2mx84x83_s78hy6e7-n6g3-7 9j0-36hf-86njyio53053@unq.gbl.spaces"
  5. Now we need to connect to Microsoft Graph. If your just adding information to a Teams Chat, all you will need is ChatMessage.Send and Chat.ReadWrite, but if you are creating a new chat, then you will also need Chat.Create.Connect-MgGraph -Scopes "Chat.ReadWrite", "ChatMessage.Send"

Before we start digging in, one thing to know is there are 2 different types of “Content Type.” The first is “text” which only allows text formatting, and there is “html” which you guessed it, allows html formatting. I haven’t gone crazy with the html formatting but you can make some nice charts and such with it and use images.

Simple Text:

Here is a simple text example, that just puts “This is my test” in the chat that you earlier added.

$Content = "This is my test"
	 
$ChatMessageBody = @{
   contentType = 'text'
   content     = $Content
}
$ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody

Using Emojis and Text:

You can add some flare to the text contentType by using Emojis.This will add the caution icon to your message

$Content = "⚠️ ALERT: Production Server DB-01 is offline!"

	 
$ChatMessageBody = @{
   contentType = 'text'
   content     = $Content
}
$ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody
	

Using bold and italicize:

If we want to add anymore style we need to change the contentType to html. Now we can bold and italicize the alert.

$Content = "<b><i>⚠️ ALERT:</b></i> Production Server DB-01 is offline!<p>"

	 
$ChatMessageBody = @{
   contentType = 'html'
   content     = $Content
} 
$ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody

Using html:

Now lets add on to it and make the alert a bit more useful, like giving a link to the change log request form to see if there was any scheduled maintenance for this time.

$Content = "<b><i>⚠️ ALERT:</b></i> Production Server DB-01 is offline!<p>"
$Content = $Content + "Please confim if this is an issue"
$Content = $Content + "<p>For more information, see <a href='<https://corporate.com/changelog>'>Corporate Change Log Requests</a>"
	 
$ChatMessageBody = @{
  contentType = 'html'
  content     = $Content
} 
$ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody

Piping functions with html:

Lets go a little more advanced and pipe in a function and put the results in teams. In this example we get the results from Get-Process, and only show the Name, Id, and CPU usage. How this works is by running Get-Process, selecting those objects, then converting the output to fragmented HTML. This is where instead of showing all of the html code, it only shows it for the table. Then pipe it to Out-String to turn the string array into a single string so that Teams can read it. If you don’t it will output but it will output System.Object[].

$FunctionTest = Get-Process | Select-Object Name, Id, CPU
$Content = ($FunctionTest | ConvertTo-Html -Fragment) | Out-String

$ChatMessageBody = @{
    contentType = 'html'
    content     = $Content
}
	 
$ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody

Yes, here is a one-liner for it, but wanted to show you could have functions before the actual “$Content” if you wanted.

$Content = Get-Process |
Select Name, Id, CPU |
ConvertTo-Html -Fragment |
Out-String

$ChatMessageBody = @{
    contentType = 'html'
    content     = $Content
}
	 
$ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody

APIs and html:

Now seeing text, html, and being able to show results from functions is great, but what about APIs? We can integrate them too, I mean who doesn’t want a Dad Joke every day right in there teams chat?? I tried to get Steven’s Judd API, but he wouldn’t give it to me. This is just scratching the surface, but wanted to show you it is possible. Full example right after the explaination.

A quick breakdown of this is it will first try what’s in the “try” block and if there is a terminating error at any part of it, it goes directly to the “catch” block and will run that code. Since we want to get information from icanhazdadjoke api, we have to first put the headers in, then call or “Invoke-RestMethod” the api with its Uri, and this grabs the information and saves it to the $response variable. Now we can use $($response.joke) to grab the actual joke for the example which we will see in the next step.

# Setting up headers and calling API for joke information
try {
    # Get a random dad joke from the API
    $headers = @{
        'Accept' = 'application/json'
        'User-Agent' = 'PowerShell Script (<https://github.com/DevClate/>)'
    }
    
    $response = Invoke-RestMethod -Uri "<https://icanhazdadjoke.com/>" -Headers $headers -Method Get
 

Here we create the Body content with “$jokeContent” as a Here-String(everything between @” and “@) which is a multi-line string literal that preserves all whitespace and line breaks. This makes the design of html easy and ensures it looks like we want in the teams chat. You can see we used a subexpresssion $($response.joke) to pull the actual joke, because if we just used $response.joke it won’t interpolate correctly inside the string.

 $jokeContent = @"
🤣 <b>Daily Dad Joke</b> 🤣<br><br>
<img src="<https://media.giphy.com/media/3oriO0OEd9QIDdllqo/giphy.gif>" alt="Dad Joke GIF" style="max-width: 200px; height: auto;"><br><br>
$($response.joke)<br><br>
<small><i>Joke ID: $($response.id)</i></small><br>
<small>Source: icanhazdadjoke.com | $(Get-Date -Format 'MMM dd, yyyy h:mm tt')</small>
"@

If your not familiar with html formatting, you can see the <b> that starts the bolding of “Daily Dad Joke” and stops bolding with </b>. Since this is a string we are using <br><br> to create line breaks.

🤣 <b>Daily Dad Joke</b> 🤣<br><br>

Here we will put in the code to show the image we want to show with the joke with the width and height.

<img src="<https://media.giphy.com/media/3oriO0OEd9QIDdllqo/giphy.gif>" alt="Dad Joke GIF" style="max-width: 200px; height: auto;"><br><br>

Also you can see with $(Get-Date -Format ‘MMM dd, yyyy h:mm tt’) to pull the date and time the joke was from.

<small>Source: icanhazdadjoke.com | $(Get-Date -Format 'MMM dd, yyyy h:mm tt')</small>

Then as long as there are no terminating errors you will get to the send your new daily joke to teams part, where you will add your $ChatMessageBody and for the content you would use $jokeContent unless you changed the variable for the joke content. Basically this makes it readable to Graph and Teams.

# Send to Teams body
    $ChatMessageBody = @{
    contentType = 'html'
    content     = $jokeContent
    }

After you’ve built the body, now we send it to Teams and output status to our terminal. Make sure you are using the correct ChatId, as you don’t want to send your message to the wrong person, so in this case, lets use our variable we created earlier $MyTestChatID. Then we will add our Body variable to make sure we get the right joke sent with $ChatMesssageBody. As long as all went smoothly we will see the joke in teams, and in our terminal we will see that it was successfully sent and the text version of the joke.

Now if it has a terminating error at any point, we move straight to the “Catch” so this is what the user will see if there is an terminating error. This is basically the same as above, except we add the “Write-Error” with the exception message, and put it a static joke in that gets sent to Teams. As you can see we changed the joke variable to $fallbackJoke and changed the content to $fallbackContent and everything else remains the same.

$ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody
    
    Write-Host "✅ Dad joke sent to Teams chat successfully!"
    Write-Host "Joke: $($response.joke)"
catch {
    Write-Error "❌ Failed to get dad joke: $($_.Exception.Message)"
    
    # Fallback joke if API fails
    $fallbackJoke = "Why don't scientists trust atoms? Because they make up everything!"
    
    $fallbackContent = @"
🤣 <b>Dad Joke (Backup)</b> 🤣<br><br>
<img src="<https://media.giphy.com/media/xT9IgG50Fb7Mi0prBC/giphy.gif>" alt="Classic Dad Joke" style="max-width: 200px; height: auto;"><br><br>
$fallbackJoke<br><br>
<small><i>API was unavailable, so here's a classic!</i></small><br>
<small>$(Get-Date -Format 'MMM dd, yyyy h:mm tt')</small>
"@
    
    $ChatMessageBody = @{
    contentType = 'html'
    content     = $fallbackContent
    }

    $ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody
    
    Write-Host "✅ Fallback dad joke sent to Teams chat!"
}

And here is the full script!

try {
    # Get a random dad joke from the API
    $headers = @{
        'Accept' = 'application/json'
        'User-Agent' = 'PowerShell Script (<https://github.com/DevClate/>)'
    }
    
    $response = Invoke-RestMethod -Uri "<https://icanhazdadjoke.com/>" -Headers $headers -Method Get
    
    # Using HTML img tag with external URL
    $jokeContent = @"
🤣 <b>Daily Dad Joke</b> 🤣<br><br>
<img src="<https://media.giphy.com/media/3oriO0OEd9QIDdllqo/giphy.gif>" alt="Dad Joke GIF" style="max-width: 200px; height: auto;"><br><br>
$($response.joke)<br><br>
<small><i>Joke ID: $($response.id)</i></small><br>
<small>Source: icanhazdadjoke.com | $(Get-Date -Format 'MMM dd, yyyy h:mm tt')</small>
"@

    # Send to Teams
    $ChatMessageBody = @{
    contentType = 'html'
    content     = $jokeContent
    }
    
    $ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody
    
    Write-Host "✅ Dad joke sent to Teams chat successfully!"
    Write-Host "Joke: $($response.joke)"
}
catch {
    Write-Error "❌ Failed to get dad joke: $($_.Exception.Message)"
    
    # Fallback joke if API fails
    $fallbackJoke = "Why don't scientists trust atoms? Because they make up everything!"
    
    $fallbackContent = @"
🤣 <b>Dad Joke (Backup)</b> 🤣<br><br>
<img src="<https://media.giphy.com/media/xT9IgG50Fb7Mi0prBC/giphy.gif>" alt="Classic Dad Joke" style="max-width: 200px; height: auto;"><br><br>
$fallbackJoke<br><br>
<small><i>API was unavailable, so here's a classic!</i></small><br>
<small>$(Get-Date -Format 'MMM dd, yyyy h:mm tt')</small>
"@
    
    $ChatMessageBody = @{
    contentType = 'html'
    content     = $fallbackContent
    }

    $ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody
    
    Write-Host "✅ Fallback dad joke sent to Teams chat!"
}

Advanced Example:

This script below is to show what is possible, and I won’t be going into detail on it, but wanted to give you a good starting point for a mini project. If you have any questions on it or want to work through it, I’d be glad to help out.

It will allow you to show completed and non completed migration status that you can sort by migration status and/or location status. If you wanted to, you could add a filter for department too. The only thing out of the box you need to do is put your ChatID in there and make sure you have the csv file saved.

I may have put in there a way to only have to put the users UPN to send them a message…

<#
.SYNOPSIS
    Get migration status from CSV and optionally send to Teams.

.DESCRIPTION
    Reads a CSV file containing user migration data and filters by location
    and migration status. Optionally sends the results to a Teams chat.

.EXAMPLE
    # All migrated users, all locations
    .\Get-MigrationStatus.ps1 -MigrationStatus migrated

.EXAMPLE
    # Not migrated users, specific location
    .\Get-MigrationStatus.ps1 -MigrationStatus notmigrated -Location Location1

.EXAMPLE
    # Both status, specific location, send to Teams
    .\Get-MigrationStatus.ps1 -MigrationStatus both -Location Location2 -RecipientUpn "manager@contoso.com"

.EXAMPLE
    # All users, all locations, to default chat
    .\Get-MigrationStatus.ps1 -MigrationStatus both

.NOTES
    Business Use Case: Migration status reporting, IT compliance tracking
    Required Scopes (if sending to Teams): Chat.Create, ChatMessage.Send
#>

param(
    [Parameter(Mandatory = $true)]
    [ValidateSet("Migrated", "NotMigrated", "Both")]
    [string]$MigrationStatus,

    [Parameter(Mandatory = $false)]
    [ValidateSet("Location1", "Location2", "Location3", "All")]
    [string]$Location = "All",

    [Parameter(Mandatory = $false)]
    [string]$RecipientUpn,

    [Parameter(Mandatory = $false)]
    [string]$ChatId = "default-chat-id",

    [Parameter(Mandatory = $false)]
    [string]$CsvPath = "$PSScriptRoot\\users.csv"
)

function Get-MigrationData {
    param(
        [string]$Path,
        [string]$Location,
        [string]$MigrationStatus
    )

    if (-not (Test-Path $Path)) {
        throw "CSV file not found: $Path"
    }

    $users = Import-Csv $Path

    # Filter by location
    if ($Location -ne "All") {
        $users = $users | Where-Object { $_.Location -eq $Location }
    }

    # Filter by migration status
    switch ($MigrationStatus) {
        "migrated" {
            $filtered = $users | Where-Object { $_.Migrated -ne "" -and $null -ne $_.Migrated }
        }
        "notmigrated" {
            $filtered = $users | Where-Object { $_.Migrated -eq "" -or $null -eq $_.Migrated }
        }
        "both" {
            $filtered = $users
        }
    }

    # Calculate counts
    $totalCount = ($users | Measure-Object).Count
    $migratedCount = ($users | Where-Object { $_.Migrated -ne "" -and $null -ne $_.Migrated } | Measure-Object).Count
    $pendingCount = $totalCount - $migratedCount

    return @{
        Users = $filtered
        TotalCount = $totalCount
        MigratedCount = $migratedCount
        PendingCount = $pendingCount
    }
}

function Send-MigrationStatusToTeams {
    param(
        [object]$Data,
        [string]$Location,
        [string]$MigrationStatus,
        [string]$RecipientUpn,
        [string]$ChatId
    )

    # Build HTML table
    $tableRows = ""
    foreach ($user in $Data.Users) {
        $migratedDisplay = if ($user.Migrated) { $user.Migrated } else { "<em>Pending</em>" }
        $tableRows += @"
<tr>
    <td>$($user.Name)</td>
    <td>$($user.Email)</td>
    <td>$($user.Location)</td>
    <td>$($user.Department)</td>
    <td>$migratedDisplay</td>
</tr>
"@
    }

    # Build title based on status and location
    $statusTitle = switch ($MigrationStatus) {
        "Migrated" { "Migrated" }
        "NotMigrated" { "Not Migrated" }
        "Both" { "All" }
    }

    $locationTitle = if ($Location -eq "All") { "All Locations" } else { $Location }

    $htmlContent = @"
<h2>📊 Migration Status - $statusTitle ($locationTitle)</h2>
<table border="1" cellpadding="5">
<tr>
    <th>Name</th>
    <th>Email</th>
    <th>Location</th>
    <th>Department</th>
    <th>Migrated</th>
</tr>
$tableRows
</table>
<h3>📈 Summary</h3>
<ul>
<li><strong>Total:</strong> $($Data.TotalCount)</li>
<li><strong>Migrated:</strong> $($Data.MigratedCount)</li>
<li><strong>Pending:</strong> $($Data.PendingCount)</li>
</ul>
<p><em>Report generated: $(Get-Date -Format "MMMM dd, yyyy HH:mm")</em></p>
"@

    # Check if already connected to Graph
    $graphContext = Get-MgContext -ErrorAction SilentlyContinue
    if ($graphContext) {
        Write-Host "Already connected to Graph as $($graphContext.Account)" -ForegroundColor Gray
        $connected = $true
    }
    else {
        # Connect to Graph
        Connect-MgGraph -Scopes "Chat.Create", "ChatMessage.Send" -ErrorAction Stop
        $connected = $true
    }

    # Send to appropriate chat
    if ($RecipientUpn) {
        # Create new 1:1 chat
        Write-Host "Creating chat with $RecipientUpn..."
        $chat = New-MgChat -ChatType OneOnOne -Members @(
            @{
                "@odata.type" = "#microsoft.graph.aadUserConversationMember"
                "roles" = @("owner")
                "user@odata.bind" = "<https://graph.microsoft.com/v1.0/users('$((Get-MgContext).Account>)')"
            },
            @{
                "@odata.type" = "#microsoft.graph.aadUserConversationMember"
                "roles" = @("owner")
                "user@odata.bind" = "<https://graph.microsoft.com/v1.0/users('$RecipientUpn')>"
            }
        )
        $targetChatId = $chat.Id
    }
    else {
        # Use default chat
        Write-Host "Using default chat: $ChatId"
        $targetChatId = $ChatId
    }

    # Send message
    $message = New-MgChatMessage -ChatId $targetChatId -Body @{
        contentType = "html"
        content = $htmlContent
    }

    # Only disconnect if we initiated the connection
    if (-not $graphContext) {
        Disconnect-MgGraph -ErrorAction SilentlyContinue
    }

    return $message.Id
}

function Show-MigrationStatus {
    param(
        [object]$Data,
        [string]$Location,
        [string]$MigrationStatus
    )

    $statusTitle = switch ($MigrationStatus) {
        "Migrated" { "Migrated" }
        "NotMigrated" { "Not Migrated" }
        "Both" { "All" }
    }

    $locationTitle = if ($Location -eq "All") { "All Locations" } else { $Location }

    Write-Host "`n=== Migration Status - $statusTitle ($locationTitle) ===" -ForegroundColor Cyan

    # Display table
    $Data.Users | Format-Table -AutoSize

    # Summary
    Write-Host "`n📊 Summary:" -ForegroundColor Green
    Write-Host "  Total:    $($Data.TotalCount)"
    Write-Host "  Migrated: $($Data.MigratedCount)"
    Write-Host "  Pending:  $($Data.PendingCount)"
}

# Main execution
Write-Host "Reading migration data from: $CsvPath" -ForegroundColor Gray

# Get data
$migrationData = Get-MigrationData -Path $CsvPath -Location $Location -MigrationStatus $MigrationStatus

# Display locally
Show-MigrationStatus -Data $migrationData -Location $Location -MigrationStatus $MigrationStatus

# Send to Teams if requested
if ($RecipientUpn -or $ChatId -ne "default-chat-id") {
    Write-Host "`nSending to Teams..." -ForegroundColor Yellow

    try {
        $messageId = Send-MigrationStatusToTeams -Data $migrationData -Location $Location -MigrationStatus $MigrationStatus -RecipientUpn $RecipientUpn -ChatId $ChatId
        Write-Host "✓ Message sent to Teams! (ID: $messageId)" -ForegroundColor Green
    }
    catch {
        Write-Warning "Failed to send to Teams: $_"
    }
}
else {
    Write-Host "`n(No Teams notification - add -RecipientUpn or -ChatId to send)" -ForegroundColor Gray
}

And here is an example for showing Migrated Devices only at Location2.

The full script is also located at Migration Example

There we have it, hope you enjoyed this blog post and learned something from it that will make your life easier. Think of any repetitive tasks, if you want to know when a long script is finished have it notify you chat, or have an agent pick what you and/or your team is having for lunch and put it in the team chat! Would love to hear how you used this blog post or how you incorporate PowerShell and Teams chat/channels.

I created a Github repository for these scripts and for using PowerShell and Teams with other examples and learning as well – Teams GitHub Repo

Have a great day and keep learning and sharing!

Tagged With: 365, Automation, PowerShell, Reporting, Teams

How to Delete Recurring Planner Tasks with PowerShell

July 30, 2025 by ClaytonT Leave a Comment

Are you using PowerShell and Microsoft Planner? I feel it doesn’t get the love it deserves, and to be honest, I hadn’t used PowerShell with Planner in a while, but wanted to get back into it. I first starting using the Microsoft.Graph.Planner and found some limitations that were possible if you used the Graph API directly. One of the things that stood out was you couldn’t call a Plan or Bucket by its real name, only by its ID. Yes, I could have added some logic to make it so, but realized that it also couldn’t remove recurring tasks. I thought it was going to be a quick fix, but found out hours later that wasn’t the case.. hence this post!

Let’s get into the process now.

First, we are going to go to planner.microsoft.cloud and click on the task you want or click on the plan, then on the task you want.

Once you are there you will find the ID in the URL

Below you will see in bold the “TaskId.” The “PlanId” is right after ‘plan/’. They will always be in these locations.

<https://planner.cloud.microsoft/webui/plan/1L__9CleiAwPwqMXDEPEALPQKPa9/view/board/task/1Peq3A7__1EXqot27RoV53QYBZuS?tid=26my427d-m317-83y1-63r0-4suv1pe421y8>

Next we will create a $TaskId variable, where you will put the TaskId inside of it

$TaskId = "1Peq3A7__1EXqot27RoV53QYBZuS"

Before we go farther, lets connect to Microsoft Graph (beta) with these scopes

Connect-MgGraph -Scopes 'Tasks.ReadWrite','Group.ReadWrite.All'

Then you’ll want to get the Task information and save it to the $Task variable to use later. This is important as this will store the ETAG value that you will need to delete the task, as this value changes anytime something changes with that task.

$task = Invoke-MgGraphRequest -Uri "<https://graph.microsoft.com/beta/planner/tasks/$taskId>"

Now here is the fun part, there is no way as of right now to delete a recurring meeting in one call. The best way I found to do it is to first cancel the recurrence then delete it. After doing more research I found later on that it does say you have to ‘$null’ out “Schedule” from Microsoft Learn. I figured it out the hard way when I was using “Developer Tools” to see the API requests it was doing on each click.

Let’s cancel the recurrence, first we have to build out the body to null out schedule and we do that like below.

$body = @{
    recurrence = @{
        schedule = $null
    }
} | ConvertTo-Json -Depth 3

After the body, we create the the “Header” for the request. We do that by below. This is very important because if you don’t Graph won’t know the exact task you are trying to change.

$headers = @{ 
    "If-Match" = $task.'@odata.etag'
    "Content-Type" = "application/json"
}

Now that we have TaskID, Body, and Header we can update(PATCH) the recurring task to a non recurring task.

Invoke-MgGraphRequest -Method PATCH -Uri "<https://graph.microsoft.com/beta/planner/tasks/$taskId>" -Body $body -Headers $headers

Perfect, you have canceled the recurring task and can now delete it. This may seem repetitive, but as of right now it’s the only way to do it. You have to get the task information again because it will now have a new ETAG, and will fail if you try to use the previous one.

$task = Invoke-MgGraphRequest -Uri "<https://graph.microsoft.com/beta/planner/tasks/$taskId>"

And we will have to put the updated ETAG in the header

$headers = @{ 
    "If-Match" = $task.'@odata.etag'
    "Content-Type" = "application/json"
}

We could have done this part in the beginning, but didn’t want to throw too much at you in the beginning, but here we will create the URI as a variable to make the API request shorter and easier to read.

$Uri = "<https://graph.microsoft.com/beta/planner/tasks/$taskId”>

The moment is finally here, where we actually get to delete the task…

Invoke-MgGraphRequest -uri $Uri -Method Delete -Headers $Headers

That’s it! Now go back to planner and confirm that is has been deleted.

Congrats on deleting your first recurring task! Below, I’ve put the whole script so you can see it all together and you can update the TaskId then run it to to delete recurring tasks.

If you’re interested in learning more about Planner and PowerShell, stay tuned as I may have some ideas to make using them together even easier.

$taskId = "1Peq3A7__1EXqot27RoV53QYBZuS"

$task = Invoke-MgGraphRequest -Uri "<https://graph.microsoft.com/beta/planner/tasks/$taskId>"

# Cancel the recurrence by setting schedule to null
$body = @{
    recurrence = @{
        schedule = $null
    }
} | ConvertTo-Json -Depth 3

$headers = @{ 
    "If-Match" = $task.'@odata.etag'
    "Content-Type" = "application/json"
}

Invoke-MgGraphRequest -Method PATCH -Uri "<https://graph.microsoft.com/beta/planner/tasks/$taskId>" -Body $body -Headers $headers

$task = Invoke-MgGraphRequest -Uri "<https://graph.microsoft.com/beta/planner/tasks/$taskId>"

$headers = @{ 
    "If-Match" = $task.'@odata.etag'
    "Content-Type" = "application/json"
}

$Uri = "<https://graph.microsoft.com/beta/planner/tasks/$taskId”>

Invoke-MgGraphRequest -uri $uri -Method Delete -Headers $headers

Let me know if you have any questions or feedback, have a great day!

Tagged With: 365, Automation, Planner, PowerShell, ProjectManagement, Reporting, Tasks

Why does my 365 Admin Audit Log sometime say it’s disabled, but other times enabled? Am I being compromised?

July 16, 2025 by ClaytonT Leave a Comment

Let me first start this off with I’m 99% sure you aren’t being compromised, but read on to see what I mean.

I first ran into this when I was running Maester and I saw that it said my test failed for having Unified Audit Log enabled. I then went to my Purview Portal and saw that it was enabled. Next I ran the command:

Get-AdminAuditLogConfig | Format-List UnifiedAuditLogIngestionEnabled

And received this output:

UnifiedAuditLogIngestionEnabled : False

It got me worried, as why is the PowerShell version saying it failed, but the GUI isn’t. Honestly, I usually trust the PowerShell output before the GUI. Then I run the PowerShell command to set it to “True.”

Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true

And received this output:

WARNING: The command completed successfully but no settings of 'Admin Audit Log Settings' have been modified.

Are you scratching your head like I was? I thought, maybe it’s because on the portal it shows it’s enabled, it is seeing it there and not changing it. Why not put that in the warning message though?

I did a little research and found Audit Log Enable Disable | MSFT which is where this little gem is located

Important

Be sure to run the previous command in Exchange Online PowerShell. Although the Get-AdminAuditLogConfig cmdlet is also available in Security & Compliance PowerShell, the UnifiedAuditLogIngestionEnabled property is always False, even when auditing is turned on.

And that is when it clicks, I connect to ExchangeOnlineManagement first then IPPSSession which must be causing the issue! I then disconnect with “Disconnect-ExhangeOnline”, and reconnect using “Connect-ExchangeOnline.” Now for the moment of truth:

Get-AdminAuditLogConfig | Format-List UnifiedAuditLogIngestionEnabled

UnifiedAuditLogIngestionEnabled : True

Success! But now the “why does this happen and why haven’t more people reported this?” I asked my buddy Sam Erde, had he seen this before? And he was perplexed as I was. Then he started digging a bit, and saw that you couldn’t use -NoClobber as it is from the same module.

The crazy part is, if you export both versions, they are the exact same code! What could it be? Is it how the IPPSSession connects to the API? If so, why not put a message saying you are connecting with IPPSSession, please disconnect and use connect-exchangeonline?

The mystery still continues, but I know Sam is working on a fix to handle this more consistently and hopefully have a fix shortly!

Have you been burned by this before?

Cliff notes version:

  • You weren’t compromised (unless you see it being changed in the logs and/or you ensure you are checking it correctly)
  • Make sure when checking for AuditLog is enabled through PS that your not using IPPSSession for the command
  • Sam Erde is working on a fix for Maester

Hope this saves you some headaches and have a great day!

Tagged With: 365, Maester, PowerShell, Purview, Reporting, Security

Custom Maester Tests: Validate Full Addresses Now and Cleaned Up Wording

February 7, 2025 by ClaytonT Leave a Comment

Added 3 new tests which I think the first two will be game changers. The first 2 are tests for validating locations, in which the user must have street, city, state, postal code, country, business phone, and company name the same as the valid location in the json. If you have 3 different addreses that your company uses, you can put each in there, and they are seen as 3 different addresses so it will only pass the location test if they have all the correct values for 1 location. The 2nd test is the same as the first, but I removed business phone in case your company doesn’t have standard for it for all employees. The last test is formatting for user email accounts that should be formatted as all lower case and its first name period last name. Also I cleaned up some of the wording in all the different tests to keep them as similar as possible. Feel free to change in your tests though!

ENTRA.UV.1010.L01 – All location information

  • Test-ContosoUsersAllowedLocations.ps1
  • Test-ContosoUsersAllowedLocations.Tests.ps1
  • Test-ContosoUsersAllowedLocations.md

ENTRA.UV.1010.L02 – All location information minus business phone

  • Test-ContosoUsersAllowedLocationsNoBusinessPhones.ps1
  • Test-ContosoUsersAllowedLocationsNoBusinessPhones.Tests.ps1
  • Test-ContosoUsersAllowedLocationsNoBusinessPhones.md

ENTRA.UF.1003.T03.Email – All lower case first name period last name

  • Test-ContosoUsersFormattingFirstLastLowerCase.ps1
  • Test-ContosoUsersFormattingFirstLastLowerCase.Tests.ps1
  • Test-ContosoUsersFormattingFirstLastLowerCase.md

Are there any other tests you’d like to see sooner than later?

GitHub: https://github.com/DevClate/Custom-Maester-Tests
Website: https://devclate.github.io/Custom-Maester-Tests/
Maester: https://maester.dev

Have a great day!

Tagged With: 365, Automation, Maester, PowerShell, Reporting, Security

Now you can use your own company standards with Maester custom tests

February 3, 2025 by ClaytonT Leave a Comment

I thought checking to see if they were filled in or even formatted correctly wasn’t enough.. now you can config the validation.json file in the Validating folder with your company standards to make only those values pass. Here are the fields so far, and will be adding more!

  • ENTRA.UV.1001 – Company Name
  • ENTRA.UV.1002 – Street Address
  • ENTRA.UV.1003 – City
  • ENTRA.UV.1004 – State
  • ENTRA.UV.1005 – Postal Code
  • ENTRA.UV.1006 – Country
  • ENTRA.UV.1007 – Business Phone Number
  • ENTRA.UV.1008 – Job Title
  • ENTRA.UV.1009 – Department

Hope you like this new update and let me know if you run into any issues or want to see any other updates. Please don’t forget to star the repo and share to get the word out so more people can add theirs.

Have a great day!

GitHub: Custom Maester Tests
Website: Custom Maester Tests
Website: Offical Maester Website

Tagged With: 365, Automation, Maester, PowerShell, Reporting, Security

If Maester couldn’t get any better…Custom Test Collection now available

January 27, 2025 by ClaytonT Leave a Comment

The time has finally come. I have created a public repository to store custom Maester tests for everyone. As well as a website for deeper understanding where needed. I haven’t seen anyone else do it yet, and worse case scenario, people can just use the ones that I create, but I envision others adding theirs to this too. Yes, you will have to create the function, test, and the markdown file (I and/or others can help), so that we can have a collection of tests that anyone can pick and choose which ones they want to add to their Maester and customize it to their needs. They don’t need to be 365 related either, as they could be checks for Windows 11 settings, server configs, or check that a certain OU should only has these mentioned users or computers and to make sure that doesn’t change.

This is still in its early stages and would love any feedback to make it better while still showing that it is a companion to Maester. I wanted to get the framework started to that we can start gaining the benefits from the repository while still making it easy to use.

I hope you are excited about this as I am, and we can create a large community collection of tests.

Please star and share the repo. Open issues for tests that you want to see and if you already have one or can make it, put that in the issue. Let’s make all our IT lives easier and safer.

Thank you for taking the time to read this and hope you find value in this and can share your knowledge as well.

Website: https://devclate.github.io/Custom-Maester-Tests/
GitHub: https://github.com/DevClate/Custom-Maester-Tests

I’m also working on a module for the Entra attribute fields that will fix any issues by either manually typing in the correct value or only allow company standard values.

Tagged With: 365, AD, Automation, Entra, Maester, PowerShell, Reporting, Windows Server

  • Page 1
  • Page 2
  • Page 3
  • Page 4
  • Go to Next Page »

Primary Sidebar

Clayton Tyger

Tech enthusiast dad who has lost 100lbs and now sometimes has crazy running/biking ideas. Read More…

Find Me On

  • Email
  • GitHub
  • Instagram
  • LinkedIn
  • Twitter

Recent Posts

  • Learning ValidateSet in PowerShell: Valid Values Only
  • Teams Chat and PowerShell – How to add value!
  • EntraFIDOFinder: New Web UI and Over 70 New Authenticators
  • January 19, 2026 Updates to EntraFIDOFinder
  • v0.0.20 EntraFIDOFinder is out

Categories

  • 365
  • Active Directory
  • AI
  • AzureAD
  • BlueSky
  • Cim
  • Dashboards
  • Documentation
  • Entra
  • Get-WMI
  • Learning
  • Module Monday
  • Nutanix
  • One Liner Wednesday
  • Passwords
  • PDF
  • Planner
  • PowerShell
  • Read-Only Friday
  • Reporting
  • Security
  • Uncategorized
  • Windows
  • WSUS

© 2026 Clatent