# Microsoft Teams

URL: https://cogitator.app/docs/channels/teams

> Answer personal chats, group chats and channel threads in Microsoft Teams, on the Bot Framework protocol with no SDK.

## Setup

Teams needs no SDK: the channel checks Bot Framework's tokens with `node:crypto`, takes activities on a webhook and answers through the Bot Connector REST API with `fetch`. `botbuilder` is archived, and the Microsoft 365 Agents SDK that follows it speaks the same protocol, which the channel implements itself.

1. **An Azure Bot.** In the Azure portal, create an **Azure Bot** with the type of app **Single Tenant** (new multi-tenant bots cannot be created since July 31, 2025). Note the **Microsoft App ID** and the **App Tenant ID** under **Configuration**, saved as `TEAMS_APP_ID=...` and `TEAMS_TENANT_ID=...`.
2. **A client secret.** Open the app registration from **Configuration**, add a client secret under **Certificates & secrets**, and save its value as `TEAMS_APP_PASSWORD=...`.
3. **The messaging endpoint.** Set it to `https://<your host>/api/messages`. Bot Framework only posts to HTTPS, so the bot needs a URL it can reach. To try it on your machine, a tunnel such as `cloudflared tunnel --url http://localhost:3978` or `ngrok http 3978` is enough.
4. **The Teams channel.** Under **Channels**, add **Microsoft Teams**.
5. **A Teams app.** Make an app package whose `manifest.json` has a `bots` entry with `botId` set to the App ID and the `scopes` the bot works in (`personal`, `groupChat`, `team`), add `"supportsFiles": true` to send files in personal chats, and upload it in Teams (**Apps**, **Manage your apps**, **Upload an app**) or the Teams admin center.

```json title="manifest.json (excerpt)"
{
  "bots": [
    {
      "botId": "<TEAMS_APP_ID>",
      "scopes": ["personal", "groupChat", "team"],
      "supportsFiles": true
    }
  ],
  "validDomains": ["<your host>"]
}
```

## Usage

```typescript
import { Agent, Cogitator } from '@cogitator-ai/core';
import { Gateway, teamsChannel } from '@cogitator-ai/channels';

const teams = teamsChannel({
  appId: process.env.TEAMS_APP_ID!,
  appPassword: process.env.TEAMS_APP_PASSWORD!,
  tenantId: process.env.TEAMS_TENANT_ID!,
  webhook: { port: 3978 }, // its own server, at /api/messages unless `path` says otherwise
});

const cogitator = new Cogitator({
  llm: { providers: { google: { apiKey: process.env.GOOGLE_API_KEY! } } },
  memory: { adapter: 'memory' },
});
const memory = await cogitator.getMemory();

const gateway = new Gateway({
  agent: new Agent({
    name: 'teams-assistant',
    model: 'google/gemini-3.5-flash-lite',
    instructions: 'You help a team in Microsoft Teams. Be brief.',
  }),
  cogitator,
  channels: [teams],
  ...(memory && { memory }),
  stream: { flushInterval: 600, minChunkSize: 40 },
});

await gateway.start();
```

## Conversations and Threads

| Conversation | `channelId`                             | `topicId`                 | `groupId`           |
| ------------ | --------------------------------------- | ------------------------- | ------------------- |
| Personal     | the conversation id, `a:...`            |                           |                     |
| Group chat   | the conversation id, `19:...@thread.v2` |                           | the conversation id |
| Channel      | the channel id, `19:...@thread.tacv2`   | the thread's root message | the team id         |

Teams writes a channel thread as `<channel id>;messageid=<root>`, which the channel splits into `channelId` and `topicId`, so the Gateway's default thread key `teams:<conversation>:<user>` gives every personal chat, group chat and channel thread a conversation of its own. A top-level post that mentions the bot starts a thread, and the answer goes in it. `userId` is the user's Entra object id (`aadObjectId`), the Teams user id when there is none, and `userName` their display name. Use the object id as an owner id.

In group chats and channels Teams sends a bot only the messages that @mention it, and the channel strips the mention of the bot (`<at>Bot</at>`) from the text, leaving mentions of other people. An app with resource-specific consent gets every message: its manifest needs `webApplicationInfo` with the App ID as `id`, and the permission `ChannelMessage.Read.Group` (channels) or `ChatMessage.Read.Chat` (group chats) under `authorization.permissions.resourceSpecific`, which a team or chat owner grants when they add the app. The channel still answers only mentions then, unless `trigger: 'all'`. Personal chats are always answered.

```json title="manifest.json (resource-specific consent)"
{
  "webApplicationInfo": { "id": "<TEAMS_APP_ID>" },
  "authorization": {
    "permissions": {
      "resourceSpecific": [
        { "name": "ChannelMessage.Read.Group", "type": "Application" },
        { "name": "ChatMessage.Read.Chat", "type": "Application" }
      ]
    }
  }
}
```

Files a user sends in a personal chat reach the agent by their download URL, as images, audio (for [speech to text](/docs/channels/gateway)), video or files by their type, and images and recordings attached to a message are downloaded with the bot's token, from the Bot Connector the activity came through only.

## Answers

Answers are Markdown, sent with `textFormat: 'extendedmarkdown'`, which renders code blocks and tables. Teams takes messages up to 100 KB and advises staying under 80 KB, so a message holds at most 13653 characters (`TEAMS_MESSAGE_CHARS`), 80 KB of JSON whatever they are, and a longer answer goes out as several messages. With `replyTo` the first one replies to that message. Answers can be edited (`PUT`), so the Gateway [streams](/docs/channels/streaming) them into a message it edits. Images in an answer (`![alt](url)`) are sent as links and raw `<img>` tags as text, outside code blocks and code spans (read as CommonMark reads them, so an escaped or unclosed backtick hides nothing): Teams loads an image as soon as it shows the message, so an answer steered by injected text could send what the agent read to any address in an image URL. `sendTyping` shows the typing indicator, and `deleteMessage` deletes a message.

Teams limits a bot in one conversation to 7 requests a second, 8 in 2 seconds, 60 in 30 seconds and 1800 an hour (`TEAMS_RATE_LIMITS`), and the channel keeps every request of a conversation (a channel with all its threads) inside all four. Requests wait in a queue and take a slot only when they go, so edits and typing never book ahead of a new message, and those of one kind go in the order they asked, also through a block. Edits and typing indicators use at most three quarters of each limit, so new messages always find room: edits of one message go out one at a time, at least a second apart (`TEAMS_MIN_EDIT_INTERVAL`) and later when the conversation is busy, with the latest text and the edits made meanwhile folded in, and a typing indicator with no room now is skipped. A request Teams throttles anyway holds the whole conversation back for `Retry-After` or the backoff. Once Teams answers `MessageWritesBlocked` (the user blocked the bot or removed the app), the channel sends nothing more to that conversation until an activity comes from it.

* **Buttons.** Buttons are an Adaptive Card under the text, one row per action set. A button with `data` is an `Action.Execute`: its press comes as an `adaptiveCard/action` invoke, answered within a few seconds with the text `answerAction` gives, or else the message's card once the press is handled, so it shows what the press changed, such as an approval's decision. It falls back to `Action.Submit` on clients without Universal Actions, whose press comes as a message with a `value`, and `answerAction` then sends its text as a message, except an `alert` (such as an expired prompt), which is meant for the one who pressed and is not posted where everyone reads it. Both reach `onAction`, so [approval](/docs/channels/gateway#approvals) prompts get Approve and Deny buttons, and `editButtons` changes or removes them (a disabled button shows as text in its color). A `url` button opens it, and a `copyText` button shows the text to copy.
* **Files.** Bots can upload files only in personal chats: `sendFile` with a `buffer` sends a file consent card, and once the user accepts it the file is uploaded to their OneDrive and shown in the chat. A declined card is answered "The file was not sent.", and one the bot cannot act on any more (it restarted, or an hour passed) with a note to ask again. An empty file is refused. In group chats and channels Teams needs Microsoft Graph for files, so there a file needs a public `url`: an image is shown inline, another file is sent as a link, and a `buffer` is refused with an error.
* **Reactions.** Bots cannot react to messages in Teams, so the channel has no `setReaction`.

## Webhook Requests

Every request must carry `Authorization: Bearer <JWT>`, and the channel checks it before it reads the activity:

* RS256 only, signed by a key of the JWKS that the OpenID metadata at `https://login.botframework.com/v1/.well-known/openidconfiguration` names. The keys are cached, and once a day they are loaded again in the background while the cached ones keep answering. A token signed by a key not seen yet loads them again, at most every five minutes, and after a failed load none is tried for ten seconds, so a rotated key is picked up and a flood of forged tokens costs nothing. Loading the metadata or the keys gives up after ten seconds.
* `iss` is `https://api.botframework.com`, `aud` is the App ID, and `nbf` and `exp` hold now, with five minutes of clock skew.
* The `serviceurl` claim equals the activity's `serviceUrl`, so a token issued for one Bot Connector cannot send the bot's answers to another.
* The signing key is endorsed for the activity's `channelId` (`msteams`). A key without it gets `403`.

A request that fails the endorsement gets `403`, one that fails any other check `401`, and neither reaches the agent. So does an activity whose `serviceUrl` is not `https` (plain `http` passes only with a test `openIdMetadataUrl`), and one that names two different tenants. A verified activity is answered at once and handled after: a message once the Gateway took it (a queueing Gateway takes it at once) or after 3 seconds, an `Action.Execute` press with its answer. Each activity is handled once, by its conversation and id (the latest 1000 in memory), and the bot's own activities are skipped. A message that comes while no `onMessage` handler is set yet, or that the Gateway refuses because it stops, is answered `503`, so Bot Framework sends it again. One the Gateway drops after its request was answered is logged, since Bot Framework does not send it again. Once `stop()` was called, requests are answered `503`, and the channel's own server lets the requests under way finish, for up to three seconds, before it closes their connections.

Without `port`, mount `handleWebhook` in your own server with the raw body, or `webhookListener()` as a Node request listener. A [WebChat](/docs/channels/webchat) server takes it as a route, `webchatChannel({ port, routes: { '/api/messages': teams.webhookListener() } })`, and the Teams server takes the routes of other channels the same way, `webhook: { port, routes: { '/github/webhook': github.webhookListener() } }`.

```typescript
import express from 'express';

const app = express();
app.post('/api/messages', express.raw({ type: '*/*' }), async (req, res) => {
  const response = await teams.handleWebhook({
    method: req.method,
    headers: req.headers,
    rawBody: req.body,
  });
  res.status(response.status).type(response.contentType).send(response.body);
});
```

The channel's own server takes bodies up to 1 MB (`webhook.maxBody`, `413` beyond), and answers `404` on other paths and `400` to a request line it cannot read.

## Tokens and Errors

Answers are sent with a client credentials token of the app registration, from `https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token` with the scope `https://api.botframework.com/.default`. It is cached until five minutes before it expires, requests that need it while it is renewed wait for the same renewal, and a token the Bot Connector refuses is renewed once.

| `appType`                | Token                                                                                       |
| ------------------------ | ------------------------------------------------------------------------------------------- |
| `SingleTenant` (default) | from `tenantId`, with `appPassword`                                                         |
| `MultiTenant`            | from `botframework.com`, for bots created before August 2025                                |
| `UserAssignedMSI`        | from `tokenProvider`, such as a managed identity token from `@azure/identity`, in code only |

```typescript
import { ManagedIdentityCredential } from '@azure/identity';

const identity = new ManagedIdentityCredential({ clientId: process.env.TEAMS_APP_ID! });
const teams = teamsChannel({
  appId: process.env.TEAMS_APP_ID!,
  tenantId: process.env.TEAMS_TENANT_ID!,
  appType: 'UserAssignedMSI',
  tokenProvider: async () => {
    const token = await identity.getToken('https://api.botframework.com/.default');
    return { token: token.token, expiresAt: token.expiresOnTimestamp };
  },
  webhook: { port: 3978 },
});
```

Requests Teams throttles or fails for a moment (`429`, `412`, `502`, `504`) and network errors are tried again up to 3 times (`retry`), waiting `Retry-After` when Teams sends it and otherwise a time that doubles from 500 ms, with jitter. A request gives up after 30 seconds (`requestTimeout`, a token request after 15), and a `POST` that timed out is not tried again, since Teams may have taken it. Failed requests throw a `TeamsApiError`, a `FeedError` (feed `teams`) with the Bot Connector's `errorCode`:

| Response                     | `code`         | Note                                                                  |
| ---------------------------- | -------------- | --------------------------------------------------------------------- |
| `429`, `412`                 | `rate_limited` | after the retries                                                     |
| `403` `MessageWritesBlocked` | `auth`         | the user blocked the bot or removed the app, so it cannot write there |
| `401`, other `403`           | `auth`         |                                                                       |
| `404`                        | `not_found`    | such as `ConversationNotFound`                                        |
| `400`, `413`                 | `invalid_post` |                                                                       |
| `5xx`, network errors        | `unavailable`  |                                                                       |

Answers go to the Bot Connector of the conversation's `serviceUrl`, taken only from activities whose token was verified. A conversation no activity came from since the start, such as a reminder after a restart, goes to `serviceUrl` (default `https://smba.trafficmanager.net/teams/`, the commercial cloud).

## Security

Everyone who can message the bot writes text the agent reads, and the agent holds its tools. Treat what arrives as untrusted input:

* **Who may talk to it.** The bot answers users of its own tenant only. List others in `tenants`, or `'*'` for every tenant its app is installed in, and narrow it to people with `users`. An activity from another tenant never reaches the agent.
* **Where it answers.** Only verified activities set a conversation's Bot Connector, and `serviceUrlHosts` (such as `['smba.trafficmanager.net']`) refuses every other host, so the bot's token never goes to a host someone else names. Images are downloaded with the bot's token from that Bot Connector only.
* **The secret.** `TEAMS_APP_PASSWORD` sends messages as the bot to everyone it can reach: keep it out of the repository and rotate it in the app registration. A managed identity has no secret at all.
* **Prompt injection.** A message can tell the agent to ignore its instructions, to call a tool, or to put what it read in the URL of an image, which is why answers carry images as links. Give the agent only the tools the bot needs and ask [approval](/docs/channels/gateway#approvals) for tools that write or delete: only the person asked can press Approve.

## Configuration

```typescript
interface TeamsChannelConfig {
  appId: string;
  appPassword?: string; // a client secret
  tenantId?: string; // required unless appType is 'MultiTenant'
  appType?: 'SingleTenant' | 'MultiTenant' | 'UserAssignedMSI'; // default 'SingleTenant'
  tokenProvider?: () => Promise<string | { token: string; expiresAt: number }>;
  webhook?: {
    port?: number;
    path?: string; // default '/api/messages'
    host?: string;
    maxBody?: number; // default 1 MB
    routes?: Record<string, RequestListener>;
  };
  tenants?: string[] | '*'; // default [tenantId]
  users?: string[]; // Entra object ids or Teams user ids
  trigger?: 'mention' | 'all'; // default 'mention'
  serviceUrl?: string; // default https://smba.trafficmanager.net/teams/
  serviceUrlHosts?: string[];
  retry?: { retries?: number; baseDelay?: number; maxDelay?: number };
  requestTimeout?: number; // default 30 000 ms
  openIdMetadataUrl?: string; // for tests
  loginUrl?: string; // default https://login.microsoftonline.com
  fetch?: typeof fetch;
}
```

In `cogitator.yml`, `channels.teams` takes `ownerIds` (Entra object ids), `appType`, `tenantId`, `tenants` (a list or `*`), `users`, `trigger`, `serviceUrlHosts` and `webhook` (`port`, default `3978`, and `path`, starting with `/`). It reads `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD` and `TEAMS_TENANT_ID`. A `MultiTenant` bot needs `tenants`. `UserAssignedMSI` is refused there: such a bot is built in code with its `tokenProvider`.

```yaml title="cogitator.yml"
channels:
  teams:
    ownerIds: ['8f2b...-object-id']
    trigger: mention
    serviceUrlHosts: [smba.trafficmanager.net]
```

The scaffolder makes a bot on Teams with `npx create-cogitator-app my-bot --preset channels --channels teams`, and `cogitator init` and `cogitator wizard` offer it too. With WebChat in the same project the messaging endpoint is a route of the WebChat server, and with GitHub the GitHub webhook is a route of the Teams server, so a deploy that exposes one port reaches them all.
