@copilotkit/channels-teams
The Microsoft Teams platform adapter for @copilotkit/channels. It's a
concrete PlatformAdapter that plugs Teams into the platform-agnostic bot
engine, exactly like @copilotkit/channels-slack does for Slack. You
write your bot once with createChannel (handlers, JSX, tools, context) and run it
on Teams by adding this adapter.
It is built on the Microsoft 365 Agents SDK (@microsoft/agents-hosting),
the successor to the Bot Framework SDK.
The adapter keeps its own Teams/Microsoft 365 credentials (clientId /
clientSecret / tenantId, or none for anonymous local dev) — in the managed
path the Channel runs inside a CopilotKit Intelligence-configured
CopilotRuntime (free plan available), which starts and owns the channel's
lifecycle. Building and operating your own channel runner on the SDK primitives
is also a supported path.
Managed Channels: the alternative to holding your own credentials
This adapter is the self-hosted path: your process holds the Microsoft Teams credentials, runs the Microsoft Teams ingress, and talks to Microsoft Teams directly.
Managed Intelligence Channels is the alternative. Intelligence owns the provider edge — signed ingress, egress, and encrypted credential storage — so your process holds no Microsoft Teams credentials and exposes no public Microsoft Teams endpoint. You also get durable threads, the Channels dashboard with per-Channel health and transcripts, and guided provider setup from either the browser wizard or the CLI. For a newly created managed app, the browser creates the durable Channel draft and issues the fully scoped provisioning command:
npx copilotkit@latest channels add --project-id <project-id> --channel-id <channel-id> --adapter teams --provision
That managed path creates a Teams-managed bot and Entra identity; it does not require Azure Bot. The peer manual path uses Teams Developer Portal plus Entra. Both keep provider secrets and one-time app-package bytes out of your project.
Your bot code is otherwise identical — the agent, tools, context, commands, and turn handlers do not change. Only the transport does. See examples/teams/app/managed.ts for the same bot wired both ways, and the copilotkit-channels skill for the runtime wiring.
This self-hosted adapter remains fully supported. Choose it when you want the provider connection inside your own infrastructure.
Install
pnpm add @copilotkit/channels @copilotkit/channels-ui @copilotkit/channels-teams
pnpm add @microsoft/agents-hosting @microsoft/agents-activity
The Microsoft 365 Agents SDK packages are optional peer dependencies, so they are not installed for you. Add them as shown above — this adapter is the self-hosted path, and it needs them to run the Teams ingress.
express is an optional peer too, but you only need it if you use the built-in
listener, createTeamsServer. It is loaded lazily inside start(), so a bot
that serves POST /api/messages from its own HTTP server does not install it:
pnpm add express # only for createTeamsServer
They are optional because the managed path does not need them. Managed Channels
reach this package only through the ./render subpath, which never touches the
Microsoft SDK, so an app that installs @copilotkit/runtime for a plain chat UI
no longer pulls the whole Microsoft Agents stack, or the exact zod pin that
comes with it.
If you import the adapter without them, Node fails at import time and names the package to install:
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@microsoft/agents-hosting'
Quickstart
import { createChannel } from "@copilotkit/channels";
import { teams } from "@copilotkit/channels-teams";
import { CopilotRuntime, CopilotKitIntelligence } from "@copilotkit/runtime/v2";
import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node";
const bot = createChannel({
name: "support-bot", // project-unique Intelligence Channel name
identifyUser: "platform",
adapters: [teams({ port: 3978 })],
});
bot.onMessage(({ thread, message }) => thread.post(`Echo: ${message.text}`));
// The runtime owns the channel's lifecycle — there is no `bot.start()`.
const runtime = new CopilotRuntime({
intelligence: new CopilotKitIntelligence({
// apiUrl and wsUrl default to cloud-hosted CopilotKit Intelligence — override
// both together only for a self-hosted deployment.
apiKey: process.env.CPK_INTELLIGENCE_API_KEY!, // free tier available
}),
channels: [bot],
});
// Creating the listener starts the Channel's connection.
const listener = createCopilotNodeListener({ runtime });
// Optional: await that activation; once settled, POST /api/messages is listening
// on :3978.
await listener.channels.ready();
Then point the Microsoft 365 Agents Playground at it. No Microsoft credentials are required for local development:
npx @microsoft/m365agentsplayground # opens http://localhost:56150
The Playground connects to http://127.0.0.1:3978/api/messages and gives you a
Teams-like chat UI to test against. See examples/teams
for a complete, runnable echo bot, and the
Microsoft Teams guide
for sideloading into real Teams via Azure Bot Service.
How it maps onto the PlatformAdapter contract
- Ingress: a
CloudAdapterreceives Teams activities atPOST /api/messages(stood up by an Express server). Eachmessageactivity is normalized intosink.onTurn(...). Uploaded files ride along as attachments:buildFileContentPartsdownloads them (afile.download.infoURL, or adata:/https media URL) and hands the agent multimodal content parts — CSV/JSON/text as decoded text, images and PDFs as binary. That's what makes "upload a CSV → get a chart" work. Note Teams only delivers uploaded files to a bot in 1:1 (personal) chat (requiressupportsFiles: truein the app manifest); in a channel or group chat Teams does NOT send the file to the bot at all, so chart-from-data there means pasting the data inline. - Egress: structured/interactive UI is rendered to an Adaptive Card
(1.5) and sent as an attachment; a reply that collapses to plain text is sent
as a normal text activity (a bare
Echo: hishouldn't be a card). Both go out on the liveTurnContextwithin the originating turn. The engine awaits the whole turn handler, so a reply (or a fullrunAgent()loop) completes before the HTTP response closes. (Out-of-turn / proactive sends fall back toCloudAdapter.continueConversationvia the captured conversation reference.) - Files out:
postFileposts a file to the conversation. An image (e.g. a rendered chart PNG) is sent as an inline attachment via adata:URI, so it renders directly in the thread — the bot-slackpostFileparallel. - Streaming: text replies stream by message edit (Teams' baseline
model). It posts the first content, then
updateActivityedits the same message as the buffer grows (throttled and serialised; seeTeamsMessageStream), after a typing indicator. Native token streaming is a later enhancement. - Agent runs:
createRunRendererbridges AG-UI events to Teams. Each text message is streamed by edit, and tool calls plus interrupts are captured for the run loop. - History: Teams does not hand the bot a queryable transcript, so an
in-memory
TeamsConversationStorekeeps one per conversation and seeds each agent run with it. Swap in a durableConversationStorefor production.
Native Teams JSX
Use the Teams namespace for Adaptive Card types outside the portable JSX
set. A native card has one explicit Teams.AdaptiveCard root. Actions can be
root children or children of Teams.ActionSet.
import { Teams } from "@copilotkit/channels-teams";
await thread.post(
<Teams.AdaptiveCard fallbackText="Deploy approval">
<Teams.TextBlock text="Deploy ready" wrap />
<Teams.ActionSet>
<Teams.Action.Submit
key="approve"
title="Approve"
value={{ decision: "approve" }}
onSubmit={({ action }) => approve(action.value)}
/>
</Teams.ActionSet>
</Teams.AdaptiveCard>,
);
The serializer computes the card version from the types and properties in use.
An explicit lower root version fails with the component and property that raised
the minimum. Named child slots stay traversable, so handlers inside an action
set survive managed delivery and action recovery. Teams.Raw accepts a
reviewed non-interactive Adaptive Card object.
The generated native catalog labels the 38 stable Teams body types, 7 stable actions, preview entries, and supporting nodes. Host badges and catalog presence come from adaptivecards.microsoft.com. “Supported” here means Microsoft marks the entry for Teams; verification in a live tenant remains a separate release check. Direct Teams and managed Teams use the same serializer and Bot Framework attachment shape.
Options
teams({
port: 3978, // POST /api/messages port (Playground default)
host, // interface to listen on (see below)
clientId, // Microsoft app id; omit for anonymous local dev
clientSecret, // omit for anonymous local dev
tenantId, // omit for multi-tenant / anonymous
interruptEventNames, // custom-event names treated as agent interrupts
allowedServiceUrls, // extra Bot Connector origins to accept (see below)
});
Credentials also resolve from the clientId / clientSecret / tenantId
environment variables, or from Connections__<id>__Settings__ClientId /
ClientSecret / TenantId (the names the M365 Agents SDK reads). The Bot
Framework SDK's MicrosoftAppId / MicrosoftAppPassword variables are not
read; if any of them is set and no app id resolves, start fails instead of
running unauthenticated. Rename them to one of the supported forms.
Request authentication
With a clientId configured (as an option, or through any of the env vars the
M365 Agents SDK reads, including Connections__*), POST /api/messages only
accepts requests that carry a valid token for that app. The SDK's authorizeJWT
checks the signature, audience and expiry. The listener then requires the bot's
own app id as the audience, and an issuer the SDK configured for the app (Bot
Framework, https://api.botframework.com, or your tenant's Azure AD issuers;
the botframework.com tenant when no tenantId is set). A Bot Framework token
must also carry a serviceurl claim, and its signing key must be endorsed for
the activity's channelId (see below). Anything else gets 401 or 403 and
never reaches your handlers. Settings such as authorityEndpoint, or a custom issuer
list (Connections__<id>__Settings__Issuers), resolve from the environment the
same way the SDK reads them.
The Connections__* env settings take precedence: when any of them is set, the
SDK builds its auth settings from those env vars alone and ignores the
clientId / clientSecret / tenantId options. If the app id it resolves is
missing or differs from the clientId you configured, start fails with an
error that names the conflict, rather than running anonymously or as another
app.
The bot sends its app token to the activity's serviceUrl when it replies, so
that URL is checked too. It must be one of the Microsoft connector hosts below
and, when the token carries a serviceurl claim, have the same origin as that
claim. Otherwise the request gets 403, and the bot never sends to it:
https://smba.trafficmanager.net(Teams)https://smba.infra.gcc.teams.microsoft.com(Teams GCC)https://smba.infra.gov.teams.microsoft.us(Teams GCC High)https://smba.infra.dod.teams.microsoft.us(Teams DoD)https://*.botframework.com(Bot Framework channels such as Web Chat)https://msteams.botframework.azure.cn(Teams operated by 21Vianet)https://*.botframework.azure.us(Azure Government)
Add any other connector origin with
allowedServiceUrls: ["https://connector.example"]. A leading *. on the host
allows any subdomain, and needs at least two labels after it
(https://*.example.com, not https://*.com). Plain http is accepted only
for localhost, 127.0.0.1 and [::1]. An entry that breaks these rules is
ignored with a warning.
To test a credentialed bot with the Bot Framework Emulator, allow the
Emulator's local connector, for example
allowedServiceUrls: ["http://localhost:<port>"].
Channel endorsements. Each key in the Bot Connector's signing-key document
lists the channels it may sign for. For a Bot Framework token
(iss: https://api.botframework.com) whose key lists endorsements, the
activity's channelId must be one of them, or the request gets 403. Like the
Bot Framework SDK, keys whose endorsements field is absent or empty are not
checked; Azure AD keys (single-tenant Azure Bot Service, the Bot Framework
Emulator) have none. The key document is cached for an hour; fetching it times out after
5 seconds, and a failed fetch rejects the request (503).
Anonymous mode. Without a clientId the endpoint is anonymous: anyone
who can reach it can drive a turn. That mode is for the local M365 Agents
Playground only, so the listener binds to 127.0.0.1 unless you set host. That
is IPv4 only: a client that connects to localhost over IPv6 (::1) without
falling back to IPv4 needs host: "localhost" or host: "::1".
Setting a non-loopback host (for example host: "0.0.0.0") exposes an
unauthenticated bot to the network; the adapter logs a warning. With a
clientId the listener binds all interfaces unless host says otherwise.
createTeamsServer. Called directly, it uses the auth settings of the
CloudAdapter you pass. An authConfig override must resolve to that
adapter's app id: one that resolves to no app id (for example {}) or to
another app makes start() fail, so an override can never turn a credentialed
adapter anonymous or switch its identity.
If you serve POST /api/messages yourself instead of using
createTeamsServer, do the same checks before CloudAdapter.process: run
authorizeJWT, accept only your app id as the audience and only its issuers,
check endorsements for Bot Framework tokens, and check that the activity's
serviceUrl is a connector you trust and matches the token's serviceurl
claim. authorizeJWT alone accepts any issuer and does not look at
serviceUrl, and the bot will send its app token there.
Inbound attachment downloads
Remote attachments require HTTPS on port 443 and a recognized Microsoft file
host. The built-in policy accepts tenant subdomains of SharePoint
(sharepoint.com, sharepoint.us, sharepoint.cn, sharepoint.de),
Teams (teams.microsoft.com, teams.microsoft.us), asm.skype.com,
botframework.com, botframework.azure.us, files.1drv.com, and svc.ms,
plus graph.microsoft.com and the exact Teams connector hosts listed above.
URLs containing credentials or pointing elsewhere are skipped. For local
Playground uploads, use bounded data: attachments.
Every download and redirect resolves DNS and rejects the destination if any
address is private, loopback, link-local, reserved, or an unsupported IPv6
transition address. The TLS connection uses only the validated addresses and
still verifies the original hostname. Redirects are limited to five. Graph
file-content redirects use their preauthenticated URL; the Graph bearer token
is never forwarded to a redirect destination. Connector allowedServiceUrls
does not extend this attachment policy.
The downloader enforces size limits while reading and cancels oversized or
timed-out requests. Inline data is bounded before decoding. Configure limits
with the adapter's files option:
| Option | Default | Scope |
|---|---|---|
maxBytesPerFile | 8 MiB | Each attachment |
maxFiles | 5 | Download attempts per message, including failed attempts |
maxTextBytes | 200 KiB | Decoded text passed to the agent |
maxBytesPerMessage | 40 MiB | Combined download bytes, including Graph metadata |
downloadTimeoutMs | 30,000 | DNS, connection, redirects, and body of each download; each Graph metadata/token operation |
maxDownloadTimeMs | 60,000 | Shared elapsed download time for the message |
The adapter validates download-budget limits when constructed. They must be
nonnegative safe integers; invalid values such as Infinity fail immediately
with an error naming the option. Each message gets a fresh budget.
The inline and Graph fallback paths share one budget. Failed downloads produce short skip notes without including preauthenticated URLs or transport error details. The Graph channel fallback uses the commercial Graph endpoint.
Status & roadmap
Implemented: message ingress; Adaptive Card rendering of the bot-ui
vocabulary (<Header>, <Section>/<Markdown>, <Fields>, <Table>,
<Image>, <Actions>/<Button>, <Select>, <Input>, <Context>) with a
plain-text path for bare replies and a Markdown table fallback; streamed-by-
edit text replies with a typing indicator; runAgent tool-call / interrupt
capture; card-action round-trip + HITL (below); conversation history;
update / delete. Verified in the M365 Agents Playground.
Card-action round-trip + HITL. Adaptive Card Action.Submit clicks arrive
as Message activities carrying the action data in activity.value;
decodeInteraction parses our opaque ckActionId + button value and routes them
to sink.onInteraction, which resolves the engine's awaitChoice waiter and
runs the button's onClick (e.g. to edit the picker in place). A tool handler
that calls await thread.awaitChoice(<Card/>) therefore gates the agent on a
human decision; see examples/teams for an approve/reject demo. Ingress and
interaction decoding derive the conversation key from one shared helper
(conversationKeyOf) so the waiter always resolves.
Async turn handoff. When credentialed, ingress acks the inbound turn
immediately and runs the agent on a detached continueConversation context, so
an awaitChoice suspend can outlive the Teams turn window (approval minutes
later). In the anonymous local Playground (where continueConversation has no
app id) the run uses the inbound turn context, which localhost holds open across
the suspend. Waiters are in-memory (v1), so they don't survive a process restart.
Planned follow-ups (the architecture leaves room for each):
- Native token streaming: token-by-token replies via the SDK's
StreamingResponse(queueInformativeUpdate/queueTextChunk/endStream), vs. the current post-then-edit model. - Durable HITL waiters: persist pending
awaitChoicestate so approvals survive a restart (today they're in-memory). - User lookup (Microsoft Graph) and arbitrary non-image file upload via
the Teams/Graph file-consent flow (today
postFilehandles inline images).
Exports
teams, TeamsAdapter, TeamsAdapterOptions, TeamsReplyTarget,
ConversationKey; TeamsConversationStore; createRunRenderer;
conversationKeyOf / parseCardAction; renderTeamsMarkdown;
renderAdaptiveCard / AdaptiveCard / isPlainText /
ADAPTIVE_CARD_CONTENT_TYPE; TEAMS_LIMITS; TeamsMessageStream;
createTeamsServer / TeamsServer / TeamsServerConfig;
SanitizingHttpAgent (deprecated — Channels sanitize by default);
buildFileContentParts / TeamsAttachmentRef /
FileDeliveryConfig.