Skip to content

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).
  • az CLI (≥ 2.x; az bot is 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.

  1. 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 runs apply it would provision + start every agent a second time: two Telegram pollers per bot → 409 conflict storms. There is no CLI to set run_on on an existing agent today (agents set rejects it; agents add only auto-pins brand-new agents to os.Hostname()), so this is a direct ledger edit for now.
  2. 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.
  3. Point the gateway's /opt/agentctl at the shared remote and adopt it. agentctl fleet adopt fetches + hard-resets to the remote's default branch and self-registers the host's age recipient.
  4. fleet adopt detects the remote's default branch (main, master, or otherwise) on its own.
  5. 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).
  6. Gateway apply: self-registers its age recipient into host_keys, skips the primary-pinned agents (assigned to another host), and pushes.
  7. 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.)
  8. 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).
  9. Set host.teams_gateway to the gateway's HOSTNAME, not its IP, and add an /etc/hosts entry on the primary so agents resolve it. Why: the gateway only recognizes itself as the gateway when host.teams_gateway == effectiveHostName() (os.Hostname()); with an IP it never self-identifies, so apply never renders TEAMS_AGENTS there. Agents dial ws://<hostname>:3978/register.
  10. Gateway apply + restart agentctl-teams-gateway: renders TEAMS_AGENTS from the now-shared ledger; agents that were retrying /register connect 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).