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. WithhistoryLimit: 0, it stores and returns nothing; with no RSC consent it constructs no thread reader. Existing Graph and token request deadlines remain in their clients.lib/files.mjs: personal-chat consent offers, pending bytes, expiration, upload and shared-file delivery.TeamsSendFileimports its 20 MiBMAX_FILE_BYTESfor core's project file reader. The owner retains each offer until consent, rejection, expiration or process teardown; a shared room receives no offer.lib/proactive.mjs: directory and bounded roster lookup, optional Graph discovery, personal-conversation creation, 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 inlib/cards.mjs. Answer order/completeness usescollectQuestionAnswersfromelowen-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 sharedDISPLAY_AXES; omitted axes remain inherited, valid choices are preserved, and invalid supplied values normalize to each axis's first value.lib/commands.mjs: catalog-based command classification, local pickers and their pending state. It uses sharedDISPLAY_AXESandresolveActiveModel,modelReasoningLevelsandsupportsReasoningChoicefromelowen-plugin-shared/modelIdentity. On a reasoning submission it rechecks the live model before consuming the card. Teams' empty reset payload maps to shareddefaultonly for this check; storage still deletes the per-chat override. Unsupported or stale choices leave state/card intact.
resolveLinkedTeamsIdentity in lib/accountLinking.mjs is the one platform-identity resolver for shared
messages and picker clicks: 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:
-
Non-
POST→ 405 (:134). Pinned bytests/plugins/msteamsPlugin.test.ts:419. -
Unparseable body → 400 (
:136). -
verifyToken(req.headers.authorization, activity)fails → 401 (:137), and the brain is never reached (tests/plugins/msteamsPlugin.test.ts:412). -
Adapter stopped, or no
listen()handler wired → 200 with an empty body (:138).stoppedis set bydisconnect()(: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). -
type === 'message':- if
activity.valueis an object →onCardAction(an Adaptive CardAction.Submitround-trip), - otherwise →
onActivity(a user message).
Either way the promise is not awaited:
void work.catch(…)and an immediate 200 (: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:405pins the 200 and, after a 20 ms wait, that the turn ran — it does not assert the ordering itself. - if
-
type === 'conversationUpdate'→rememberConversationonly (: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). -
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
serviceUrlclaim against the activity'sserviceUrl, trailing slashes stripped, and rejects a mismatch (:44-46); - on failure logs
webhook token rejected: …and returns a quietfalse; a metadata/JWKS error clears the cached handle so a fetch hiccup is not cached forever (: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)
:375drops an activity with no conversation, or one whosefrom.idequalsrecipient.id(our own echo).rememberConversation(:158) persists{ serviceUrl, conversationType, tenantId, botId }under the conversation id, and the bareserviceUrlunder_meta, only when they changed — this runs on every message and every patch rewrites the state file. It then callsnotePerson(:178), which records the sender in the people directory, attaching the conversation id only for apersonalchat (:188) — a group id would address the whole room, not the person.- Question lifetime belongs to core. The turn's
ask_resolvedevent callsasks.resolveAsk, removes the pending entry and settles a timed-out, aborted or superseded card. No local question sweep or second timeout runs on inbound messages.
2.2 Mention resolution — 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
- Mention gate (
:390-391): apersonalconversation always responds. Anything else responds only ifrespondWithoutMention !== falseorisForMe(m)(:295, which matches a mention entity whosementioned.idequalsrecipient.id). A team-channel post reaches the bot only when @mentioned anyway, so the same gate covers it. - UPN resolution (
:393→resolveUpn,:355): one Bot Connector roster call per account id, cached inupnCache. 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 cachesundefinedand just narrows policy matching to the id/GUID forms. - Identity (
:394→senderIds,lib/ids.mjs:18): the sender'saadObjectId, their29:channel account id, the resolved UPN, the conversation id, and — for a team channel — the bare channel id with the;messageid=…thread suffix stripped (lib/ids.mjs:29-30), because the bare form is what an operator copies out of a Teams deep link. Pinned bytests/plugins/msteamsPlugin.test.ts:158. - Role policy (
:395→accessFor,:338): the first policy whoseroleIdmatches 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 and29:ids compare strictly. No match →access: undefined→ the turn is dropped silently — no reply and no log line.tests/plugins/msteamsPlugin.test.ts:193pins the absence of a reply specifically; it is not the absence of all traffic, because step 2 above already made a roster call before this gate. A match is turned into the access descriptor by the sharedbuildRoleAccess(elowen-plugin-shared/access.mjs), folded with this conversation's saved model / reasoning / fast state.
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 helper | Connector call | REST |
|---|---|---|
tmSend with replyToId | reply (lib/connector.mjs:93) | POST /v3/conversations/{id}/activities/{replyToId} |
tmSend without | send (lib/connector.mjs:99) | POST /v3/conversations/{id}/activities |
tmEdit | update (lib/connector.mjs:105) | PUT /v3/conversations/{id}/activities/{activityId} |
tmDelete | remove (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 Nameoccurrences, 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'stm*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 bytests/plugins/msteamsPlugin.test.ts:556; postFinalTextsends the final text in transport-sized pieces, sequentially, with the trigger reply reference only on the first piece. It throws iftmSendreturns its existingnullfailure 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 authorizedimageevents. The binding does not register anything or change the low-leveltmSendcontract; successful final delivery costs one send per split piece. A null result raisesFinalTextDeliveryError(fromelowen-plugin-shared/errors, thrown inlib/stream.mjs:16). The adapter's existingerrorTextboundary maps only this error toMESSAGES.deliveryFailedin the configured en/cs/sk service language; the turn runner logs the original English diagnostic before rendering the notice.
Sizing lives in lib/format.mjs: CHUNK = 20000 (: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.
3.4 Files the agent shares: ShareFile → the file consent card
Direct TeamsSendFile offers and MicrosoftFiles uploads read through
ctx.host.projectFiles().read(path, { maxBytes }), 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:
- The engine collects a
filebrain event's{ ref, name, size }(liveMessage.mjs,fileRefs) and callsadapter.resolveSharedFiles(refs)— the counterpart ofresolveImageFiles, reading<config dir>/chat-files/<sha256>.binoff the daemon'schat-filesdir (lib/adapter.mjs:1028,platformChatFilesDir(dataDir)passed fromindex.mjs:220, capped at four per turn, the same number core'sShareFileallows). adapter.offerSharedFiles(:1037) delegates to the existingofferFile(:517), which posts thefile.consentcard and parks the bytes inpendingFilesfor the acceptance. Nothing about the card, the 20 MBMAX_FILE_BYTEScap, the 15-minuteFILE_TTL_MSor the upload is duplicated — the same methodTeamsSendFile(lib/tools.mjs:204, callingofferFileat:250) already drives in production.- 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
asks.postAskpostsbuildAskCardunder a monotonic token and stores{ id, conversationId, activityId, questions, askerId, selected, other }inasks.pendingAsks.asks.onAskActionignores unknown tokens. The original sender is recorded withownerKey(from)and checked withisOwner, so Entra and Teams ids agree on the message and card paths. Only that sender or an operator may answer; another person receives the existing refusal message.- A single single-select question submits on tap. Other cards toggle their marks and explicitly submit.
Each question with custom input enabled receives its own native
Input.Text; an empty custom value clears only that question's custom text, andcustom: falseignores supplied custom text. asks.settleAskuses the shared ordered answer projection. An incomplete answer re-renders the first missing question without invoking core. If core rejects a complete answer, the pending question remains; only an accepted answer deletes it and edits the card to a summary.ask_resolvedis the only expiry/completion signal. It deletes pending state and edits unresolved cards to the localized expired message. Anansweredevent does not edit again: the answering surface already settled its card. Core owns the sole deadline, not an adapter timer or inbound-message sweep.
Card size is bounded by design: option labels are clamped to 60 characters, at most 12 options per
question are rendered, and pickers page 8 at a time (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:
- Target = the explicit
channelId(suffix stripped) elsecfg.notifyConversationId. - No target → a warning naming both
notifyConversationIdand the job'snotifyChannelId, 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:613pins the warning. - No
serviceUrlknown yet → warn and return (:775); proactive sends ride the last seen service host, so the bot must have received at least one activity. Pinned bytests/plugins/msteamsPlugin.test.ts:327. - 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 anotifyChannelIdholding an Entra object id has always meant (:801-809). - The text is translated through
lifecycleTextbefore 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/namerestrict matching to that field; a free-formquerytries identifiers first and the display name last. An exact (case-insensitive) name wins outright; otherwise every substring hit is a candidate, and more than one candidate is refused with the list, never guessed (lib/directory.mjs:130-136,lib/adapter.mjs:697-699). Pinned bytests/plugins/msteamsPlugin.test.ts:686. - Layer 2 is Graph, consulted only for an e-mail and only when
graphLookupis 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 withTeamsMembers, or switch on Graph), not a bare error. conversationForPersonreturns the rememberedconvwhen there is one, else opens a 1:1 chat viaopenPersonalConversation(:650,POST /v3/conversationswithbot: { id: '28:<appId>' }, the single member,isGroup: false, and the tenant on bothchannelData.tenant.idand the top-leveltenantId— the connector's own form). Teams returns the same conversation for the same bot/user pair, so it is idempotent; the id is still remembered both in the directory and as a conversationref(:659-663), so the second message costs no extra call. Pinned bytests/plugins/msteamsPlugin.test.ts:695.- If every piece was accepted but no activity id came back,
messagePersonthrows 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:
| Field | Written at | Meaning |
|---|---|---|
ref | :168, :660 | { serviceUrl, conversationType, tenantId, botId } — the route to reach this conversation later. |
gen | elowen-plugin-shared/chatCommands (/new) | Conversation generation; folded into the channel key as id#gen. |
model | :1041 | { provider, model } chosen with /model. |
thinkingLevel | :1047 | Reasoning effort chosen with /reasoning; undefined = model default. |
fast | /fast, cleared at :1041 | Fast mode; cleared when the newly picked model has no fast tier. |
display | :1059 | Per-chat /display overrides; an axis set to default is deleted from the object. |
log | :252 | The 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:
| Structure | Where | Lifetime |
|---|---|---|
upnCache | :99 | Per account id, unbounded, no TTL. |
rosterCache | :100 | 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. |
| Connector bearer | lib/token.mjs:18 | Refreshed ~60 s before expiry; concurrent callers share one in-flight refresh. |
| Graph bearer | lib/graph.mjs:38 (GraphClient holds its own TokenSource) | A separate TokenSource — different audience, so one cached token cannot serve both. |
| JWKS handle | lib/auth.mjs:17 | 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 returns at :863) and re-warms the token and roster caches on demand, but
loses no conversation settings, no route and no person.