# MS Teams Provider Implementation Guide (Clawdbot)

Practical implementation notes for adding `msteams` as a new provider to Clawdbot.

This document is written to match **this repo’s actual conventions** (verified against `src/` as of 2026-01-07), and to be used as an implementation checklist.

---

## 0) Scope / MVP

**MVP (recommended first milestone)**

- Inbound: receive DMs + channel mentions via Bot Framework webhook.
- Outbound: reply in the same conversation (and optionally proactive follow-ups) using the **Bot Framework connector** (not Graph message-post).
- Basic media inbound: download Teams file attachments when possible; outbound media: send link (or Adaptive Card image) initially.
- DM security: reuse existing Clawdbot `dmPolicy` + pairing store behavior.

**Nice-to-have**

- Rich cards (Adaptive Cards), message update/delete, reactions, channel-wide (non-mention) listening, proactive app installation via Graph, meeting chat support, multi-bot accounts.

---

## 1) Repo Conventions (Verified)

### 1.1 Provider layout

Most providers live in `src/<provider>/` and follow the Slack/Discord pattern:

```
src/slack/
├── index.ts
├── monitor.ts
├── monitor.test.ts
├── monitor.tool-result.test.ts
├── send.ts
├── actions.ts
├── token.ts
└── probe.ts
```

Notes:

- WhatsApp (web) is the exception: it’s split across `src/providers/web/` and shared helpers in `src/web/`.
- Providers often include extra helpers (`webhook.ts`, `client.ts`, `targets.ts`, `daemon.ts`, etc.) when needed (see `src/telegram/`, `src/signal/`, `src/imessage/`).

### 1.2 Monitor pattern & message pipeline

Inbound providers ultimately build a `ctx` payload and call the shared pipeline:

- `dispatchReplyFromConfig()` (auto-reply) + `createReplyDispatcherWithTyping()` (provider typing indicator).
- `resolveAgentRoute()` for session key + agent routing.
- `enqueueSystemEvent()` for human-readable “what happened” logging.
- Pairing gates via `readProviderAllowFromStore()` and `upsertProviderPairingRequest()` for `dmPolicy=pairing`.

A minimal (but accurate) sequence looks like:

1. Validate activity (ignore bot echoes; ignore edits unless you want system events).
2. Resolve peer identity + chat type + routing (`resolveAgentRoute()`).
3. Apply access policy: DM policy + allowFrom/pairing; channel allowlist/mention requirements.
4. Download attachments (bounded by `mediaMaxMb`).
5. Build `ctx` envelope (matches other providers’ field names).
6. Dispatch reply through `dispatchReplyFromConfig()`.

### 1.3 Gateway lifecycle

Providers started by the gateway are managed in:

- `src/gateway/server-providers.ts` (start/stop + runtime snapshot)
- `src/gateway/server.ts` (logger + `runtimeForLogger()` wiring)
- `src/gateway/config-reload.ts` (restart rules + provider kind union)
- `src/gateway/server-methods/providers.ts` (status endpoint)

### 1.4 Outbound delivery plumbing (easy to miss)

The CLI + gateway send paths share outbound helpers:

- `src/infra/outbound/targets.ts` (validates `--to` per provider)
- `src/infra/outbound/deliver.ts` (chunking + send abstraction)
- `src/infra/outbound/format.ts` (summaries / JSON)
- `src/gateway/server-methods/send.ts` (gateway “send” supports multiple providers)
- `src/commands/send.ts` + `src/cli/deps.ts` (direct CLI send wiring)

### 1.5 Pairing integration points

Adding a new provider that supports `dmPolicy=pairing` requires:

- `src/pairing/pairing-store.ts` (extend `PairingProvider`)
- `src/cli/pairing-cli.ts` (provider list + optional notify-on-approve)

### 1.6 UI surfaces

The local web UI has explicit provider forms + unions:

- `ui/src/ui/app.ts` (state + forms per provider)
- `ui/src/ui/types.ts` and `ui/src/ui/ui-types.ts` (provider unions)
- `ui/src/ui/controllers/connections.ts` (load/save config per provider)

If we add `msteams`, the UI must be updated alongside backend config/types.

---

## 2) 2025/2026 Microsoft Guidance (What Changed)

### 2.1 Bot Framework SDK v4 “modern” baseline (Node)

For Node bots, Microsoft’s maintained samples now use:

- `CloudAdapter` + `ConfigurationBotFrameworkAuthentication` (instead of older adapter patterns)
- Express/Restify middleware to parse JSON into `req.body` before `adapter.process(...)`

CloudAdapter’s request processing explicitly requires parsed JSON bodies (it will 400 if `req.body` isn’t an object).

### 2.2 Proactive messaging is required for “slow” work

Teams delivers messages via **HTTP webhook**. If we block the request while waiting on an LLM run, we risk:

- gateway timeouts,
- Teams retries (duplicate inbound),
- or dropped replies.

Best practice for long-running work is:

- capture a `ConversationReference`,
- **return quickly**,
- then send replies later via proactive messaging (`continueConversationAsync` in CloudAdapter).

### 2.3 Microsoft 365 Agents SDK exists (potential future path)

Microsoft is actively building the **Microsoft 365 Agents SDK** (Node/TS) which positions itself as a replacement for parts of Bot Framework (`botbuilder`) for Teams and other channels.

Practical implication for Clawdbot:

- **Ship v1 with Bot Framework** (most stable, most docs, matches Teams docs),
- but structure our MS Teams provider so it can be swapped to Agents SDK later (thin adapter boundary around “receive activity” + “send activity”).

### 2.4 Deprecations / platform shifts to note

- Creation of **new multi-tenant bots** has been announced as deprecated after **2025-07-31** (plan for **single-tenant** by default).
- Office 365 connectors / incoming webhooks retirement has been extended to **2026-03-31** (don’t build a provider around incoming webhooks; use bots).

---

## 3) Recommended Architecture for Clawdbot

### 3.1 Use Bot Framework for both receive + send

Avoid “Graph API sendMessage” as the default path. For Teams, **posting chat/channel messages via Graph** is heavily constrained (often delegated-only and/or policy-restricted), while bots can reliably send messages in the conversations where they’re installed.

**Key idea:** treat Teams as a “bot conversation provider”:

- Receive activity via webhook.
- Reply (and send follow-ups) via the connector using the stored conversation reference.

### 3.2 Run a dedicated webhook server inside the provider monitor

This matches how Telegram webhooks are done (`src/telegram/webhook.ts`): the provider can run its own HTTP server on a configured port/path.

This avoids entangling the Teams webhook with the gateway HTTP server routes and lets users expose only the Teams webhook port if desired.

### 3.3 Explicitly store conversation references

To send proactive replies (or to support `clawdbot send --provider msteams ...`), we need a small store that maps a stable key to a `ConversationReference`.

Recommendation:

- Key by `conversation.id` (works for DMs, group chats, channels).
- Also store `tenantId`, `serviceUrl`, and useful labels (team/channel name when available) for debugging and allowlists.

---

## 4) Configuration Design

### 4.1 Proposed `msteams` config block

Suggested shape (mirrors Slack/Discord style + existing `DmPolicy` and `GroupPolicy`):

```ts
export type MSTeamsConfig = {
  enabled?: boolean;

  // Bot registration (Azure Bot / Entra app)
  appId?: string; // Entra app (bot) ID
  appPassword?: string; // secret
  tenantId?: string; // recommended: single tenant
  appType?: "singleTenant" | "multiTenant"; // default: singleTenant

  // Webhook listener (provider-owned HTTP server)
  webhook?: {
    host?: string; // default: 0.0.0.0
    port?: number; // default: 3978 (Bot Framework conventional)
    path?: string; // default: /msteams/messages
  };

  // Access control
  dm?: {
    enabled?: boolean;
    policy?: DmPolicy; // pairing|open|disabled
    allowFrom?: Array<string | number>; // allowlist for open/allowlist-like flows
  };
  groupPolicy?: GroupPolicy; // open|disabled|allowlist
  channels?: Record<
    string,
    {
      enabled?: boolean;
      requireMention?: boolean;
      users?: Array<string | number>;
      skills?: string[];
      systemPrompt?: string;
    }
  >;

  // Limits
  textChunkLimit?: number;
  mediaMaxMb?: number;
};
```

### 4.2 Env var conventions

To match repo patterns and Microsoft docs, support both:

- Clawdbot-style: `MSTEAMS_APP_ID`, `MSTEAMS_APP_PASSWORD`, `MSTEAMS_TENANT_ID`
- Bot Framework defaults: `MicrosoftAppId`, `MicrosoftAppPassword`, `MicrosoftAppTenantId`, `MicrosoftAppType`

Resolution order should follow other providers: `opts > env > config`.

---

## 5) File/Module Plan (`src/msteams/`)

Recommended structure (intentionally similar to Slack, with Teams-specific extras):

```
src/msteams/
├── index.ts
├── token.ts
├── monitor.ts
├── webhook.ts              # Express server + CloudAdapter.process
├── conversation-store.ts   # Persist ConversationReference by conversation.id
├── send.ts                 # Proactive send via adapter.continueConversationAsync
├── attachments.ts          # Download helpers for Teams attachment types
├── probe.ts                # Basic credential check (optional)
├── monitor.test.ts
└── monitor.tool-result.test.ts
```

---

## 6) Concrete Code Examples

These are not drop-in (because `botbuilder` isn’t currently a dependency in this repo), but they’re written in the style of existing providers.

### 6.1 `src/msteams/token.ts` (credential resolution)

```ts
export type ResolvedMSTeamsCreds = {
  appId: string | null;
  appPassword: string | null;
  tenantId: string | null;
  appType: "singleTenant" | "multiTenant";
  source: {
    appId: "opts" | "env" | "config" | "missing";
    appPassword: "opts" | "env" | "config" | "missing";
  };
};

export function resolveMSTeamsCreds(
  cfg: { msteams?: { appId?: string; appPassword?: string; tenantId?: string; appType?: string } },
  opts?: { appId?: string; appPassword?: string; tenantId?: string; appType?: string },
): ResolvedMSTeamsCreds {
  const env = process.env;
  const appId =
    opts?.appId?.trim() ||
    env.MSTEAMS_APP_ID?.trim() ||
    env.MicrosoftAppId?.trim() ||
    cfg.msteams?.appId?.trim() ||
    null;
  const appPassword =
    opts?.appPassword?.trim() ||
    env.MSTEAMS_APP_PASSWORD?.trim() ||
    env.MicrosoftAppPassword?.trim() ||
    cfg.msteams?.appPassword?.trim() ||
    null;
  const tenantId =
    opts?.tenantId?.trim() ||
    env.MSTEAMS_TENANT_ID?.trim() ||
    env.MicrosoftAppTenantId?.trim() ||
    cfg.msteams?.tenantId?.trim() ||
    null;

  const appTypeRaw =
    (opts?.appType || env.MicrosoftAppType || cfg.msteams?.appType || "")
      .trim()
      .toLowerCase();
  const appType =
    appTypeRaw === "multitenant" || appTypeRaw === "multi-tenant"
      ? "multiTenant"
      : "singleTenant";

  return {
    appId,
    appPassword,
    tenantId,
    appType,
    source: {
      appId: opts?.appId
        ? "opts"
        : env.MSTEAMS_APP_ID || env.MicrosoftAppId
          ? "env"
          : cfg.msteams?.appId
            ? "config"
            : "missing",
      appPassword: opts?.appPassword
        ? "opts"
        : env.MSTEAMS_APP_PASSWORD || env.MicrosoftAppPassword
          ? "env"
          : cfg.msteams?.appPassword
            ? "config"
            : "missing",
    },
  };
}
```

### 6.2 `src/msteams/webhook.ts` (Express + CloudAdapter)

Key best-practice points:

- `adapter.process(...)` requires JSON middleware (parsed `req.body`).
- Keep request handling fast; offload long work to proactive sends.

```ts
import express from "express";
import type { Server } from "node:http";
import {
  CloudAdapter,
  ConfigurationBotFrameworkAuthentication,
} from "botbuilder";
import type { RuntimeEnv } from "../runtime.js";

export async function startMSTeamsWebhook(opts: {
  host: string;
  port: number;
  path: string;
  runtime: RuntimeEnv;
  onTurn: (adapter: CloudAdapter) => (turnContext: unknown) => Promise<void>;
}) {
  const runtime = opts.runtime;
  const app = express();
  app.use(express.json({ limit: "10mb" }));

  const botFrameworkAuthentication = new ConfigurationBotFrameworkAuthentication(
    process.env,
  );
  const adapter = new CloudAdapter(botFrameworkAuthentication);

  app.get("/healthz", (_req, res) => res.status(200).send("ok"));
  app.post(opts.path, async (req, res) => {
    await adapter.process(req, res, async (turnContext) => {
      await opts.onTurn(adapter)(turnContext);
    });
  });

  const server: Server = await new Promise((resolve) => {
    const srv = app.listen(opts.port, opts.host, () => resolve(srv));
  });

  runtime.log?.(
    `msteams webhook listening on http://${opts.host}:${opts.port}${opts.path}`,
  );
  return { adapter, server, stop: () => server.close() };
}
```

### 6.3 `src/msteams/monitor.ts` (proactive dispatch pattern)

This is the key “Clawdbot-specific” adaptation: don’t do the long LLM run inside the webhook turn.

```ts
import type { ConversationReference, TurnContext } from "botbuilder";
import { TurnContext as TurnContextApi } from "botbuilder";
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
import { loadConfig } from "../config/config.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import type { RuntimeEnv } from "../runtime.js";
import { saveConversationReference } from "./conversation-store.js";
import { startMSTeamsWebhook } from "./webhook.js";

export async function monitorMSTeamsProvider(opts: {
  runtime?: RuntimeEnv;
  abortSignal?: AbortSignal;
}) {
  const cfg = loadConfig();
  const runtime = opts.runtime;
  if (cfg.msteams?.enabled === false) return;

  const host = cfg.msteams?.webhook?.host ?? "0.0.0.0";
  const port = cfg.msteams?.webhook?.port ?? 3978;
  const path = cfg.msteams?.webhook?.path ?? "/msteams/messages";

  const seen = new Map<string, number>(); // activity de-dupe
  const ttlMs = 2 * 60_000;

  const { adapter, stop } = await startMSTeamsWebhook({
    host,
    port,
    path,
    runtime:
      runtime ?? { log: console.log, error: console.error, exit: process.exit as any },
    onTurn: (adapter) => async (ctxAny) => {
      const context = ctxAny as TurnContext;
      if (context.activity.type !== "message") return;
      if (
        !context.activity.text &&
        (!context.activity.attachments ||
          context.activity.attachments.length === 0)
      )
        return;

      const activity = context.activity;
      const convoId = activity.conversation?.id ?? "unknown";
      const activityId = activity.id ?? "unknown";
      const dedupeKey = `${convoId}:${activityId}`;
      const now = Date.now();
      for (const [key, ts] of seen) if (now - ts > ttlMs) seen.delete(key);
      if (seen.has(dedupeKey)) return;
      seen.set(dedupeKey, now);

      const reference: ConversationReference =
        TurnContextApi.getConversationReference(activity);
      saveConversationReference(convoId, reference).catch(() => {});

      // Kick off the long-running work without blocking the webhook request:
      void (async () => {
        const cfg = loadConfig();
        const route = resolveAgentRoute({
          cfg,
          provider: "msteams",
          teamId: (activity.channelData as any)?.team?.id ?? undefined,
          peer: {
            kind:
              (activity.conversation as any)?.conversationType === "channel"
                ? "channel"
                : "dm",
            id:
              (activity.from as any)?.aadObjectId ??
              activity.from?.id ??
              "unknown",
          },
        });

        enqueueSystemEvent(
          `Teams message: ${String(activity.text ?? "").slice(0, 160)}`,
          {
            sessionKey: route.sessionKey,
            contextKey: `msteams:message:${convoId}:${activityId}`,
          },
        );

        const appId =
          cfg.msteams?.appId ??
          process.env.MSTEAMS_APP_ID ??
          process.env.MicrosoftAppId ??
          "";

        const { dispatcher, replyOptions, markDispatchIdle } =
          createReplyDispatcherWithTyping({
            responsePrefix: cfg.messages?.responsePrefix,
            onReplyStart: async () => {
              // typing indicator
              await adapter.continueConversationAsync(appId, reference, async (ctx) => {
                await (ctx as any).sendActivity({ type: "typing" });
              });
            },
            deliver: async (payload) => {
              await adapter.continueConversationAsync(appId, reference, async (ctx) => {
                await (ctx as any).sendActivity(payload.text ?? "");
              });
            },
            onError: (err, info) => {
              runtime?.error?.(`msteams ${info.kind} reply failed: ${String(err)}`);
            },
          });

        const ctxPayload = {
          Provider: "msteams" as const,
          Surface: "msteams" as const,
          From: `msteams:${activity.from?.id ?? "unknown"}`,
          To: `conversation:${convoId}`,
          SessionKey: route.sessionKey,
          AccountId: route.accountId,
          ChatType:
            (activity.conversation as any)?.conversationType === "channel"
              ? "room"
              : "direct",
          MessageSid: activityId,
          ReplyToId: activity.replyToId ?? activityId,
          Timestamp: activity.timestamp ? Date.parse(String(activity.timestamp)) : undefined,
          Body: String(activity.text ?? ""),
        };

        await dispatchReplyFromConfig({
          ctx: ctxPayload as any,
          cfg,
          dispatcher,
          replyOptions,
        });
        markDispatchIdle();
      })().catch((err) => runtime?.error?.(String(err)));
    },
  });

  const shutdown = () => stop();
  opts.abortSignal?.addEventListener("abort", shutdown, { once: true });
}
```

### 6.4 Attachment download (Teams file attachments)

Teams commonly sends file uploads as an attachment with content type:

- `application/vnd.microsoft.teams.file.download.info`

The `downloadUrl` is the URL to fetch (often time-limited). A minimal helper:

```ts
type TeamsFileDownloadInfo = {
  downloadUrl?: string;
  uniqueId?: string;
  fileType?: string;
};

export function resolveTeamsDownloadUrl(att: {
  contentType?: string;
  content?: unknown;
}): string | null {
  if (att.contentType !== "application/vnd.microsoft.teams.file.download.info")
    return null;
  const content = (att.content ?? {}) as TeamsFileDownloadInfo;
  const url = typeof content.downloadUrl === "string" ? content.downloadUrl.trim() : "";
  return url ? url : null;
}
```

Initial recommendation: support this type first; treat other attachment types as “link-only” until needed.

---

## 7) Integration Checklist (Files to Create/Modify)

### 7.1 New backend files

- `src/msteams/*` (new provider implementation; see structure above)

### 7.2 Backend integration points (must update)

**Config & validation**

- `src/config/types.ts` (add `MSTeamsConfig`; extend unions like `QueueModeByProvider`, `AgentElevatedAllowFromConfig`, `HookMappingConfig.provider`)
- `src/config/zod-schema.ts` (add schema + cross-field validation for `dm.policy="open"` → allowFrom includes `"*"`, etc.)
- `src/config/schema.ts` (labels + descriptions used by tooling/UI)

**Gateway provider lifecycle**

- `src/gateway/server-providers.ts` (runtime status + start/stop + snapshot)
- `src/gateway/server.ts` (logger + runtime env wiring)
- `src/gateway/config-reload.ts` (provider kind union + reload rules)
- `src/gateway/server-methods/providers.ts` (status payload)
- `src/infra/provider-summary.ts` (optional but recommended: show “Teams configured” in `clawdbot status`)

**Outbound sending**

- `src/infra/outbound/targets.ts` (validate `--to` format for Teams)
- `src/infra/outbound/deliver.ts` (provider caps + handler + result union)
- `src/infra/outbound/format.ts` (optional: add more metadata fields)
- `src/commands/send.ts` (treat `msteams` as direct-send provider if we implement `sendMessageMSTeams`)
- `src/cli/deps.ts` (add `sendMessageMSTeams`)
- `src/gateway/server-methods/send.ts` (support `provider === "msteams"` for gateway sends)

**Pairing**

- `src/pairing/pairing-store.ts` (add `"msteams"` to `PairingProvider`)
- `src/cli/pairing-cli.ts` (include provider in CLI; decide whether `--notify` is supported for Teams)

**Onboarding wizard**

- `src/commands/onboard-types.ts` (add `"msteams"` to `ProviderChoice`)
- `src/commands/onboard-providers.ts` (collect appId/secret/tenant, write config, add primer notes)

**Hooks**

- `src/gateway/hooks.ts` (extend provider allowlist validation: `last|whatsapp|telegram|discord|slack|signal|imessage|msteams`)

**Docs**

- `docs/providers/msteams.md` (Mintlify link conventions apply under `docs/**`)

### 7.3 UI integration points

- `ui/src/ui/ui-types.ts` (provider unions)
- `ui/src/ui/types.ts` (gateway status typing)
- `ui/src/ui/controllers/connections.ts` (load/save `msteams` config)
- `ui/src/ui/app.ts` (form state, validation, UX)

---

## 8) MS Teams Gotchas (Plan for These)

1. **Webhook timeouts / retries**: don’t block the webhook while waiting on LLM output; send replies proactively and dedupe inbound activities.
2. **Proactive messaging requirements**: the app must be installed in the chat/team; and you need a valid conversation reference (or you must create a conversation).
3. **Threading**: channel replies often need `replyToId` to keep replies in-thread; verify behavior for channel vs chat and standardize.
4. **Mentions**: Teams message text includes `<at>...</at>`; strip bot mentions before sending to the agent and implement mention gating using `entities`.
5. **Attachment downloads**: file uploads commonly arrive as `file.download.info` with time-limited URLs; enforce `mediaMaxMb` and handle 403/expired URLs.
6. **Formatting limits**: Teams markdown is more limited than Slack; assume “plain text + links” for v1, and only later add Adaptive Cards.
7. **Tenant/admin restrictions**: many orgs restrict custom app install or bot scopes. Expect setup friction; document it clearly.
8. **Single-tenant default**: multi-tenant bot creation has a deprecation cutoff (2025-07-31); prefer single-tenant in config defaults and docs.
9. **Incoming webhooks retirement**: Office 365 connectors / incoming webhooks retirement has moved to 2026-03-31; don’t rely on it as the primary integration surface.

---

## References (Current as of 2026-01)

- Bot Framework (Node) CloudAdapter sample: https://raw.githubusercontent.com/microsoft/BotBuilder-Samples/main/samples/javascript_nodejs/02.echo-bot/index.js
- Teams proactive messaging overview: https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages
- Teams bot file uploads / downloadUrl attachments: https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4
- CloudAdapter proactive API (`continueConversationAsync`): https://raw.githubusercontent.com/microsoft/botbuilder-js/main/libraries/botbuilder-core/src/cloudAdapterBase.ts
- Microsoft 365 Agents SDK (Node/TS): https://raw.githubusercontent.com/microsoft/Agents-for-js/main/README.md
- Office 365 connectors retirement update: https://techcommunity.microsoft.com/blog/microsoftteamsblog/retirement-of-office-365-connectors-within-microsoft-teams/4369576

---

## Next Steps (Actionable Implementation Order)

1. **Pick SDK + add deps**: start with Bot Framework (`botbuilder`) unless you’re ready to bet on Agents SDK; add packages + types in `package.json`.
2. **Config plumbing**: add `msteams` types + zod schema + schema metadata (`src/config/types.ts`, `src/config/zod-schema.ts`, `src/config/schema.ts`).
3. **Provider skeleton**: add `src/msteams/index.ts`, `token.ts`, and a stub `monitor.ts` that starts/stops cleanly (abortSignal).
4. **Webhook + echo**: implement `webhook.ts` + minimal activity handler that logs inbound text and sends a fast “ok” reply (no agent yet).
5. **Conversation store**: persist `ConversationReference` by `conversation.id` and include tenant/serviceUrl; add a small unit test.
6. **Agent dispatch (async)**: wire inbound messages to `dispatchReplyFromConfig()` using proactive sends (`continueConversationAsync`) to avoid webhook timeouts.
7. **Access control**: implement DM policy + pairing (reuse existing pairing store) + mention gating in channels.
8. **Gateway integration**: add provider manager start/stop/status wiring + config reload rules + hook provider allowlist; ensure gateway status UI reflects it.
9. **Outbound CLI/gateway sends**: add `sendMessageMSTeams` that targets stored conversation IDs; wire `clawdbot send --provider msteams`.
10. **Media**: implement inbound attachment download for `file.download.info` and a safe outbound strategy (link-only first, cards later).
11. **Docs + UI + Onboard**: write `docs/providers/msteams.md`, add a minimal UI config form (appId/secret/tenant + webhook port/path), and update `clawdbot onboard` provider selection.
12. **Hardening**: add dedupe TTL tuning, better error reporting, probe/health endpoints, and integration tests (`monitor.tool-result.test.ts`).
