The Teams gateway¶
Microsoft Teams needs more standing-up than Telegram or Slack, and none of it is agentctl's doing: Teams is the one channel whose transport lives inside your own Azure tenant, so the bot identity, the consent, and the public messaging endpoint are all yours to create. This page is that setup. The Channels page covers Teams alongside its peers and is the shorter read if you only need the shape.
A Teams bot is fronted by one shared gateway (agentctl teams-gateway) that routes by
owner identity to per-agent sessions. One-time tenant setup for the whole fleet; after
that a Teams agent is just an agents.yaml entry. Replace the <...> placeholders; if
IT owns your Azure/Entra, the prerequisites list what to request.
The only public surface is POST /api/messages (Bot Framework → gateway,
JWT-validated). /register, agent↔gateway traffic, and secrets stay private.
flowchart LR
subgraph OWNER["Owner laptop"]
TC["Teams client<br/>desktop / web"]
end
subgraph MSG["Microsoft 365 / Bot Framework — global, multi-tenant (Microsoft-operated)"]
TEAMS["Microsoft Teams<br/>service"]
CONN["Bot Framework Connector<br/>(activity serviceUrl relay)"]
end
subgraph TENANT["Your Azure tenant — single tenant + subscription"]
ENTRA["Entra ID / Azure AD<br/>app registration · validate inbound JWT · mint app token"]
subgraph VNET["Private network / VNet (NSG)"]
subgraph VMGW["Gateway VM — no agents"]
TLS["TLS reverse proxy :443<br/>exposes ONLY /api/messages"]
GW["Teams gateway<br/>/api/messages + /register<br/>route by owner AAD id"]
end
subgraph VMA["Agent VM A"]
AGA["Teams agent<br/>+ local secret store"]
end
subgraph VMB["Agent VM B"]
AGB["Teams agent<br/>+ local secret store"]
end
end
end
PROVIDER["LLM provider<br/>(model API)"]
AGA -. "0 · wss://gateway/register + reg token · PRIVATE" .-> GW
AGB -. "0 · wss + reg token · PRIVATE" .-> GW
TC -- "type message" --> TEAMS
TEAMS --> CONN
CONN == "1 · POST /api/messages · PUBLIC INTERNET<br/>HTTPS + signed JWT" ==> TLS
TLS -- "to gateway (loopback)" --> GW
ENTRA -. "validate inbound JWT" .-> GW
GW -- "2 · turn · WSS (private)" --> AGA
AGA -- "3 · inference · HTTPS egress" --> PROVIDER
PROVIDER -- "tokens" --> AGA
AGA -- "4 · stream chunks · WSS (private)" --> GW
ENTRA -. "mint outbound app token" .-> GW
GW == "5 · POST activities → serviceUrl<br/>HTTPS egress + app token" ==> CONN
CONN --> TEAMS
TEAMS -- "streamed reply" --> TC
classDef public fill:#ffe1e1,stroke:#d33,stroke-width:3px,color:#000;
classDef internal fill:#e6f2ff,stroke:#06c,color:#000;
classDef cloud fill:#eef7e6,stroke:#3a3,color:#000;
class TLS public;
class GW,AGA,AGB internal;
class TEAMS,CONN,ENTRA cloud;
Prerequisites¶
Request from IT if you lack them:
- Azure subscription + resource group, with Contributor (or Azure Bot Contributor) on it.
- App-registration rights in Entra (Application Administrator), or have IT register the app and hand you the appId + client secret.
- Teams Administrator (or an app-setup policy) to allow custom-app sideloading or org-catalog publish.
- Admin consent for Graph application permissions: only for proactive messaging / directory lookups; a 1:1 reply bot needs none.
- A public HTTPS route to the gateway's
/api/messages(real domain + trusted cert; a bare IP won't work). azCLI (≥ 2.x;az botis in the core CLI, no extension), signed in with the rights above.
Tenant setup¶
1. Register the provider (once per subscription, as Owner/Contributor):
az provider register -n Microsoft.BotService
2. Create the bot identity + Azure Bot. If IT registers the app, skip the az ad lines and start from their appId + secret.
az login
az ad app create --display-name "<BOT_DISPLAY_NAME>" --sign-in-audience AzureADMyOrg # → note appId
az ad sp create --id <APP_ID>
az ad app credential reset --id <APP_ID> --display-name teams-gateway --years 1 # → store the password (secret) in sops
az bot create -g <RG> -n <BOT_NAME> --app-type SingleTenant --appid <APP_ID> --tenant-id <TENANT_ID> --sku F0 --location global
az bot msteams create -g <RG> -n <BOT_NAME> # enable Teams
az bot update -g <RG> -n <BOT_NAME> --endpoint "https://<GATEWAY_HOST>/api/messages" # point at the gateway
3. Build the Teams app package. Fill runtime/teams/manifest/manifest.json ({{BOT_APP_ID}}, names, {{GATEWAY_HOST}}) + two icons (color 192x192, outline 32x32), zip, upload in Teams (sideload or org catalog).
4. Wire the app into the gateway. With TEAMS_APP_ID set the gateway runs in Azure mode: validates the inbound Bot Framework JWT on /api/messages and mints an Entra token for outbound replies.
agentctl secret set TEAMS_APP_ID --value <APP_ID>
agentctl secret set TEAMS_APP_PASSWORD --value <CLIENT_SECRET>
agentctl secret set TEAMS_APP_TENANT_ID --value <TENANT_ID>
agentctl teams-gateway install
5. Add a Teams agent, routed by owner Entra object ID (a GUID), not the UPN; no bot token, no pairing:
# get the object id: az ad user show --id <user@tenant> --query id -o tsv
agentctl secret set OPENROUTER_API_KEY --value <KEY> --agent <name>
agentctl agents add <name> --channel teams --owner-aad <owner-object-id> \
--runtime pi --provider openrouter --model z-ai/glm-5.2
Hosting the gateway¶
It needs a long-lived process (a persistent /register WebSocket, so not an Azure
Function) and a public HTTPS /api/messages on a real domain with a trusted cert (a bare
IP won't work). Simplest: a VM running agentctl + the gateway with Caddy in front
(auto Let's Encrypt cert), exposing only /api/messages publicly and 404ing
everything else so /register never reaches the public face:
<domain> {
handle /api/messages* {
reverse_proxy 127.0.0.1:3978
}
handle {
respond 404
}
}
Use handle blocks, not a bare reverse_proxy
A plain reverse_proxy 127.0.0.1:3978 would expose /register too, and pairing
respond 404 with reverse_proxy @webhook at the same level shadows the proxy
(Caddy directive ordering). handle blocks are mutually exclusive, so the first
match wins: public /api/messages, private everything else (/register is reached
only over the private vnet, NSG-limited).
Set the bot endpoint to https://<domain>/api/messages. The gateway also ships as a
container (ghcr.io/<owner>/teams-gateway) to run behind an existing reverse proxy.
Cross-host: joining the fleet¶
A cross-host Teams setup runs the public gateway on its own VM (only
teams-gateway, no agents) while the agents run on a separate private primary
host. Both hosts are writers on the same shared GitOps remote (multi-writer
fleet, per-agent shards commute). The gateway is just a fleet member that adopts
the shared ledger: it builds its Teams allow-map (TEAMS_AGENTS) from its own
copy of the ledger's Teams agents, so until it adopts the shared ledger it knows
zero agents and rejects every /register (teams-gateway status →
agents: 0 in the ledger).
Below, primary = the existing fleet writer running the agents; gateway = the freshly-provisioned second host. Steps are ordered, and each has the why. These are the sharp edges.
- Pin every agent to the primary FIRST (
run_on: <primary hostname>on each agent shard). Critical and non-obvious:agentRunsHere()treats an empty agent host as "run on EVERY host". Agents from a single-host ledger carry no pin, so the instant the gateway adopts the ledger and runsapplyit would provision + start every agent a second time: two Telegram pollers per bot → 409 conflict storms. There is no CLI to setrun_onon an existing agent today (agents setrejects it;agents addonly auto-pins brand-new agents toos.Hostname()), so this is a direct ledger edit for now. - Give the gateway credentials to reach the shared repo, e.g. copy the git credential / deploy key the primary uses to push the fleet remote.
- Point the gateway's
/opt/agentctlat the shared remote and adopt it.agentctl fleet adoptfetches + hard-resets to the remote's default branch and self-registers the host's age recipient. fleet adoptdetects the remote's default branch (main,master, or otherwise) on its own.- The adopt hard-reset overwrites the gateway's local ledger, so any gateway-local-only secrets (its Teams bot creds) must be re-seeded afterward (step 6).
- Gateway
apply: self-registers its age recipient intohost_keys, skips the primary-pinned agents (assigned to another host), and pushes. - Primary
fleet sync+apply: picks up the gateway's recipient and re-wraps the sops store to include it, so the gateway can decrypt/seed fleet secrets. (Only an existing recipient can re-wrap.) - Gateway
fleet sync+ re-seed the Teams bot creds the adopt wiped:TEAMS_APP_ID/TEAMS_APP_PASSWORD/TEAMS_APP_TENANT_ID. They're decryptable now because the gateway is a recipient (step 5). - Set
host.teams_gatewayto the gateway's HOSTNAME, not its IP, and add an/etc/hostsentry on the primary so agents resolve it. Why: the gateway only recognizes itself as the gateway whenhost.teams_gateway == effectiveHostName()(os.Hostname()); with an IP it never self-identifies, soapplynever rendersTEAMS_AGENTSthere. Agents dialws://<hostname>:3978/register. - Gateway
apply+ restartagentctl-teams-gateway: rendersTEAMS_AGENTSfrom the now-shared ledger; agents that were retrying/registerconnect immediately.
Troubleshooting¶
teams-gateway status showing agents: 0 in the ledger means the gateway hasn't
adopted the shared ledger (step 3), or host.teams_gateway is set to an IP rather than
the hostname (step 7).