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

Clatent

Technology | Fitness | Food

  • About
  • Resources
  • Contact

Uncategorized

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

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