msteams — maintainer documentation
References name symbols in the files listed rather than line numbers, so they stay valid as the code moves. For the complete, current config and tool reference, see the published Microsoft Teams & Microsoft 365 page.
Four files, no overlap:
- maintainer.md (this file) — what the plugin is, what it needs, what it exposes, which file owns what.
- architecture.md — how a message actually travels through the code, and what is persisted.
- operations.md — Azure/Teams setup, config semantics, failure modes, redeploy behaviour.
- permissions.md — every Microsoft Graph and Teams permission the plugin can need, which of the four consent surfaces it belongs to, and the checklist for standing a new tenant up the same way.
What it is
A Microsoft Teams platform adapter built on the Azure Bot Framework. Inbound Bot Framework
activities arrive on the daemon webhook /hooks/msteams/messages; outbound replies, typing indicators,
card edits and images go out through the Bot Connector REST API. It registers itself as an Elowen
PlatformAdapter (register in index.mjs), so a Teams conversation becomes a brain channel
session the same way a Discord channel or a Telegram chat does.
On top of plain chat it provides: a live tool trace edited in place, AskUserQuestion rendered as
Adaptive Cards, slash commands with card pickers, per-chat model/reasoning/display overrides, image
round-trips, proactive (host-initiated) pushes, and a downloadable Teams app package.
It can also write to a person first rather than only reply: it builds a people directory out of
traffic it already sees, and TeamsMessagePerson opens the 1:1 chat itself. See "Proactive messaging"
below and the detail in architecture.md.
What it needs to run
Credential gate
The plugin registers the app-package API route unconditionally, then requires all three of appId,
appPassword and tenantId before it does anything else (register in index.mjs). With any
of them missing it logs enabled but appId/appPassword/tenantId are not all configured — not connecting
and returns: no platform, no webhook, no tools. Pinned by tests/msteams.test.ts (no platform and
no HTTP routes without credentials, and both appear once they are configured).
Azure / Teams prerequisites
Summarised here, with the click-path in operations.md:
- An Entra app registration (single tenant) — its Application (client) ID is
appId, the directory ID istenantId, and a client secret isappPassword. - An Azure Bot resource bound to that app id, with the messaging endpoint set to
https://<your-domain>/hooks/msteams/messagesand the Microsoft Teams channel enabled. - The Teams app package (downloadable from this plugin — see below) uploaded to the org's Teams app catalog so users can install the bot.
Nothing beyond the bot credentials is required for normal chat: the member and roster lookups the plugin
uses for UPN/e-mail resolution are Bot Connector calls (ConnectorClient.member and ConnectorClient.members
in lib/connector.mjs), not Graph calls. Microsoft Graph is a separate, optional layer — see graphLookup below.
Config fields
Declared in the configSchema of elowen-plugin.json; Czech and Slovak translations of every label and
hint live in i18n/cs.json and i18n/sk.json.
| Key | Type | Default | What it does |
|---|---|---|---|
appId |
string, required | — | Entra Application (client) ID; also the bot id. Used as the JWT audience (makeTokenVerifier in lib/auth.mjs) and to build the bot member id 28:<appId> (openPersonalConversation in lib/proactive.mjs, personalSignInLink in lib/adapter.mjs). |
appPassword |
secret, required | — | Client secret for the client-credentials token (TokenSource in lib/token.mjs). |
tenantId |
string, required | — | Tenant-scoped OAuth token endpoint (TokenSource.tokenUrl in lib/token.mjs) and the tenant stamped on a newly opened 1:1 chat (openPersonalConversation). |
notifyConversationId |
string | empty | Default target for proactive pushes. A conversation id, or a person (e-mail / Entra object ID / display name) whose 1:1 chat the bot opens (notify and notifyConversationFor in lib/proactive.mjs). Empty = pushes are dropped with a warning. |
graphLookup |
boolean | false |
Enables the optional Microsoft Graph layer. Off means the adapter constructor never constructs a GraphClient. The channel-history reader is created only when channelMessagesRsc is on (threadReader in lib/transcript.mjs). |
graphCatalogAppId |
string | empty | The app's id in the org Teams catalog, needed to install the app for a user (installApp in lib/graph.mjs). Only visible when graphLookup is on. |
respondWithoutMention |
boolean | true |
When false, a non-personal conversation is answered only if the bot is @mentioned (the mention gate in handleActivity). |
toolActivity |
enum off/status/live |
status |
What the progress message shows while the agent works. |
answerMode |
enum final/live |
final |
One reply at the end, or the answer streamed into a message. |
toolOutput |
enum hidden/summary/tail |
summary |
How much of a finished tool's result the progress message keeps. |
toolMessageMode |
enum single/per_tool |
single |
One edited progress message, or one message per tool call. |
runtimeFooter |
boolean | true |
Appends the model · context % line (footerLine in lib/format.mjs). |
showReasoning |
boolean | false |
Streams model reasoning into the progress message. Also forces a live stream to exist (observesLiveEvents in elowen-plugin-shared/display). |
language |
enum en/cs/sk |
en |
Language of the bot's own service texts (lib/messages.mjs). Not the agent's answer language. |
historyLimit |
number 0–100 | 0 |
How many remembered messages seed a brand-new conversation. 0 means nothing is written to disk (recordHistory in lib/transcript.mjs). |
visionModel |
model | empty | Model used for turns carrying image attachments (handleActivity). |
maxImageBytes |
number | 5242880 |
Largest inbound image the bot downloads, clamped to 1 MiB–20 MiB (collectMedia in lib/adapter.mjs). |
maxImages |
number 1–10 | 4 |
Inbound image attachments sent to the vision model (collectMedia). |
maxUploadImages |
number 1–10 | 4 |
Images shared with ShareImage attached to one outgoing reply (resolveImageFiles). |
rolePolicies |
rolePolicies | — | Sender → role name, extra role prompt and the admin flag. First match wins; unmapped senders are ignored (accessFor in lib/adapter.mjs). |
The table covers the core adapter settings. Every other key in configSchema (personal-chat sign-in,
Microsoft sign-in on the web login page, Microsoft 365 access, channel history, app branding, reply
behaviour) is documented with its current default in the
Configuration section of the published page.
agentName is in configSchema; see operations.md for how empty
values fall back.
Two further keys are read by the code but are not in configSchema, so the settings UI never
offers them. Both are E2E seams used by tests/e2e/msteams/:
openIdMetadataUrl(makeTokenVerifierinlib/auth.mjs), the Bot Framework OpenID metadata URL.oauthTokenUrl(theConnectorClientconstructor inlib/connector.mjs, read byTokenSource.tokenUrlinlib/token.mjs), the token endpoint override.
Tools it exposes
Registered by registerTools in lib/tools.mjs and declared in elowen-plugin.json. Tool availability
is decided by the linked account's plugin grants and per-user tool deny-list in the users modal.
| Tool | Plan-safe | What it does |
|---|---|---|
TeamsSend |
no | Post into a conversation by id. |
TeamsMessagePerson |
no | Message a person by e-mail / Entra object id / 29:… id / display name. A linked recipient's own agent writes the reply; otherwise the 1:1 chat is opened if needed and the text is sent directly. |
TeamsFindPerson |
yes | Read-only directory lookup; sends nothing. Lists at most 25 matches and sweeps the known group rosters on a miss. |
TeamsChatInfo |
yes | Conversation type, tenant, member count. |
TeamsMembers |
yes | Fresh roster read, at most 50 listed; also feeds the people directory. |
TeamsMemberInfo |
yes | One member's name / Entra id / UPN. |
TeamsListConversations |
yes | Conversations on the current service host, paged. |
TeamsSendFile |
no | Offer a file in a 1:1 chat; uploaded to the recipient's OneDrive only after they accept the card. In a 1:1 chat with no recipient named, the current sender is the recipient. |
TeamsApi |
no | Raw Bot Connector REST: any method + path. Output truncated at 4000 characters. |
That is 9 Teams* tools; the plugin also exposes 8 Microsoft* tools for the signed-in person's
delegated Microsoft 365 access (directory, SharePoint, files, Outlook, tasks, OneNote, Excel, Teams as
that person) — see microsoft-365-plugin.md for the full list and their access
mode. readOnlyTools covers exactly the five read-only Teams* tools listed above the table split, plus
MicrosoftDirectory, whose actions are all reads. The tools that write are deliberately absent, so plan
mode and read-only sub-agents withhold them. The manifest also makes every Teams
tool's output visible in the transcript.
The permission behavior is pinned by tests in tests/msteams.test.ts: a project-scoped, non-owner session
can reach curated and raw connector tools when the registry exposes them, while input validation such as
requiring a named recipient still applies.
Proactive messaging
The Bot Connector cannot be addressed by e-mail — it only takes a conversationId. So the plugin builds
a people directory (lib/directory.mjs) out of what it already sees: every inbound activity and
every conversation roster carries a 29: account id, an Entra aadObjectId, a display name and (from
the roster) a UPN/e-mail. No extra Microsoft permission is involved. Once a person is known, their 1:1
chat is opened once and remembered.
When the recipient has a linked Elowen account, TeamsMessagePerson first hands the text to that
account's own durable channel session through the host relay control (relayToPerson in
lib/proactive.mjs). The relayed turn denies the Teams send, message and file tools, the raw
connector tool and every Microsoft 365 tool (MICROSOFT_TOOLS in lib/microsoftTools.mjs), and the original text is framed as untrusted JSON data. A direct send happens only when
no relay reply came back, when the recipient is the sender's own account, or when the recipient is not
mapped.
graphLookup is a second, optional layer (lib/graph.mjs), off by default. It exists only for
people the bot has never met: it resolves an e-mail against the tenant directory and installs the Teams
app for that user so a chat can be opened. It requires the application permissions
User.ReadBasic.All and TeamsAppInstallation.ReadWriteSelfForUser.All with tenant admin consent, plus
graphCatalogAppId for the install. What an admin must do, and what happens when they have not done it,
is in operations.md.
Test tests/msteams.test.ts asserts that with the switch off nothing leaves the
process on an unknown e-mail — the stubbed global fetch records zero calls.
File map
| File | What it owns |
|---|---|
index.mjs |
Registration (register): the app-package and people API routes, the credential gate, the StateStore, the notification-destination provider, the adapter, the /hooks mount and the microsoft-requests service, the tools and the microsoftIdentity control. |
elowen-plugin.json |
Manifest: description, provides, icons, showOutput, readOnlyTools, the whole configSchema. |
icon.svg |
The plugin's icon in the Elowen UI. |
i18n/cs.json, i18n/sk.json |
Czech/Slovak translations of the manifest description and every config label/hint/option. Enforced by scripts/check-languages.mjs. |
lib/adapter.mjs |
Webhook dispatch, admission, brain turn, transport and per-turn targeted audiences; wires the protocol owners with explicit dependencies. |
lib/transcript.mjs |
Opt-in rolling history, bounded Graph thread projection and recorded-history fallback. |
lib/files.mjs |
Personal-chat file offers (consent cards), the shared 20 MiB cap, pending bytes, consent, expiration and shared-file delivery. |
lib/proactive.mjs |
Person resolution, roster sweep, personal conversations, the relay to a linked recipient and notifications. |
lib/asks.mjs |
Question owner checks, shared ordered answer projection and core-owned ask_resolved lifecycle; no local expiry clock. |
lib/commands.mjs |
Slash-command classification, local and control pickers, shared display axes and live model/reasoning validation. |
lib/accountLinking.mjs |
OAuth admission, activity validation, one Entra-before-Teams identity resolver and fresh durable-account delegated sessions. |
lib/requests.mjs |
Shared PluginContext policy subscription, finite request scheduling, cancellation and awaited cleanup for Connector/Graph/Microsoft 365 callers. |
lib/connector.mjs |
Bot Connector REST client: reply/send/update/delete/typing/members/createConversation/download, with policy-bounded 429-only retry, and the bounded binary reader. |
lib/token.mjs |
Entra client-credentials tokens, one cached bearer per scope, refreshed ~60 s before expiry. |
lib/auth.mjs |
Inbound JWT verification against Microsoft's JWKS, pinned to our appId and the connector issuer, with a serviceUrl cross-check. |
lib/directory.mjs |
The people directory: remember/list/evict/resolve, persisted under one reserved StateStore key. |
lib/graph.mjs |
The optional Microsoft Graph layer: e-mail → tenant user, app installation for that user, profile photos and channel-thread reads. |
lib/tools.mjs |
The nine Teams* tools. |
lib/messages.mjs |
The bot's own service texts in en/cs/sk. |
lib/cards.mjs |
Adaptive Card builders: the ask card, the paged picker card, the settled one-liner. |
lib/stream.mjs |
Teams binding for the shared live-message engine: transport closures, markdown style, image and shared-file strategy. |
lib/format.mjs |
Teams sizing (CHUNK = 20000) and the runtime-footer markup. |
lib/ids.mjs |
Identity helpers: matchesId, senderIds, senderIsAdmin, displayNameOf. |
lib/appPackage.mjs |
The sideloadable Teams app package: hand-rolled stored ZIP, the colour and outline icons and the Teams manifest. |
elowen-plugin-shared/stateStore |
Imported directly by the entry; owns atomic conversation-state persistence. |
lib/config.mjs |
Config normalization and defaults for the adapter and tools. |
lib/microsoftTools.mjs |
Registers the eight Microsoft* tools and dispatches each call to its product owner. |
lib/m365/*.mjs |
One owner per Microsoft 365 product (directory, SharePoint, files, Outlook, tasks, OneNote, Excel, Teams), plus schema.mjs and shared.mjs. |
lib/identityControl.mjs |
The microsoftIdentity control other plugins can use: identityFor, and driveGraphFor, which returns a client restricted to drive paths. |
lib/delegatedGraph.mjs |
Delegated Graph client helpers, including bounded output and HTML-to-text for thread bodies. |
prompt/10-surface.md |
Prompt fragment describing the Teams surface to the model. |
web-src/ |
Browser sources for the MS Teams workspace (TeamsWorkspace.tsx, PolicyEditor.tsx, policyModel.ts, and the runtime entry). |
The entry's browser-safe exports are peopleForUi and notificationDestinationsForUi. Helper
modules are imported directly from their owners. tests/msteams.test.ts also exercises the plugin through
the host loader.
Verify it works
In the repo, from the registry repository root:
npx vitest run tests/msteams.test.ts tests/msteamsConnector.test.ts tests/msteamsAccountLinking.test.ts tests/msteamsIdentityControl.test.ts --maxWorkers=1
node scripts/check-languages.mjs # i18n covers every manifest string
npm run test:e2e:msteams # builds, then a real daemon + a fake Bot Framework
The E2E scenario (tests/e2e/msteams/run.mjs) drives a signed activity into the
real webhook, checks the async threaded reply and the in-place live-trace edits, and asserts that a
garbage JWT bounces with 401 without ever reaching the brain.
On a running instance:
- The daemon log should carry
msteams platform registered (webhook /hooks/msteams/messages + Teams and delegated Microsoft 365 tools)(fromregisterinindex.mjs) and thenmsteams webhook locally available (app <appId>); provider transport unknown(fromconnectinlib/adapter.mjs). This confirms local credential initialization, not provider health.msteams credential check failed: …rejects initialization and withdraws readiness; it does not claim the adapter is available. GET /plugins/msteams/app-package(admin) returns the ZIP. It returns 503 while the plugin is enabled but not configured — deliberately 503 rather than 404. Any non-empty sub-path under that mount is a 404.- Send the bot a direct message from a mapped sender. You should see a typing indicator, then a reply threaded under your message.
/helpin that chat answers with the command list (TeamsCommands.handleCommandinlib/commands.mjs);/statsreports the live model and context for an operator.