Teams Adapter — Design Note (M-4, #1665)
Status: Design only — implementation deferred to cloud launch. Date: 2026-07-07 · Author: Mikey Context: M-series native messaging (#1662 core, #1663 Telegram, #1664 Slack). Constraint honored: no Azure resources created; no code beyond interface affordances (of which none turned out to be needed — see §Interface fit).
Why deferred
Teams is structurally different from Telegram/Telegram and Slack/Socket Mode in one decisive way: there is no polling or outbound-socket mode. The Bot Framework only delivers activities to a public HTTPS messaging endpoint registered on an Azure Bot resource, authenticated via AAD (Entra) app registration. That is cloud-shaped infrastructure:
| Requirement | Telegram (M-2) | Slack (M-3) | Teams |
|---|---|---|---|
| Public endpoint | ❌ long-poll | ❌ Socket Mode | ✅ required (HTTPS + valid cert) |
| Platform-side registration | BotFather (free, 30s) | Slack app (free, self-serve) | Azure Bot resource + AAD app + Teams app manifest upload |
| Inbound auth | none (token implies identity) | none (socket implies identity) | verify JWT on every inbound request (Bot Connector token, AAD-issued) |
| Outbound auth | bot token | bot token | client-credentials token from AAD, per-request serviceUrl from the conversation |
| Tenant admin involvement | none | workspace admin installs | org admin approves/uploads app (sideloading often disabled) |
A local Org Studio install cannot meet the public-endpoint requirement without a tunnel, and the tenant-admin + AAD ceremony only makes sense when Org Studio itself is cloud-hosted (multi-tenant, #1387 track). Hence: design now, implement at cloud launch.
What a Teams adapter needs (implementation checklist)
- Azure Bot resource (single-tenant or multi-tenant AAD app):
- App ID + client secret (or managed identity when hosted in Azure).
- Messaging endpoint:
https://<org-studio-host>/api/messaging/teams.
- Inbound webhook route —
src/app/api/messaging/teams/route.ts:- Verify the Bot Connector JWT on every POST (issuer
https://api.botframework.com, audience = our app ID, signing keys from the published OpenID metadata). Reject anything else. This is the part with real security weight — it replaces "the socket implies identity" from M-2/M-3. - Parse
Activityobjects:type=message(text commands) andtype=invoke/ Adaptive CardAction.Submitpayloads (buttons).
- Verify the Bot Connector JWT on every POST (issuer
- Outbound — Bot Connector REST:
- Client-credentials token from AAD (
https://api.botframework.com/.default), cached until expiry. - POST to
{serviceUrl}/v3/conversations/{conversationId}/activities. serviceUrlandconversationIdmust be captured from a prior inbound activity — Teams bots cannot cold-DM arbitrary users; the user (or a channel install event) opens the conversation first. The adapter must persist conversation references (see §Binding fit).
- Client-credentials token from AAD (
- Buttons — Adaptive Cards with
Action.Submit, wheredata: { command: "<canonical command string>" }— the M-1 rule (button payload IS the command) carries over 1:1. - Teams app manifest — JSON package (bot ID, scopes:
personal, optionallyteam) uploaded to the org catalog by a tenant admin.
Interface fit — does M-1 accommodate this?
Checked each M-1 contract against the Teams shape. No interface changes required. Details:
MessagingAdapter.start(handler)— Telegram/Slack use it to open their poll/socket loops. A webhook-based adapter'sstart()simply stores the handler and returns; the Next.js route callsgetMessagingRegistry().get('teams')→ an exposedhandleActivity(activity)that routes into the stored handler. The interface never promised a loop, only "begin listening." ✅InboundMessage { channel, chatUserId, text }— Teams AAD user ID (from.aadObjectId, stable per tenant) slots intochatUserId; Adaptive CardAction.Submitdata.command slots intotext. ✅ChatBinding—chatUserId= AAD object ID;chatTargetId= the serialized conversation reference (conversationId + serviceUrl), which the adapter maintains when it sees inbound activities. Binding shape already allows this (it's an opaque string). ✅sendNotification(binding, n)— works, with one behavioral caveat: returnsfalseuntil a conversation reference exists (no cold-DM). That's a documented Teams platform property, not an interface gap.CommandAction { label, command }— maps to Adaptive Card actions. No 64-byte-style cap (Adaptive Card payloads are KB-scale). ✅- Registry fan-out / fail-closed authz / deterministic pipeline — all channel-agnostic already. ✅
One deliberate non-gap: M-1 has no "verify inbound platform signature" hook because Telegram/Slack didn't need one. For Teams this lives entirely inside the webhook route (JWT check before the registry is ever touched), which is the right layer — the registry should never see unauthenticated traffic. No M-1 change needed.
Follow-ups filed: none — after checking each contract point, there are
no interface gaps to file. If the conversation-reference store turns out to
need first-class support (rather than riding on chatTargetId), that's an
implementation-time decision for the cloud-launch ticket.
Env plan (naming reserved now, consistent with M-2/M-3)
MESSAGING_TEAMS_APP_ID=<AAD app id>
MESSAGING_TEAMS_APP_SECRET=<client secret> # or managed identity in Azure
MESSAGING_NATIVE_CHANNELS=…,teams # same legacy-disable mechanism
Revisit trigger
Implement when either: (a) Org Studio cloud hosting (#1387 track) gives us a stable public HTTPS endpoint, or (b) a paying design partner requires Teams before that. Estimated effort at that point: ~1–2 weeks (the JWT verification + conversation-reference store are the substance; the rest is M-3-shaped).