Files
openclaw/docs/channels/msteams.md
Aamir Jawaid 04c2982535 fix(msteams): rebase TeamsSDK patterns to simplify Teams Integration (#76262)
* fix(msteams): rebase SDK migration onto current main

Reapply the msteams SDK migration (originally on feat/msteams-sdk-migration)
on top of upstream/main, resolving conflicts with parallel msteams work that
landed upstream during our session.

What got applied vs decisions made:

CLEANLY APPLIED (3-way patch):
- monitor.ts, monitor-handler.ts, polls.ts, reply-stream-controller.ts/.test.ts,
  reply-dispatcher.ts, attachments/download.ts, monitor.lifecycle.test.ts,
  monitor-handler/message-handler.ts, monitor-handler.types.ts, etc.
- streaming-message.ts + .test.ts deletions

WHOLESALE TAKE FROM ORIGINAL BRANCH (partial 3-way left broken cross-refs):
- sdk.ts, sdk.test.ts, messenger.ts, feedback-reflection.ts,
  send-context.ts, send.test.ts

KEPT UPSTREAM (deferred for separate cleanup):
- extensions/msteams/package.json (still has jsonwebtoken/jwks-rsa per
  Peter's b3bc60ae25 incremental approach)
- src/plugins/contracts/package-manifest.contract.test.ts (consistent with
  package.json)
- pnpm-lock.yaml (avoids lockfile churn; pnpm install --frozen-lockfile clean)

ADAPTED:
- Dockerfile matrix-sdk-crypto check now wraps upstream's new retry-loop in
  the if-matrix-bundled gate

KNOWN TEST FAILURES (need eyes — see PR comment):
- attachments.test.ts: 1 fail (pre-existing — warn meta arg shape changed in
  our migration but test wasn't updated)
- reply-dispatcher.test.ts: 6 fails (pre-existing — tests mock old
  TeamsHttpStream, not updated for our ctx.stream rewrite)
- send.test.ts: 4 fails (NEW from merge — upstream's send.ts changed media
  loading; our mocks need updating or take upstream's send.test.ts wholesale)

UPSTREAM COMMITS POTENTIALLY MISSED (in wholesale-take files):
- 08c4af0ddf fix(msteams): accept conversation id allowlists
- e1840b8581 fix(msteams): bind global audience tokens to app id
- Channels turn-kernel refactor (ffe67e9cdc / 1ead1b2d18 / 9a9cd0c0ab) —
  may be partially preserved in cleanly-patched files

Static checks pass: pnpm check:changed is green (typecheck, lint, contract
tests, import cycles, etc.). Manual testing required before merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(msteams): preserve thread routing for channel and group-chat replies

- monitor.ts: adaptSdkContext now uses ctx.reply() for channel and groupChat
  conversations (so the SDK threads outbound activities to the inbound's
  replyToId/serviceUrl) and ctx.send() only for personal DMs (where
  reply()'s blockquote-prepend is ugly).
- messenger.ts: sendProactively passes resolvedThreadId on the non-thread
  fallback path so channel @mentions that fall through outbound.ts -> send.ts
  still land in the original thread instead of top-level.

Live-validated: channel @mention -> bot replies in thread, threaded reply
-> bot replies in same thread, no top-level leakage.

* fix(msteams): tag outbound SDK calls with OpenClaw User-Agent

- user-agent.ts: add buildOpenClawUserAgentFragment() that returns just
  'OpenClaw/<version>'. The SDK's Client.clone merges this with its own
  'teams.ts[apps]/<sdk-version>' identifier — passing the full buildUserAgent()
  here would double-print the SDK token.
- sdk.ts: pass the fragment via AppOptions.client.headers['User-Agent'] so
  the Teams backend can identify OpenClaw traffic for usage telemetry.

Final UA looks like 'OpenClaw/<openclaw-version> teams.ts[apps]/<sdk-version>'.

* fix(msteams): handle StreamCancelledError when user presses Stop mid-stream

The new SDK throws StreamCancelledError synchronously from stream.emit/update
when the user pressed Stop in Teams: Teams replies 403 to the next chunk
update, the SDK flips _canceled, and any subsequent emit() throws. The old
custom TeamsHttpStream either swallowed cancel or didn't expose this exception
type, so the migration inherited an SDK behavior the original code didn't have
to handle.

Symptom on 2026-05-05: pressing Stop during a streaming reply caused an
unhandled promise rejection that crashed the Node 24 process. Docker restarted
the gateway about two minutes after each Stop click. Two related bugs surfaced
once the crash was caught: the would-be block fallback re-delivered the full
text as a second message (duplicate after Stop), and the typing-keepalive kept
pulsing in Teams for the rest of the agent run because nothing told it to
stop.

reply-stream-controller.ts:
- Wrap stream.update / stream.emit / stream.close in try/catch that swallows
  StreamCancelledError (matched by .name to dodge tsgo's SDK re-export
  resolution quirk). Latch a wasCanceled flag so subsequent calls
  short-circuit even if stream.canceled is stale.
- preparePayload() returns undefined when the stream was canceled — the
  streamed prefix is already visible to the user, so dropping the payload
  prevents a duplicate block message from overriding the cancel intent.

reply-dispatcher.ts:
- Typing-keepalive gate now also checks streamController.wasCanceled() so
  typing pulses stop firing once Stop is observed. Otherwise the bot keeps
  pulsing for the rest of the (uncancellable) agent run.

reply-stream-controller.test.ts:
- 6 new regression tests cover: cancel-during-emit (the crash scenario),
  cancel-during-update, cancel-during-finalize, non-cancel error propagation,
  post-cancel inactivity, and dropped-payload-on-cancel.

Live-validated: long streaming reply + Stop mid-stream -> stream freezes,
no duplicate message, no zombie typing, container stays healthy.

* fix(msteams): allow Bearer-token retry on Skype CDN attachment downloads

Teams puts inline DM images and clipboard-pasted images on
*.asm.skype.com URLs (e.g. us-api.asm.skype.com/v1/objects/<id>/views/imgo).
The download path in attachments/download.ts already does a plain GET first
and falls back to a Bearer-token retry on 401/403 — but the retry was gated
on the URL being in DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST. asm.skype.com hosts
were in DEFAULT_MEDIA_HOST_ALLOWLIST (download permitted) but not in the
auth-host list, so a 401 plain-GET response skipped the retry and surfaced
as a missing image to the agent.

Add asm.skype.com and ams.skype.com to the auth allowlist so openclaw
attempts the Bearer-token retry consistently, matching how it treats the
other CDN/Bot-Framework hosts already in the list.

Note: this does not unblock all clipboard-pasted DM images — for at least
some tenants asm.skype.com rejects the Bot Framework token (returns 401
even with auth). Routing those URLs through <serviceUrl>/v3/attachments/...
the way #62219 already handles HTML-wrapped attachments is a separate
follow-up. The +button 'Upload from this device' path works today because
Teams generates an attachment with an HTML wrapper that triggers the
existing BF v3 attachments fallback in monitor-handler/inbound-media.ts.

* fix(msteams): align docker-compose msteams port default with plugin default

The plugin defaults webhook.port to 3978 (the Bot Framework standard used in
Microsoft samples) and listens on whatever the operator sets there. The
docker-compose.yml port mapping was exposing ${OPENCLAW_MSTEAMS_PORT:-3000}:3000
which only works for operators who explicitly set webhook.port to 3000.
Default-config users would have the plugin listening on 3978 inside the
container while compose forwarded 3000, causing connection refused.

Realign to ${OPENCLAW_MSTEAMS_PORT:-3978}:3978 so a default-config docker
compose up Just Works with Teams. Operators wanting a custom port override
both webhook.port in openclaw.json and OPENCLAW_MSTEAMS_PORT env var.

* fix(msteams): post-rebase reconciliation with main

Three follow-ups after rebasing the SDK migration onto current main:

- reply-dispatcher.ts: rename createChannelReplyPipeline to its post-rebase
  identifier createChannelMessageReplyPipeline (the plugin-sdk barrel renamed
  it during the 1454-commit rebase window).
- reply-dispatcher.ts: tighten the typing-keepalive onStartError signature to
  (err: unknown) to satisfy upstream's stricter type checks.
- messenger.ts: drop the unconditional thread suffix on the bottom proactive
  fallback. The previous behavior threaded all top-level proactive sends when
  the stored ref had a threadId, which contradicts replyStyle='top-level'
  semantics (and breaks the new upstream test). Threading on the proactive
  path is preserved where it matters — the onRevoked branch within
  replyStyle==='thread' still passes resolvedThreadId, which is the original
  #55198 fix path.
- attachments.test.ts: update the warn-call assertion to match the migration's
  inline message format (host=... error=...) — the structured meta object was
  being dropped by the logger formatter pre-migration.

* feat(msteams): port streaming preview/progress features to ctx.stream

While the SDK migration was open, upstream landed preview/progress/draft
streaming features built on the OLD custom TeamsHttpStream class (which the
migration deletes). This commit ports the user-visible parts of those
features onto the new ctx.stream substrate so the migration doesn't lose
ground:

- pickInformativeStatusText: reads custom labels from
  msteams.streaming.progressDraft config via resolveChannelProgressDraftLabel.
  Falls back to the plugin-sdk default rotation. Pre-rebase used a hardcoded
  4-string array.
- streamMode resolution: "partial" (default, per-token streaming),
  "progress" (no tokens; preview card carries informative label that updates
  as tools run), or "block" (no native streaming). Mode is read from
  cfg.channels.msteams.streaming.preview.
- progress-draft gate: createChannelProgressDraftGate gates informative
  updates so the rotating label only starts firing once meaningful work has
  begun (avoids flicker before the first tool call).
- noteProgressWork() / pushProgressLine(): public methods on the controller
  for callers (typing keepalive ticks, tool-event callbacks) to signal work.
  pushProgressLine appends tool names as bullets above the rotating label
  when streaming.previewToolProgress is enabled. Wiring these into actual
  tool events is a separate follow-up.
- preparePayload progress-mode path: when stream is active but no tokens
  streamed (progress mode) and a final text payload arrives, emit the text
  into the stream so the preview card transitions in place to the final
  reply on close().

reply-dispatcher: pass log + msteamsConfig + a stable progressSeed
(${accountId}:${conversation.id}) to createTeamsReplyStreamController so the
informative-label rotation is consistent across reconnects.

What's NOT ported and why:
- Live-edit-via-replaceInformativeWithFinal: the SDK's HttpStream natively
  accumulates emitted text + entities + channelData and flushes ONE final
  activity at close() using the same activity id as the preview. So the
  separate "replace informative with final" call from upstream is
  unnecessary — we get live-finalization for free via the SDK's design.
- pushProgressLine triggers from tool events: needs reply-pipeline-side
  callbacks the new SDK migration didn't surface yet. Follow-up.

Tests: existing 22 reply-stream-controller tests still pass (the new
behaviors are additive).

* feat(msteams): wire pipeline tool events to streaming progress + fix test debt

Two follow-ups from yesterday's stopping point:

1. Wire pipeline events into the stream controller's progress-draft surface.
   reply-dispatcher's replyOptions now exposes onReasoningStream, onToolStart,
   onItemEvent, onPlanUpdate, onApprovalEvent, onCommandOutput callbacks that
   format each event via the channel-streaming helpers and route through
   streamController.pushProgressLine(). Mirrors the discord adapter's wiring.
   Also:
   - resolveChannelStreamingPreviewToolProgress + ...SuppressDefaultTool... so
     the dispatcher exposes suppressDefaultToolProgressMessages on its
     replyOptions when progress mode is on.
   - Switch disableBlockStreaming resolution to the channel-streaming helpers
     (resolveChannelPreviewStreamMode + resolveChannelStreamingBlockEnabled)
     so streaming.mode='block' and streaming.block.enabled=true are honored
     alongside the legacy blockStreaming boolean.

2. Fix the test debt that the rebase exposed:
   - reply-dispatcher.test.ts: drop the streamInstances + TeamsHttpStream
     mock pattern (file deleted by migration); replace with a streamMock
     provided via context.stream that mirrors the SDK's IStreamer shape
     (update/emit/close/canceled). Update assertions on sendInformativeUpdate
     -> stream.update, stream.update -> stream.emit. Drop the
     resumes-typing-between-segments test (no equivalent in the new
     ctx.stream model — the SDK's HttpStream doesn't have a 'between
     segments' notion; close ends the stream).
   - send.test.ts: fix two stale mock targets — loadOutboundMediaFromUrl
     comes from openclaw/plugin-sdk/outbound-media (not /msteams), and
     resolveMarkdownTableMode comes from openclaw/plugin-sdk/markdown-table-runtime
     (not /config-runtime). The previous mock paths were no-ops post-migration.

All 854 msteams tests now pass (was 17 failing in 4 files yesterday).

* fix(msteams): SDK streaming delta + use app.reply for proactive thread sends

Two narrow regressions exposed by the @microsoft/teams.apps migration:

- The SDK's HttpStream.emit appends each chunk to its internal buffer
  (`this.text += activity.text`), but the channel reply pipeline emits
  cumulative text on each chunk. Forwarding cumulative text into an
  appending sink produced "chunk1 + chunk1chunk2 + chunk1chunk2chunk3..."
  duplication for streamed (DM) replies. Track the emitted prefix length
  in the stream controller and only forward the new tail.
- Replace the manual `${convId};messageid=${msgId}` URL construction in
  the proactive thread fallback with `app.reply()`, which builds the
  threaded conversation id via the SDK's own toThreadedConversationId
  helper. Mechanically equivalent today; removes coupling to Teams' URL
  format and tracks any future SDK changes.

Also adds the `reply` method to the structural MSTeamsApp type so the
refactor typechecks without casts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(msteams): bump @microsoft/teams.api and teams.apps to 2.0.10

2.0.10 adds support for the AAD v1 token issuer that the Bot Framework
JWT validator needs. The minor version bump pulls teams.cards / common /
graph along to 2.0.10 too.

Add `@microsoft/teams.*` to `minimumReleaseAgeExclude` in
pnpm-workspace.yaml because 2.0.10 was published <48h ago and the default
`minimumReleaseAge: 2880` (~2 days) would otherwise reject it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* revert(msteams): remove asm.skype.com auth-host allowlist additions

These hosts were added in dfc169d31d for inline DM image auth-retry, but
the commit's own footnote acknowledges it doesn't actually unblock
clipboard-pasted images (asm.skype.com rejects Bot Framework tokens in
at least some tenants). The change is unrelated to the SDK migration and
the user-visible bug it claimed to fix isn't fixed; lifting it out keeps
this PR focused on the migration. Will land as a separate PR if the
auth-allowlist consistency improvement is wanted on its own.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(msteams): typed ExpressAdapter helper, drop unknown-cast pyramid

The monitor's SDK bootstrap had an awkward chain:

  httpServerAdapter: new (
    (await import("@microsoft/teams.apps")) as unknown as {
      ExpressAdapter: new (app: unknown) => unknown;
    }
  ).ExpressAdapter(expressApp) as never,

Three casts (`unknown`, structural shape literal, `never`) were a
defensive workaround from when the SDK's hashed d.ts files tripped up
tsgo. With the SDK's exports now resolving cleanly, the same import can
be done with full types.

- Extend the lazy `loadSdkModules()` cache to include `ExpressAdapter`
  alongside `App` so the dynamic import is shared.
- Add `createMSTeamsExpressAdapter(serverOrApp)` helper in `sdk.ts` that
  encapsulates the lazy import and returns a properly-typed adapter
  instance.
- Replace `httpServerAdapter`'s structural shape on `CreateMSTeamsAppOptions`
  with the SDK's own `IHttpServerAdapter` interface (re-exported from
  `@microsoft/teams.apps`).

The call site in `monitor.ts` becomes a single typed call with no `any`,
no `unknown`, no `as never`. The lazy-load behavior is preserved: nothing
imports `@microsoft/teams.apps` at module load time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(msteams): unbreak tsgo:extensions on the ExpressAdapter helper

CI's check-prod-types failed because the previous commit's typed helper
used `typeof import("@microsoft/teams.apps").ExpressAdapter`, which
tsc/tsgo's NodeNext resolution can't follow through the SDK's chained
`export *` barrel:

    @microsoft/teams.apps/dist/index.d.ts:
        export * from "./http";          // folder with index.d.ts
        export * from "./app";           // single .d.ts file

The folder re-export drops `ExpressAdapter` and `IHttpServerAdapter` from
the namespace shape under `tsconfig.extensions.json` (passes under the
per-extension `tsconfig.json` because of inherited `paths`). Same root
cause as why we already model `MSTeamsApp` structurally (line 47 comment).

Switch the ExpressAdapter side to the same structural-shape pattern:
- Define `MSTeamsHttpServerAdapter` and `MSTeamsExpressAdapterCtor` locally.
- Cast `m.ExpressAdapter` once inside `loadSdkModules` (the runtime export
  is fine; only the type surface is hidden).
- `httpServerAdapter` on `CreateMSTeamsAppOptions` and the return type of
  `createMSTeamsExpressAdapter` use the local structural type.

Net result: the call site in `monitor.ts` stays the cast-free single line
the previous commit landed; the one remaining cast is confined to the
SDK-loading helper with an explanatory comment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(msteams): drop unused jsonwebtoken/jwks-rsa deps

The SDK migration removed all `import "jsonwebtoken"` / `import "jwks-rsa"`
from source code (the SDK does JWT validation internally now), but the
package.json entries and the matching `package-manifest.contract.test.ts`
expectation were left orphaned. Drop both:

- `extensions/msteams/package.json`: remove `jsonwebtoken` (^9), `jwks-rsa`
  (^4) from `dependencies` and `@types/jsonwebtoken` from `devDependencies`.
- `src/plugins/contracts/package-manifest.contract.test.ts`: remove the
  two entries from msteams's `pluginLocalRuntimeDeps` expectation.
- `monitor.lifecycle.test.ts`: extend the `./sdk.js` mock with the
  `createMSTeamsExpressAdapter` export added in the typed-helper cleanup,
  so the lifecycle suite still mounts after the deps drop.

Lockfile regenerates accordingly. All msteams tests (865) pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(msteams): drop unused @microsoft/teams.api direct dep

CI's deadcode:dependencies (knip) flagged @microsoft/teams.api as
unused in extensions/msteams. The plugin source uses structural type
aliases (MSTeamsActivityParams, MSTeamsActivityLike, etc.) to dodge
tsgo resolution bugs with teams.api's hashed d.ts files, so it never
imports teams.api directly. The package is brought in transitively
via @microsoft/teams.apps; the only other reference is
probe.test.ts's vi.mock("@microsoft/teams.api"), which works on the
import-path string and doesn't require a direct dep declaration.

Lockfile regenerates accordingly. tsgo:extensions, knip, and all
865 msteams tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(msteams): clear three CI gate failures (lint, contract, deprecated config API)

Three CI checks flagged on the latest run; all three are msteams-local
and unrelated to one another:

- **check-lint** / **check-additional-extension-bundled**:
  `oxlint` flagged a redundant `as string[]` assertion in
  `reply-dispatcher.ts:431`. The preceding `every((s: unknown) => typeof
  s === "string")` already narrows the array type, so the cast does
  nothing. Drop it.

- **checks-fast-contracts-plugins-c**: the
  `package-manifest.contract.test.ts` `pluginLocalRuntimeDeps` for
  msteams still expected `@microsoft/teams.api`, but the deadcode
  cleanup commit (8f4050f51a) dropped it from
  `extensions/msteams/package.json`. Remove it from the contract test
  too — `teams.api` is only present transitively via `teams.apps`,
  which is the reason knip flagged it.

- **check-additional-runtime-topology-architecture**: the deprecated
  internal config API guard caught `messenger.ts:223` calling
  `getMSTeamsRuntime().config.loadConfig()`. Switch to
  `config.current()` to match the pattern used by phone-control,
  synology-chat, and matrix.

Pre-existing failures on this run that are NOT msteams-related and not
caused by this PR: `check-test-types` (errors in
`src/agents/openai-transport-stream.test.ts` and
`pi-embedded-runner/openai-stream-wrappers.test.ts`) and `macos-swift`
(`hoistAwait` in `MacNodeRuntime.swift`). Leaving those for upstream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(msteams): cast config.current() return to OpenClawConfig

The previous commit switched `messenger.ts:223` from the deprecated
`config.loadConfig()` to `config.current()` to satisfy the architecture
guard, but `config.current()` returns a deeply-readonly type that's not
assignable to the `Partial<OpenClawConfig>` parameter
`resolveMarkdownTableMode` expects (a mutable type from the SDK
contract). Phone-control, synology-chat, and matrix all cast at this
seam — adopt the same pattern.

Verified locally: tsgo:core, tsgo:extensions, check:architecture, and
test:extensions:package-boundary:compile all pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(msteams): address PR review — pre-auth body limit, allowlist log level, /api/messages forwarder, narrow release-age exclude

Four narrow fixes from the PR review (BradGroux + clawsweeper bot +
galiniliev's plan), each its own concern:

- **pre-auth-body-limit** (monitor.ts) — install
  `express.json({ limit: DEFAULT_WEBHOOK_MAX_BODY_BYTES })` before the
  bearer-presence gate and SDK route. Express memoizes the parsed body
  on the request, so the SDK's later `json()` becomes a no-op and our
  limit applies before any handler parses bodies. Closes the gap where
  a `Bearer garbage`-shaped attacker could force unbounded JSON parsing
  before token validation.

- **allowlist-error-logging** (monitor.ts) — restore main's `runtime.error`
  level for the `msteams resolve failed` catch (was downgraded to
  `runtime.log` mid-merge). Graph allowlist resolution failures are
  security-relevant; they need to surface to operators.

- **legacy-messages-route** (monitor.ts) — when `webhook.path` is set
  to a custom value, also accept POSTs on the legacy `/api/messages`
  path with a one-time deprecation warning, then re-enter the Express
  middleware chain on the configured path. Keeps existing Azure Bot
  registrations working through the transition. Cast-free
  (`expressApp(req, res, next)` works because `Application extends
  IRouter extends RequestHandler`).

- **release-age-scope** (pnpm-workspace.yaml) — narrow
  `@microsoft/teams.*` glob to the single direct dep
  `@microsoft/teams.apps`. Future scoped packages no longer get a
  freshness-guard pass.

Tests + checks: msteams suite (867), tsgo:core, tsgo:extensions,
tsgo:test, lint:extensions, check:architecture, knip --dependencies,
package-manifest contract, all green.

Still pending from the review (separate commits):
- auth-coverage-tests (Brad #1 + comment) — tests proving the SDK accepts
  `aud=<bot app id>` and rejects `aud=api.botframework.com`.
- invoke-response-handling (Brad #2, codex P2) — file-consent invoke ack
  must return through the SDK invoke handler, not `ctx.sendActivity`.
- stream-failure-fallback (codex P2, galin F5) — `streamFailed` latch so
  partial streams fall back to block delivery on non-cancel errors.
- serviceurl-routing (Brad #4, codex P2) — proposed rebuttal pending
  empirical confirmation that `smba.trafficmanager.net/teams` routes to
  non-default-region conversations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(msteams): lock SDK auth contract — aud + v1/v2 issuer coverage

Adds extensions/msteams/src/auth-coverage.test.ts driving ServiceTokenValidator
and createEntraTokenValidator directly with jose-minted RS256 tokens against an
in-memory JWKS (via JwksClient.prototype patch). Locks in the three contract
cases @BradGroux flagged on #76262: aud=<bot app id> accepted, aud=api.botframework.com
rejected even when appid/azp match, and v1/v2 issuers accepted for allowed tenant
(disallowed tenant rejected).

Drops a stale ambient module declaration in src/types/microsoft-teams-sdk.d.ts
that was shadowing the SDK's real jwt-validator types with a long-renamed
createServiceTokenValidator surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(msteams): route file-consent invokes through typed app.on, drop broken invokeResponse send

Brad #2 / codex #4 on PR #76262 — `ctx.sendActivity({ type: "invokeResponse", ... })`
no longer reaches Teams as an HTTP InvokeResponse on the new SDK; it becomes
an outbound Bot Framework activity instead. Move file-consent accept/decline
to typed `app.on("file.consent.accept|decline", ...)` handlers. The SDK's
typed-route layer wraps a void return into `{ status: 200 }`
(`app.process.js:130`), so the manual ack disappears.

While in here, type `MSTeamsApp.on` properly. Borrowing the SDK's `App.on`
directly fails because that function carries a `this: App<TPlugin>`
constraint our structural alias can't satisfy, so we model an equivalent
generic over `IRoutes` with route-specific overloads (`card.action`,
`file.consent.*`, `activity`). The overloads work around a tsgo bug — the
`@microsoft/teams.api` `Activity` discriminated union collapses to `any`,
turning `ActivityRoutes` into a `[string]: RouteHandler<X, void>` index
signature that swallows every typed `Out` not already void-compatible
(card.action returns `AdaptiveCardActionResponse`; the others happen to
include `void`). Real tsc resolves cleanly. Linked upstream:
https://github.com/microsoft/typescript-go/issues/1057.

Other cleanups:
- Cast-free call sites for `adaptSdkContext` (now returns
  `MSTeamsTurnContext` instead of `unknown`).
- card.action error responses include `innerHttpError` per the SDK's
  `HttpError` shape requirement.
- Activity catch-all also skips `fileConsent/invoke` now that it's
  typed-routed (parallel to the existing `adaptiveCard/action` skip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(msteams): route SSO sign-in invokes through typed app.on, drop broken invokeResponse send

Brad #2 / codex #4 on PR #76262, SSO half. Continue the typed-route migration:
`signin/tokenExchange` and `signin/verifyState` now register via
`app.on("signin.token-exchange" | "signin.verify-state", ...)`. Per the
SDK's router, registering a user route with the same name as a system
route removes the system default — so the SDK's built-in handlers (which
would call `api.users.token.exchange` themselves and emit a `signin` event
nobody currently subscribes to) are silenced, and only ours runs. The SDK
wraps a void return into the HTTP 200 InvokeResponse, so the legacy
`ctx.sendActivity({ type: "invokeResponse", ... })` ack — broken on the new
SDK because it becomes an outbound BF activity instead of the HTTP
response — is gone.

The handler body is extracted from the activity-catch-all dispatch in
`monitor-handler.ts` to a new `signin-invoke.ts`, parallel to
`file-consent-invoke.ts`. `isSigninInvokeAuthorized` is now exported from
`monitor-handler.ts` so the new handler can reuse it. The activity
catch-all skips the SSO invoke names alongside the existing skips for
`adaptiveCard/action` and `fileConsent/invoke`.

`MSTeamsAppOn` overloads now cover the two SSO routes with their typed
ctx (`ISignInTokenExchangeInvokeActivity` / `ISignInVerifyStateInvokeActivity`).
Tests in `monitor-handler.sso.test.ts` were rewritten to call the
extracted handler directly — the `registered.run(ctx)` shape no longer
covers SSO, and the `expect(ctx.sendActivity).toHaveBeenCalledWith({ type:
"invokeResponse" })` assertions were dropped to match the new contract
(the SDK ack happens via the typed-route return value).

Note on overlap with #77784 (Stefan Stüben, Microsoft): that PR is doing
a much bigger SSO rework (sign-in card / sign-in-link / six-digit-code
fallbacks plus a `ctx.auth` plumbed to plugin tools). This change is
the small migration-correctness fix and is structured so #77784's SSO
body changes drop into the typed-route registrations cleanly on rebase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(msteams): route message-submit (feedback) invokes through typed app.on

Last invoke off the activity catch-all dispatch. `message/submitAction`
(thumbs up/down on AI-generated messages) now registers via
`app.on("message.submit", ...)`. Same shape as file-consent and SSO:
handler body extracted to a new `feedback-invoke.ts`, the SDK wraps a
void return into the HTTP 200 InvokeResponse, the broken
`ctx.sendActivity({ type: "invokeResponse", ... })` line is gone, and
the activity catch-all skips this invoke name alongside the others.

`isFeedbackInvokeAuthorized` is exported from `monitor-handler.ts` so
`feedback-invoke.ts` can reuse it. Tests in
`monitor-handler.feedback-authz.test.ts` were rewritten to call the
extracted handler directly — the old `handler.run(ctx)` shape no longer
intercepts feedback, and `originalRun` was removed because the typed
route is the dispatch point now.

`MSTeamsAppOn` overload added with the typed
`IMessageSubmitActionInvokeActivity` ctx, slotted between the SSO
overloads and the `activity` catch-all so `activity` stays last.

This leaves only `message`, `conversationUpdate`, and `messageReaction`
flowing through `app.on("activity", ...)` → `handler.run`. Promoting
those is the path to deleting the catch-all entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(msteams): fall back to block delivery when partial-mode stream fails mid-flight

codex #5 / Galin F5 on PR #76262. `reply-stream-controller.ts` previously
re-threw any non-cancel error from `stream.emit` during partial streaming
and from `stream.emit`/`stream.close` during finalize. Combined with
`preparePayload` suppressing block delivery once `tokensEmitted` was
true, that meant a network blip or API error mid-stream produced a
truncated reply with no recovery — the user saw the prefix that made it
through and nothing else.

Add a `streamFailed` latch parallel to `canceledLocally` / `tokensEmitted`:

- `onPartialReply`: catch non-cancel errors, set `streamFailed = true`,
  log a warn, don't propagate (the pipeline must keep running so
  `preparePayload` can decide).
- `preparePayload`: when `tokensEmitted && streamFailed`, fall through to
  block delivery instead of suppressing. The user may see a duplicate
  (streamed prefix + full block reply); intentional — matches the
  pre-migration `TeamsHttpStream.hasContent` recovery and is better than
  truncated-only.
- `finalize`: same latch + warn on non-cancel close failure, swallow
  rather than throw. The streamed content already reached the user; the
  closing activity (AI-Generated marker, feedback channelData) is the
  only loss, not worth blowing up the dispatcher.
- `isStreamActive` returns false once the stream has failed.

New tests cover crash-mid-stream after tokens were emitted (assert block
delivery payload is returned), happy-path no-duplicate behavior (assert
`preparePayload` still suppresses when nothing failed), and finalize
close-failure (assert no throw). The pre-existing "re-throws non-cancel"
test was inverted to assert non-throwing latch behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(msteams): declare @microsoft/teams.api as a runtime dependency

Type-only `import("@microsoft/teams.api/dist/...").TypeName` references
in `sdk.ts` (added when typed `MSTeamsApp.on` overloads were introduced)
are picked up by the `extension-runtime-dependencies` contract test as
genuine runtime imports. Declaring `@microsoft/teams.api` as a direct
dep makes the contract pass; the package was already coming in
transitively via `@microsoft/teams.apps`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(msteams): keep SSO on SDK signin routes

* test(msteams): avoid redundant signin handler assertion

* docs(msteams): clarify Teams cloud support

* fix(msteams): use current SDK string helper

* fix(msteams): gate SDK invoke side effects

* test(msteams): avoid implicit any in lifecycle tests

* fix(msteams): preserve SDK user agent and matrix check

* fix(msteams): expose SDK common dependency

* fix(msteams): use SDK user agent merge

* fix(msteams): fall back when stream close no-ops

* chore(msteams): drop unrelated merge artifacts

* chore(msteams): restore unrelated main files

* chore(msteams): restore unrelated main files

* chore(msteams): restore unrelated main files

* test(msteams): type stream close mock result

* fix(msteams): configure Teams cloud service URL

* chore(msteams): refresh shrinkwrap

* chore(deps): refresh shrinkwrap locks

* chore(ci): rerun guards after main sync

* chore(deps): refresh shrinkwrap for node 24

* chore(config): refresh docs baseline

* fix(msteams): preserve Teams SDK proactive references

* fix(msteams): harden SDK proactive sends

* fix(msteams): align service url contract

* test: fix bonjour beacon type narrowing

* fix(msteams): ignore ambient service url

* fix(msteams): fall through submit invokes

* test: align shrinkwrap override policy with Teams SDK deps

* fix(msteams): ack invoke routes promptly

* fix(msteams): support china cloud boundaries

* test: sync PR with current CI gates

* test: isolate channel setup registry metadata

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-28 22:54:34 +01:00

48 KiB

summary, read_when, title
summary read_when title
Microsoft Teams bot support status, capabilities, and configuration
Working on Microsoft Teams channel features
Microsoft Teams

Status: text + DM attachments are supported; channel/group file sending requires sharePointSiteId + Graph permissions (see Sending files in group chats). Polls are sent via Adaptive Cards. Message actions expose explicit upload-file for file-first sends.

Bundled plugin

Microsoft Teams ships as a bundled plugin in current OpenClaw releases, so no separate install is required in the normal packaged build.

If you are on an older build or a custom install that excludes bundled Teams, install the npm package directly:

openclaw plugins install @openclaw/msteams

Use the bare package to follow the current official release tag. Pin an exact version only when you need a reproducible install.

Local checkout (when running from a git repo):

openclaw plugins install ./path/to/local/msteams-plugin

Details: Plugins

Quick setup

The @microsoft/teams.cli handles bot registration, manifest creation, and credential generation in a single command.

1. Install and log in

npm install -g @microsoft/teams.cli@preview
teams login
teams status   # verify you're logged in and see your tenant info
The Teams CLI is currently in preview. Commands and flags may change between releases.

2. Start a tunnel (Teams can't reach localhost)

Install and authenticate the devtunnel CLI if you haven't already (getting started guide).

# One-time setup (persistent URL across sessions):
devtunnel create my-openclaw-bot --allow-anonymous
devtunnel port create my-openclaw-bot -p 3978 --protocol auto

# Each dev session:
devtunnel host my-openclaw-bot
# Your endpoint: https://<tunnel-id>.devtunnels.ms/api/messages
`--allow-anonymous` is required because Teams cannot authenticate with devtunnels. Each incoming bot request is still validated by the Teams SDK automatically.

Alternatives: ngrok http 3978 or tailscale funnel 3978 (but these may change URLs each session).

3. Create the app

teams app create \
  --name "OpenClaw" \
  --endpoint "https://<your-tunnel-url>/api/messages"

This single command:

  • Creates an Entra ID (Azure AD) application
  • Generates a client secret
  • Builds and uploads a Teams app manifest (with icons)
  • Registers the bot (Teams-managed by default - no Azure subscription needed)

The output will show CLIENT_ID, CLIENT_SECRET, TENANT_ID, and a Teams App ID - note these for the next steps. It also offers to install the app in Teams directly.

4. Configure OpenClaw using the credentials from the output:

{
  channels: {
    msteams: {
      enabled: true,
      appId: "<CLIENT_ID>",
      appPassword: "<CLIENT_SECRET>",
      tenantId: "<TENANT_ID>",
      webhook: { port: 3978, path: "/api/messages" },
    },
  },
}

Or use environment variables directly: MSTEAMS_APP_ID, MSTEAMS_APP_PASSWORD, MSTEAMS_TENANT_ID.

5. Install the app in Teams

teams app create will prompt you to install the app - select "Install in Teams". If you skipped it, you can get the link later:

teams app get <teamsAppId> --install-link

6. Verify everything works

teams app doctor <teamsAppId>

This runs diagnostics across bot registration, AAD app config, manifest validity, and SSO setup.

For production deployments, consider using federated authentication (certificate or managed identity) instead of client secrets.

Group chats are blocked by default (`channels.msteams.groupPolicy: "allowlist"`). To allow group replies, set `channels.msteams.groupAllowFrom`, or use `groupPolicy: "open"` to allow any member (mention-gated).

Goals

  • Talk to OpenClaw via Teams DMs, group chats, or channels.
  • Keep routing deterministic: replies always go back to the channel they arrived on.
  • Default to safe channel behavior (mentions required unless configured otherwise).

Config writes

By default, Microsoft Teams is allowed to write config updates triggered by /config set|unset (requires commands.config: true).

Disable with:

{
  channels: { msteams: { configWrites: false } },
}

Access control (DMs + groups)

DM access

  • Default: channels.msteams.dmPolicy = "pairing". Unknown senders are ignored until approved.
  • channels.msteams.allowFrom should use stable AAD object IDs or static sender access groups such as accessGroup:core-team.
  • Do not rely on UPN/display-name matching for allowlists - they can change. OpenClaw disables direct name matching by default; opt in explicitly with channels.msteams.dangerouslyAllowNameMatching: true.
  • The wizard can resolve names to IDs via Microsoft Graph when credentials allow.

Group access

  • Default: channels.msteams.groupPolicy = "allowlist" (blocked unless you add groupAllowFrom). Use channels.defaults.groupPolicy to override the default when unset.
  • channels.msteams.groupAllowFrom controls which senders or static sender access groups can trigger in group chats/channels (falls back to channels.msteams.allowFrom).
  • Set groupPolicy: "open" to allow any member (still mention-gated by default).
  • To allow no channels, set channels.msteams.groupPolicy: "disabled".

Example:

{
  channels: {
    msteams: {
      groupPolicy: "allowlist",
      groupAllowFrom: ["00000000-0000-0000-0000-000000000000", "accessGroup:core-team"],
    },
  },
}

Teams + channel allowlist

  • Scope group/channel replies by listing teams and channels under channels.msteams.teams.
  • Keys should use stable Teams conversation IDs from Teams links, not mutable display names.
  • When groupPolicy="allowlist" and a teams allowlist is present, only listed teams/channels are accepted (mention-gated).
  • The configure wizard accepts Team/Channel entries and stores them for you.
  • On startup, OpenClaw resolves team/channel and user allowlist names to IDs (when Graph permissions allow) and logs the mapping; unresolved team/channel names are kept as typed but ignored for routing by default unless channels.msteams.dangerouslyAllowNameMatching: true is enabled.

Example:

{
  channels: {
    msteams: {
      groupPolicy: "allowlist",
      teams: {
        "My Team": {
          channels: {
            General: { requireMention: true },
          },
        },
      },
    },
  },
}
Manual setup (without the Teams CLI)

If you can't use the Teams CLI, you can set up the bot manually through the Azure Portal.

How it works

  1. Ensure the Microsoft Teams plugin is available (bundled in current releases).
  2. Create an Azure Bot (App ID + secret + tenant ID).
  3. Build a Teams app package that references the bot and includes the RSC permissions below.
  4. Upload/install the Teams app into a team (or personal scope for DMs).
  5. Configure msteams in ~/.openclaw/openclaw.json (or env vars) and start the gateway.
  6. The gateway listens for Bot Framework webhook traffic on /api/messages by default.

Step 1: Create Azure Bot

  1. Go to Create Azure Bot
  2. Fill in the Basics tab:
    Field Value
    Bot handle Your bot name, e.g., openclaw-msteams (must be unique)
    Subscription Select your Azure subscription
    Resource group Create new or use existing
    Pricing tier Free for dev/testing
    Type of App Single Tenant (recommended - see note below)
    Creation type Create new Microsoft App ID
Creation of new multi-tenant bots was deprecated after 2025-07-31. Use **Single Tenant** for new bots.
  1. Click Review + createCreate (wait ~1-2 minutes)

Step 2: Get Credentials

  1. Go to your Azure Bot resource → Configuration
  2. Copy Microsoft App ID → this is your appId
  3. Click Manage Password → go to the App Registration
  4. Under Certificates & secretsNew client secret → copy the Value → this is your appPassword
  5. Go to Overview → copy Directory (tenant) ID → this is your tenantId

Step 3: Configure Messaging Endpoint

  1. In Azure Bot → Configuration
  2. Set Messaging endpoint to your webhook URL:
    • Production: https://your-domain.com/api/messages
    • Local dev: Use a tunnel (see Local Development below)

Step 4: Enable Teams Channel

  1. In Azure Bot → Channels
  2. Click Microsoft Teams → Configure → Save
  3. Accept the Terms of Service

Step 5: Build Teams App Manifest

  • Include a bot entry with botId = <App ID>.
  • Scopes: personal, team, groupChat.
  • supportsFiles: true (required for personal scope file handling).
  • Add RSC permissions (see RSC Permissions).
  • Create icons: outline.png (32x32) and color.png (192x192).
  • Zip all three files together: manifest.json, outline.png, color.png.

Step 6: Configure OpenClaw

{
  channels: {
    msteams: {
      enabled: true,
      appId: "<APP_ID>",
      appPassword: "<APP_PASSWORD>",
      tenantId: "<TENANT_ID>",
      webhook: { port: 3978, path: "/api/messages" },
    },
  },
}

Environment variables: MSTEAMS_APP_ID, MSTEAMS_APP_PASSWORD, MSTEAMS_TENANT_ID.

Step 7: Run the Gateway

The Teams channel starts automatically when the plugin is available and msteams config exists with credentials.

Federated authentication (certificate plus managed identity)

Added in 2026.4.11

For production deployments, OpenClaw supports federated authentication as a more secure alternative to client secrets. Two methods are available:

Option A: Certificate-based authentication

Use a PEM certificate registered with your Entra ID app registration.

Setup:

  1. Generate or obtain a certificate (PEM format with private key).
  2. In Entra ID → App Registration → Certificates & secretsCertificates → Upload the public certificate.

Config:

{
  channels: {
    msteams: {
      enabled: true,
      appId: "<APP_ID>",
      tenantId: "<TENANT_ID>",
      authType: "federated",
      certificatePath: "/path/to/cert.pem",
      webhook: { port: 3978, path: "/api/messages" },
    },
  },
}

Env vars:

  • MSTEAMS_AUTH_TYPE=federated
  • MSTEAMS_CERTIFICATE_PATH=/path/to/cert.pem

Option B: Azure Managed Identity

Use Azure Managed Identity for passwordless authentication. This is ideal for deployments on Azure infrastructure (AKS, App Service, Azure VMs) where a managed identity is available.

How it works:

  1. The bot pod/VM has a managed identity (system-assigned or user-assigned).
  2. A federated identity credential links the managed identity to the Entra ID app registration.
  3. At runtime, OpenClaw uses @azure/identity to acquire tokens from the Azure IMDS endpoint (169.254.169.254).
  4. The token is passed to the Teams SDK for bot authentication.

Prerequisites:

  • Azure infrastructure with managed identity enabled (AKS workload identity, App Service, VM)
  • Federated identity credential created on the Entra ID app registration
  • Network access to IMDS (169.254.169.254:80) from the pod/VM

Config (system-assigned managed identity):

{
  channels: {
    msteams: {
      enabled: true,
      appId: "<APP_ID>",
      tenantId: "<TENANT_ID>",
      authType: "federated",
      useManagedIdentity: true,
      webhook: { port: 3978, path: "/api/messages" },
    },
  },
}

Config (user-assigned managed identity):

{
  channels: {
    msteams: {
      enabled: true,
      appId: "<APP_ID>",
      tenantId: "<TENANT_ID>",
      authType: "federated",
      useManagedIdentity: true,
      managedIdentityClientId: "<MI_CLIENT_ID>",
      webhook: { port: 3978, path: "/api/messages" },
    },
  },
}

Env vars:

  • MSTEAMS_AUTH_TYPE=federated
  • MSTEAMS_USE_MANAGED_IDENTITY=true
  • MSTEAMS_MANAGED_IDENTITY_CLIENT_ID=<client-id> (only for user-assigned)

AKS Workload Identity Setup

For AKS deployments using workload identity:

  1. Enable workload identity on your AKS cluster.

  2. Create a federated identity credential on the Entra ID app registration:

    az ad app federated-credential create --id <APP_OBJECT_ID> --parameters '{
      "name": "my-bot-workload-identity",
      "issuer": "<AKS_OIDC_ISSUER_URL>",
      "subject": "system:serviceaccount:<NAMESPACE>:<SERVICE_ACCOUNT>",
      "audiences": ["api://AzureADTokenExchange"]
    }'
    
  3. Annotate the Kubernetes service account with the app client ID:

    apiVersion: v1
    kind: ServiceAccount
    metadata:
      name: my-bot-sa
      annotations:
        azure.workload.identity/client-id: "<APP_CLIENT_ID>"
    
  4. Label the pod for workload identity injection:

    metadata:
      labels:
        azure.workload.identity/use: "true"
    
  5. Ensure network access to IMDS (169.254.169.254) - if using NetworkPolicy, add an egress rule allowing traffic to 169.254.169.254/32 on port 80.

Auth type comparison

Method Config Pros Cons
Client secret appPassword Simple setup Secret rotation required, less secure
Certificate authType: "federated" + certificatePath No shared secret over network Certificate management overhead
Managed Identity authType: "federated" + useManagedIdentity Passwordless, no secrets to manage Azure infrastructure required

Default behavior: When authType is not set, OpenClaw defaults to client secret authentication. Existing configurations continue to work without changes.

Local development (tunneling)

Teams can't reach localhost. Use a persistent dev tunnel so your URL stays the same across sessions:

# One-time setup:
devtunnel create my-openclaw-bot --allow-anonymous
devtunnel port create my-openclaw-bot -p 3978 --protocol auto

# Each dev session:
devtunnel host my-openclaw-bot

Alternatives: ngrok http 3978 or tailscale funnel 3978 (URLs may change each session).

If your tunnel URL changes, update the endpoint:

teams app update <teamsAppId> --endpoint "https://<new-url>/api/messages"

Testing the Bot

Run diagnostics:

teams app doctor <teamsAppId>

Checks bot registration, AAD app, manifest, and SSO configuration in one pass.

Send a test message:

  1. Install the Teams app (use the install link from teams app get <id> --install-link)
  2. Find the bot in Teams and send a DM
  3. Check gateway logs for incoming activity

Environment variables

All config keys can be set via environment variables instead:

  • MSTEAMS_APP_ID
  • MSTEAMS_APP_PASSWORD
  • MSTEAMS_TENANT_ID
  • MSTEAMS_AUTH_TYPE (optional: "secret" or "federated")
  • MSTEAMS_CERTIFICATE_PATH (federated + certificate)
  • MSTEAMS_CERTIFICATE_THUMBPRINT (optional, not required for auth)
  • MSTEAMS_USE_MANAGED_IDENTITY (federated + managed identity)
  • MSTEAMS_MANAGED_IDENTITY_CLIENT_ID (user-assigned MI only)

Member info action

OpenClaw exposes a Graph-backed member-info action for Microsoft Teams so agents and automations can resolve channel member details (display name, email, role) directly from Microsoft Graph.

Requirements:

  • Member.Read.Group RSC permission (already in the recommended manifest)
  • For cross-team lookups: User.Read.All Graph Application permission with admin consent

The action is gated by channels.msteams.actions.memberInfo (default: enabled when Graph credentials are available).

History context

  • channels.msteams.historyLimit controls how many recent channel/group messages are wrapped into the prompt.
  • Falls back to messages.groupChat.historyLimit. Set 0 to disable (default 50).
  • Fetched thread history is filtered by sender allowlists (allowFrom / groupAllowFrom), so thread context seeding only includes messages from allowed senders.
  • Quoted attachment context (ReplyTo* derived from Teams reply HTML) is currently passed as received.
  • In other words, allowlists gate who can trigger the agent; only specific supplemental context paths are filtered today.
  • DM history can be limited with channels.msteams.dmHistoryLimit (user turns). Per-user overrides: channels.msteams.dms["<user_id>"].historyLimit.

Current Teams RSC permissions (manifest)

These are the existing resourceSpecific permissions in our Teams app manifest. They only apply inside the team/chat where the app is installed.

For channels (team scope):

  • ChannelMessage.Read.Group (Application) - receive all channel messages without @mention
  • ChannelMessage.Send.Group (Application)
  • Member.Read.Group (Application)
  • Owner.Read.Group (Application)
  • ChannelSettings.Read.Group (Application)
  • TeamMember.Read.Group (Application)
  • TeamSettings.Read.Group (Application)

For group chats:

  • ChatMessage.Read.Chat (Application) - receive all group chat messages without @mention

To add RSC permissions via the Teams CLI:

teams app rsc add <teamsAppId> ChannelMessage.Read.Group --type Application

Example Teams manifest (redacted)

Minimal, valid example with the required fields. Replace IDs and URLs.

{
  $schema: "https://developer.microsoft.com/en-us/json-schemas/teams/v1.23/MicrosoftTeams.schema.json",
  manifestVersion: "1.23",
  version: "1.0.0",
  id: "00000000-0000-0000-0000-000000000000",
  name: { short: "OpenClaw" },
  developer: {
    name: "Your Org",
    websiteUrl: "https://example.com",
    privacyUrl: "https://example.com/privacy",
    termsOfUseUrl: "https://example.com/terms",
  },
  description: { short: "OpenClaw in Teams", full: "OpenClaw in Teams" },
  icons: { outline: "outline.png", color: "color.png" },
  accentColor: "#5B6DEF",
  bots: [
    {
      botId: "11111111-1111-1111-1111-111111111111",
      scopes: ["personal", "team", "groupChat"],
      isNotificationOnly: false,
      supportsCalling: false,
      supportsVideo: false,
      supportsFiles: true,
    },
  ],
  webApplicationInfo: {
    id: "11111111-1111-1111-1111-111111111111",
  },
  authorization: {
    permissions: {
      resourceSpecific: [
        { name: "ChannelMessage.Read.Group", type: "Application" },
        { name: "ChannelMessage.Send.Group", type: "Application" },
        { name: "Member.Read.Group", type: "Application" },
        { name: "Owner.Read.Group", type: "Application" },
        { name: "ChannelSettings.Read.Group", type: "Application" },
        { name: "TeamMember.Read.Group", type: "Application" },
        { name: "TeamSettings.Read.Group", type: "Application" },
        { name: "ChatMessage.Read.Chat", type: "Application" },
      ],
    },
  },
}

Manifest caveats (must-have fields)

  • bots[].botId must match the Azure Bot App ID.
  • webApplicationInfo.id must match the Azure Bot App ID.
  • bots[].scopes must include the surfaces you plan to use (personal, team, groupChat).
  • bots[].supportsFiles: true is required for file handling in personal scope.
  • authorization.permissions.resourceSpecific must include channel read/send if you want channel traffic.

Updating an existing app

To update an already-installed Teams app (e.g., to add RSC permissions):

# Download, edit, and re-upload the manifest
teams app manifest download <teamsAppId> manifest.json
# Edit manifest.json locally...
teams app manifest upload manifest.json <teamsAppId>
# Version is auto-bumped if content changed

After updating, reinstall the app in each team for new permissions to take effect, and fully quit and relaunch Teams (not just close the window) to clear cached app metadata.

Manual manifest update (without CLI)
  1. Update your manifest.json with the new settings
  2. Increment the version field (e.g., 1.0.01.1.0)
  3. Re-zip the manifest with icons (manifest.json, outline.png, color.png)
  4. Upload the new zip:
    • Teams Admin Center: Teams apps → Manage apps → find your app → Upload new version
    • Sideload: In Teams → Apps → Manage your apps → Upload a custom app

Capabilities: RSC only vs Graph

With Teams RSC only (app installed, no Graph API permissions)

Works:

  • Read channel message text content.
  • Send channel message text content.
  • Receive personal (DM) file attachments.

Does NOT work:

  • Channel/group image or file contents (payload only includes HTML stub).
  • Downloading attachments stored in SharePoint/OneDrive.
  • Reading message history (beyond the live webhook event).

With Teams RSC + Microsoft Graph Application permissions

Adds:

  • Downloading hosted contents (images pasted into messages).
  • Downloading file attachments stored in SharePoint/OneDrive.
  • Reading channel/chat message history via Graph.

RSC vs Graph API

Capability RSC Permissions Graph API
Real-time messages Yes (via webhook) No (polling only)
Historical messages No Yes (can query history)
Setup complexity App manifest only Requires admin consent + token flow
Works offline No (must be running) Yes (query anytime)

Bottom line: RSC is for real-time listening; Graph API is for historical access. For catching up on missed messages while offline, you need Graph API with ChannelMessage.Read.All (requires admin consent).

Graph-enabled media + history (required for channels)

If you need images/files in channels or want to fetch message history, you must enable Microsoft Graph permissions and grant admin consent.

  1. In Entra ID (Azure AD) App Registration, add Microsoft Graph Application permissions:
    • ChannelMessage.Read.All (channel attachments + history)
    • Chat.Read.All or ChatMessage.Read.All (group chats)
  2. Grant admin consent for the tenant.
  3. Bump the Teams app manifest version, re-upload, and reinstall the app in Teams.
  4. Fully quit and relaunch Teams to clear cached app metadata.

Additional permission for user mentions: User @mentions work out of the box for users in the conversation. However, if you want to dynamically search and mention users who are not in the current conversation, add User.Read.All (Application) permission and grant admin consent.

Known limitations

Webhook timeouts

Teams delivers messages via HTTP webhook. If processing takes too long (e.g., slow LLM responses), you may see:

  • Gateway timeouts
  • Teams retrying the message (causing duplicates)
  • Dropped replies

OpenClaw handles this by returning quickly and sending replies proactively, but very slow responses may still cause issues.

Teams cloud and service URL support

This SDK-backed Teams path is live-validated for Microsoft Teams public cloud.

Inbound replies use the incoming Teams SDK turn context. Out-of-context proactive operations - sends, edits, deletes, cards, polls, file-consent messages, and queued long-running replies - use the stored conversation reference serviceUrl. Public cloud defaults to the Teams SDK public cloud environment and allows stored references on the public Teams Connector host: https://smba.trafficmanager.net/.

Public cloud is the default. You do not need to set channels.msteams.cloud or channels.msteams.serviceUrl for normal public-cloud bots.

For non-public Teams clouds, set cloud and the matching proactive boundary when Microsoft publishes one:

  • channels.msteams.cloud selects the Teams SDK cloud preset for authentication, JWT validation, token services, and Graph scope.
  • channels.msteams.serviceUrl selects the Bot Connector endpoint boundary used to validate stored conversation references before proactive sends, edits, deletes, cards, polls, file-consent messages, and queued long-running replies. It is required for USGov and DoD SDK clouds. For China/21Vianet, OpenClaw uses the SDK China preset and accepts stored/configured service URLs only on Azure China Bot Framework channel hosts.

Microsoft publishes the global proactive Bot Connector endpoints in the Create the conversation section of the Teams proactive messaging docs. Use the incoming activity's serviceUrl when available; if you need a global proactive endpoint, use Microsoft's table.

Teams environment OpenClaw config Proactive serviceUrl
Public no cloud/serviceUrl config needed https://smba.trafficmanager.net/teams
GCC set serviceUrl; no separate Teams SDK cloud preset exists https://smba.infra.gcc.teams.microsoft.com/teams
GCC High cloud: "USGov" + serviceUrl https://smba.infra.gov.teams.microsoft.us/teams
DoD cloud: "USGovDoD" + serviceUrl https://smba.infra.dod.teams.microsoft.us/teams
China/21Vianet cloud: "China" use the incoming activity's serviceUrl

Example for GCC, where Microsoft documents a separate proactive service URL but the Teams SDK does not expose a separate GCC cloud preset:

{
  "channels": {
    "msteams": {
      "serviceUrl": "https://smba.infra.gcc.teams.microsoft.com/teams"
    }
  }
}

Example for GCC High:

{
  "channels": {
    "msteams": {
      "cloud": "USGov",
      "serviceUrl": "https://smba.infra.gov.teams.microsoft.us/teams"
    }
  }
}

channels.msteams.serviceUrl is restricted to supported Microsoft Teams Bot Connector hosts. When a service URL is configured, OpenClaw checks that the stored conversation serviceUrl uses the same host before proactive sends, edits, deletes, cards, polls, or queued long-running replies run. With the default public-cloud config, OpenClaw fails closed if a stored conversation points outside the public Teams Connector host. Receive a fresh message from the conversation after changing cloud/service URL settings so the stored conversation reference is current.

China/21Vianet does not have a separate global proactive smba URL in Microsoft's Teams proactive endpoint table. Configure cloud: "China" so the Teams SDK uses Azure China auth, token, and JWT endpoints. Proactive sends then require a stored conversation reference from an incoming China Teams activity, or an explicitly configured service URL, on the Azure China Bot Framework channel boundary (*.botframework.azure.cn). Graph-backed Teams helpers are currently disabled for cloud: "China" until OpenClaw routes Graph requests through the Azure China Graph endpoint.

Formatting

Teams markdown is more limited than Slack or Discord:

  • Basic formatting works: bold, italic, code, links
  • Complex markdown (tables, nested lists) may not render correctly
  • Adaptive Cards are supported for polls and semantic presentation sends (see below)

Configuration

Key settings (see /gateway/configuration for shared channel patterns):

  • channels.msteams.enabled: enable/disable the channel.
  • channels.msteams.appId, channels.msteams.appPassword, channels.msteams.tenantId: bot credentials.
  • channels.msteams.cloud: Teams SDK cloud environment (Public, USGov, USGovDoD, or China; default Public). Set this with serviceUrl for USGov/DoD SDK clouds; China uses the SDK preset and stored Azure China Bot Framework conversation references, with Graph-backed helpers disabled until Azure China Graph routing is implemented.
  • channels.msteams.serviceUrl: Bot Connector service URL boundary for SDK proactive operations. Public cloud uses the SDK default; set this for GCC (https://smba.infra.gcc.teams.microsoft.com/teams), GCC High, or DoD. China accepts Azure China Bot Framework channel hosts when the stored conversation reference comes from Teams operated by 21Vianet.
  • channels.msteams.webhook.port (default 3978)
  • channels.msteams.webhook.path (default /api/messages)
  • channels.msteams.dmPolicy: pairing | allowlist | open | disabled (default: pairing)
  • channels.msteams.allowFrom: DM allowlist (AAD object IDs recommended). The wizard resolves names to IDs during setup when Graph access is available.
  • channels.msteams.dangerouslyAllowNameMatching: break-glass toggle to re-enable mutable UPN/display-name matching and direct team/channel name routing.
  • channels.msteams.textChunkLimit: outbound text chunk size.
  • channels.msteams.chunkMode: length (default) or newline to split on blank lines (paragraph boundaries) before length chunking.
  • channels.msteams.mediaAllowHosts: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
  • channels.msteams.mediaAuthAllowHosts: allowlist for attaching Authorization headers on media retries (defaults to Graph + Bot Framework hosts).
  • channels.msteams.requireMention: require @mention in channels/groups (default true).
  • channels.msteams.replyStyle: thread | top-level (see Reply Style).
  • channels.msteams.teams.<teamId>.replyStyle: per-team override.
  • channels.msteams.teams.<teamId>.requireMention: per-team override.
  • channels.msteams.teams.<teamId>.tools: default per-team tool policy overrides (allow/deny/alsoAllow) used when a channel override is missing.
  • channels.msteams.teams.<teamId>.toolsBySender: default per-team per-sender tool policy overrides ("*" wildcard supported).
  • channels.msteams.teams.<teamId>.channels.<conversationId>.replyStyle: per-channel override.
  • channels.msteams.teams.<teamId>.channels.<conversationId>.requireMention: per-channel override.
  • channels.msteams.teams.<teamId>.channels.<conversationId>.tools: per-channel tool policy overrides (allow/deny/alsoAllow).
  • channels.msteams.teams.<teamId>.channels.<conversationId>.toolsBySender: per-channel per-sender tool policy overrides ("*" wildcard supported).
  • toolsBySender keys should use explicit prefixes: channel:, id:, e164:, username:, name: (legacy unprefixed keys still map to id: only).
  • channels.msteams.actions.memberInfo: enable or disable the Graph-backed member info action (default: enabled when Graph credentials are available).
  • channels.msteams.authType: authentication type - "secret" (default) or "federated".
  • channels.msteams.certificatePath: path to PEM certificate file (federated + certificate auth).
  • channels.msteams.certificateThumbprint: certificate thumbprint (optional, not required for auth).
  • channels.msteams.useManagedIdentity: enable managed identity auth (federated mode).
  • channels.msteams.managedIdentityClientId: client ID for user-assigned managed identity.
  • channels.msteams.sharePointSiteId: SharePoint site ID for file uploads in group chats/channels (see Sending files in group chats).

Routing and sessions

  • Session keys follow the standard agent format (see /concepts/session):
    • Direct messages share the main session (agent:<agentId>:<mainKey>).
    • Channel/group messages use conversation id:
      • agent:<agentId>:msteams:channel:<conversationId>
      • agent:<agentId>:msteams:group:<conversationId>

Reply style: threads vs posts

Teams recently introduced two channel UI styles over the same underlying data model:

Style Description Recommended replyStyle
Posts (classic) Messages appear as cards with threaded replies underneath thread (default)
Threads (Slack-like) Messages flow linearly, more like Slack top-level

The problem: The Teams API does not expose which UI style a channel uses. If you use the wrong replyStyle:

  • thread in a Threads-style channel → replies appear nested awkwardly
  • top-level in a Posts-style channel → replies appear as separate top-level posts instead of in-thread

Solution: Configure replyStyle per-channel based on how the channel is set up:

{
  channels: {
    msteams: {
      replyStyle: "thread",
      teams: {
        "19:abc...@thread.tacv2": {
          channels: {
            "19:xyz...@thread.tacv2": {
              replyStyle: "top-level",
            },
          },
        },
      },
    },
  },
}

Resolution precedence

When the bot sends a reply into a channel, replyStyle is resolved from the most specific override down to the default. The first non-undefined value wins:

  1. Per-channelchannels.msteams.teams.<teamId>.channels.<conversationId>.replyStyle
  2. Per-teamchannels.msteams.teams.<teamId>.replyStyle
  3. Globalchannels.msteams.replyStyle
  4. Implicit default — derived from requireMention:
    • requireMention: truethread
    • requireMention: falsetop-level

If you set requireMention: false globally without an explicit replyStyle, mentions in Posts-style channels will surface as top-level posts even when the inbound was a thread reply. Pin replyStyle: "thread" at the global, team, or channel level to avoid surprises.

Thread context preservation

When replyStyle: "thread" is in effect and the bot was @mentioned from inside a channel thread, OpenClaw re-attaches the original thread root to the outbound conversation reference (19:…@thread.tacv2;messageid=<root>) so the reply lands inside the same thread. This holds for both live (in-turn) sends and proactive sends made after the Bot Framework turn context has expired (e.g., long-running agents, queued tool-call replies via mcp__openclaw__message).

The thread root is taken from the stored threadId on the conversation reference. Older stored references that predate threadId fall back to activityId (whatever inbound activity last seeded the conversation), so existing deployments keep working without a re-seed.

When replyStyle: "top-level" is in effect, channel-thread inbounds are intentionally answered as new top-level posts — no thread suffix is attached. This is the correct behavior for Threads-style channels; if you see top-level posts where you expected threaded replies, your replyStyle is set incorrectly for that channel.

Attachments and images

Current limitations:

  • DMs: Images and file attachments work via Teams bot file APIs.
  • Channels/groups: Attachments live in M365 storage (SharePoint/OneDrive). The webhook payload only includes an HTML stub, not the actual file bytes. Graph API permissions are required to download channel attachments.
  • For explicit file-first sends, use action=upload-file with media / filePath / path; optional message becomes the accompanying text/comment, and filename overrides the uploaded name.

Without Graph permissions, channel messages with images will be received as text-only (the image content is not accessible to the bot). By default, OpenClaw only downloads media from Microsoft/Teams hostnames. Override with channels.msteams.mediaAllowHosts (use ["*"] to allow any host). Authorization headers are only attached for hosts in channels.msteams.mediaAuthAllowHosts (defaults to Graph + Bot Framework hosts). Keep this list strict (avoid multi-tenant suffixes).

Sending files in group chats

Bots can send files in DMs using the FileConsentCard flow (built-in). However, sending files in group chats/channels requires additional setup:

Context How files are sent Setup needed
DMs FileConsentCard → user accepts → bot uploads Works out of the box
Group chats/channels Upload to SharePoint → share link Requires sharePointSiteId + Graph permissions
Images (any context) Base64-encoded inline Works out of the box

Why group chats need SharePoint

Bots don't have a personal OneDrive drive (the /me/drive Graph API endpoint doesn't work for application identities). To send files in group chats/channels, the bot uploads to a SharePoint site and creates a sharing link.

Setup

  1. Add Graph API permissions in Entra ID (Azure AD) → App Registration:

    • Sites.ReadWrite.All (Application) - upload files to SharePoint
    • Chat.Read.All (Application) - optional, enables per-user sharing links
  2. Grant admin consent for the tenant.

  3. Get your SharePoint site ID:

    # Via Graph Explorer or curl with a valid token:
    curl -H "Authorization: Bearer $TOKEN" \
      "https://graph.microsoft.com/v1.0/sites/{hostname}:/{site-path}"
    
    # Example: for a site at "contoso.sharepoint.com/sites/BotFiles"
    curl -H "Authorization: Bearer $TOKEN" \
      "https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/BotFiles"
    
    # Response includes: "id": "contoso.sharepoint.com,guid1,guid2"
    
  4. Configure OpenClaw:

    {
      channels: {
        msteams: {
          // ... other config ...
          sharePointSiteId: "contoso.sharepoint.com,guid1,guid2",
        },
      },
    }
    

Sharing behavior

Permission Sharing behavior
Sites.ReadWrite.All only Organization-wide sharing link (anyone in org can access)
Sites.ReadWrite.All + Chat.Read.All Per-user sharing link (only chat members can access)

Per-user sharing is more secure as only the chat participants can access the file. If Chat.Read.All permission is missing, the bot falls back to organization-wide sharing.

Fallback behavior

Scenario Result
Group chat + file + sharePointSiteId configured Upload to SharePoint, send sharing link
Group chat + file + no sharePointSiteId Attempt OneDrive upload (may fail), send text only
Personal chat + file FileConsentCard flow (works without SharePoint)
Any context + image Base64-encoded inline (works without SharePoint)

Files stored location

Uploaded files are stored in a /OpenClawShared/ folder in the configured SharePoint site's default document library.

Polls (Adaptive Cards)

OpenClaw sends Teams polls as Adaptive Cards (there is no native Teams poll API).

  • CLI: openclaw message poll --channel msteams --target conversation:<id> ...
  • Votes are recorded by the gateway in ~/.openclaw/msteams-polls.json.
  • The gateway must stay online to record votes.
  • Polls do not auto-post result summaries yet (inspect the store file if needed).

Presentation cards

Send semantic presentation payloads to Teams users or conversations using the message tool, CLI, or normal reply delivery. OpenClaw renders them as Teams Adaptive Cards from the generic presentation contract.

The presentation parameter accepts semantic blocks. When presentation is provided, the message text is optional. Buttons render as Adaptive Card submit or URL actions. Select menus are not native in the Teams renderer yet, so OpenClaw downgrades them to readable text before delivery.

Agent tool:

{
  action: "send",
  channel: "msteams",
  target: "user:<id>",
  presentation: {
    title: "Hello",
    blocks: [{ type: "text", text: "Hello!" }],
  },
}

CLI:

openclaw message send --channel msteams \
  --target "conversation:19:abc...@thread.tacv2" \
  --presentation '{"title":"Hello","blocks":[{"type":"text","text":"Hello!"}]}'

For target format details, see Target formats below.

Target formats

MSTeams targets use prefixes to distinguish between users and conversations:

Target type Format Example
User (by ID) user:<aad-object-id> user:40a1a0ed-4ff2-4164-a219-55518990c197
User (by name) user:<display-name> user:John Smith (requires Graph API)
Group/channel conversation:<conversation-id> conversation:19:abc123...@thread.tacv2
Group/channel (raw) <conversation-id> 19:abc123...@thread.tacv2 (if contains @thread)

CLI examples:

# Send to a user by ID
openclaw message send --channel msteams --target "user:40a1a0ed-..." --message "Hello"

# Send to a user by display name (triggers Graph API lookup)
openclaw message send --channel msteams --target "user:John Smith" --message "Hello"

# Send to a group chat or channel
openclaw message send --channel msteams --target "conversation:19:abc...@thread.tacv2" --message "Hello"

# Send a presentation card to a conversation
openclaw message send --channel msteams --target "conversation:19:abc...@thread.tacv2" \
  --presentation '{"title":"Hello","blocks":[{"type":"text","text":"Hello"}]}'

Agent tool examples:

{
  action: "send",
  channel: "msteams",
  target: "user:John Smith",
  message: "Hello!",
}
{
  action: "send",
  channel: "msteams",
  target: "conversation:19:abc...@thread.tacv2",
  presentation: {
    title: "Hello",
    blocks: [{ type: "text", text: "Hello" }],
  },
}
Without the `user:` prefix, names default to group or team resolution. Always use `user:` when targeting people by display name.

Proactive messaging

  • Proactive messages are only possible after a user has interacted, because we store conversation references at that point.
  • See /gateway/configuration for dmPolicy and allowlist gating.

Team and Channel IDs (Common Gotcha)

The groupId query parameter in Teams URLs is NOT the team ID used for configuration. Extract IDs from the URL path instead:

Team URL:

https://teams.microsoft.com/l/team/19%3ABk4j...%40thread.tacv2/conversations?groupId=...
                                    └────────────────────────────┘
                                    Team conversation ID (URL-decode this)

Channel URL:

https://teams.microsoft.com/l/channel/19%3A15bc...%40thread.tacv2/ChannelName?groupId=...
                                      └─────────────────────────┘
                                      Channel ID (URL-decode this)

For config:

  • Team key = path segment after /team/ (URL-decoded, e.g., 19:Bk4j...@thread.tacv2; older tenants may show @thread.skype, which is also valid)
  • Channel key = path segment after /channel/ (URL-decoded)
  • Ignore the groupId query parameter for OpenClaw routing. It is the Microsoft Entra group ID, not the Bot Framework conversation ID used in incoming Teams activities.

Private channels

Bots have limited support in private channels:

Feature Standard Channels Private Channels
Bot installation Yes Limited
Real-time messages (webhook) Yes May not work
RSC permissions Yes May behave differently
@mentions Yes If bot is accessible
Graph API history Yes Yes (with permissions)

Workarounds if private channels don't work:

  1. Use standard channels for bot interactions
  2. Use DMs - users can always message the bot directly
  3. Use Graph API for historical access (requires ChannelMessage.Read.All)

Troubleshooting

Common issues

  • Images not showing in channels: Graph permissions or admin consent missing. Reinstall the Teams app and fully quit/reopen Teams.
  • No responses in channel: mentions are required by default; set channels.msteams.requireMention=false or configure per team/channel.
  • Version mismatch (Teams still shows old manifest): remove + re-add the app and fully quit Teams to refresh.
  • 401 Unauthorized from webhook: Expected when testing manually without Azure JWT - means endpoint is reachable but auth failed. Use Azure Web Chat to test properly.

Manifest upload errors

  • "Icon file cannot be empty": The manifest references icon files that are 0 bytes. Create valid PNG icons (32x32 for outline.png, 192x192 for color.png).
  • "webApplicationInfo.Id already in use": The app is still installed in another team/chat. Find and uninstall it first, or wait 5-10 minutes for propagation.
  • "Something went wrong" on upload: Upload via https://admin.teams.microsoft.com instead, open browser DevTools (F12) → Network tab, and check the response body for the actual error.
  • Sideload failing: Try "Upload an app to your org's app catalog" instead of "Upload a custom app" - this often bypasses sideload restrictions.

RSC permissions not working

  1. Verify webApplicationInfo.id matches your bot's App ID exactly
  2. Re-upload the app and reinstall in the team/chat
  3. Check if your org admin has blocked RSC permissions
  4. Confirm you're using the right scope: ChannelMessage.Read.Group for teams, ChatMessage.Read.Chat for group chats

References