Connecting Microsoft Teams
The Teams bot is a bidirectional channel: a person messages the bot in Teams, a Memex agent answers
in the same chat. It reuses the same pipeline as the email channel
— inbound message → find-or-create a thread → agent → reply — so a Teams chat is just another front end
onto an agent thread. It ships inert and turns on only when an admin provisions an Azure Bot and sets
Teams:Enabled.
How it works
Teams user message
│
▼
POST /api/teams/messages ──(Bot Framework JWT)──► ITeamsClient.ValidateInboundAsync ── reject forged ──► 401
│ (validated)
▼
TeamsInboundProcessor
│ • map Teams user → Memex user by AAD object id (User.objectId)
│ • find-or-create ONE thread per Teams conversationId (TeamsConversation link)
│ new → hub.StartThread (first message seeded into PendingUserMessages)
│ exists → hub.SubmitMessage (appended to pending)
▼
agent runs as that user ──► TeamsReplySender
• ThreadFlow.ObserveResponses(threadPath) ← shared read-side primitive
• on each completed assistant reply → ITeamsClient.SendMessageAsync
• send-once via TeamsConversation.LastDeliveredMessageId
▼
reply appears in the Teams chat
Key points:
- One Teams conversation = one agent thread. The thread is the user's own ordinary thread
(
{username}/_Thread/{id}), and theconversationIdis matched to it via aTeamsConversationlink node that is CONTENT in the Admin partition —Admin/_TeamsConversation/{key},keya stable hash of the conversation id — never a node under the thread:_Threadis a configured satellite segment, so a node beneath a thread path lives in the satellite table and is excluded from every UNTARGETED content query on both backends (a query that targets the satellite path itself is routed to that table); the old mesh-widenodeType:TeamsConversation …lookup was untargeted, which is why it found the link by nobody (MeshWeaver.Plugins#1665 / #1773). New →StartThread, existing →SubmitMessage— the canonical thread extensions. - The agent runs as the mapped Memex user. Teams users are mapped by AAD object id to a
Usernode (content.objectId); an unmapped sender gets a polite "no account" reply. 🚨 NoUsernode carries that field today — the portal keys users by email and the activity carries only the Entra GUID — so every real sender is currently "unmapped"; that is MeshWeaver.Plugins#1774. - The reply is read, not re-emitted.
TeamsReplySenderusesThreadFlow.ObserveResponses— the same read-side abstraction the GUI uses to render messages — to read each completed assistantThreadMessageat{threadPath}/{messageId}and post it back. Nothing Teams-specific in the agent. - Secure by construction.
/api/teams/messagesis anonymous at the pipeline, but every request is validated against the Bot Framework's OpenID metadata (issuerapi.botframework.com, audience = the bot's app id) before any work happens. When disabled, the endpoint returnsNotFoundand the reply sender isn't even registered (the hosted service is feature-gated).
Components
| Piece | Role |
|---|---|
TeamsBotController (/api/teams/messages) |
Validates the inbound JWT, parses the message activity, routes it |
ITeamsClient / TeamsClient |
Inbound JWT validation + outbound reply via an app-only connector token; test seam (fake on CI) |
TeamsInboundProcessor |
Map user → find-or-create thread per conversation → StartThread/SubmitMessage |
TeamsConversation (NodeType) |
Links a thread ↔ Teams conversation (serviceUrl, conversationId, LastDeliveredMessageId) |
TeamsReplySender (hosted) |
ObserveResponses → SendMessageAsync; send-once; registered only when enabled |
Configuration
| Key | Meaning |
|---|---|
Teams:Enabled |
master switch — false = inert (endpoint NotFound, no reply sender) |
Teams:AppId |
the Azure Bot / app registration id (MicrosoftAppId) |
Teams:AppPassword |
the bot app client secret (MicrosoftAppPassword) — keep in Key Vault |
Teams:TenantId |
Entra tenant id for a single-tenant bot (optional; multi-tenant when empty) |
Configured on the Deployment record's advanced rung (portal configuration keys), and the secret handed over separately — never on the record:
builder.AddMemex("memex")
// …
.WithPortalConfig("Teams__Enabled", "true")
.WithPortalConfig("Teams__AppId", "<bot app id>")
.WithPortalConfig("Teams__TenantId", "<tenant>")
.WithSecret("Teams__AppPassword", builder.AddParameter("teams-app-password", secret: true));
Deploy parameters (Memex.Deploy.AppHost): teams-enabled, teams-app-id, teams-app-password,
teams-tenant-id → emitted as Teams__*. On AKS the secret comes from Key Vault through the
record's keyVaultSecrets map (teams-apppassword → Teams__AppPassword), like the email client
secret (ConfiguringAnInstanceFromAspire).
Azure setup (one-time, by an admin)
- Create an Azure Bot resource (Azure Portal → Azure Bot). Use a multi-tenant or single-tenant Microsoft App; note the App ID and create a client secret.
- Messaging endpoint: set it to
{BaseUrl}/api/teams/messages(e.g.https://portal.example.com/api/teams/messages). - Enable the Teams channel on the Bot resource.
- Teams app manifest: create a Teams app whose
bots[0].botIdis the App ID, with thepersonal(and optionallyteam/groupchat) scopes, and sideload/publish it to your tenant so users can chat with it. - Configure Memex: set
Teams:Enabled=true,Teams:AppId,Teams:TenantId, and supplyTeams:AppPasswordvia Key Vault. Redeploy; the endpoint goes live and validates inbound activities. - User mapping: ensure Memex
Usernodes carry the user's AAD object id (content.objectId) so the bot can run as the right person. Unmapped users get a "no account" reply.
Until step 5, the bot is completely inert — the code ships with
Teams:Enabled=false, the endpoint returnsNotFound, and no reply sender runs.
What the bot does NOT do
The bot reads nothing on a user's behalf: it cannot list a user's teams, enumerate channels,
read channel history or chats, or post as a user. Its outbound credential is an app-only Bot
Framework connector token, valid only for conversations the bot is part of. Reading or sending a
user's Teams content is a different integration — delegated Graph scopes on the Executive
Assistant's consent link plus tools on its plugin — and a team the user reaches as a guest
(another company's tenant) is not reachable on a home-tenant token at all. An agent that answers
"I have no Teams access" is describing this accurately; the engineering notes live with the
repository's /teams skill.
Notifications over Teams
The notification system
already models a Teams channel kind; once the bot is connected, a recipient's
notification rules can escalate notifications into Teams the same way
they do email.