Microsoft Teams integration β parity plan
Status: Proposed
Date: 2026-08-24
Branch: feat/teams-integration-0824
Supersedes: the "NOT_STARTED / stub" description of Teams in channels.md and implementation-status.md, both of which understate what is already built.
Related: #8 (Teams channel adapter, closed without shipping), #122 (BLK-032 credentials, closed), #167 (channels completion), #2112 (fail-open webhook verification, P0 precedent)
Governing architecture: ADR-046, ADR-045
1. Why this document existsβ
Teams is documented as a stub. It is not. The adapter was written in May 2026 and has sat untouched since, and the docs never caught up. Anyone picking this up from channels.md will rediscover the same facts by reading source, so this document establishes the true baseline, the real gaps against Slack, and the work required to close them.
The one-line summary: the Teams transport exists end to end and is already wired to the V2 runtime. What is missing is a thread model, a security posture, and the routing semantics that make Slack usable in a shared workspace.
2. What already worksβ
Verified against origin/dev at a5d43165e.
| Capability | Where | State |
|---|---|---|
| Bot Framework JWT validation | api/src/channels/teams/teams-jwt.validator.ts | Complete. No SDK dependency; Node crypto only. JWKS fetch from the Bot Framework OIDC endpoint with a 24h cache. Validates RS256, aud against app id, issuer prefix allowlist, exp/nbf with 5-minute skew, and the signature. ~20 tests in api/test/teams.spec.ts covering tampering, unknown kid, JWKS failure, and cache reuse. |
| Inbound webhook | channels.controller.ts teamsWebhook(), teamsMessages() | Both POST /channels/teams/webhook and POST /channels/teams/messages exist and delegate to the same validation and routing. |
| Org resolution | channels.controller.ts resolveTeamsOrgId() | Matches tenant / team / channel / conversation / recipient ids against stored credentials. Works, but see Β§3.3. |
| V2 runtime routing | channels.controller.ts routeTeamsMessage() | Already on the current session path: sessionsService.resolveOrMintForChannel({ source: "teams" }) then dispatchInbound(). Teams is not stranded on a retired API. |
| Outbound reply | channel-dispatcher.service.ts dispatchTeams() (line ~1760) | Acquires an AAD client-credentials token, calls the Connector API replyToActivity, wrapped in the shared CircuitBreaker. |
| Routing hints | channel-dispatcher.service.ts MessageRouting | teamsServiceUrl, teamsConversation, teamsRecipient, teamsReplyToId already defined and populated. |
| Unresolved ingress | routeTeamsMessage() | Quarantines to UnresolvedIngressService when the org cannot be resolved. |
| Credential storage + health check | credentials.service.ts testTeams() | Encrypted app_id / app_password / tenant_id; health check validates against the AAD token endpoint. |
Because SessionsService.resolveOrMintForChannel takes source as a plain string, no type-union change is needed to carry Teams. The V2 integration surface is already correct in shape.
3. Gaps against Slackβ
Slack is 6,881 lines across 33 files in api/src/channels/slack/. Teams is 138 lines across 3 files. Not all of that difference is parity β a good deal is Slack-specific product surface. What follows separates the two.
3.1 Thread model β the single biggest functional gapβ
Slack keys a session on the thread root:
// slack.service.ts routeMessage()
const conversationThreadId = threadTs; // thread_ts, else this message's own ts
resolveOrMintForChannel({ ..., chatId: channelId, threadId: conversationThreadId })
Every Slack thread root is its own conversation β DMs and channels alike. A new top-level post opens a fresh session; a reply continues the existing one.
Teams passes no threadId at all:
// channels.controller.ts routeTeamsMessage()
resolveOrMintForChannel({ ..., chatId: channelId }) // no threadId
Every message in a Teams channel therefore collapses into one session, forever. Unrelated conversations from different people on different days share a single Hermes session and its entire history. This is not a degraded experience; it is the wrong product.
Bot Framework supplies what is needed: activity.conversation.id carries the thread key for channel messages (19:...@thread.tacv2;messageid=...), and activity.replyToId identifies the parent.
3.2 Routing semantics β no engagement gateβ
Slack decides whether to respond at all, in SlackOsaRoutingService.decide() against the slack_osa_thread_routes table:
- a mention or a DM opens a route;
- an already-engaged thread continues for a TTL (default 24h);
[done]completes a thread;- an expired or completed thread is ignored;
- bot messages and unsupported event types are ignored;
- the route pins
(org, team, channel, thread) β assignment + specialist + sessionunder a pessimistic write lock.
Teams has none of this. Every message activity with non-empty text routes to the agent. In a shared Teams channel the bot answers every message from everyone, forever. That is both a product failure and an uncapped cost.
3.3 Security and correctnessβ
JWT validation fails open. The webhook resolves the app id like this:
let appId: string | undefined = process.env.TEAMS_APP_ID;
const possibleOrgId = activity?.channelData?.tenant?.id; // Azure AD tenant GUID
const cred = await this.integrationCredentialRepo.findOne({
where: { orgId: possibleOrgId, integrationType: "teams" }, // ...used as a Humanwork org UUID
});
...
if (appId) { /* validate */ } // no appId β no validation
The Azure tenant GUID is not a Humanwork orgId. They are different key spaces, so this lookup essentially never matches, and the code falls back to process.env.TEAMS_APP_ID. If that env var is unset, the if (appId) guard is false and JWT validation is skipped entirely β the endpoint accepts unauthenticated activities. #2112 was raised as a P0 for exactly this pattern on other channel webhooks; Teams still has it.
No inbound dedup. InboundDedupService exposes extractSlackDedupKey, extractWhatsAppDedupKey, extractTelegramDedupKey, extractEmailDedupKey β and nothing for Teams. Bot Framework redelivers on non-2xx. Every redelivery is a duplicate turn: duplicate model spend, duplicate reply. Slack's dedup gate exists because a retry storm doubled every inbound message under multi-pod.
No bot-message filter. Nothing compares activity.from.id against the bot's own id. Slack's routing ignores isBot first. Without it, the bot's own reply can re-enter as inbound β an echo loop.
Cross-org credential scan. resolveTeamsOrgId() runs find({ where: { integrationType: "teams" } }) β every Teams credential for every org β then matches in application code against ids supplied in the request body. This is unbounded as tenants grow, and it resolves tenancy from attacker-suppliable values with no binding proof.
3.4 Content handlingβ
routeTeamsMessage() reads activity.text and nothing else. There is no normalizeTeams in normalize.ts (which has normalizers for Slack, email, WhatsApp, Telegram and Gmail). Consequences:
- inbound
activity.attachmentsare dropped; - outbound
dispatchTeams()sends text only; - Teams messages are HTML-bearing and carry
<at>mention markup, none of which is stripped.
Under ADR-046 Β§1, a real binary attachment must be written to the org's cloud AgentFS before dispatch and referenced as a standard ACP resource block β not copied to R2 or base64, and not folded into the text row. Slack's path already forwards attachments; Teams must match that contract, not invent a second one.
3.5 Structure and operationsβ
| Gap | Slack has | Teams has |
|---|---|---|
| Module boundary | SlackModule (187 lines of explicit wiring) | Lives inside the 2,556-line channels.controller.ts monolith. #167 already called for the split. |
| Metrics | slack.metrics.ts β webhook request counter by outcome, latency histogram by phase, dedup-drop counter split retry-vs-event | none |
| Correlation id | deriveCorrelationId("slack", dedupKey) minted at the webhook, carried on job data | generic queueBackgroundTask |
| Retry policy | slack-stepped backoff (1 β 5 β 10 min), attempts: 4 | queue default |
| Backpressure / throttle | @UseGuards(BackpressureGuard), @Throttle({ limit: 1000, ttl: 60_000 }) | neither |
| Sender identity | cached users.info lookup (6h TTL) when the event omits the profile | activity.from.name only, frequently absent on channel activities |
| Install flow | app manifest + OAuth + install controllers | manual credential paste |
| Uninstall lifecycle | app_uninstalled / tokens_revoked clear credentials and bindings | none (installationUpdate unhandled) |
| ChannelβSpecialist binding | SlackChannelBinding, auto-bind on join, welcome message, unbind on leave | always the org's primary specialist |
| Receipt signal | pending reaction, swapped to a checkmark on reply | none |
| Client enablement | connectable in settings | listed in the coming-soon set in ChannelsTab.tsx |
| Tests | ~10 spec files | JWT validator only |
3.6 Explicitly out of scope for parity v1β
These are Slack product surface, not channel parity. They are worth doing later and are deliberately not in the plan below: /human slash-command escalation, the AI-rewrite message action, SlackSearchService, the semantic matcher, and the keyword-trigger config.
Adaptive Cards are a special case β see Β§5, phase 4.
4. Target architectureβ
Teams becomes a first-class channel module that mirrors Slack's shape without copying its Slack-specific product surface.
api/src/channels/teams/
βββ teams.module.ts # wiring, mirrors slack.module.ts
βββ teams.controller.ts # POST /channels/teams/{webhook,messages}
βββ teams.service.ts # activity routing, org resolution, outbound
βββ teams-routing.service.ts # engagement decisions (mirrors SlackOsaRoutingService)
βββ teams-thread-route.entity.ts # teams_thread_routes table
βββ teams-channel-binding.service.ts # channel β Specialist binding
βββ teams-jwt.validator.ts # EXISTS β keep as-is
βββ teams-auth.ts # EXISTS β keep as-is
βββ teams.metrics.ts # mirrors slack.metrics.ts
βββ index.ts
The inbound pipeline mirrors Slack exactly:
Bot Framework activity
β teams.controller: JWT validation (fail closed) β dedup gate β BullMQ channels-inbound
β ChannelsInboundProcessor: teams-route job
β teams.service.handleActivity: bot filter β org resolve β routing decision
β teams.service.routeMessage: normalize β resolveOrMintForChannel(threadId!) β dispatchInbound
β SessionsService owns the turn and the reply (ADR-046)
β ChannelDispatcherService.dispatchTeams: Connector API replyToActivity
ADR-046 boundaries that constrain every issue below. The adapter transports, authenticates, persists, observes and delivers. It must not assemble prompts, retrieve KB, classify the turn, narrow tools, or gate delivery. A successful reply delivers exactly once. Attachments are written to AgentFS and referenced as ACP resource blocks. The adapter is a transport, not a second agent.
Identity mappingβ
| Concept | Slack | Teams |
|---|---|---|
| Workspace | team_id | channelData.tenant.id |
| Channel | event.channel | channelData.channel.id, else conversation.id |
| Thread root | thread_ts, else ts | conversation.id (carries ;messageid= for channel threads) |
| Message id | event_ts | activity.id |
| Sender | event.user | activity.from.id |
| Direct message | channel_type === "im" | conversation.conversationType === "personal" |
| Addressed to bot | app_mention event | entities[] mention whose mentioned.id is the bot |
| Customer key | slack:team:channel | teams:tenant:conversation |
5. Phasingβ
Four phases, ordered so each lands something shippable and nothing depends on Azure credentials until phase 4.
Phase 1 β Make it safe (P0). Fail-closed JWT, dedup, bot filter, indexed tenant binding. No new product behavior; this is the security floor. Issues 1β3.
Phase 2 β Make it correct (P1). Thread model, module extraction, engagement gate. After this Teams behaves like Slack in a shared channel. Issues 4β6.
Phase 3 β Make it complete (P1). Binding lifecycle, attachments, ops parity. Issues 7β9.
Phase 4 β Make it available (P1/P2). Azure registration, client enablement, receipt signal, Adaptive Cards for the ADR-046 Β§4 clarifying-card directive. Issues 10β11.
Phases 1β3 are testable with synthetic activities and require no Azure tenant. Only phase 4 needs BLK-032 cleared β which is why the credential blocker must stop gating the engineering work, as it has since May.
6. Acceptanceβ
Teams reaches parity when all of the following hold:
- An unsigned or wrongly-signed activity is rejected in production with no valid app id configured β proven by test, not by configuration.
- A redelivered activity produces exactly one turn.
- The bot's own reply, re-entering as an activity, produces no turn.
- Two unrelated threads in the same Teams channel resolve to two distinct Hermes sessions; two messages in the same thread resolve to one.
- An unaddressed message in a channel with no active engagement produces no turn; a mention opens one; a reply inside the TTL continues it;
[done]closes it. - An inbound file lands in the conversation's AgentFS
artifacts/and reaches Hermes as a standard ACP resource block, with no R2 or base64 copy. - A successful reply is delivered exactly once, with no review or approval state involved.
- Webhook outcome, latency-by-phase and dedup-drop metrics are exported for Teams as they are for Slack.
- Teams is connectable from client settings and no longer in the coming-soon set.
7. Risksβ
No test tenant. #122 flagged in May that this cannot be exercised without a real Teams tenant and bot registration, and that is still true for phase 4. Phases 1β3 are designed to be fully testable with synthetic activities precisely so the credential blocker cannot stall them again.
Bot Framework thread semantics are less uniform than Slack's. conversation.id shape differs between personal chats, group chats and channel threads. Issue 4 must pin the exact derivation per conversationType with fixtures for each, rather than assuming one rule.
Tenant-to-org binding is the tenancy boundary. Getting Β§3.3 wrong means cross-org message delivery. Issue 3 must make the binding explicit and indexed, not inferred from a scan.
Connector API token lifetime. dispatchTeams() acquires an AAD token per dispatch. At volume this wants caching with expiry, mirroring the JWKS cache already in the validator.