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 README.md.
Line references below are pointers into the named file, not exact anchors against a specific commit; the pin these notes once carried no longer exists in history. If a reference no longer resolves, search the file instead of trusting the line number.
1. Azure app registration
The click-path is carried in the manifest itself, as the sec_connection section hint
(elowen-plugin.json:26), 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 (lib/token.mjs:25, lib/connector.mjs:6).
Inbound, it verifies Microsoft's JWT with the audience pinned to appId and the issuer pinned to
https://api.botframework.com (lib/auth.mjs:11, :38-40). 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 (lib/auth.mjs:6-7).
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 (index.mjs:101-124). - The route is registered before the credential check on purpose (
index.mjs:101-105): 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 (
lib/appPackage.mjs:139):manifest.json, a 192 px solid Teams-purplecolor.pngand a 32 px whiteoutline.png, in a hand-rolled stored (uncompressed) ZIP. - The Teams manifest (
lib/appPackage.mjs:99) uses schema v1.16, setsidandbotIdto yourappId, scopes the bot topersonal,teamandgroupChat, declaressupportsFiles: true,validDomains(token.botframework.comonly when personal-chat sign-in is on, otherwise empty), the permissionsidentityandmessageTeamMembers, and fills the compose-box command list from the first twelve entries ofhelpCommands()(lib/appPackage.mjs:112,:147) — the host's live chat commands plus a syntheticdisplayentry the plugin appends itself (lib/adapter.mjs:929-934). The same list backs/help.
Upload it in Teams admin center → Manage apps. tests/plugins/msteamsPlugin.test.ts:333 verifies the
ZIP framing, the manifest id/botId/scopes/icons and that both PNGs are really there.
3. The webhook route
index.mjs:222 mounts messages, which the daemon serves at /hooks/msteams/messages. The bearer-auth
layer treats /hooks/* as public (src/api/auth.ts:27) — 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:39-46), and the
config health check reports <vhost> does not route /hooks/ with a fix hint when it does not
(src/api/routes/config.ts:387-388).
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 README.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 (lib/adapter.mjs:396). 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@(lib/ids.mjs:12) — 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 (lib/ids.mjs:26-31). admin: truemarks the operator: it unlocks/model,/reasoning,/displayand/context, lets that person act on a card opened by someone else, and drivessenderIsAdmin(lib/ids.mjs:36). At701144ddit is also the only way anyone can answer anAskUserQuestioncard — see the known discrepancy in architecture.md.
- A
respondWithoutMentiononly affects non-personal conversations. A personal chat always answers (lib/adapter.mjs:390-391). A team-channel post is delivered to the bot by Teams only when @mentioned, so that case is gated regardless of this setting.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 (lib/adapter.mjs:248-250).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 (lib/adapter.mjs:844,:943,:1001,:1020→lib/cards.mjs:46-49,:70-72), so withlanguage: 'sk'the buttons ("Submit", "✏️ Other", "‹ Prev", "Next ›") come out in English.toolActivity/answerMode/toolOutput/toolMessageModeare global defaults; each is overridable per chat with/display, and/display <axis> defaultremoves the override (lib/adapter.mjs:1005-1007).
Per-channel / per-role policy in practice
Access is per sender, not per channel — but a policy whose roleId is a conversation id grants the
whole room, which is how a channel-wide policy is expressed. The matched policy contributes the project
scope, the role prompt, and an optional per-role tool allowlist; the conversation's own state contributes
the model, reasoning level and fast flag (elowen-plugin-shared/access.mjs, buildRoleAccess).
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 cron job may override it with notifyChannelId (plugins/cronjob/index.mjs:835).
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 (lib/adapter.mjs:791-809).
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 … (lib/adapter.mjs:771) | Neither the job nor the config named anywhere. |
msteams notify: no serviceUrl known yet — send the bot one message first (:775) | 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 (:795) | An ambiguous display name. It is refused, never guessed. |
msteams notify: could not open a conversation with <x> (:805) | Teams accepted the create call but returned no conversation id. |
msteams notify: could not reach <x>: … (:811) | Resolving or opening the conversation threw (see §6). |
6. The optional Microsoft Graph layer
Off by default. With graphLookup unset or false, no GraphClient is constructed at all
(lib/adapter.mjs:102), so no directory Graph call is even possible — tests/plugins/msteamsPlugin.test.ts:720
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 (lib/graph.mjs:15, :60). |
TeamsAppInstallation.ReadWriteSelfForUser.All | Installing the Teams app for that user so a 1:1 chat can be opened (lib/graph.mjs:16, :80). |
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
(lib/graph.mjs:21-22, :44-46). Pinned by tests/plugins/msteamsPlugin.test.ts:770.
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 (lib/adapter.mjs:711-714).
Graph failure modes, all translated into an instruction rather than a status code:
An e-mail is resolved in two steps (lib/graph.mjs:60-72): 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 (lib/graph.mjs:88).
| Situation | What the operator sees |
|---|---|
| 401/403 on any call | The permission name plus the full consent path (lib/graph.mjs:45). |
| No such address in the tenant | Microsoft Graph found no user with the address "…" in this tenant. (lib/adapter.mjs:710) |
| Address matches several users | Refused, with a suggestion to address the person by Entra object id (lib/graph.mjs:71). |
graphCatalogAppId empty when an install is needed | An explicit instruction naming the Teams admin center column (lib/graph.mjs:83). |
| Catalog id not found (404) | The app must be uploaded and approved in the org catalog first (lib/graph.mjs:90). |
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 (index.mjs:191) | 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) (index.mjs:232) | info | Registration succeeded. |
msteams connected (app <appId>) (lib/adapter.mjs:162) | info | The eager credential check obtained a token. |
msteams credential check failed: … (lib/adapter.mjs:164) | warn | The token call failed at enable time — usually a wrong secret or tenant id. The adapter deliberately stays up; inbound JWT validation still guards the webhook, but every outbound call will fail. |
webhook token rejected: … (lib/auth.mjs:49) | 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> (lib/adapter.mjs:459) | error | The brain turn threw. The same message is also replied into the chat as ⚠️ …. |
msteams turn failed: … (lib/adapter.mjs:184) | 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> (lib/adapter.mjs:569) | 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: … (:578, :601) | error / warn | A Connector call failed. The message body carries the HTTP status and the first 300 characters of Microsoft's response (lib/connector.mjs:41). |
msteams roster lookup failed for <id>: … (:509) | warn | Membership could not be refreshed; the previous roster is reused, so mentions still resolve for up to 5 minutes. |
image download failed: … (:482) | error | An inbound image exceeded maxImageBytes or the download failed; the turn continues with [Attachment: image (download failed or too large)]. |
image upload failed: … (:630) | error | An outbound generated image could not be attached; the text still went out. |
msteams shared file offer failed for <name>: … (:1059) | error | A file the agent shared (ShareFile) could not be offered in a 1:1 chat. The chat is told, the other files in the turn and the answer still go out. |
msteams file upload failed for <name>: … (:573) | error | The recipient accepted an offer and the upload to their OneDrive URL failed; the card is replaced with ⚠️ <name> could not be uploaded. |
msteams directory: could not persist <key>: … (lib/directory.mjs:86) | 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.mjs:27) | 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 at
lib/adapter.mjs:396. CheckrolePolicies. - In a group chat with
respondWithoutMention: false, a message with no bot mention returns at:391.
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 (lib/token.mjs:44-46), and
tests/plugins/msteamsPlugin.test.ts:859 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 —
index.mjs:195-202spreadsctx.configonce at registration, so a field change only takes effect on the next daemon start. - Whether the directory Graph layer exists at all (
graphLookupis read once,lib/adapter.mjs:102). - 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 token and is ignored (lib/adapter.mjs:863,:1016). The card is left visibly un-settled. A parked question is separately cancelled by the reload itself (src/brain/brainService.ts:1248).
Does not change:
channel-state.jsonand everything in it: conversation routes (ref),/newgenerations, per-chat model / reasoning / fast / 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 (a different database location, since the plugin data
root is derived from it — src/daemon/brainCore.ts:255) 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
agentName is the "App name in Teams" setting in configSchema. It is read in three places: the
adapter's app name (lib/adapter.mjs:817), the /help heading (lib/commands.mjs:98) and the Teams
app manifest name (lib/appPackage.mjs:154). Each falls back to the literal 'Elowen' when the
setting is empty (index.mjs:94-96 computes the same fallback for the package file name).
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. The Discord
and Telegram adapters follow the same pattern. Setting the value is the only way to rename the bot.