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
conversationIdis matched to its thread via aTeamsConversationlink node ({threadPath}/_TeamsConversation/…); 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. - 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.