/teams — reading and sending Teams on a user's behalf
Measured 2026-09-09 by asking the Executive Assistant on the control instance "can you see my Teams channels,
in particular the enterprise client's team?": it answered no, and it was right — then. Since Mail 1.5/1.6 (read,
2026-09-09/10) and Mail 1.7 (post, 2026-09-10) the EA reads a user's own-tenant teams, channels and
chats, and posts to them where the deployment sets Teams:AgentSend=Send. This skill records what
exists, what the ask needs, and the one wall that no scope can climb.
What exists: a bot, not a reader
MeshWeaver.Teams (MeshWeaver.Plugins, src/MeshWeaver.Teams/) is the Bot Framework channel
documented in Doc/AI/TeamsBot: a person messages the bot in Teams, TeamsInboundProcessor
finds-or-creates one agent thread per conversation, TeamsReplySender posts the agent's reply
back. Its outbound credential is an app-only connector token for api.botframework.com — it
can answer in a conversation the bot is part of and nothing else. On the control instance it is inert
(Teams:Enabled=false, zero TeamsConversation nodes).
The Executive Assistant (src/MeshWeaver.Mail.MicrosoftGraph/ExecutiveAssistantPlugin.cs) has
mail, calendar and Teams tools. Its credential is the delegated one minted by the EA consent link,
and that credential is the seam everything below hangs on.
The seam: the EA consent link IS a delegated Graph token
{BaseUrl}/auth/ea/connect sends the user through Microsoft's authorize endpoint with
the scope string in EaGraphAuth.Scopes — this repo,
memex/Memex.Portal.Shared/Authentication/EaGraphAuth.cs:
Mail.ReadWrite Mail.Send Calendars.ReadWrite
Team.ReadBasic.All Channel.ReadBasic.All ChannelMessage.Read.All Chat.Read
ChannelMessage.Send ChatMessage.Send offline_access
🚨 Widening that string invalidates every stored grant. Entra refuses to redeem a refresh token
for scopes the user never consented to (400 invalid_grant), and the consent controller's fast path
bounces a user whose credential node exists straight back — so on 2026-09-10 the read scopes landed,
the reconnect link "just brought me in", and every Teams call kept failing. EaGraphAuth.Classify
now compares the credential's stored Scopes with the constant and reports a mismatch as
NotConnected, which hands the user the link AND makes /auth/ea/connect run the dialog. Do not
add a scope without that classification in place; ?force=true on the connect link is the manual
escape hatch for a portal that predates it.
Add Teams scopes to that string and every user reconnects once; from then on the same
GraphServiceClient the plugin already builds reaches /me/joinedTeams, /teams/{id}/channels,
/teams/{id}/channels/{id}/messages, /me/chats, /chats/{id}/messages — as the user. Nothing
app-only, no new secret, no new token store. That is the whole point of routing it through the EA.
Read
| Scope (delegated) | Lets the EA | Admin consent |
|---|
Team.ReadBasic.All | list the user's teams | user-consentable |
Channel.ReadBasic.All | list a team's channels | user-consentable |
ChannelMessage.Read.All | read channel messages | admin consent required |
Chat.Read | list and read the user's chats | user-consentable |
Send
| Scope (delegated) | Lets the EA | Admin consent |
|---|
ChannelMessage.Send | post to a channel as the user | user-consentable |
ChatMessage.Send | post in a chat as the user | user-consentable |
Re-verify the consent column against the
Graph permissions reference
before adding a scope — a tenant's consent policy can make any of them admin-only. The Entra
sign-in app registration must list the same delegated permissions, or the authorize call fails
before the user sees a screen.
🚨 Teams has no draft state. Mail is DraftOnly by design (Email:AgentSend, the human presses
Send); a Teams post is immediate and irreversible. The gate is the same shape: Teams:AgentSend
defaults to Off, in which PostChannelMessage / ReplyToChannelMessage / SendChatMessage are
never handed to the model (and refuse by name when reached directly); Send hands them over, with
no per-message confirmation — the boundary Email:AgentSend=Send has too. The scopes are consented
regardless of the mode, so flipping the mode later costs nobody a reconnect. The public instance runs
Teams__AgentSend: "Send" (its deployment values); the control instance does not.
The three pieces (built 2026-09-09/10)
- This repo: the scopes in
EaGraphAuth.Scopes plus the stale-grant classification above; the
delegated permissions on the app registration (Roland's Entra tenant for the Systemorph portals)
are optional under dynamic consent except the admin-restricted ChannelMessage.Read.All.
- Plugins repo: the tools on
ExecutiveAssistantPlugin — read: ListTeams, ListChannels,
ReadChannelMessages, ListChats, ReadChat (every mode); post: PostChannelMessage,
ReplyToChannelMessage, SendChatMessage (Teams:AgentSend=Send only). 🚨 A tool exists for
the model only when CreateTools() LISTS it — Mail 1.5 shipped the read methods without listing
them and the EA answered "not in my toolset" (Plugins#1611).
- Plugins repo:
src/MeshWeaver.AI/Data/Agent/ExecutiveAssistant.md names the surface, or the
agent keeps answering "I have no Teams tool" from its instructions.
🚨 The wall: a client's team lives in the client's tenant
A delegated token is minted by the user's home tenant. A team the user reaches as a guest
(B2B) lives in the resource tenant, and Graph does not cross that line on a home-tenant token:
/me/joinedTeams does not list guest teams, and the channel endpoints answer 403/404 for them —
measured and documented by others
(Q&A,
Tech Community).
So the scopes above make the EA see Systemorph's teams. A client's team (e.g. Globex/Team) needs one of:
- A token for the client's tenant. Run the same consent flow against
login.microsoftonline.com/<tenant-id> (the app registration must be multi-tenant),
store the resulting refresh token per (user, tenant), and route calls for that team through it.
ChannelMessage.Read.All then needs the client's admin to consent to the Memex app in their
tenant — a conversation with the client's IT owner and their security lead, not a setting
on our side.
- The bot, installed in their team. The existing
MeshWeaver.Teams bot with resource-specific
consent (ChannelMessage.Read.Group) in its manifest, added to the client's team by a team
owner. Messages are then PUSHED to /api/teams/messages as they happen; nothing is enumerated.
Needs Teams:Enabled on the receiving portal and the client's app-upload policy to allow it.
- Their export. Ask for a channel export; ingest it as content. No integration at all.
Do not promise "I'll read the client's channel" until one of these exists; the honest answer today
is the Graph-visible substitute — the mailbox — which the EA already gives.
Checking what an agent says about Teams
The EA's answer is only as good as its tool list. Before trusting a "no Teams access" or a
"connected" claim: get @Agent/ExecutiveAssistant/data/ for the plugins, then read
ExecutiveAssistantPlugin.cs for the tools actually exposed, then EaGraphAuth.Scopes for what
the token can carry. All three moved independently in the past; the doc Doc/AI/TeamsBot describes
the bot only.