Skip to content
Open
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
4f6a5e9
Add pack+sideload Teams app script to activity-protocol postdeploy (#…
Copilot Jul 17, 2026
e236622
Suppress local invoke hint for websocket agents
Copilot Jul 17, 2026
e7a4c35
Revert "Suppress local invoke hint for websocket agents"
Copilot Jul 17, 2026
b70acb6
Fix golangci-lint lll violation in teams_sideload_script.go
Copilot Jul 17, 2026
41006fd
Silence cspell for base64 icon constants in teams_sideload_script.go
Copilot Jul 17, 2026
451f2bf
Address Copilot review: build-only mode, Teams field limits, safe hints
Copilot Jul 17, 2026
1408d80
Avoid gosec G101 false positive in sideload run-command test
Copilot Jul 17, 2026
24d5454
Rename test var to clear gosec G101 (pw substring match)
Copilot Jul 17, 2026
2757e1d
Address 2nd Copilot review: manifest version, scopes, exit codes, quo…
Copilot Jul 17, 2026
1c0f3ae
Bound Teams manifest version components to the 65535 limit
Copilot Jul 17, 2026
1b677f5
Fix PS7.4+ native-error abort and qualify the no-admin claims
Copilot Jul 17, 2026
dc89ae6
Enforce script mode on rewrite and show build-only opt-out per shell
Copilot Jul 17, 2026
f5b4726
Re-chunk embedded base64 icons under the 125-char Go line limit
Copilot Jul 17, 2026
6b2de0a
Do not clobber user-owned files sharing the sideload script name
Copilot Jul 17, 2026
ba95231
Reject non-regular files, unique pwsh temp dir, guide fast-path condi…
Copilot Jul 17, 2026
f85eb4e
Enable the fast path only when all sideload scripts were written
Copilot Jul 17, 2026
cd21bb5
Always give a next action; scope the build-only flag in PowerShell
Copilot Jul 17, 2026
9291d75
fix(agents): guarantee monotonic Teams version and drop unrunnable sc…
Copilot Jul 17, 2026
d445022
fix(agents): harden version state file and use full atk login command
Copilot Jul 17, 2026
c1c9eb5
fix(agents): detect ATK 'Cannot get token' unauthenticated error
Copilot Jul 17, 2026
b54f5cc
fix(agents): scope generated Teams artifacts per agent
Copilot Jul 17, 2026
e64b0c9
fix(agents): reword comment to satisfy cspell
Copilot Jul 17, 2026
4d647d7
Key Teams sideload ownership on the stable bot name; add execution tests
Copilot Jul 17, 2026
5400a6e
Make the run hint shell-agnostic and stop the ps1 auto-opening a browser
Copilot Jul 17, 2026
f0ee571
Harden generated-file writes and make the run hint portable to Window…
Copilot Jul 17, 2026
b0997f5
fix(activity): key Teams script ownership on agent name; lock version…
Copilot Jul 17, 2026
6b72194
test(activity): use a unique sentinel in the refresh assertion
Copilot Jul 17, 2026
188a1a9
fix(activity): expansion-safe run hint, safer version lock, cd hint i…
Copilot Jul 17, 2026
445345f
fix(activity): Windows execution-policy bypass, shell-safe cd, faithf…
Copilot Jul 17, 2026
695c969
fix(agents): harden Teams sideload guide/scripts (round 21 review)
Copilot Jul 17, 2026
f122be1
fix(agents): shell-independent run hint + verifiable version-lock (ro…
Copilot Jul 17, 2026
1a51292
fix(agents): transactional pair write, agent-scoped legacy guide, loc…
Copilot Jul 17, 2026
a58ff27
fix(agents): consistent guide cd + tolerate npm install failure (roun…
Copilot Jul 17, 2026
2292c80
Slim down Teams pack+sideload script generation
Copilot Jul 23, 2026
a27b1ac
Correct stale comments/docs after slim-down; drop dead field
Copilot Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,70 @@
<!-- Generated by 'azd deploy' (activity protocol) for agent {{.AgentName}}. azd may replace this file on the next deploy; copy it elsewhere to keep manual edits. -->
# Connect {{.AgentName}} to Microsoft Teams

`azd deploy` already did the Azure side for you:

- Azure Bot: `{{.BotName}}` (Microsoft Teams channel enabled)
- Bot ID (msaAppId): `{{.MsaAppID}}` <- you will paste this as the bot id
- Bot ID (msaAppId): `{{.MsaAppID}}` <- you will paste this as the bot id{{if .TenantID}}
- Microsoft 365 tenant: `{{.TenantID}}` <- the bot is single-tenant; sign in / sideload here{{end}}

Two manual steps remain: (A) create a Teams app package, then (B) upload it.
They are the same for any activity-protocol agent.
{{if .ScriptsGenerated}}
## Fastest path — run the generated script

`azd deploy` also wrote a runnable **pack-and-sideload script** next to this guide
that does A and B for you in one command (the Bot ID is already baked in). Run it
from the folder that contains this guide — the agent's source folder — since the
commands below are relative to it (`azd deploy` itself runs from the project root,
so `cd` there first):
Comment thread
v1212 marked this conversation as resolved.
Outdated

```powershell
cd {{.ServiceCdPwsh}} # the agent's source folder, from the project root
powershell -NoProfile -ExecutionPolicy Bypass -File ./pack-and-sideload-teams-app.ps1 # Windows / PowerShell
```
```sh
cd {{.ServiceCdPosix}} # the agent's source folder, from the project root
./pack-and-sideload-teams-app.sh # macOS / Linux
```

It builds the Teams app package and installs it **for you** (`atk install --scope
Personal` — no org-catalog admin approval needed), then prints an "Open in Teams"
link. It is idempotent, so you can re-run it safely. Custom app upload
(sideloading) must be enabled for your tenant; if it is turned off, a Teams admin
must enable it (see the restricted-tenant note below).

Prerequisites:

- **Node.js** (for `npm`) — the script installs the Microsoft 365 Agents Toolkit
CLI (`atk`) via npm if it is missing.
- A one-time **`atk auth login m365`** with your M365 account — the script launches
this for you if you are not signed in.{{if .TenantID}} Sign in with an account in
tenant `{{.TenantID}}` (the bot is single-tenant), or the install will fail.{{end}}
- `--scope Personal` installs only for you and needs **no org-catalog admin
approval** (an org-wide catalog upload does; see step B below). Custom app
upload still has to be enabled for your tenant — if it is off, a Teams admin
must turn it on (see the restricted-tenant note below).

To build the package without installing it (packaging still runs), set the
build-only opt-out for your shell:

```powershell
# PowerShell: scope the flag to this one run so later runs are not stuck in build-only mode
try { $env:SKIP_TEAMS_INSTALL = "1"; powershell -NoProfile -ExecutionPolicy Bypass -File ./pack-and-sideload-teams-app.ps1 } finally { $env:SKIP_TEAMS_INSTALL = $null }
```
```sh
SKIP_TEAMS_INSTALL=1 ./pack-and-sideload-teams-app.sh # macOS / Linux (per-command)
```

Prefer the manual / UI flow, a restricted tenant, or custom manifest edits?
Follow steps A and B below instead.
{{else}}
> **Note:** azd did not generate the pack-and-sideload script this time — a file
> named `pack-and-sideload-teams-app.ps1` / `pack-and-sideload-teams-app.sh`
> already exists in this folder (or writing it was skipped), so it was left
> untouched to avoid overwriting your file. Follow the manual steps A and B below,
> or rename/remove the existing file and re-run `azd deploy` to get the script.
{{end}}

## A. Create the Teams app package

Expand Down Expand Up @@ -87,11 +145,12 @@ Compress-Archive manifest.json,color.png,outline.png {{.AgentName}}-teams-app.zi
```

Sideload for yourself with the Microsoft 365 Agents Toolkit CLI (atk). `--scope Personal` is a
per-user install and needs NO Teams admin:
per-user install and needs no org-catalog admin approval (custom app upload must still be
enabled for your tenant):

```sh
npm install -g @microsoft/m365agentstoolkit-cli # one-time; requires Node.js
atk auth login # sign in with your M365 account
atk auth login m365 # sign in with your M365 account{{if .TenantID}} in tenant {{.TenantID}} (single-tenant bot){{end}}
atk install --file-path {{.AgentName}}-teams-app.zip --scope Personal
```

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
#!/usr/bin/env pwsh

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I'm not a fan of including these scripts, and would like @therealjohn to weigh in when he's back.

My concern is that we're signing up to be responsible for debugging any failures that happen with setting up the Teams stuff, and updating it as they make changes. As it is, I also feel like the guide should realistically just be a pointer to an aka.ms link which is maintained by the folks responsible for the Teams side of things, so it can be used by everyone, not just azd users.

# Pack and sideload the Microsoft Teams app for {{.AgentName}} -- one command.
#
# Generated by 'azd deploy' (activity protocol) for agent {{.AgentName}}. Every id below was filled in by
# azd from the deployed agent and its auto-created Azure Bot, so this script does
# NOT call Azure -- it just packages and installs the Teams app.
#
# What it does (idempotent -- safe to re-run):
# 1. builds a Teams app package (manifest.json + icons) in a temp dir,
# 2. ensures the Microsoft 365 Agents Toolkit CLI (atk) is installed,
# 3. installs the app FOR THE CURRENT USER (atk install --scope Personal --
# no org-catalog admin approval needed; your tenant must still allow custom
# app upload/sideloading, which a Teams admin can enable if it is off), and
# 4. prints an "Open in Teams" chat deep link.
#
# Prerequisites: Node.js (npm) for the atk CLI, and a one-time 'atk auth login m365'
# with your M365 account (this script launches it for you if you are not signed
# in). Set SKIP_TEAMS_INSTALL=1 to build the package only and skip the install.

$ErrorActionPreference = "Stop"

# ---- Ids baked in by azd deploy (do not edit) -------------------------------
$AgentName = "{{.AgentName}}"
$BotId = "{{.MsaAppID}}" # Teams manifest bots[].botId = the Azure Bot msaAppId
$TeamsAppId = "{{.TeamsAppId}}" # stable per agent, so re-runs update the same app

# Teams manifest v1.19 caps name.short at 30 and description.short at 80 chars,
# but agent names may be longer, so bound the constrained fields.
$shortName = if ($AgentName.Length -gt 30) { $AgentName.Substring(0, 30) } else { $AgentName }
$shortDesc = "Chat with $shortName in Microsoft Teams."
if ($shortDesc.Length -gt 80) { $shortDesc = $shortDesc.Substring(0, 80) }

# Teams only treats a re-uploaded package (same app id) as an update when the
# manifest version is higher, so we need a strictly increasing version on every
# run -- even two runs in the same second. Persist a monotonic build number next
# to this script (seeded from wall-clock seconds, then always at least
# last + 1) and encode it into two bounded components (Teams caps each at 65535):
# minor = N / 65536, patch = N % 65536. N ~ epoch keeps minor < 65535 until ~2106.
$stateFile = Join-Path $PSScriptRoot ".teams-app-version-$TeamsAppId"
# Serialize the read-increment-write of the counter so two concurrent runs cannot
# compute the same version. New-Item -ItemType Directory fails if the directory
# already exists, giving an atomic create-or-fail lock. We NEVER force-delete the
# lock to reclaim it: force-removal could evict a live holder or race two waiters.
# Instead, if we cannot acquire it within the timeout -- which only happens if a
# crashed run left it behind -- we fall back to an epoch-only version (still
# monotonic second-over-second) and leave the counter file untouched. The lock
# directory is created empty and only ever removed by its own holder via a
# non-recursive delete (which fails on a non-empty directory, so it cannot delete
# unrelated contents). try/finally guarantees release (PowerShell runs finally
# blocks even on 'exit').
$lockDir = "$stateFile.lock"
$versionLocked = $false
$nowEpoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$verN = [int64]$nowEpoch
try {
$lockWait = 0
while ($lockWait -lt 100) {
try {
New-Item -ItemType Directory -Path $lockDir -ErrorAction Stop | Out-Null
$versionLocked = $true
break
} catch {
$lockWait++
Start-Sleep -Milliseconds 100
}
}

# Only bump/persist the monotonic counter while we actually hold the lock. If
# we fell through without it (stuck lock from a crashed run), use epoch alone.
Comment thread
v1212 marked this conversation as resolved.
Outdated
if ($versionLocked) {
$lastN = [int64]0
# Only read the counter from a plain regular file -- never follow a symlink
# or reparse point, and ignore directories.
$stateItem = Get-Item -LiteralPath $stateFile -Force -ErrorAction SilentlyContinue
$stateIsLink = $stateItem -and ($stateItem.LinkType -or ($stateItem.Attributes -band [IO.FileAttributes]::ReparsePoint))
$stateIsDir = $stateItem -and $stateItem.PSIsContainer
if ($stateItem -and -not $stateIsLink -and -not $stateIsDir) {
$raw = (Get-Content -LiteralPath $stateFile -Raw -ErrorAction SilentlyContinue) -replace '[^0-9]', ''
if ($raw) { $lastN = [int64]$raw }
}
$verN = [int64][math]::Max($nowEpoch, $lastN + 1)
# Persist the counter, but never overwrite a pre-existing symlink/reparse
# point or directory (a planted link could redirect the write to an
# arbitrary file). Write to a temp file and move it into place so the link
# itself is replaced.
if (-not $stateIsLink -and -not $stateIsDir) {
$stateTmp = "$stateFile.$PID.tmp"
Set-Content -LiteralPath $stateTmp -Value ([string]$verN) -NoNewline -ErrorAction SilentlyContinue
Move-Item -LiteralPath $stateTmp -Destination $stateFile -Force -ErrorAction SilentlyContinue
}
}
} finally {
if ($versionLocked) {
# Non-recursive delete: removes our own (empty) lock directory only and
# fails safely if anything unexpected is inside it.
try { [System.IO.Directory]::Delete($lockDir, $false) } catch { }
}
}
$verMinor = [int]([math]::Floor($verN / 65536))
$verPatch = [int]($verN % 65536)
$pkgVersion = "1.$verMinor.$verPatch"
if ($verMinor -gt 65535 -or $verPatch -gt 65535) {
Write-Error "Computed manifest version $pkgVersion exceeds the Teams component limit (65535)."
exit 1
}

Write-Host "Agent: $AgentName"
Write-Host "Bot ID: $BotId"
Write-Host "Teams app id: $TeamsAppId"

# ---- Build the Teams app package in a temp dir ------------------------------
# Use a unique per-invocation directory (like the Bash script's mktemp -d) so
# concurrent runs -- including two different projects that share an agent name --
# never share a manifest/zip or overwrite each other's package.
$buildDir = Join-Path ([System.IO.Path]::GetTempPath()) ("teams-app-" + [Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $buildDir | Out-Null

$manifest = [ordered]@{
'$schema' = "https://developer.microsoft.com/en-us/json-schemas/teams/v1.19/MicrosoftTeams.schema.json"
manifestVersion = "1.19"
version = $pkgVersion
id = $TeamsAppId
developer = [ordered]@{
name = "Microsoft Foundry"
websiteUrl = "https://www.example.com"
privacyUrl = "https://www.example.com/privacy"
termsOfUseUrl = "https://www.example.com/termsofuse"
}
icons = [ordered]@{ color = "color.png"; outline = "outline.png" }
name = [ordered]@{ short = $shortName; full = "$AgentName (Activity, Teams)" }
description = [ordered]@{
short = $shortDesc
full = "A Microsoft Foundry hosted agent on the Activity protocol. Send it a message in Teams."
}
accentColor = "#5B5FC7"
bots = @([ordered]@{ botId = $BotId; scopes = @("personal"); supportsFiles = $false; isNotificationOnly = $false })
permissions = @("identity")
validDomains = @()
}
# Write manifest.json as UTF-8 WITHOUT a BOM. Windows PowerShell 5.1's
# Set-Content -Encoding UTF8 prepends a BOM, which some Teams/atk JSON parsers
# reject; WriteAllText with an explicit no-BOM encoding is correct on 5.1 and 7.
$noBomUtf8 = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText(
(Join-Path $buildDir "manifest.json"), ($manifest | ConvertTo-Json -Depth 10), $noBomUtf8)

# ---- Write the icons (embedded PNGs -- no image tooling needed) --------------
# color.png is 192x192, outline.png is 32x32, per the Teams manifest icon rules.
$colorB64 = "{{.ColorPngB64}}"
$outlineB64 = "{{.OutlinePngB64}}"
[System.IO.File]::WriteAllBytes((Join-Path $buildDir "color.png"), [Convert]::FromBase64String($colorB64))
[System.IO.File]::WriteAllBytes((Join-Path $buildDir "outline.png"), [Convert]::FromBase64String($outlineB64))

# ---- Zip the package (files at the zip root, not in a subfolder) ------------
$zipPath = Join-Path $buildDir "$AgentName-teams-app.zip"
if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
Compress-Archive `
-Path (Join-Path $buildDir "manifest.json"), (Join-Path $buildDir "color.png"), (Join-Path $buildDir "outline.png") `
-DestinationPath $zipPath -Force
Write-Host "Teams app package: $zipPath"

# Build-only mode: the package (with icons) is ready; skip the atk install.
if ($env:SKIP_TEAMS_INSTALL -eq "1") {
Write-Host "SKIP_TEAMS_INSTALL=1 - package built; skipping the per-user Teams install."
Write-Host "Sideload it manually (requires custom app upload to be enabled for your tenant):"
Write-Host " $zipPath"
Write-Host " Teams -> Apps -> Manage your apps -> Upload an app -> Upload a custom app"
return
}

# ---- Ensure the atk CLI is available ----------------------------------------
if (-not (Get-Command atk -ErrorAction SilentlyContinue)) {
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
throw "Node.js/npm is required for the atk CLI (per-user Teams install). Install Node.js, then re-run this script."
}
Write-Host "Installing the Microsoft 365 Agents Toolkit CLI (atk)..."
npm install -g '@microsoft/m365agentstoolkit-cli' | Out-Null
Comment thread
v1212 marked this conversation as resolved.
Outdated
if (-not (Get-Command atk -ErrorAction SilentlyContinue)) {
throw "Failed to install atk. Install it manually: npm i -g @microsoft/m365agentstoolkit-cli"
}
}

# atk is a native command; on PowerShell 7.4+ a nonzero exit combined with
# $ErrorActionPreference = "Stop" would terminate the script before we can inspect
# the output (e.g. the "not signed in" case). Run the probe installs with
# non-terminating error handling and return the combined output so the
# login-and-retry logic below can run.
function Invoke-AtkInstallProbe {
param([Parameter(Mandatory)][string]$Zip)
$ErrorActionPreference = "Continue"
atk install --file-path "$Zip" --scope Personal --interactive false 2>&1 | Out-String
}

# ---- Install the app for the current user (Personal scope) ------------------
Write-Host ""
Write-Host "Installing the Teams app for the current user (atk, scope Personal)..."
$installOut = Invoke-AtkInstallProbe -Zip $zipPath
Write-Host $installOut

# If atk reports the user is not signed in, launch an interactive login and retry.
if ($installOut -match "(?i)(not\s+(logged|signed)\s+in|auth.*required|please\s+login|login\s+first|no\s+account|cannot\s+get\s+token|log\s+in\s+the\s+correct\s+account)") {
Write-Host "Not signed in - launching 'atk auth login m365' (complete the sign-in prompt)..."
atk auth login m365
$installOut = Invoke-AtkInstallProbe -Zip $zipPath
Write-Host $installOut
}

$titleId = $null
$m = [regex]::Match($installOut, 'TitleId:\s*(\S+)')
if ($m.Success) { $titleId = $m.Groups[1].Value.Trim() }

# Teams addresses a bot 1:1 as "28:<botId>". Once the app is installed for the
# user, this opens the conversation with the agent directly.
$chatLink = "https://teams.microsoft.com/l/chat/0/0?users=28:$BotId"

if ([string]::IsNullOrWhiteSpace($titleId)) {
Write-Host ""
Write-Host "Could not confirm the per-user install."
Write-Host "If you were prompted to sign in, run 'atk auth login m365' then re-run this script."
Write-Host "Or sideload the package manually (requires custom app upload to be enabled for your tenant):"
Write-Host " $zipPath"
Write-Host " Teams -> Apps -> Manage your apps -> Upload an app -> Upload a custom app"
# The install did not complete, so report failure (build-only mode already
# returned earlier). The package + manual steps above remain available.
exit 1
}

Write-Host ""
Write-Host "Installed for the current user (titleId: $titleId)."
Write-Host "Chat with the agent in Teams:"
Write-Host " $chatLink"
Loading
Loading