Connect and run your agent in Microsoft Teams
Run any supported agent framework in Teams through a managed CopilotKit Intelligence connection.
In this guide, you will connect an AG-UI agent to a managed Microsoft Teams bot, start a long-running Channels SDK listener, and verify a real Microsoft Teams message. CopilotKit Intelligence owns the public messaging endpoint and Teams credentials; your process owns the agent and application logic.
The bot is created in your own Microsoft tenant and belongs to you. Setup registers it in Teams Developer Portal — no Azure subscription and no Azure Bot resource are involved.
New to the product? Start with the Channels overview to understand how the SDK, Runtime, Intelligence, and provider connection fit together.
Before you continue, configure the Channel in Intelligence.
You should have CHANNEL_CODE and INTELLIGENCE_API_KEY.
What kind of bot this is#
Microsoft supports more than one kind of bot identity. Setup creates a Teams-managed bot: Teams Developer Portal owns the registration, and its app ID and client secrets live there under Tools → Bot management, in your directory, rotatable by your own admins. It is registered single-tenant, so it is scoped to your organization. Nothing is created in your Azure account and nothing is billable. If you stop using CopilotKit the registration stays in your Developer Portal; removing it is yours to do.
The alternative Microsoft offers is an Azure Bot — a billable Azure Bot Service resource in your own subscription, needing a subscription, resource group, and region. Microsoft's Teams CLI recognizes only these two kinds; a self-managed Microsoft Entra app registration that you create and manage yourself is not something it can produce or consume.
Treat the choice as permanent. Microsoft provides a one-way teams app bot migrate to Azure that replaces the existing registration, with nothing to
migrate back. CopilotKit itself is indifferent to which kind you point a Channel
at — Intelligence performs an ordinary client-credentials grant with the app ID,
tenant ID, and secret — but the app ID is embedded in the app package as the bot
ID, so changing identity means generating a fresh package, uploading it, and
re-installing.
Before you start#
- Node.js 22 or later; the managed launcher requires the global
WebSocketavailable in Node.js 22+ - A long-running Node host or container; serverless request handlers cannot own the persistent gateway connection
Build and run your Channel#
Create the runner#
The install command below uses an exact, tested SDK pair; upgrade
@copilotkit/channels and @copilotkit/runtime together.
mkdir my-teams-channel
cd my-teams-channel
npm init -y
npm pkg set type=module
npm install --save-exact @copilotkit/channels@0.6.1 @copilotkit/runtime@1.65.0
npm install -D tsx typescript @types/node{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["*.ts", "*.tsx"]
}Connect your agent backend#
The selector's Agent backend controls the setup below. It must export a
fresh makeAgent(threadId) result for each Teams conversation.
The Built-in Agent runs in the same Node.js process as Channels, so it does
not need an AGENT_URL or a second server. Follow the
Built-in Agent quickstart to configure your model provider,
then use the Runtime package already installed by the Channels runner.
Create a new agent for each channel thread:
import { BuiltInAgent } from "@copilotkit/runtime/v2";
export function makeAgent(threadId: string) {
const agent = new BuiltInAgent({
model: "openai:gpt-5.4-mini",
});
agent.threadId = threadId;
return agent;
}Declare the managed Teams Channel#
Create the listener below. Replace support-teams with the exact Code shown
in Intelligence.
import { createServer } from "node:http";
import { createChannel } from "@copilotkit/channels";
import {
CopilotKitIntelligence,
CopilotRuntime,
} from "@copilotkit/runtime/v2";
import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node";
import { makeAgent } from "./agent.js";
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing required environment variable: ${name}`);
return value;
}
const channel = createChannel({
name: required("CHANNEL_CODE"),
identifyUser: "platform",
agent: makeAgent,
});
channel.onMessage(async ({ thread, message }) => {
await thread.runAgent({
prompt: message.contentParts?.length
? [
...(message.text
? [{ type: "text" as const, text: message.text }]
: []),
...message.contentParts,
]
: message.text,
context: [
{ description: "Originating platform", value: message.platform },
],
});
});
const intelligence = new CopilotKitIntelligence({
apiKey: required("INTELLIGENCE_API_KEY"),
// Managed deployments use the hosted defaults. Override both URLs
// together only for self-hosted or non-production Intelligence.
apiUrl: process.env.INTELLIGENCE_API_URL,
wsUrl: process.env.INTELLIGENCE_GATEWAY_WS_URL,
});
const runtime = new CopilotRuntime({
agents: {},
intelligence,
channels: [channel],
});
// Wire teardown before the listener exists, because creating it is what
// starts the Channel. A Ctrl-C during the connect window then still tears
// the Channel down instead of hitting Node's default handler.
let teardown: (() => Promise<void>) | undefined;
const shutdown = async () => {
await teardown?.();
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
const listener = createCopilotNodeListener({
runtime,
basePath: "/api/copilotkit",
});
const channels = listener.channels;
const server = createServer(listener);
teardown = async () => {
await channels.stop();
if (server.listening) server.close();
};
// Optional: block startup until the activation above settles, so a broken
// deploy fails loudly instead of serving as a bot that never answers.
await channels.ready({ timeoutMs: 30_000 });
const status = channels.status();
if (status.overall !== "online") {
throw new Error(`Teams Channel is not online: ${JSON.stringify(status)}`);
}
const port = Number(process.env.PORT ?? 3000);
server.listen(port, () => {
console.log(`Teams Channel online; lifecycle server listening on :${port}`);
});Creating the Node listener starts the Channel: it owns its own process
lifetime, so a declared Channel connects because it was declared. ready()
is therefore optional and purely await-and-observe — it resolves once
activation settles and rejects with the activation failure. Because it can
settle with setup still required, inspect status() before reporting the
Channel online. Skip ready() and activation failures land in your logs
instead.
The runner does not expose a Teams webhook. Teams delivers to the Intelligence messaging endpoint, which setup registered on the bot for you.
Configure secrets and start#
INTELLIGENCE_API_KEY=<project-api-key>
CHANNEL_CODE=support-teams
# Add the agent variables shown for your selected backend.
PORT=3000
# Optional paired overrides for self-hosted or non-production Intelligence:
# INTELLIGENCE_API_URL=https://intelligence.example.com
# INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.example.comHosted Intelligence supplies both managed base URLs by default. For a
self-hosted or non-production deployment, override both together. The REST
and realtime planes use separate hosts, so do not derive the WebSocket URL
from the API URL. Pass each as a bare base URL without /api, /socket,
/runner, or /client. Create the project-scoped runtime key from API
Keys in the Intelligence project sidebar.
Start the selected agent backend, then run:
node --env-file=.env --import tsx channel.tsIntelligence should report Online.
Know the healthy state
Starting the process is what connects the Channel, so it should become
Online on its own; the await channels.ready(...) in this guide only
waits for that to settle.
| Status | What to check |
|---|---|
| Disabled | Enable the Channel before expecting delivery. |
| Setup incomplete | Finish the selected provider's required setup fields before starting the runtime. |
| Setup failed | Reopen platform setup and correct the rejected credentials or configuration. |
| Waiting for runtime | Start the process — creating the listener connects the Channel — and match its Code and provider to this Channel. |
| Conflict | Compare every replica's complete Channel declaration set. Identical replicas should elect one active owner and connected standbys; different but overlapping sets are unsafe. |
| Offline | Check the listener process, network, and Intelligence gateway connection. |
| Delivery failing | Check platform credentials, app permissions, and the provider response. |
| Online | The runtime is connected; send a real provider message to verify the full path. |
Verify a real Teams message#
Open the app in Teams, add it to a chat or team, then send:
Summarize the decisions in this conversation.In a team channel, mention the bot. A message that does not mention it is ambient, and an ambient message only reaches your agent once a thread has been subscribed to.
A real response validates the bot registration, Intelligence messaging endpoint, managed credentials, gateway listener, agent, and Adaptive Card reply path.
Troubleshooting#
The Channel stays at Waiting for runtime
Confirm the Code matches CHANNEL_CODE and the API key belongs to the same
Intelligence project. Provider routing comes from the Teams connection
attached in Intelligence, not from a createChannel option.
Teams cannot reach the bot
In Teams Developer Portal, open Tools → Bot management, select the bot, and compare its endpoint address with the messaging endpoint shown in Intelligence. They must match exactly, including no leading or trailing space — Developer Portal rejects a padded value and reports it as a save failure, which reads like a portal fault rather than a bad value. A bot with the wrong endpoint completes setup and then never receives anything.
This is not your runner's PORT. The runner takes no inbound provider
traffic at all.
The app package is rejected
Upload the complete zip that setup produced; do not unzip it and upload only
manifest.json. If the app is already in the team and you are re-adding it
to change permissions, remove it first — Teams settles app permissions only
while the app is being added.
Startup settles but does not become Online
Inspect channels.status() after the guard in the runner. Finish any
Setup incomplete state in Intelligence. If Intelligence reports
Conflict, compare the complete Channel declaration set on every replica;
identical replicas should elect an active owner and standbys.
Next, map application users and choose Memory grants, add tools and context, or build interactive approvals.