msteams — operations
Running the plugin in production: Azure setup, what each config field actually changes, failure modes and what they look like in the log. The code walkthrough is in architecture.md; the config reference and file map are in maintainer.md.
1. Azure app registration
The click-path is carried in the manifest itself, as the sec_connection section help in
elowen-plugin.json, so the Settings UI shows it next to the fields:
- Entra admin center → App registrations → New registration, single tenant. Copy the
Application (client) ID →
appId, and the Directory (tenant) ID →tenantId. - Certificates & secrets → New client secret →
appPassword. - Azure portal → Create an Azure Bot bound to that app id. Set the messaging endpoint to
https://<your-domain>/hooks/msteams/messagesand enable the Microsoft Teams channel.
The plugin authenticates outbound calls as this app registration, against
https://login.microsoftonline.com/<tenantId>/oauth2/v2.0/token for the
https://api.botframework.com/.default scope (TokenSource in lib/token.mjs, SCOPE in lib/connector.mjs).
Inbound, it verifies Microsoft's JWT with the audience pinned to appId and the issuer pinned to
https://api.botframework.com (makeTokenVerifier in lib/auth.mjs). The issuer being the connector service and
not your tenant is correct and deliberate — the tenant-scoped issuer applies to user tokens, never to
service-to-bot callbacks (see the comment at the top of lib/auth.mjs).
2. The Teams app package
The plugin builds the sideloadable package itself from the live config — there is no file to maintain.
- Served at
GET /plugins/msteams/app-package, admin-only, aselowen-teams-app.zipby default, or<agentName>-teams-app.zipwith the name slugged (registerinindex.mjs). - The route is registered before the credential check on purpose (
registerinindex.mjs): while the plugin is enabled but unconfigured it answers 503, not 404. Registering it after the gate would make an enabled-but-unconfigured instance look like a missing route. - Contents (
buildAppPackageinlib/appPackage.mjs):manifest.json, a 192 px colour icon and a 32 px whiteoutline.png, in a hand-rolled stored (uncompressed) ZIP. The colour icon is the PNG named byappIconPathwhen set, otherwise a solid Teams-purple square. - The Teams manifest (
appManifestinlib/appPackage.mjs, schema version inMANIFEST_VERSION) setsidandbotIdto yourappId, scopes the bot topersonal,teamandgroupChat, declaressupportsFiles: trueandsupportsTargetedMessages: true, setsvalidDomains(token.botframework.comonly when personal-chat sign-in is on, otherwise empty) and the permissionsidentityandmessageTeamMembers. The command lists take the first twelve entries ofhelpCommands(), the host's live chat commands plus the syntheticdisplayentry thatADAPTER_STATE_COMMANDSadds. Team and group chats offer them in the/menu and after an @mention; personal chats offer them after an @mention only. The same list backs/help. - The package
versionis a UTC build stamp (packageVersion). Teams refuses an upload whose version is not higher than the one already in the catalog, so every download carries a newer number.
Upload it in Teams admin center → Manage apps. tests/msteams.test.ts verifies the
ZIP framing, the manifest id/botId/scopes/icons and that both PNGs are really there.
3. The webhook route
register in index.mjs mounts messages, which the daemon serves at /hooks/msteams/messages. The bearer-auth
layer treats /hooks/* as public (src/api/auth.ts) — the plugin authenticates every request itself.
Your reverse proxy must forward /hooks/ to the daemon port, before the catch-all / location. The
managed nginx vhost that elowen setup writes already does (src/cli/install/proxy.ts), and the
config health check reports <vhost> does not route /hooks/ with a fix hint when it does not
(src/api/routes/system.ts).
Symptom of a missing proxy rule: the Azure Bot channel health check fails and nothing at all appears in the daemon log — the request never reaches the plugin.
4. What the config fields do at runtime
The full table is in maintainer.md. Operationally, the ones with consequences beyond cosmetics:
rolePoliciesis the access boundary. First match wins, and a sender matching no policy is ignored in complete silence — no reply, no log line, no outbound traffic (theaccessForcheck inhandleActivity). If "the bot ignores me" is the report, this is almost always the cause.- A
roleIdmay be an Entra object ID (a GUID), a UPN/e-mail, or a whole conversation id, which grants everyone in that chat. Matching is case-insensitive whenever either side contains an@(sameIdinlib/ids.mjs) — so UPNs, e-mails and conversation ids like19:…@thread.tacv2are all case-insensitive; only GUIDs and29:account ids must match exactly. - For a team channel, either the full
19:…@thread.tacv2;messageid=…form or the bare19:…@thread.tacv2from a Teams deep link works (senderIdsinlib/ids.mjs). admin: truemarks the operator: it unlocks/model,/reasoning,/display,/context,/stats,/compactand/stop, lets that person act on a card opened by someone else, and drivessenderIsAdmin(lib/ids.mjs).
- A
respondWithoutMentiononly affects non-personal conversations. A personal chat always answers (the mention gate inhandleActivity). A team-channel post reaches the bot without a mention only when channel message consent is active (see permissions.md). Otherwise Teams delivers a team-channel post only when the bot is @mentioned, and this setting does not change that.historyLimitis the only field that makes the plugin persist message text to disk.0(the default) writes nothing at all. Lowering it takes effect on the next recorded message, which trims the stored list to the new limit rather than leaving a tail a later raise would resurrect (recordHistoryinlib/transcript.mjs).languagechanges the bot's own service texts only (errors, command replies); the agent answers in the user's language regardless. Note the coverage is uneven: the service texts exist in all three languages (lib/messages.mjs), but Adaptive Card button labels are switched by a singlelanguage === 'cs'boolean (thecsflag passed tobuildAskCardandbuildPickerCardinlib/cards.mjs, set inTeamsAsksandTeamsCommands), so withlanguage: 'sk'the buttons ("Submit", "➤ Send custom answer", "‹ Prev", "Next ›") come out in English.toolActivity/answerMode/toolOutput/toolMessageModeare global defaults; each is overridable per chat with/display. The/displaycard has adefaultoption on every axis, which removes that axis's override (updateDisplayOverrides, used byTeamsCommands).
Per-channel / per-role policy in practice
Access is per sender, not per channel — but a policy whose roleId is a conversation id applies to the
whole room, which is how a channel-wide policy is expressed. The matched policy contributes the role name,
the extra role prompt and the admin flag; the conversation's own state contributes the model and the
reasoning level (elowen-plugin-shared/access.mjs, buildRoleAccess). Project and tool scope are not part
of that descriptor at all — they come from the linked account.
admin: true in a role policy grants the operator role inside the chat for platform behavior such as
slash commands. It does not decide which Teams* tools the person may call. Tool availability comes from
the linked account's plugin grants and per-user tool deny-list in the users modal.
5. The notify (cron) target
notifyConversationId is where a scheduled job's result lands when the job names no channel of its own.
A scheduled job may override it with its own notifyChannelId.
Either value may be:
- a conversation id the bot has already seen, or
- a person — e-mail, Entra object ID,
29:…account id or display name — whose 1:1 chat the plugin opens and then remembers (notifyConversationForinlib/proactive.mjs).
Every way a push can be dropped has its own warning:
Log line (all warn) |
Cause |
|---|---|
msteams notify: no target — set the plugin's notifyConversationId, or give the job a notifyChannelId … (notify in lib/proactive.mjs) |
Neither the job nor the config named anywhere. |
msteams notify: no serviceUrl known yet — send the bot one message first (notify) |
The bot has never received an activity, so there is no service host to send through. |
msteams notify: "<x>" matches N people — name the person exactly or use their e-mail; the push was dropped (notifyConversationFor) |
An ambiguous display name. It is refused, never guessed. |
msteams notify: could not open a conversation with <x> (notifyConversationFor) |
Teams accepted the create call but returned no conversation id. |
msteams notify: could not reach <x>: … (notifyConversationFor) |
Resolving or opening the conversation threw (see §6). |
An explicit destination:msteams: envelope does not drop silently. It rejects instead, so the consumer
can keep its notice pending and retry.
6. The optional Microsoft Graph layer
Off by default. With graphLookup unset or false, no GraphClient is constructed at all (the adapter
constructor in lib/adapter.mjs), so no directory Graph call is even possible — tests/msteams.test.ts
asserts zero outbound requests on an unknown e-mail in that state.
Layer 1 (the people directory) needs no Microsoft permission whatsoever: it is assembled from inbound activities and conversation rosters the bot already reads. Turn Graph on only when you need the bot to write to people it has never met.
Every application Graph request, including channel history and profile photos, has a 15-second
per-request deadline covering both response headers and the complete response body. The client uses
AbortSignal.timeout and does not retry. Token requests for both Graph and the Bot Connector have their
own 15-second deadline, including successful JSON and failed-request diagnostic bodies. The Graph
request's deadline starts after its token is obtained, so these are separate budgets, not a whole-turn
limit. An aborted token refresh releases the shared in-flight promise; a later caller can refresh again.
A channel-history timeout follows the existing recorded-history fallback, logging the Graph failure once per adapter. Directory lookup, app installation and photo timeouts reject through their existing caller error handling. Completed Graph 401/403 responses still report application consent instructions, while optional photo refusals keep their avatar fallback and five-minute permission cooldown. The delegated Microsoft 365 client keeps its own request deadlines and refusal handling.
What a Microsoft admin must grant
This section covers the app-only Graph layer alone. For the full picture — the four separate consent surfaces, the delegated Microsoft 365 scopes, resource-specific consent and a per-tenant setup checklist — see permissions.md.
Both are application permissions on the bot's app registration, and both need tenant admin consent:
| Permission | Needed for |
|---|---|
User.ReadBasic.All |
Resolving an e-mail to a tenant user (findUser in lib/graph.mjs). |
TeamsAppInstallation.ReadWriteSelfForUser.All |
Installing the Teams app for that user so a 1:1 chat can be opened (installApp in lib/graph.mjs). |
Path: Entra admin center → App registrations → the bot's app → API permissions → Add a permission →
Microsoft Graph → Application permissions → the permission → Grant admin consent for the tenant. The
plugin quotes exactly this back at you when Graph answers 401/403, instead of forwarding a bare status
(consentHelp and GraphClient.call in lib/graph.mjs). Pinned by tests/msteams.test.ts.
Additionally, installing the app for a user requires graphCatalogAppId — the app's id in the org
Teams catalog (Teams admin center → Manage apps → the uploaded Elowen app), which is not the bot's
appId. Leave it empty only when the app is already deployed to users by Teams app policy; the plugin
then skips the install and opens the chat directly (findPersonViaGraph in lib/proactive.mjs).
Graph failure modes, all translated into an instruction rather than a status code:
An e-mail is resolved in two steps (findUser in lib/graph.mjs): a direct GET /users/{address} first, which
covers the common case where the e-mail is the UPN, then a $filter search on mail or
userPrincipalName for users whose mail differs. An install that answers 409 (the user already has
the app) counts as success (installApp).
| Situation | What the operator sees |
|---|---|
| 401/403 on any call | The permission name plus the full consent path (GraphClient.call). |
| No such address in the tenant | Microsoft Graph found no user with the address "…" in this tenant. (findPersonViaGraph in lib/proactive.mjs) |
| Address matches several users | Refused, with a suggestion to address the person by Entra object id (findUser). |
graphCatalogAppId empty when an install is needed |
An explicit instruction naming the Teams admin center column (installApp). |
| Catalog id not found (404) | The app must be uploaded and approved in the org catalog first (installApp). |
7. Common failures and what they look like in the log
| Log line | Level | Meaning / what to do |
|---|---|---|
enabled but appId/appPassword/tenantId are not all configured — not connecting (register in index.mjs) |
warn | The plugin loaded but registered no platform, no webhook and no tools. Fill in all three. |
msteams platform registered (webhook /hooks/msteams/messages + Teams and delegated Microsoft 365 tools) (register) |
info | Registration succeeded. |
msteams webhook locally available (app <appId>); provider transport unknown (connect in lib/adapter.mjs) |
info | Local credential initialization succeeded; this is not a provider connectivity/health observation. |
msteams credential check failed: … (connect) |
warn | The token call failed at enable time — usually a wrong secret or tenant id. Initialization rejects and readiness stays unknown; no successful connection is claimed. Inbound JWT validation retains its separate boundary. |
webhook token rejected: … (makeTokenVerifier in lib/auth.mjs) |
warn | An inbound activity failed JWT validation → 401. Wrong appId, a clock far out of the 300 s tolerance, an unreachable JWKS, or a spoofed/replayed serviceUrl. |
msteams turn failed in <conv>: <stack> (runTurn log in handleActivity) |
error | The brain turn threw. The same message is also replied into the chat as ⚠️ …. |
msteams turn failed: … (handleWebhook) |
error | The detached webhook work rejected outside the per-turn catch — a card action, or a failure before onActivity reached its own try/catch. |
msteams send: no stored route for conversation <id> (tmSend) |
warn | No ref.serviceUrl for that conversation and no _meta.serviceUrl either. The bot must receive one activity before it can send. |
msteams send failed: … / msteams edit failed: … (tmSend, tmEdit) |
error / warn | A Connector call failed. The message body carries the HTTP status and the first 300 characters of Microsoft's response (ConnectorClient.call). |
msteams roster lookup failed for <id>: … (roster) |
warn | Membership could not be refreshed; the previous roster is reused, so mentions still resolve for up to 5 minutes. |
image download failed: … (collectMedia) |
error | An inbound image exceeded maxImageBytes or the download failed; the turn continues with [Attachment: image (download failed or too large)]. |
image upload failed: … (sendImages) |
error | An outbound image Connector send failed. The callback logs msteams.file_upload_failed and rejects with the original cause to the shared finalizer/turn owner; notify stops before final result text. No upload replay is added. |
msteams shared file offer failed for <name>: … (offerSharedFiles in lib/files.mjs) |
error | An actual consent-card offer failed in a 1:1 chat. The original cause rejects the callback and stops later files; earlier confirmed offers stay valid. The canonical turn owner handles failure without a second local chat notice or replay. Declared size-cap notices still permit later eligible files; unsupported scopes remain no-ops. |
msteams file upload failed for <name>: … (onFileConsent in lib/files.mjs) |
error | The recipient accepted an offer and the upload to their OneDrive URL failed; the card is replaced with ⚠️ <name> could not be uploaded. Please try again. |
msteams directory: could not persist <key>: … (PeopleDirectory.remember) |
warn | The people directory could not be written. Harmless in itself — it is re-learned from the next roster read. |
stateStore: failed to persist … (elowen-plugin-shared/stateStore) |
error | The state file could not be written; this one is re-thrown, so the command that caused it fails visibly rather than confirming a change that never stuck. |
Two quiet failure modes with no log line at all, worth knowing about:
- An unmapped sender is dropped silently by the admission check in
handleActivity. CheckrolePolicies. - In a group chat with
respondWithoutMention: false, a message with no bot mention returns at the mention gate inhandleActivity.
Secrets never appear in any of this: the token layer echoes the response of a failed token call and
never the request that carried the secret (TokenSource.token), and
tests/msteams.test.ts asserts the app password is absent from tool output, the log,
the persisted state file and every outbound connector payload.
8. What a redeploy or config save does — and does not — change
Saving plugin configuration persists it through the host revision boundary and requests a daemon restart. The adapter keeps its current configuration until that restart completes. A saved change with pending activation must not be presented as already effective.
Changes / is rebuilt:
- The adapter instance and its whole config snapshot —
registerspreadsctx.config(normalized bynormalizeConfig) once, so a field change only takes effect on the next daemon start. - Whether the directory Graph layer exists at all (
graphLookupis read once, in the adapter constructor). - Every in-memory cache: the connector and Graph bearers, the JWKS handle,
upnCache,rosterCache. - Open interactive cards stop working.
pendingAsksandpendingPickersare in-memory, so a button tapped after a reload finds no pending entry and is ignored (onAskActioninlib/asks.mjs,onPickerActioninlib/commands.mjs). The card is left visibly un-settled.
Does not change:
channel-state.jsonand everything in it: conversation routes (ref),/newgenerations, per-chat model / reasoning / display overrides, the transcript, and the whole_peopledirectory. A restart loses no person and no opened 1:1 chat.- Azure-side state: the app registration, the bot resource, the messaging endpoint, the uploaded Teams app package. Nothing in a redeploy touches those.
- The webhook URL. It is derived from the plugin name, not from config.
A redeploy that changes the plugin's data directory (the plugin data root is derived from the daemon's data
directory, see src/daemon/brainCore.ts) would orphan channel-state.json, and the bot would then have no
route to any conversation until it receives a message again.
9. The agentName caveat
register in index.mjs resolves the configured agent and product names once: non-empty values are trimmed,
agentName defaults to Elowen, and productName defaults to the resolved agent name.
The transcript label, help heading, generated manifest and package filename consume those values.
The plugin does not read the instance brand. Nothing in the plugin calls ctx.brand(), so a renamed
instance still calls itself "Elowen" in Teams until an administrator fills in this setting. Setting the
value is the only way to rename the bot.