NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · Microsoft 365 architecture
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 README.md.

Line references below are pointers into the named file, not exact anchors against a specific commit; the pin these notes once carried no longer exists in history. If a reference no longer resolves, search the file instead of trusting the line number.


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.

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. No public manifest field, config key or route changes.

  • 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, relay 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. No local projection or compatibility re-export remains.
  • 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: try Entra object id first, then the Teams account id, passing the verified roster email to the host resolver. A linked candidate returns its account and exact platform id. With no link, retain the raw Entra/Teams id and a null account. 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. The unused activity-local token context is gone; Microsoft tools continue to 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 select code-only for external prose or local-only for operator configuration and expected refusals.

MsTeamsAdapter forwards its constructor's listModels and lazy chatCommands callbacks directly to TeamsCommands, and answerQuestion directly to TeamsAsks. Only those protocol owners retain the callbacks; the adapter does not publish duplicate callback fields. Picker commands resolve models when invoked and ask submissions deliver complete answers through the existing owner. The constructor inputs, default callbacks, host entry points and their existing limits are unchanged.

Microsoft 365 product owners

lib/microsoftTools.mjs remains the registration entry called by index.mjs. It registers the same eight tools in the same order 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. Neither the split nor registration adds a lifecycle hook or another state store.

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 new timer or sweep. Graph requests, output bytes, tool names/descriptions/schemas, permissions, manifest fields, routes and config keys retain their existing contracts.

1. Inbound: webhook → activity handling

1.1 The mount

index.mjs:222 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:27), so the handler owns its own authentication — that is the seam contract stated at src/plugins/api.ts:283-290.

1.2 handleWebhook — lib/adapter.mjs:172

In order:

  1. Non-POST → 405 (:134). Pinned by tests/plugins/msteamsPlugin.test.ts:419.

  2. Unparseable body → 400 (:136).

  3. verifyToken(req.headers.authorization, activity) fails → 401 (:137), and the brain is never reached (tests/plugins/msteamsPlugin.test.ts:412).

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

  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 (:142-146). 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/plugins/msteamsPlugin.test.ts:405 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 (:148). This is what an app install arrives as, and it carries the personal conversation id before the person has said anything (lib/adapter.mjs:174-177).

  7. Anything else → 200, ignored.

1.3 Token verification — lib/auth.mjs:15

makeTokenVerifier is built once per adapter (lib/adapter.mjs:98) and returns the verify closure (lib/auth.mjs:32). It:

  • lazily fetches the OpenID metadata document (default https://login.botframework.com/v1/.well-known/openidconfiguration, lib/auth.mjs:10) and builds a remote JWKS handle (:25);
  • verifies with audience = cfg.appId, issuer = https://api.botframework.com (:11) and a 300 s clock tolerance (:37-41) — note the issuer is the connector service, not the tenant, even for a single-tenant bot (:6-7);
  • cross-checks the token's serviceUrl claim against the activity's serviceUrl, trailing slashes stripped, and rejects a mismatch (:44-46);
  • 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 (:49-51).

tests/plugins/msteamsPlugin.test.ts:370 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 (lib/adapter.mjs:372)

2.1 Route and directory bookkeeping (before any gate)

  • :375 drops an activity with no conversation, or one whose from.id equals recipient.id (our own echo).
  • rememberConversation (:158) persists { serviceUrl, conversationType, tenantId, botId } under the conversation id, and the bare serviceUrl under _meta, only when they changed — this runs on every message and every patch rewrites the state file. It then calls notePerson (:178), which records the sender in the people directory, attaching the conversation id only for a personal chat (:188) — 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 — resolveMentions (:314)

Declared mention entities are rewritten in the text: our own mention disappears, everybody else's <at>Name</at> becomes a readable @Name, so the model can see the message was aimed at a colleague. Undeclared <at>…</at> spans get the same treatment by regex fallback (:324-327), matching the bot's own name case-insensitively. stripMention (:306) is the blunt version used where no entity list exists. Pinned by tests/plugins/msteamsPlugin.test.ts:429.

2.3 Transcript recording (:382-386)

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 via botControlCommandsFrom(this.chatCommands(), ADAPTER_STATE_COMMANDS), 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. The hand-written CONTROL_ONLY list it replaced was a second registry of the same classification, and the one that would have gone stale the first time core added a command. 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 (:236) 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/plugins/msteamsPlugin.test.ts:487).

2.4 The gates

  1. Mention gate (:390-391): a personal conversation always responds. Anything else responds only if respondWithoutMention !== false or isForMe(m) (:295, which matches a mention entity whose mentioned.id equals recipient.id). A team-channel post reaches the bot only when @mentioned anyway, so the same gate covers it.
  2. UPN resolution (:393 → resolveUpn, :355): one Bot Connector roster call per account id, cached in upnCache. A resolved UPN is fed into the people directory (:364) — this is the only way an e-mail becomes knowable without a Graph permission. A failure caches undefined and just narrows policy matching to the id/GUID forms.
  3. Identity (:394 → senderIds, lib/ids.mjs:18): 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 (lib/ids.mjs:29-30), because the bare form is what an operator copies out of a Teams deep link. Pinned by tests/plugins/msteamsPlugin.test.ts:158.
  4. Role policy (:395 → accessFor, :338): the first policy whose roleId matches any of those ids wins. matchesId (lib/ids.mjs:8-14) 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/plugins/msteamsPlugin.test.ts:193 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.

Policy settings surface

The browser bundle requires UI API 55. 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. The additive shared export requires core 0.29.81 and retains shared API 8.

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, and no plugin serializer or older-host fallback is provided.

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. There is no plugin-local interpolator and no network cost; the real consumers are IdentityCard and PolicyEditor.

2.5 Commands (:401-404)

A leading / goes to handleCommand (:937) first. Shared control commands (new, fast, stop, status, compact, restart) are delegated to runControlCommand, which renders the reply from the command chat event core records in the room's conversation for compact, fast and restart (a failure replies with its exact error text; shared API 6); help, model, reasoning, display are handled by lib/commands.mjs; session-control pickers are rendered locally from shared command descriptors. /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.

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. Graph failures log 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 / tmDelete (:567, :591, :606), which resolve the route with serviceUrlFor (:213: the conversation's stored ref.serviceUrl, else the global _meta.serviceUrl) and then call ConnectorClient:

Adapter helperConnector callREST
tmSend with replyToIdreply (lib/connector.mjs:93)POST /v3/conversations/{id}/activities/{replyToId}
tmSend withoutsend (lib/connector.mjs:99)POST /v3/conversations/{id}/activities
tmEditupdate (lib/connector.mjs:105)PUT /v3/conversations/{id}/activities/{activityId}
tmDeleteremove (lib/connector.mjs:109)DELETE …

tmSend with no known route logs msteams send: no stored route for conversation … and returns null rather than throwing (:569). A failed send logs and returns null (:577-580); a failed edit logs and returns false (:600-603).

Every connector call attaches a client-credentials bearer for the https://api.botframework.com/.default scope (lib/connector.mjs:6, lib/token.mjs:29) and retries once on a 429, waiting Retry-After capped at 15 s (lib/connector.mjs:36-40).

3.2 Mentions on the way out — withMentions (:535)

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 (:537);
  • rewrites the explicit <@…> token (MENTION_TOKEN, :54) when the inner value matches any roster key — id, Entra id, UPN, e-mail, name, or given+surname (memberKeys, :57);
  • then rewrites bare @Display Name occurrences, longest name first (:553-557), 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 (:551);
  • names are HTML-escaped into the span (escapeSpan, :65) and a literal <at> written by the model was already neutralised to ‹at› upstream (lib/stream.mjs:41).

Pinned by tests/plugins/msteamsPlugin.test.ts:443 (both shapes ring, a stranger stays text) and :457 (no entity is declared when the answer names nobody).

The roster used here is cached for 5 minutes (ROSTER_TTL_MS, :50; roster, :497), 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 (:501, :508-510). Every roster read feeds the people directory (notePeople, :195).

3.3 The live message

lib/stream.mjs is a thin Teams binding for the shared engine elowen-plugin-shared/liveMessage.mjs. It supplies:

  • the transport closures (create/edit/remove/postImages/postFiles) that call the adapter's tm* helpers (lib/stream.mjs:30-52) — 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' (lib/stream.mjs:62) — 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/plugins/msteamsPlugin.test.ts:556;
  • postFinalText sends the final text in transport-sized pieces, sequentially, with the trigger reply reference only on the first piece. It throws if tmSend returns its existing null failure signal and stops before sending further pieces. The shared turn runner can then mark delivery failed rather than done; the host's live-message engine must also propagate final-text failures. Shared images are handled separately through authorized image events. The binding does not register anything or change the low-level tmSend contract; successful final delivery costs one send per split piece. A null result raises FinalTextDeliveryError (from elowen-plugin-shared/errors, thrown in lib/stream.mjs:16). 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 (:7) against Teams' ~28 KB payload cap, and the runtime footer is a non-breaking-space paragraph followed by *— model · context %* (:24-27) — 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/plugins/msteamsPlugin.test.ts:467.

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 upload logs an error and does not prevent the text reply.

Direct TeamsSendFile offers and MicrosoftFiles uploads read through ctx.host.projectFiles().read(path, { maxBytes }), available since core 0.29.81. 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 retain the existing project-root lexical and symlink check. The reader size-checks before loading bytes and returns a version-checked buffer for managed files. TeamsSendFile uses the adapter's existing 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:51-52 declares the hasFiles/postFiles pair the engine calls 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) — the counterpart of resolveImageFiles, reading <config dir>/chat-files/<sha256>.bin off the daemon's chat-files dir (lib/adapter.mjs:1028, platformChatFilesDir(dataDir) passed from index.mjs:220, capped at four per turn, the same number core's ShareFile allows).
  2. adapter.offerSharedFiles (:1037) delegates to the existing offerFile (:517), which 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 TeamsSendFile (lib/tools.mjs:204, calling offerFile at :250) already drives in production.
  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 the adapter (:1043), 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 behaviour is exactly what it was before this pair existed: no card, no error, and the answer text unaffected. hasFiles deliberately does not carry that decision — it sees no conversation.

Two limits are surfaced to the person rather than swallowed, because a shared file makes them visible: a file over the 20 MB upload cap is answered in the chat with fileTooLarge (core's ShareFile allows 25 MB, lib/adapter.mjs:1051), and a refused offer is logged as msteams shared file offer failed for … and answered with fileFailed (:1059) instead of throwing out of the turn.

One limit is NOT surfaced, and cannot be from here: a stored file the shared resolver cannot read is dropped inside elowen-plugin-shared/images.mjs:87 — the ref fails its shape check, the blob is missing, or the read throws — and the engine then calls no transport at all (liveMessage.mjs:582-585), 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).


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 (:142) and onCardAction (:850) dispatches on the discriminator: value.ea = an ask, value.ep = a picker (:856-857). The compact payload shapes are documented at lib/cards.mjs:4-6.

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 (lib/cards.mjs:10-11, :39, :57-73).

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 without changing an obsolete plugin Fast flag. 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 (:634)

The PlatformAdapter.send(channelId, text) seam strips the #gen suffix and posts the split text to the stored ref. 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 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. Image delivery remains best-effort through the existing attachment path.

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 (:770-773). Silence was the old behaviour and it dropped a result the scheduler had already paid a real turn for; tests/plugins/msteamsPlugin.test.ts:613 pins the warning.
  3. No serviceUrl known yet → warn and return (:775); proactive sends ride the last seen service host, so the bot must have received at least one activity. Pinned by tests/plugins/msteamsPlugin.test.ts:327.
  4. If the target is not a known conversation, notifyConversationFor (:791) resolves it: an ambiguous directory hit is dropped with a warning rather than guessed (:794-797, tests/plugins/msteamsPlugin.test.ts:797); a unique person gets their 1:1 chat opened; an unseen e-mail goes to Graph only if the switch is on (:800); 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 (:801-809).
  5. The text is translated through lifecycleText before splitting (:783), because a translation has its own length.

5.3 messagePerson (:745) — the TeamsMessagePerson path

An empty message is refused up front (:747). Then findPerson (:694) → conversationForPerson (:668) → send, and nothing is sent when either step fails:

  • Layer 1 is PeopleDirectory.resolve (lib/directory.mjs:111): email/aadObjectId/userId/ name restrict matching to that field; 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 (lib/directory.mjs:130-136, lib/adapter.mjs:697-699). Pinned by tests/plugins/msteamsPlugin.test.ts:686.
  • Layer 2 is Graph, consulted only for an e-mail and only when graphLookup is on (:702). It resolves the tenant user, installs the app when a catalog id is configured, and writes the person into the directory (findPersonViaGraph, :708-717).
  • Neither → unknownPersonHelp (:720), which is 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.
  • conversationForPerson returns the remembered conv when there is one, else opens a 1:1 chat via openPersonalConversation (:650, 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 (:659-663), so the second message costs no extra call. Pinned by tests/plugins/msteamsPlugin.test.ts:695.
  • If every piece was accepted but no activity id came back, messagePerson throws rather than reporting success (:754).

6. Persisted state

6.1 The StateStore

index.mjs:88 creates it at <plugin data dir>/channel-state.json — the per-plugin data dir the host hands over via ctx.dataDir() (rooted at plugins-data/, src/daemon/brainCore.ts:255). lib/state.mjs is a one-line re-export of the shared implementation (elowen-plugin-shared/stateStore.mjs): the whole file is read once and cached in memory, and every patch() rewrites it through a temp file + atomic rename. A write failure is logged and re-thrown, so a /model or /display handler cannot confirm a change that never stuck.

6.2 Keys

<conversationId> — one entry per Teams conversation, written by rememberConversation and the command handlers:

FieldWritten atMeaning
ref:168, :660{ serviceUrl, conversationType, tenantId, botId } — the route to reach this conversation later.
genelowen-plugin-shared/chatCommands (/new)Conversation generation; folded into the channel key as id#gen.
model:1041{ provider, model } chosen with /model.
thinkingLevel:1047Reasoning effort chosen with /reasoning; undefined = model default.
fast/fast, cleared at :1041Fast mode; cleared when the newly picked model has no fast tier.
display:1059Per-chat /display overrides; an axis set to default is deleted from the object.
log:252The rolling transcript — { n: name, t: text, 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.

_meta — { serviceUrl }, the last service host seen on any activity (:169). This is the fallback route for a conversation with no stored ref, and the base for callApi (:830).

_people — the people directory, under the reserved key _people (lib/directory.mjs:14; 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 (lib/directory.mjs:25-27):

{ 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 (lib/directory.mjs:79), because it runs on every inbound message. The directory is capped at 500 people with oldest-first eviction (lib/directory.mjs:18, :92-101), 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 (lib/directory.mjs:83-87). 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:

StructureWhereLifetime
upnCache:99Per account id, unbounded, no TTL.
rosterCache:100Per conversation, 5 min TTL, stale entries reused on a failed refresh.
pendingAskslib/asks.mjsUntil accepted or ask_resolved, or the process ends.
pendingPickerslib/commands.mjsOne per conversation; replaced by the next picker.
pendingFileslib/files.mjsUntil accepted, declined, swept after 15 minutes, or the process ends.
Connector bearerlib/token.mjs:18Refreshed ~60 s before expiry; concurrent callers share one in-flight refresh.
Graph bearerlib/graph.mjs:38 (GraphClient holds its own TokenSource)A separate TokenSource — different audience, so one cached token cannot serve both.
JWKS handlelib/auth.mjs:17Cached 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 returns at :863) and re-warms the token and roster caches on demand, but loses no conversation settings, no route and no person.