Skip to main content

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.

CapabilityWhereState
Bot Framework JWT validationapi/src/channels/teams/teams-jwt.validator.tsComplete. 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 webhookchannels.controller.ts teamsWebhook(), teamsMessages()Both POST /channels/teams/webhook and POST /channels/teams/messages exist and delegate to the same validation and routing.
Org resolutionchannels.controller.ts resolveTeamsOrgId()Matches tenant / team / channel / conversation / recipient ids against stored credentials. Works, but see Β§3.3.
V2 runtime routingchannels.controller.ts routeTeamsMessage()Already on the current session path: sessionsService.resolveOrMintForChannel({ source: "teams" }) then dispatchInbound(). Teams is not stranded on a retired API.
Outbound replychannel-dispatcher.service.ts dispatchTeams() (line ~1760)Acquires an AAD client-credentials token, calls the Connector API replyToActivity, wrapped in the shared CircuitBreaker.
Routing hintschannel-dispatcher.service.ts MessageRoutingteamsServiceUrl, teamsConversation, teamsRecipient, teamsReplyToId already defined and populated.
Unresolved ingressrouteTeamsMessage()Quarantines to UnresolvedIngressService when the org cannot be resolved.
Credential storage + health checkcredentials.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 + session under 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.attachments are 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​

GapSlack hasTeams has
Module boundarySlackModule (187 lines of explicit wiring)Lives inside the 2,556-line channels.controller.ts monolith. #167 already called for the split.
Metricsslack.metrics.ts β€” webhook request counter by outcome, latency histogram by phase, dedup-drop counter split retry-vs-eventnone
Correlation idderiveCorrelationId("slack", dedupKey) minted at the webhook, carried on job datageneric queueBackgroundTask
Retry policyslack-stepped backoff (1 β†’ 5 β†’ 10 min), attempts: 4queue default
Backpressure / throttle@UseGuards(BackpressureGuard), @Throttle({ limit: 1000, ttl: 60_000 })neither
Sender identitycached users.info lookup (6h TTL) when the event omits the profileactivity.from.name only, frequently absent on channel activities
Install flowapp manifest + OAuth + install controllersmanual credential paste
Uninstall lifecycleapp_uninstalled / tokens_revoked clear credentials and bindingsnone (installationUpdate unhandled)
Channel↔Specialist bindingSlackChannelBinding, auto-bind on join, welcome message, unbind on leavealways the org's primary specialist
Receipt signalpending reaction, swapped to a checkmark on replynone
Client enablementconnectable in settingslisted in the coming-soon set in ChannelsTab.tsx
Tests~10 spec filesJWT 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​

ConceptSlackTeams
Workspaceteam_idchannelData.tenant.id
Channelevent.channelchannelData.channel.id, else conversation.id
Thread rootthread_ts, else tsconversation.id (carries ;messageid= for channel threads)
Message idevent_tsactivity.id
Senderevent.useractivity.from.id
Direct messagechannel_type === "im"conversation.conversationType === "personal"
Addressed to botapp_mention evententities[] mention whose mentioned.id is the bot
Customer keyslack:team:channelteams: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:

  1. An unsigned or wrongly-signed activity is rejected in production with no valid app id configured β€” proven by test, not by configuration.
  2. A redelivered activity produces exactly one turn.
  3. The bot's own reply, re-entering as an activity, produces no turn.
  4. Two unrelated threads in the same Teams channel resolve to two distinct Hermes sessions; two messages in the same thread resolve to one.
  5. 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.
  6. 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.
  7. A successful reply is delivered exactly once, with no review or approval state involved.
  8. Webhook outcome, latency-by-phase and dedup-drop metrics are exported for Teams as they are for Slack.
  9. 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.