A hands-on walkthrough for a developer who has never used the PSI Notify Bot. Goal: in about 15 minutes, you will have sent a real Teams message from a PowerShell script you wrote, understand the four moving pieces, and know where to go next for production wiring.
A script that posts an HTML message into a Teams chat the bot owns. By the end of the tutorial:
You have a personal sandbox chat in Teams (you + one teammate + the bot) that you can spam without bothering anyone.
You have a hello-notify.ps1 script that pulls credentials from Key Vault and sends a styled message.
You understand the four pieces (identity, secrets, chat, message) well enough to read the more advanced docs without getting lost.
Before you start
Tick these off before opening a terminal. If any are missing, fix that first — chasing the wrong error later is no fun.
You’re on the PSI VPN (or on-network).
You have PowerShell 7+ (pwsh --version).
You have the Azure CLI signed in: az account show returns the PSI tenant a83ae943-0a50-49cc-83c3-479b7a44b7fb.
Your account has Key Vault Secrets User on ps-certificates-kv. Test with:
az keyvault secret show --vault-name ps-certificates-kv --name psi-notify--tenant-id --query value -o tsv
If this prints a GUID, you’re good. If it 403s, ask Adam Devereaux to grant the role.
You have Microsoft Teams open and signed in as the same PSI account.
You have one teammate’s name handy who is willing to be in your sandbox chat. (Group chats need ≥2 humans — see §6.)
Step 1 — Get the module on your machine
The reusable PowerShell module lives in the psi-notify-bot repo on GHE.
cd C:\gitgit clone https://progressivesurface.ghe.com/ProgressiveSurface/psi-notify-bot.gitcd psi-notify-botImport-Module .\src\PSI.Notify.psd1 -ForceGet-Command -Module PSI.Notify | Select-Object Name
You should see functions like Get-NotifyBotToken, New-NotifyChat, Send-NotifyMessage, Get-NotifySecretsFromKeyVault. These are the only functions you need to touch — everything else inside the module is plumbing the wiki guides handle for you.
Why a module instead of copy-paste? The module bakes in three Microsoft Graph footguns (object-ID-not-UPN binding, ordered hashtables, ≥2-unique-members enforcement) that have burned us in the past. Calling the module means you skip those landmines.
Step 2 — Find your own AAD object ID
To DM you (and later, to add you to a chat), Microsoft Graph needs your AAD object GUID, not your username or email. Get it:
az ad signed-in-user show --query id -o tsv# → 8a3e1234-5678-90ab-cdef-1234567890ab (your GUID)
Save that. Now get your teammate’s. Replace the name:
az ad user show --id "teammate@progressivesurface.com" --query id -o tsv
If you don’t know the UPN, search:
az ad user list --filter "startswith(displayName,'Dan')" --query "[].{name:displayName, upn:userPrincipalName, id:id}" -o table
Why GUIDs, not UPNs? Graph documents that you can bind users by UPN, but in practice the @ character in users('alice@psi.com') is silently misparsed by Graph’s OData layer and the whole field gets dropped — with a misleading “user@odata.bind missing” error. Bind by GUID. Always.
Step 3 — Bootstrap your sandbox chat
This is the cleanest training shape: a chat you own, with you + one teammate + the bot, that you can post to as many times as you want without polluting IT Critical Notifications or someone else’s space.
The repo ships a script that does the whole bootstrap — creates the chat, installs the bot, posts a hello message, and stashes the chat ID in Key Vault.
The script pulls bot credentials from ps-certificates-kv (you need Key Vault Secrets User — that’s why we checked in prereqs).
It asks Microsoft Graph to create a group chat with you + your teammate. Both of you get a Teams notification within seconds.
It calls POST /chats/{id}/installedApps to install the bot into the chat, so the bot can post messages there afterward.
It sends your bootstrap message via the Bot Framework so the chat has immediate content.
It writes the chat ID back to Key Vault under the name you supplied.
Verify it worked:
Open Teams. You should see a new group chat titled “Notify Sandbox — <YourName>” with three members: you, your teammate, and PSI Notify Bot.
The bootstrap message should be visible in the chat, posted by the bot.
The chat ID is now in KV:
az keyvault secret show --vault-name ps-certificates-kv ` --name "psi-notify--chat-sandbox-<yourinitials>" --query value -o tsv
If any of those three fail, jump to Common failures before continuing.
Step 4 — Write your first send script
Now the fun part. Create hello-notify.ps1:
# hello-notify.ps1 — send a message to your sandbox chatImport-Module C:\git\psi-notify-bot\src\PSI.Notify.psd1 -Force# 1. Pull all four bot secrets + the chat ID from Key Vault$secrets = Get-NotifySecretsFromKeyVault -VaultName 'ps-certificates-kv'$chatId = az keyvault secret show ` --vault-name ps-certificates-kv ` --name "psi-notify--chat-sandbox-<yourinitials>" ` --query value -o tsv# 2. Trade the bot's client credentials for a short-lived OAuth token$botToken = Get-NotifyBotToken ` -TenantId $secrets.TenantId ` -ClientId $secrets.ClientId ` -ClientSecret $secrets.ClientSecret# 3. Post a message into your sandbox chatSend-NotifyMessage ` -BotToken $botToken ` -ChatId $chatId ` -BodyHtml "<b>Hello from $env:USERNAME!</b><br>This is my first PSI Notify Bot message."
Run it:
.\hello-notify.ps1
Within a couple of seconds, the message lands in your sandbox chat. That is the entire contract — once you have a chat ID and bot credentials, sending a message is three function calls.
Step 5 — Make it look like a real PSI alert
The repo and the wiki both have a house style for alert bodies. The shape is: bold subject line on top, then field-by-field details below, optional code block for paths or commands. Consistency matters more than creativity because recipients skim.
Replace the body in your script with something more realistic:
$body = @"<b>Sandbox: tutorial message</b><br><br><b>What:</b> First end-to-end notification from the tutorial<br><b>When:</b> $(Get-Date -Format 'yyyy-MM-dd HH:mm') EDT<br><b>From:</b> $env:USERNAME on $env:COMPUTERNAME<br><b>Run path:</b> <code>$PSScriptRoot\hello-notify.ps1</code>"@Send-NotifyMessage -BotToken $botToken -ChatId $chatId -BodyHtml $body
Re-run. The new message lands styled like a production alert.
Why HTML and not Markdown? The Bot Framework activity endpoint takes textFormat: "xml" (HTML subset). Markdown works in some Teams surfaces but renders inconsistently in group chats. HTML is the safe default. Adaptive Cards are an option for richer messages (see Send-NotifyCard in the module), but plain HTML covers 95% of alerts.
Step 6 — Understand what just happened
Four layers had to line up. If you can sketch them, you can debug any future notification issue.
The bot’s identity lives in Azure AD. It’s an app registration, not a user.
The bot’s secrets live in Key Vault. Nothing else stores them — not GitHub Secrets (mostly), not on disk, not in the repo.
The chat ID is just a string. The bot doesn’t “own” the chat in any deep sense — it was installed as a chat app and is allowed to post.
The message is an HTTP POST. Once you have a valid token and a chat ID, it’s an Invoke-RestMethod call.
When something breaks, ask: which layer? KV access? Token acquisition? Chat ID stale? Message body malformed? That’s almost always enough to find the fix.
Common failures
What you’ll actually run into, what it looks like, what to do.
”Forbidden” on az keyvault secret show
(Forbidden) The user, group or application 'appid=...' does not have secrets get permission
→ Your account is missing Key Vault Secrets User on ps-certificates-kv. Ask Adam Devereaux. This is the most common first-time blocker.
Initialize-NotifyChat.ps1 fails with “user@odata.bind field is missing”
This single misleading error covers three causes (see pitfalls). In the tutorial, the most likely is:
Only one unique member. You passed your own GUID twice, or you and your teammate ended up resolving to the same account. Graph requires ≥2 distinct human GUIDs for a group chat. Double-check both GUIDs.
If you genuinely want a solo test chat (just you + the bot), the bot’s personal 1:1 scope is the path — but admin consent for TeamsAppInstallation.ReadWriteSelfForUser.All is pending in Entra, so it doesn’t work yet. Stick to a two-human sandbox.
Send-NotifyMessage returns 401 Unauthorized
→ The bot’s client secret in KV was rotated and the cached token is stale. Re-run from the top — Get-NotifyBotToken will fetch a fresh one. If it still 401s, the secret value in KV (psi-notify--client-secret) genuinely doesn’t match the app registration’s current secret — see Rotate the client secret.
Send-NotifyMessage returns 403 Forbidden
→ The chat exists but the bot isn’t installed in it. This happens if you try to post to a chat ID you didn’t create through this script. Run Install-NotifyBotInChat -ChatId $chatId to install the bot, or use a chat ID the script bootstrapped (which auto-installs).
Message sends successfully but never appears in Teams
→ Almost always: you’re posting to the wrong chat ID. Compare $chatId in your script against az keyvault secret show ... --name psi-notify--chat-sandbox-<you>. Stale chat IDs hardcoded in scripts is a recurring footgun — always pull from KV.
Where to go next
You’ve now done end-to-end what every production consumer does. The remaining work for a real consumer is mostly polish around the edges.
If you want to…
Read
Wire a scheduled task / web app to send real alerts
Wire a New Alert Consumer — uses egnyte-stp-sync as the worked example, covers managed-identity secret fetch, rate-limiting, and the smoke-test loop
Understand the full architecture, secrets layout, and pitfalls
Your sandbox chat will sit in Teams indefinitely if you leave it. Two options:
Keep it. It’s a useful test surface for future work. Just rename it in Teams (“Sandbox — <You>”) so future-you knows what it is.
Delete it. Leave the chat manually from Teams; the chat ID secret in KV becomes orphaned. Optional but tidy: delete the secret too:
az keyvault secret delete --vault-name ps-certificates-kv ` --name "psi-notify--chat-sandbox-<yourinitials>"
Self-check — are you actually trained?
If you can answer these without scrolling back, you’re ready to wire a real consumer.
What four pieces of information do you need to send a message? (Bot tenant ID, client ID, client secret; the chat ID.)
Why bind chat members by AAD object GUID instead of UPN? (Graph’s OData layer silently misparses the @ in UPNs.)
Why does a group chat need at least two human members? (The bot is an installed app, not a conversationMember; Graph counts only humans.)
Where do bot credentials live? (Key Vault ps-certificates-kv under psi-notify--*. Nothing else stores them.)
If a notification stops arriving in Teams but the script reports success, what’s the most likely cause? (Wrong chat ID — pull from KV, don’t hardcode.)