msteams — architecture
How a message actually travels through this plugin, and what survives a restart. Setup and operational concerns are in operations.md; the file map and config reference are in maintainer.md.
References name functions and methods in the files given, not line numbers, so they stay valid as the code moves.
Problem audiences
Connector and delegated Graph HTTP errors carry the core InstanceConditionError cause for credentials, access, deleted resources, conflicts, quota and remote availability. Account-linking refusals, configured app-icon validation, missing routes/recipients and attachment-size limits are also local conditions. Logging producers use the typed cause, SDK statusCode and native network errno without inspecting prose; invalid arguments, response-contract defects, missing successful conversation identifiers and persistence failures remain reportable. tmSend returns an activity id or throws with the classified cause, including a typed local refusal when no route exists. Final sends preserve the cause in FinalTextDeliveryError; editable-message create/edit propagate their original typed failures without null/false success masks. Recipient delivery retains partial-send outcomes, while a zero-part direct send propagates the original failure. The shared runTurn callback preserves the original failure and core reporting metadata. These contracts require core 0.30.7. Existing messages and local delivery are unchanged. The paired core candidate exports the cause through elowen-plugin-shared/errors; delegated Graph retains it for OneDrive consumers.
Upload retry reporting
Consent upload makes one PUT and never replays a transport failure with unknown delivery.
The obsolete intermediate msteams.file_upload_retry event is removed.
TeamsFiles.onFileConsent retains final msteams.file_upload_failed, its typed
cause/redacted upload detail and failed consent-card settlement; no cross-upload
counter or replacement upload is introduced.
Finite Microsoft request and initialization ownership
One MicrosoftRequests in lib/requests.mjs receives the existing PluginContext
policy read/subscription. Subscription precedes async reads, revisions fence stale
snapshots and every finite request pins one policy. MsTeamsAdapter requires that
context; Connector/Application Graph receive the SAME request owner, delegated
Graph receives options.requests, transcript lazy Graph receives it, and
createMicrosoftIdentityRuntime({linking,people,requests}) forwards it to drive and
Microsoft 365 tools. The microsoft-requests registered service disposes this
owner even when the platform connection never starts. With no operation it adds
no HTTP poller or synthetic transport retry loop.
The shared request controller alone schedules deterministic exponential policy
with 60000 ms cap, no jitter and canonical Retry-After as a not-before minimum,
never clipped to private 15000/2000 ms waits. Connector normal calls permit at
most min(1,policy.maxRetries) retries; reactions at most
min(3,policy.maxRetries). ONLY authoritative HTTP 429 permits replay. Network,
auth, 5xx and unknown writes never do. Delegated Graph GET/HEAD permit at most
min(2,policy.maxRetries) retries for fetch TypeError/network or 429/5xx; mutations
remain one attempt. Application Graph/photo retain zero retries. Consent upload
is also exactly one PUT, including unknown fetch outcomes.
One delegated deadline covers waits, all attempts AND response body reads.
json()/binary() decode inside the owned attempt so disconnect joins their body
cleanup, not merely headers. Raw request() still returns Response; its caller
owns consumption with the same original host/deadline signal. Token POST remains
a separately bounded, 15000 ms singleflight, not another retry owner. Current
account, tenant, auth and drive-namespace checks remain unchanged.
connect({signal,onConnectionState}) reports fresh local observations for
msteams:<tenantId>:<appId>, transport unknown, readiness pending/ready/unknown and
retry null. Ready means local credential initialization only, NOT provider/gateway
health or an external identity mapped to an Elowen account. Token failure rejects
initialization instead of logging success. Host abort withdraws readiness and
aborts requests; awaited disconnect joins owned requests and token refreshes.
The unlimited always-on transport exception does not apply to this webhook or
outbound requests. Live create/edit propagate original typed errors, never null/
false masks authorizing replacement writes. Focused mocked HTTP/native-caller
tests do not establish browser, live tenant or physical-device acceptance.
Bounded binary downloads
lib/connector.mjs owns the credential-neutral readResponseBytes(response, maxBytes) reader used by Connector attachments and delegated Graph binary downloads, including OneDrive. Authentication and requests stay in each client. The reader rejects declared overflow without reading, counts actual streamed bytes for absent or understated lengths, cancels overflow and always releases its reader lock. Accepted retained bytes never exceed the supplied cap; the first overflowing chunk is read but not retained. Successful concatenation temporarily holds both the accepted chunks and the output Buffer, up to twice the accepted payload size, excluding transport buffering. Without a cap the Connector caller permits the full stream. Graph returns a Uint8Array view over that Buffer's exact byte range without another full payload allocation, preserves content type and translates cap failures to DelegatedGraphError.
Declared administration API patterns
Core 0.30.7 with shared API 14 resolves the complete authenticated API declarations this plugin makes in index.mjs, including people/:id/account, people/:id/signout and people/:id/avatar. Core decodes req.params.id once and supplies an empty req.path; undeclared suffixes return dispatcher 404 before directory or adapter access. Administrator authorization remains in core. Missing account linking still returns 503, and a missing avatar adapter still returns 404. Public /hooks/msteams/messages keeps its separate prefix-routing contract.
Protocol owners
lib/adapter.mjs owns webhook dispatch, admission, the brain turn and Bot Connector transport, including
per-turn targeted audiences. It constructs concrete collaborators with named dependencies and transport
callbacks, never passes the whole adapter to a protocol owner, and forwards only the entry points used by
the host, Teams tools and live-message transport.
lib/transcript.mjs: opt-in rolling transcript, history budgets, Graph thread projection and its warn-once recorded-history fallback. WithhistoryLimit: 0, it stores and returns nothing; with no RSC consent it constructs no thread reader. Existing Graph and token request deadlines remain in their clients.lib/files.mjs: personal-chat consent offers, pending bytes, expiration, upload and shared-file delivery.TeamsSendFileimports its 20 MiBMAX_FILE_BYTESfor core's project file reader. The owner retains each offer until consent, rejection, expiration or process teardown; a shared room receives no offer.lib/proactive.mjs: directory and bounded roster lookup, optional Graph discovery, personal-conversation creation, the relay to a linked recipient, and notification delivery. It preserves verified recipient binding, self-relay refusal, untrusted JSON framing, partial-delivery accounting and the 25-conversation roster sweep.lib/asks.mjs: pending questions, owner/operator checks, complete-answer submission and resolution. Card drawing stays inlib/cards.mjs. Answer order/completeness usescollectQuestionAnswersfromelowen-plugin-shared/ask; Teams receives structured card submissions, so it has no numbered text reply parser.lib/config.mjs: Teams config normalization consumes sharedDISPLAY_AXES; omitted axes remain inherited, valid choices are preserved, and invalid supplied values normalize to each axis's first value.lib/commands.mjs: catalog-based command classification, local pickers and their pending state. It uses sharedDISPLAY_AXESandresolveActiveModel,modelReasoningLevelsandsupportsReasoningChoicefromelowen-plugin-shared/modelIdentity. On a reasoning submission it rechecks the live model before consuming the card. Teams' empty reset payload maps to shareddefaultonly for this check; storage still deletes the per-chat override. Unsupported or stale choices leave state/card intact.
resolveLinkedTeamsIdentity in lib/accountLinking.mjs is the one platform-identity resolver for shared
messages and picker clicks: it awaits the Entra object id first, then the Teams account id, passing the
verified roster email to the host's asynchronous externalUsers.resolvePlatformUser method. bindingFor
returns the host's describe promise, and explicit account binding awaits linkExisting; supported hosts
provide all three. identityFor, delegated-session checks and the people list await durable binding reads
before selecting an account or projecting linked status.
A linked candidate returns its account and exact platform id. With no link, the raw Entra or Teams id is
kept and the account is null. Personal chats bypass this lookup and use OAuth admission. Before invoking the
brain handler, the adapter still calls validateActivity whenever an account linker exists, even with OAuth
admission disabled. Microsoft tools mint fresh delegated sessions for durably bound Elowen accounts through
identityControl.mjs and delegatedSessionForPerson.
The microsoftIdentity.driveGraphFor(accountId) control returns null only when no Microsoft person is
bound or the token service reports the typed TeamsAccountError code sign_in_required. That expected
state logs no warning. Token-service, Graph verification and identity-validation failures reject and
remain the caller's reporting responsibility. OneDrive therefore preserves mirror state during an outage,
while a genuinely absent sign-in can still request reconnection. Each call performs the existing fresh
session verification; no cache, token copy or extra request is introduced. Plugin warn/error emitters carry
catalogued msteams.event_name codes and report the redacted message sample, while operator
configuration and expected refusals stay local-only.
The adapter passes its constructor's listModels and lazy chatCommands callbacks to TeamsCommands, and
answerQuestion to TeamsAsks. Picker commands resolve models when invoked, and ask submissions deliver
complete answers through TeamsAsks. Settlement awaits ctx.answerQuestion before removing the
pending card or publishing its summary. A false result leaves the question available for retry;
a rejected repository read propagates to the existing action handler.
Microsoft 365 product owners
lib/microsoftTools.mjs registers the eight Microsoft* tools, called by index.mjs, and dispatches each
invocation to one concrete product owner under lib/m365/:
directory.mjs: profile, relevant people, Entra users, organization chart and groups.sharepoint.mjs: content search, sites, lists, list items and modern pages, including its query-bound search cursors and 1,000-result limit.files.mjs: drive items, workspace transfers, upload sessions, sharing links and versions.outlook.mjs: mail, attachment classification, folder traversal/resolution, calendar and contacts.tasks.mjs: To Do and Planner.onenote.mjs: notebooks, sections, pages and their HTML projection.excel.mjs: workbook operations and the sole in-memory workbook-session map.teams.mjs: delegated human chats and channel messages, separate from the bot adapter.
schema.mjs owns the complete common TypeBox parameter structure. shared.mjs owns registration,
fresh delegated-client resolution, bounded results, classified permission errors, validation helpers,
drive addressing, file-transfer limits and the mutation gate. Registration performs no Graph request;
each execution resolves the current identity through sessionForIdentity before entering its product
handler. Missing linking rejects through the existing error result. Read-only mode returns its existing
write refusal; read/write mode previews until commit: true.
shared.mjs also owns readText(graph, path, action, downloadAction, permission) and
saveDownload(graph, ctx, cfg, path, file_path, permission, missingProjectMessage). path identifies the remote Graph resource; file_path is the local project destination. MicrosoftFiles uploads also use file_path for their local source, while their remote drive item or directory remains path. TeamsSendFile uses an absolute local file_path through the project-file authority seam.
MicrosoftFiles and MicrosoftOutlook call these helpers only for inline text reads or workspace
downloads. Inline reads retain the 1 MiB cap, MIME check, UTF-8 decoding and action-specific refusal.
Downloads validate the target and require a current work directory, fetch bytes with the configured
transfer cap and the caller's delegated permission, then save them through
ctx.host.projectFiles().write(file_path, bytes, { overwrite: true }). Core writes into the conversation's
execution target: a managed turn writes inside its Project container through the live Sandbox provider
(never the host), and a host turn passes the caller's path guard and publishes a staged file whole,
creating missing parent directories. Missing-project errors remain specific to drive files and mail
attachments. Registration alone performs no transfer.
The plugin entry registers Teams and exposes its browser directory and destination projections.
Identity helpers are imported directly from lib/ids.mjs.
Excel's opaque handles remain module-scoped across registrations, tied to the Microsoft subject and workbook item. Each successful handle lookup renews its ten-minute idle expiry. Expired handles are removed on lookup, explicit close removes its handle, and process teardown loses the map; there is no timer or sweep.
1. Inbound: webhook → activity handling
1.1 The mount
register in index.mjs registers a webhook handler under the relative path messages, which the daemon serves at
/hooks/msteams/messages. The bearer-auth layer treats /hooks/* as public (src/api/auth.ts), so
the handler owns its own authentication — that is the seam contract stated in the plugin HTTP route
documentation in src/plugins/api.ts.
1.2 handleWebhook (lib/adapter.mjs)
In order:
Non-
POST→ 405. Pinned bytests/msteams.test.ts.Unparseable body → 400.
verifyToken(req.headers.authorization, activity)fails → 401, and the brain is never reached (tests/msteams.test.ts).Adapter stopped, or no
listen()handler wired → 200 with an empty body.stoppedis set bydisconnect(), which the host calls when it tears adapters down;connect()resets it and eagerly fetches a token so a typo'd secret surfaces at enable time rather than on the first message.type === 'message':- if
activity.valueis an object →onCardAction(an Adaptive CardAction.Submitround-trip), - otherwise →
onActivity(a user message).
Either way the promise is not awaited:
void work.catch(…)and an immediate 200. Microsoft's callback deadline is far shorter than an agent turn, so the reply is delivered later through the Connector, never as the HTTP response.tests/msteams.test.tspins the 200 and, after a 20 ms wait, that the turn ran — it does not assert the ordering itself.- if
type === 'conversationUpdate'→rememberConversationonly. This is what an app install arrives as, and it carries the personal conversation id before the person has said anything.type === 'invoke'→onInvoke, answered inline because Teams waits for the HTTP response and retries before it shows "Unable to reach the app". Nothing here may wait on a model:message/submitActionwith the feedback action: the thumbs vote and its comment are written to the daemon log (recordFeedback). Teams stores no vote, so the log is the only record.fileConsent/invoke: acknowledged at once; the upload runs detached and its outcome reaches the person as a card edit.signin/verifyStatein a personal chat: acknowledged at once; the account binding completes detached, through the Connector.
Anything else → 200, ignored.
1.3 Token verification — makeTokenVerifier (lib/auth.mjs)
makeTokenVerifier is built once per adapter, in the adapter constructor, and returns the verify closure. It:
- lazily fetches the OpenID metadata document (default
https://login.botframework.com/v1/.well-known/openidconfiguration) and builds a remote JWKS handle; - verifies with
audience = cfg.appId,issuer = https://api.botframework.comand a 300 s clock tolerance — note the issuer is the connector service, not the tenant, even for a single-tenant bot; - cross-checks the token's
serviceUrlclaim against the activity'sserviceUrl, trailing slashes stripped, and rejects a mismatch; - on failure logs
webhook token rejected: …and returns a quietfalse; a metadata/JWKS error clears the cached handle so a fetch hiccup is not cached forever.
tests/msteams.test.ts runs a real key pair against a local JWKS server and pins all
six outcomes: valid, missing header, non-JWT, wrong audience, wrong issuer, wrong serviceUrl.
2. onActivity: the user-message path
2.1 Route and directory bookkeeping (before any gate)
handleActivitydrops an activity with no conversation or sender, or one whosefrom.idequalsrecipient.id(our own echo).rememberConversationpersists the route under the conversation id:serviceUrl,conversationTypeandtenantId, plusteamGroupId,teamName,channelNameandconversationNamewhen Teams supplies them. The team's AAD group id appears only in an inbound channel activity'schannelData, and Graph needs it to read a thread later, so it is kept even when a later activity omits it. The bareserviceUrlis written under_meta. Both are written only when they changed, because this runs on every message. It then callsnotePerson, which records the sender in the people directory and attaches the conversation id only for apersonalchat: a group id would address the whole room, not the person.- Question lifetime belongs to core. The turn's
ask_resolvedevent callsasks.resolveAsk, removes the pending entry and settles a timed-out, aborted or superseded card. No local question sweep or second timeout runs on inbound messages.
2.2 Mention resolution
MsTeamsAdapter.resolveMentions(activity) removes the bot's own declared mention
and rewrites other declared mentions to readable @Name text. Undeclared <at>…</at> spans use the
same bot-name comparison before whitespace is folded. The real coverage is in tests/msteams.test.ts,
including bot and colleague mention cases.
2.3 Transcript recording
recordHistory is called above the gates, so background chatter from an unmapped sender still
becomes context for a later session (tests/msteams.test.ts). Bot-control commands are excluded through
TeamsCommands.isControlCommand, because they are addressed to the plugin, not said to the conversation. That
set is derived from the catalog the daemon published for this surface — everything it marks session-control
or surface-local — plus the adapter-state names this adapter implements itself (/display), which the
catalog declares but deliberately never publishes. A plugin prompt macro (execution: 'plugin-prompt') is
deliberately not in the set: it is a turn the conversation genuinely had. With no catalog at all the set is
empty, so a /command is recorded as ordinary text rather than silently swallowed.
recordHistory (lib/transcript.mjs) is a no-op while historyLimit is 0, which is the default — this is the one
place the plugin persists message text, so it is strictly opt-in
(tests/msteams.test.ts).
2.4 The gates
- Mention gate (in
handleActivity): apersonalconversation always responds. Anything else responds only ifrespondWithoutMention !== falseorisForMe(m).isForMematches a mention entity whosementioned.idequalsrecipient.id, and also any targeted message, which is how Teams delivers a slash command picked from the/menu. Without channel message consent, Teams delivers a team-channel post only when the bot is @mentioned, so this gate covers that case too. WithChannelMessage.Read.Groupconsent, unmentioned posts arrive andrespondWithoutMentiondecides. - UPN resolution (
resolveUpn): one Bot Connector roster call per account id, cached inupnCache. A resolved UPN is fed into the people directory — this is the only way an e-mail becomes knowable without a Graph permission. A failed lookup is logged and not cached, so the next message retries; meanwhile policy matching is narrowed to the id/GUID forms. - Identity (
senderIds,lib/ids.mjs): the sender'saadObjectId, their29:channel account id, the resolved UPN, the conversation id, and — for a team channel — the bare channel id with the;messageid=…thread suffix stripped, because the bare form is what an operator copies out of a Teams deep link. Pinned bytests/msteams.test.ts. - Role policy (
inboundAccessForandaccessFor): the first policy whoseroleIdmatches any of those ids wins (matchPolicy).matchesId(lib/ids.mjs) switches on whether either side contains an@: if so it compares lowercased, otherwise exactly. That covers UPN/e-mail as intended, but note it also makes a conversation id (19:…@thread.tacv2) case-insensitive; only GUIDs and29:ids compare strictly. No match →access: undefined→ the turn is dropped silently — no reply and no log line.tests/msteams.test.tspins the absence of a reply specifically; it is not the absence of all traffic, because step 2 above already made a roster call before this gate. A match is turned into the access descriptor by the sharedbuildRoleAccess(elowen-plugin-shared/access.mjs), folded with this conversation's saved model / reasoning / fast state. When account linking is on, a personal chat from an unmapped sender still gets the sign-in flow. A mapped sender in a shared room who has no linked account gets a sign-in card, offered at most once every 15 minutes per person and room, and the turn does not run.
Policy settings surface
TeamsWorkspace passes the complete manifest detail to the
typed host PluginConfigEditor, including i18n, and shares one PluginConfigDraft with PolicyEditor.
Only rolePolicies declares display.placement: 'pluginForm'; the generic editor omits that field and
its empty section while validation, stored data and revision checks remain intact. All other settings
are intentionally editable in both the plugin detail and the workspace.
PolicyEditor owns the complete ordered list and its form. Person actions select an existing row or
seed a new rule; they never maintain a second policy list. Structured saves, moves and confirmed
deletions call draft.commitValue('rolePolicies', next) and retain unrelated rows and unknown row
properties. Failed writes leave the form/confirmation available. The returned pending flag records
saved-but-not-yet-active changes. Shared AutoSaveStatus exposes retry and revision resolution.
For an explicit form write rejected with 409, keeping changes reloads the host's current snapshot and
revision while leaving the local form open. The next explicit save finds the original rule by its
stored identifier, verifies its complete contents are unchanged and replaces only that rule in the
current list. Remote additions, edits to other rules and reordering survive. If the original rule
changed or disappeared, the form retains its inputs but blocks saving until reopened. A new rule is
validated against the current list. No rejected full-list replacement is merged or autosaved.
For rejected moves or deletions, the People header offers reload only. A conflicted deletion also
blocks the confirmation button and offers the same reload inside the confirmation. Reload adopts
the current list and closes the stale confirmation; the operator selects and confirms the intended
action again against that list. Ordinary settings retain their host draft controls.
web-src/policyModel.ts calls the browser-safe lib/ids.mjs comparisons and
normalizeRolePolicies(value, sameId) from elowen-plugin-shared/access, also used by
lib/config.mjs at the runtime boundary. sameId owns Teams identity equality for matching,
normalization and PolicyEditor duplicate validation: identifiers containing @ ignore case,
while other identifiers compare exactly. The shared normalizer keeps the first equivalent rule
and puts the wildcard last. It distinguishes stored direct person rules from personal
identity/fallback projections; it never guesses conversation membership from an ID prefix.
Editable titled groups use shared SettingsGroup with initially closed, persistent folds. Custom
keys include plugin, surface and person where applicable. Numeric controls in the generic editor use
shared Input.unit, including the empty unit slot for counts; raw byte limits use B.
The browser bundle requires UI API 55. Account binding in TeamsWorkspace passes
{ method: 'PATCH', json: { userId, ... } } through apiJson to the host's
ElowenUiRuntime.api. bindAccountRequest selects only userId and, for a confirmed
replacement, replace: true; the host alone sets JSON headers and serializes the
body on its authenticated request path. Calls without json, such as account
detail reads and signout, retain their existing request semantics. JSON and raw
body are mutually exclusive in PluginApiRequestInit. No request is made until
the existing form action runs.
Identity timestamps and account replacement confirmations use the published
ElowenUiRuntime.utils.interpolate signature, for example
utils.interpolate(s.identityReplaceDescription, { username }). The helper runs
during rendering, replaces every supplied placeholder literally, leaves unknown
placeholders intact and does not expand replacement syntax or nested templates.
Missing timestamp values still use the existing timestamp formatter's placeholder.
The real consumers are IdentityCard and PolicyEditor.
2.5 Commands
A leading / goes to TeamsCommands.handleCommand first. Shared control commands from the daemon's catalog
(new, fast, stop, status, compact, restart and the session-control pickers) are delegated to
runControlCommand and runPickerCommand in elowen-plugin-shared/chatCommands. For compact, fast and
restart, the reply is rendered from the command chat event that core records in the room's conversation, and
a failure replies with its exact error text. help, model, reasoning and display are handled by lib/commands.mjs:
/model and /reasoning post Adaptive Card pickers and /display posts the per-axis picker. /help uses the
published catalog plus the adapter's own display declaration. An unrecognized command falls through; a
plugin prompt macro reaches the brain with its raw slash text, so PI expands it. Commands preserve argument
case, including project slugs.
2.6 Media
collectMedia skips text/html and text/plain echoes. Image downloads use the Connector's bounded binary
reader, constrained by maxImageBytes and maxImages, then become base64 for the brain. A failed or
oversized image adds a textual note and an operator log entry instead of dropping the turn. Other named
attachments become textual notes through attachmentNote.
2.7 The brain turn
runTurn owns the common live-event, question, typing, reaction and final-delivery sequence. The adapter
supplies the handler with a structural sender descriptor, the exact resolved platform identity, role/access,
channel generation, direct/shared classification, images and an async history callback. Core envelopes
shared-room attribution rather than this adapter prefixing the message. A prompt macro remains raw.
Display settings decide whether to open LiveMessage; questions still receive cards with the stream off.
Image turns apply the configured vision model. Reactions stay disabled for targeted messages, whose async
context also isolates text, progress and image audiences. A successful reply is recorded from the model's
text before the runtime footer. Failed turns and refused final delivery retain their localized reply and
operator log path.
2.8 History backfill
transcript.buildHistory is evaluated lazily for a new brain session. It emits oldest-first role-aware
messages, excludes the current activity and preserves author, attachment and timestamp data from Graph.
Core wraps these items as untrusted data. The recorded log bounds individual lines to 400 characters and
retains at most the configured 100 messages; the final history block is bounded to 6000 text characters.
With channelMessagesRsc consent and a known team/thread route, the transcript reads real channel posts,
including unmentioned posts. Missing consent/route or a Graph failure uses recorded traffic; an actual
empty Graph thread remains empty. A 403 (GraphRefusedError) means the team owner has not granted the
optional consent; it logs once per transcript instance as local-only msteams.history_failed. Any other
Graph failure logs and reports that code once per transcript instance. This is distinct from directory
Graph lookup and retains its existing request deadlines.
3. Outbound: replies, edits, mentions
3.1 Transport
Everything outbound goes through tmSend, tmEdit and tmDelete in lib/adapter.mjs, which resolve
the route with serviceUrlFor (the conversation's stored ref.serviceUrl, else the global
_meta.serviceUrl) and then call ConnectorClient:
| Adapter helper | Connector call | REST |
|---|---|---|
tmSend with replyToId |
ConnectorClient.reply |
POST /v3/conversations/{id}/activities/{replyToId} |
tmSend without |
ConnectorClient.send |
POST /v3/conversations/{id}/activities |
tmEdit |
ConnectorClient.update |
PUT /v3/conversations/{id}/activities/{activityId} |
tmDelete |
ConnectorClient.remove |
DELETE … |
tmSend with no known route logs msteams send: no stored route for conversation … and throws a typed local condition. Failed send/edit logs and throws the original classified cause; an edit returns true only after confirmation.
Every connector call attaches a client-credentials bearer for the
https://api.botframework.com/.default scope (SCOPE in lib/connector.mjs, TokenSource in lib/token.mjs) and retries
only on authoritative 429 under its finite canonical request owner, with at most
min(1,policy.maxRetries) retries and an unclipped canonical Retry-After minimum.
3.2 Mentions on the way out — withMentions
Teams only rings someone when the text carries an <at> span and the activity declares a matching
mention entity, so a mention cannot be produced by the model alone. withMentions assembles it against
the real roster:
- short-circuits when the body contains no
@at all; - rewrites the explicit
<@…>token (MENTION_TOKEN) when the inner value matches any roster key — id, Entra id, UPN, e-mail, name, or given+surname (memberKeys); - then rewrites bare
@Display Nameoccurrences, longest name first, so a colleague called "Alex" cannot claim the "@Alex Rivera" in the text; - a token matching nobody degrades to plain text — never a dead ping;
- names are HTML-escaped into the span (
escapeSpan) and a literal<at>written by the model was already neutralised to‹at›upstream (style.mentionSafeinlib/stream.mjs).
Pinned by tests/msteams.test.ts: both shapes ring, a stranger stays text, and no entity is declared when the answer names nobody.
The roster used here is cached for 5 minutes (ROSTER_TTL_MS; roster), because mention
resolution runs on every outbound edit of a live answer while membership changes rarely. A failed lookup
keeps the previous roster rather than dropping every mention. Every roster read
feeds the people directory (notePeople).
3.3 The live message
lib/stream.mjs is a thin Teams binding for the shared engine elowen-plugin-shared/liveMessage. It
supplies:
- the transport closures (
create/edit/remove/postImages/postFiles) that call the adapter'stm*helpers — which is also the seam the plugin tests mock; - a markdown style: bold tool names, struck-through failures, plain subtext (Teams has no small text for
bot messages), and
lineBreak: '\n\n'— Teams treats a single newline as a soft wrap, so without this the whole tool trace renders as one run-on paragraph. Pinned bytests/msteams.test.ts; postFinalTextsends the final text in transport-sized pieces, sequentially, with the trigger reply reference only on the first piece. It stops on a rejectedtmSendand preserves the original cause inFinalTextDeliveryErrorfromelowen-plugin-shared/errors. The shared turn runner then marks delivery failed and keeps typed instance conditions local. The live-message engine propagates final-text failures; shared images travel through authorizedimageevents. Successful delivery costs one send per split piece. The adapter's existingerrorTextboundary maps only this error toMESSAGES.deliveryFailedin the configured en/cs/sk service language; the turn runner logs the original English diagnostic before rendering the notice.
Sizing lives in lib/format.mjs: CHUNK = 20000 against Teams' ~28 KB payload cap. The runtime footer is
a non-breaking-space paragraph followed by *— model · context %* (FOOTER_FENCE) — not a
blockquote, which Teams draws as a full-width bordered strip, and not * which would read as a bullet.
Pinned character-for-character by tests/msteams.test.ts.
Images shared through ShareImage arrive as authorized image events. Their validated names
resolve only against the daemon's chat-images directory through platformImageDir, capped by
maxUploadImages. They go out as inline data-URI attachments with one content type used for both
the attachment and the URI; mismatched types produce a picture Teams cannot render.
A failed Connector send logs msteams.file_upload_failed and rejects with the
original error. The image callback returns that rejection to the canonical shared
finalizer; it is not a successful attachment and does not authorize replay.
Targeted recipient/entities, private Connector routing, MIME and caption remain
unchanged. notify awaits the same image path before its final result text.
3.4 Files the agent shares: ShareFile → the file consent card
Direct TeamsSendFile offers and MicrosoftFiles uploads read through
ctx.host.projectFiles().read(path, { maxBytes }). Core uses the same source boundary as ShareFile and
ShareImage: host paths pass the caller's path guard, while managed paths are read by the live Sandbox
provider under the current conversation's account and Project. There is no host fallback for
a managed read. Relative Microsoft upload sources resolve against the current work
directory; host uploads pass the caller's path guard, including its symlink check.
The reader size-checks before loading bytes and returns a version-checked buffer for
managed files. TeamsSendFile uses the adapter's 20 MB cap, then
offerFile keeps the same recipient addressing, personal-chat check and consent flow.
Image events already contain a reference to bytes copied by core's ShareImage
into the daemon attachment store. The stream resolves that stored reference rather
than reading a guest path, so no separate container reader belongs in the adapter.
A general file cannot ride a Bot Connector message the way an image can — Teams takes one only through its
file consent handshake — so the engine's file half is a different Teams protocol, not a second copy of
the image path. lib/stream.mjs declares the hasFiles/postFiles pair in its transport, and the engine
calls them at the end of a turn:
- The engine collects a
filebrain event's{ ref, name, size }(liveMessage.mjs,fileRefs) and callsadapter.resolveSharedFiles(refs), which delegates toTeamsFiles.resolveSharedFiles. That reads<config dir>/chat-files/<sha256>.binoff the daemon'schat-filesdirectory (platformChatFilesDir(dataDir), passed fromregisterinindex.mjs), capped at four per turn, the same number core'sShareFileallows. adapter.offerSharedFilesdelegates toTeamsFiles.offerSharedFiles, which calls the existingofferFile.offerFileposts thefile.consentcard and parks the bytes inpendingFilesfor the acceptance. Nothing about the card, the 20 MBMAX_FILE_BYTEScap, the 15-minuteFILE_TTL_MSor the upload is duplicated — the same method theTeamsSendFiletool drives.- The engine posts attachments before the answer text, so the card arrives above the reply and the reply stays the conversation's last message.
Personal scope only, and the check lives in TeamsFiles.offerSharedFiles, read from
state.get(id).ref.conversationType — never from a failed upload: Microsoft's file consent APIs do not
work in channels or group chats, where an offer would be a card nobody could complete. In a shared room
the call returns immediately, so the answer text lands with no card and no error. hasFiles deliberately does not carry that decision — it sees
no conversation.
The declared 20 MB upload cap remains a local refusal: fileTooLarge is a
best-effort chat notice. Core applies the same ceiling on its own side, because the adapter declares it as
sharedFileMaxBytes and core transfers a Project file only up to the smaller of its harness limit and that
declared value, so ShareFile never copies a larger file for this platform. Eligible later files
are still offered even when that notice cannot be sent. Unsupported or unknown
conversation scopes still return without a consent card or an upload failure.
An actual offerFile rejection instead logs msteams.file_offer_failed,
propagates the SAME original error and stops the batch at that first unconfirmed
mutation. Earlier confirmed offers remain in pendingFiles; the failed offer
and later files are not added or replayed. The callback rejects to the canonical
shared finalizer/turn owner, without a second local fileFailed chat notice.
The later consent-acceptance upload and its existing failed-card settlement
still use fileFailed; that separate callback and handshake are unchanged.
One limit is NOT surfaced, and cannot be from here: a stored file the shared resolver cannot read is
dropped inside the shared resolveSharedFiles in elowen-plugin-shared/images — the ref fails its shape check, the blob is missing,
or the read throws — and the engine then calls no transport at all, so the
plugin is never handed the conversation id and has nothing to answer into. Re-deriving which ref failed
in the plugin would mean a second copy of the resolver's per-ref rules, and the same silence exists on
Discord, Telegram and WhatsApp for that reason. A file event's ref is always a stored
/brain/chat-files/<sha256>.bin written before the tool returned, so this needs a broken store or disk
rather than ordinary use.
Cards park their bytes in process memory, so a daemon restart loses an unaccepted offer — the card
stays, and clicking it answers "that file offer is no longer available" (fileExpired, onFileConsent).
3.5 Targeted turns and AI labels
Teams can deliver a message as targeted (recipient.isTargeted), which is how a slash command picked from
the / menu arrives. Such a message is visible only to its sender, so the bot answers it privately as well.
onActivity runs the whole turn inside targetedTurn, an AsyncLocalStorage that holds the sender as the only
recipient. Every outbound piece of that turn — progress, answer, images and shared files — is posted with
isTargetedActivity=true, so only that person sees it. Reactions are not added to a targeted message, so no
public marker appears. Two turns in the same channel cannot swap recipients, because the context belongs to each
async turn and not to the conversation.
Text the model writes is labelled as AI (AI_GENERATED_ENTITY and a feedback loop), so Teams draws its own
"AI generated" marker and the thumbs up/down pair. Plugin-written text such as errors, command replies and pickers
is not labelled.
4. Cards: AskUserQuestion and pickers
An Adaptive Card Action.Submit comes back as a message activity with a value object, which is
why handleWebhook branches on activity.value and onCardAction dispatches on the
discriminator: value.ea = an ask, value.ep = a picker. The compact payload shapes are
documented in the header comment of lib/cards.mjs.
Ask flow
asks.postAskpostsbuildAskCardunder a monotonic token and stores{ id, conversationId, activityId, questions, askerId, selected, other }inasks.pendingAsks.asks.onAskActionignores unknown tokens. The original sender is recorded withownerKey(from)and checked withisOwner, so Entra and Teams ids agree on the message and card paths. Only that sender or an operator may answer; another person receives the existing refusal message.- A single single-select question submits on tap. Other cards toggle their marks and explicitly submit.
Each question with custom input enabled receives its own native
Input.Text; an empty custom value clears only that question's custom text, andcustom: falseignores supplied custom text. asks.settleAskuses the shared ordered answer projection. An incomplete answer re-renders the first missing question without invoking core. If core rejects a complete answer, the pending question remains; only an accepted answer deletes it and edits the card to a summary.ask_resolvedis the only expiry/completion signal. It deletes pending state and edits unresolved cards to the localized expired message. Anansweredevent does not edit again: the answering surface already settled its card. Core owns the sole deadline, not an adapter timer or inbound-message sweep.
Card size is bounded by design: option labels are clamped to 60 characters, at most 12 options per
question are rendered, and pickers page 8 at a time (ASK_MAX_CHOICES, PICKER_PAGE_SIZE, buildPickerCard
in lib/cards.mjs).
Picker flow
/model, /reasoning, /display, /context and /project post buildPickerCard and store one pending
entry per conversation in commands.pendingPickers; a new picker replaces the previous one.
commands.onPickerAction ignores mismatched kinds and re-renders page turns. Local pickers changing shared
conversation state require the card owner or an operator. Session-control pickers act as the clicker;
/context checks its operator gate before consuming the card, while /project remains account-scoped
and available to linked members. All host calls retain core's authorization checks.
Model selection stores the chosen provider/model pair. Reasoning choices are offered from the active descriptor and rechecked on submission, including the empty
Teams reset value. Every shared display axis offers default, which removes that per-chat override.
5. The proactive / outbound-first path
5.1 Host send
The PlatformAdapter.send(channelId, text) seam strips the #gen suffix and posts the split text to the
stored ref, labelled as AI text. The text is recorded in the transcript as the bot's own line when at least one piece lands.
This is bound-session output, not a reply.
5.2 notify in lib/adapter.mjs: cron/tick pushes
An automation consumer such as meeting-confirm uses the existing notification seam:
await ctx.notify(text, 'destination:msteams:' + encodeURIComponent(ownerEmail));
The adapter's notify delegates to TeamsProactive.notify in lib/proactive.mjs. That decodes the explicit
destination envelope and uses the same directory, Graph lookup, personal-conversation creation and final-text
sender as ordinary notifications. An empty recipient,
missing service URL, unresolved or ambiguous person, or failed conversation creation rejects.
Final-text Connector failure also rejects through the existing postFinalText path. A consumer clears
its pending text-notice flag only after the promise resolves and retries after rejection; this is
Connector acceptance, not a read receipt. No new queue or retry loop lives in Teams. Text splitting can
produce several sends, so retrying a partially delivered notice may repeat its earlier parts.
Images use the same awaited sendImages callback: rejection preserves the original
error and stops notify before final result text, rather than resolving after a
best-effort image loss. This adds no notification retry or mutation replay.
Targets without a destination envelope retain the following warning-and-drop routing behavior:
- Target = the explicit
channelId(suffix stripped) elsecfg.notifyConversationId. - No target → a warning naming both
notifyConversationIdand the job'snotifyChannelId, and the push is dropped. Silence would drop a result the scheduler had already paid a real turn for, with nothing to say so;tests/msteams.test.tspins the warning. - No
serviceUrlknown yet → warn and return; proactive sends ride the last seen service host, so the bot must have received at least one activity. Pinned bytests/msteams.test.ts. - If the target is not a known conversation,
notifyConversationForresolves it: an ambiguous directory hit is dropped with a warning rather than guessed (tests/msteams.test.ts); a unique person gets their 1:1 chat opened; an unseen e-mail goes to Graph only if the switch is on; anything else is treated as a raw Teams user id and handed to Teams, which is what anotifyChannelIdholding an Entra object id has always meant. - The text is translated through
lifecycleTextbefore splitting, because a translation has its own length.
5.3 messagePerson — the TeamsMessagePerson path
messagePerson in lib/proactive.mjs refuses an empty message up front. It then resolves the person,
asks the recipient's own agent to answer when one can, and sends directly only as a fallback. Nothing is sent
when the person cannot be resolved or reached.
- Resolve.
findPersontries the people directory first (PeopleDirectory.resolveinlib/directory.mjs).email,aadObjectId,userIdandnamerestrict matching to that field, and a free-formquerytries identifiers first and the display name last. An exact (case-insensitive) name wins outright; otherwise every substring hit is a candidate, and more than one candidate is refused with the list, never guessed. If the directory has no match,learnFromKnownRostersreads the rosters of up to 25 group chats and channels the bot has already seen, and remembers every person it sees on the way. - Graph (layer 2). Consulted only for an e-mail, and only when
graphLookupis on. It resolves the tenant user, installs the app when a catalog id is configured, and writes the person into the directory (findPersonViaGraph). - Neither.
unknownPersonHelpgives a three-option instruction (have them message the bot, add the bot to a chat they are in and read it withTeamsMembers, or switch on Graph), not a bare error. - Chat.
conversationForPersonreturns the rememberedconvwhen there is one, else opens a 1:1 chat viaopenPersonalConversation(POST /v3/conversationswithbot: { id: '28:<appId>' }, the single member,isGroup: false, and the tenant on bothchannelData.tenant.idand the top-leveltenantId— the connector's own form). Teams returns the same conversation for the same bot/user pair, so it is idempotent; the id is still remembered both in the directory and as a conversationref, so the second message costs no extra call. Pinned bytests/msteams.test.ts. - Relay first. When the recipient is admitted by a role policy for that 1:1 chat and has a verified Entra
binding to an Elowen account,
relayToPersonhands the text to that account's durable channel session through the hostrelaycontrol. The recipient's agent writes the one reply the person should see, and it is recorded in history (deliveryagent). The relayed turn runs withTeamsSend,TeamsMessagePerson,TeamsSendFileandTeamsApiand every Microsoft 365 tool (MICROSOFT_TOOLS) denied, and the original text is framed as untrusted JSON. A relay to the sender's own account is not attempted, because re-entering a turn that holds the same channel lock would deadlock. - Direct fallback. When the relay is not available, the recipient is unmapped, or the agent produced no
reply, the text is split and sent to the 1:1 chat (delivery
direct). If Teams accepts none of the parts, the call throws. If it accepts some, the result reportsdirect-partialwith the counts. If the relay had already delivered some parts, no direct send follows, and the result reportspartial, so the person does not get two answers.
6. Persisted state
6.1 The StateStore
register in index.mjs creates it at <plugin data dir>/channel-state.json — the per-plugin data dir the host
hands over via ctx.dataDir(), rooted at the daemon's plugins-data/. The entry imports
StateStore directly from elowen-plugin-shared/stateStore: the whole file is
read once and cached in memory. Every patch() returns a promise that resolves after its serialized,
coalesced batch reaches atomic rename. Synchronous reads expose only the committed cache. A failed batch
logs and rejects without changing that cache. Conversation references, picker settings, directory
remember() and transcript recordHistory() all propagate their write completion through inbound,
roster and proactive-message callers. The turn's afterReply returns the history promise, so completion
waits for it. Pickers confirm only after the write resolves, and dependent enrichment or history reads
await the preceding call. Directory and transcript persistence retain their existing best-effort policy.
6.2 Keys
<conversationId> — one entry per Teams conversation, written by rememberConversation and the
command handlers:
| Field | Written by | Meaning |
|---|---|---|
ref |
rememberConversation, openPersonalConversation |
{ serviceUrl, conversationType, tenantId }, plus teamGroupId, teamName, channelName and conversationName when Teams supplies them — the route to reach this conversation later. |
gen |
elowen-plugin-shared/chatCommands (/new) |
Conversation generation; folded into the channel key as id#gen. |
model |
the model picker (onPickerAction) |
{ provider, model } chosen with /model. |
thinkingLevel |
the reasoning picker (onPickerAction) |
Reasoning effort chosen with /reasoning; undefined = model default. |
display |
the display picker (onPickerAction) |
Per-chat /display overrides; an axis set to default is deleted from the object. |
log |
TeamsTranscript.recordHistory |
The rolling transcript — { n: name, t: text, r: role, a: activityId }, names clamped to 80 chars, lines to 400, list trimmed to the current historyLimit so lowering the setting takes effect at once. Only ever written while historyLimit > 0. |
Shared control commands such as /fast run through the shared core, which receives this state store; this plugin
does not write their keys itself.
_meta — { serviceUrl }, the last service host seen on any activity. This is the fallback
route for a conversation with no stored ref, and the base for callApi.
_people — the people directory, under the reserved key _people (DIR_KEY in lib/directory.mjs; a
conversation id is always a:…/19:…, so it cannot collide). One record per person, keyed by lowercased
Entra object id, falling back to the lowercased channel account id — never a display name
(personKey):
{ aad, id, name, upn, conv, url, at }
conv is the person's 1:1 conversation id (only ever set from a personal conversation), url their
service host, at the last write. remember() merges over what was stored and writes only when
something actually changed, because it runs on every inbound message. The
directory is capped at 500 people with oldest-first eviction (MAX_PEOPLE, evictions), and a
persistence failure is warned and swallowed — it is an optimisation over what the next roster read can
re-learn, never a reason to fail a turn the user is waiting on. It stores
identity fields only; no message text.
6.3 Lifetime
The state file is durable: it survives daemon restarts, plugin reloads and redeploys. Nothing prunes
per-conversation entries — the only bounded structure is the people directory's 500-record cap. /new
bumps gen but does not clear log, which is intentional: the whole point of the transcript is to
seed the fresh session.
Everything else is in-memory and lost when the adapter instance is torn down, including a daemon restart:
| Structure | Where | Lifetime |
|---|---|---|
upnCache |
the adapter constructor | Per account id, unbounded, no TTL. |
rosterCache |
the adapter constructor | Per conversation, 5 min TTL, stale entries reused on a failed refresh. |
pendingAsks |
lib/asks.mjs |
Until accepted or ask_resolved, or the process ends. |
pendingPickers |
lib/commands.mjs |
One per conversation; replaced by the next picker. |
pendingFiles |
lib/files.mjs |
Until accepted, declined, swept after 15 minutes, or the process ends. |
sharedSignInOffers |
the adapter constructor | One sign-in card per person and room, for 15 minutes. |
| Connector bearer | TokenSource in lib/token.mjs |
Refreshed ~60 s before expiry; concurrent callers share one in-flight refresh. |
| Graph bearer | GraphClient constructor in lib/graph.mjs (its own TokenSource) |
A separate TokenSource — different audience, so one cached token cannot serve both. |
| JWKS handle | makeTokenVerifier in lib/auth.mjs |
Cached until a metadata/JWKS error clears it. |
A restart therefore loses any card that was still open (its buttons stop responding — the token is
unknown, so onAskAction finds no pending entry and returns) and re-warms the token and roster caches on demand, but
loses no conversation settings, no route and no person.
attachmentNote(name, type) from elowen-plugin-shared/voice returns the attachment or untranscribed-audio marker for caller-normalized inputs without downloads, storage, localization or inference. Minimal use is attachmentNote(clip.name, clip.type). Callers retain MIME normalization and defaults; no attachment means no call. Teams uses it for non-image attachments in collectMedia.