Navigace
ELOWEN · Dokumentace

Poslední aktualizace: 20. října 2026

Procházet dokumentaci · Microsoft 365 architecture

Tento článek zatím není přeložen. Je dostupný v angličtině.

Developer reference

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. With historyLimit: 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. TeamsSendFile imports its 20 MiB MAX_FILE_BYTES for 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 in lib/cards.mjs. Answer order/completeness uses collectQuestionAnswers from elowen-plugin-shared/ask; Teams receives structured card submissions, so it has no numbered text reply parser.
  • lib/config.mjs: Teams config normalization consumes shared DISPLAY_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 shared DISPLAY_AXES and resolveActiveModel, modelReasoningLevels and supportsReasoningChoice from elowen-plugin-shared/modelIdentity. On a reasoning submission it rechecks the live model before consuming the card. Teams' empty reset payload maps to shared default only 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:

  1. Non-POST → 405. Pinned by tests/msteams.test.ts.

  2. Unparseable body → 400.

  3. verifyToken(req.headers.authorization, activity) fails → 401, and the brain is never reached (tests/msteams.test.ts).

  4. Adapter stopped, or no listen() handler wired → 200 with an empty body. stopped is set by disconnect(), 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.

  5. type === 'message':

    • if activity.value is an object → onCardAction (an Adaptive Card Action.Submit round-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.ts pins the 200 and, after a 20 ms wait, that the turn ran — it does not assert the ordering itself.

  6. type === 'conversationUpdate' → rememberConversation only. This is what an app install arrives as, and it carries the personal conversation id before the person has said anything.

  7. 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/submitAction with 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/verifyState in a personal chat: acknowledged at once; the account binding completes detached, through the Connector.
  8. 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.com and 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 serviceUrl claim against the activity's serviceUrl, trailing slashes stripped, and rejects a mismatch;
  • on failure logs webhook token rejected: … and returns a quiet false; 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)

  • handleActivity drops an activity with no conversation or sender, or one whose from.id equals recipient.id (our own echo).
  • rememberConversation persists the route under the conversation id: serviceUrl, conversationType and tenantId, plus teamGroupId, teamName, channelName and conversationName when Teams supplies them. The team's AAD group id appears only in an inbound channel activity's channelData, and Graph needs it to read a thread later, so it is kept even when a later activity omits it. The bare serviceUrl is written under _meta. Both are written only when they changed, because this runs on every message. It then calls notePerson, which records the sender in the people directory and attaches the conversation id only for a personal chat: a group id would address the whole room, not the person.
  • Question lifetime belongs to core. The turn's ask_resolved event calls asks.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

  1. Mention gate (in handleActivity): a personal conversation always responds. Anything else responds only if respondWithoutMention !== false or isForMe(m). isForMe matches a mention entity whose mentioned.id equals recipient.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. With ChannelMessage.Read.Group consent, unmentioned posts arrive and respondWithoutMention decides.
  2. UPN resolution (resolveUpn): one Bot Connector roster call per account id, cached in upnCache. 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.
  3. Identity (senderIds, lib/ids.mjs): the sender's aadObjectId, their 29: 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 by tests/msteams.test.ts.
  4. Role policy (inboundAccessFor and accessFor): the first policy whose roleId matches 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 and 29: ids compare strictly. No match → access: undefined → the turn is dropped silently — no reply and no log line. tests/msteams.test.ts pins 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 shared buildRoleAccess (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 Name occurrences, 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.mentionSafe in lib/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's tm* 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 by tests/msteams.test.ts;
  • postFinalText sends the final text in transport-sized pieces, sequentially, with the trigger reply reference only on the first piece. It stops on a rejected tmSend and preserves the original cause in FinalTextDeliveryError from elowen-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 authorized image events. Successful delivery costs one send per split piece. The adapter's existing errorText boundary maps only this error to MESSAGES.deliveryFailed in 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.

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:

  1. The engine collects a file brain event's { ref, name, size } (liveMessage.mjs, fileRefs) and calls adapter.resolveSharedFiles(refs), which delegates to TeamsFiles.resolveSharedFiles. That reads <config dir>/chat-files/<sha256>.bin off the daemon's chat-files directory (platformChatFilesDir(dataDir), passed from register in index.mjs), capped at four per turn, the same number core's ShareFile allows.
  2. adapter.offerSharedFiles delegates to TeamsFiles.offerSharedFiles, which calls the existing offerFile. offerFile posts the file.consent card and parks the bytes in pendingFiles for the acceptance. Nothing about the card, the 20 MB MAX_FILE_BYTES cap, the 15-minute FILE_TTL_MS or the upload is duplicated — the same method the TeamsSendFile tool drives.
  3. 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

  1. asks.postAsk posts buildAskCard under a monotonic token and stores { id, conversationId, activityId, questions, askerId, selected, other } in asks.pendingAsks.
  2. asks.onAskAction ignores unknown tokens. The original sender is recorded with ownerKey(from) and checked with isOwner, 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.
  3. 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, and custom: false ignores supplied custom text.
  4. asks.settleAsk uses 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.
  5. ask_resolved is the only expiry/completion signal. It deletes pending state and edits unresolved cards to the localized expired message. An answered event 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:

  1. Target = the explicit channelId (suffix stripped) else cfg.notifyConversationId.
  2. No target → a warning naming both notifyConversationId and the job's notifyChannelId, 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.ts pins the warning.
  3. No serviceUrl known yet → warn and return; proactive sends ride the last seen service host, so the bot must have received at least one activity. Pinned by tests/msteams.test.ts.
  4. If the target is not a known conversation, notifyConversationFor resolves 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 a notifyChannelId holding an Entra object id has always meant.
  5. The text is translated through lifecycleText before 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.

  1. Resolve. findPerson tries the people directory first (PeopleDirectory.resolve in lib/directory.mjs). email, aadObjectId, userId and name restrict matching to that field, and a free-form query tries 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, learnFromKnownRosters reads the rosters of up to 25 group chats and channels the bot has already seen, and remembers every person it sees on the way.
  2. Graph (layer 2). Consulted only for an e-mail, and only when graphLookup is on. It resolves the tenant user, installs the app when a catalog id is configured, and writes the person into the directory (findPersonViaGraph).
  3. Neither. unknownPersonHelp gives a three-option instruction (have them message the bot, add the bot to a chat they are in and read it with TeamsMembers, or switch on Graph), not a bare error.
  4. Chat. conversationForPerson returns the remembered conv when there is one, else opens a 1:1 chat via openPersonalConversation (POST /v3/conversations with bot: { id: '28:<appId>' }, the single member, isGroup: false, and the tenant on both channelData.tenant.id and the top-level tenantId — 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 conversation ref, so the second message costs no extra call. Pinned by tests/msteams.test.ts.
  5. 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, relayToPerson hands the text to that account's durable channel session through the host relay control. The recipient's agent writes the one reply the person should see, and it is recorded in history (delivery agent). The relayed turn runs with TeamsSend, TeamsMessagePerson, TeamsSendFile and TeamsApi and 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.
  6. 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 reports direct-partial with the counts. If the relay had already delivered some parts, no direct send follows, and the result reports partial, 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.