teams-bot
Reference Microsoft Teams bot using `@theokit/gateway-teams`. Built on the modern `@microsoft/teams.apps` v2 SDK.
teams-bot
Reference Microsoft Teams bot using @theokit/gateway-teams. Built on the modern @microsoft/teams.apps v2 SDK.
What you'll need
- Microsoft Azure account (free tier OK).
- Azure AD app registration with Bot Framework configured.
- A webhook URL publicly reachable — use ngrok for local dev.
- An LLM provider key (this example uses OpenRouter — sign up at https://openrouter.ai).
Setup (8 steps)
1. Azure AD App Registration
Open https://portal.azure.com → Microsoft Entra ID → App registrations → + New registration.
- Name: anything (e.g.
theo-teams-bot) - Supported account types: Choose
Single tenantfor org-only ORMultitenantfor any work account - Register
Copy:
- Application (client) ID → your
TEAMS_CLIENT_ID - Directory (tenant) ID → your
TEAMS_TENANT_ID
2. Client secret
In the same app: Certificates & secrets → + New client secret → 6/12/24 months. Copy the Value (not the Secret ID) → your TEAMS_CLIENT_SECRET.
3. API permissions (Microsoft Graph)
In the same app: API permissions → + Add a permission → Microsoft Graph → Application permissions → grant your org's required minimum (typically none if you only respond to mentions). Click Grant admin consent.
4. Azure Bot Service registration
https://portal.azure.com → Create a resource → search "Azure Bot" → Create.
- Bot handle: anything (e.g.
theo-bot) - Subscription / resource group: pick yours
- Pricing tier: F0 (free) is fine
- Microsoft App ID: select Use existing app registration and paste your
TEAMS_CLIENT_ID - Create
5. Configure messaging endpoint
In the Bot resource: Configuration → Messaging endpoint → set to your future webhook URL: https://YOUR-NGROK-DOMAIN/api/messages (replace once you have ngrok running in step 7).
6. Configure .env
cp .env.example .envFill in:
TEAMS_CLIENT_ID=00000000-0000-0000-0000-000000000000
TEAMS_CLIENT_SECRET=your-client-secret-value
TEAMS_TENANT_ID=00000000-0000-0000-0000-000000000000
TEAMS_BOT_DISPLAY_NAME=Theo
OPENROUTER_API_KEY=sk-or-v1-...
PORT=39787. Expose your local server
ngrok http 3978
# Note the https:// URL (e.g. https://ab12cd34.ngrok-free.app)Go back to step 5 and paste https://ab12cd34.ngrok-free.app/api/messages as the Messaging endpoint. Click Apply.
8. Install bot into Teams
In the Bot resource: Channels → Microsoft Teams → Apply to enable the Teams channel.
Then you need a Teams app manifest. The simplest path:
- Go to Channels → Microsoft Teams → Open in Teams to test 1:1 chat directly.
- For org-wide install, create a manifest via Microsoft Teams Developer Portal and upload it.
Run
pnpm install
pnpm run runYou should see:
[teams-bot] listening on http://localhost:3978
[teams-bot] webhook URL: http://localhost:3978/api/messagesSend a 1:1 message to your bot in Teams. The bot replies via the agent.
Smoke test (validates credentials only)
If you just want to verify the credentials work without setting up Bot Service:
pnpm run smokeThe smoke prints PASS if the SDK accepts your tenant/client/secret combo, FAIL otherwise.
Architecture
Teams client (you on phone / desktop)
│
▼
Microsoft Teams backend
│ (signs an outbound HTTPS request with JWT)
▼
ngrok → http://localhost:3978/api/messages
│
▼
Express server (you control the lifecycle)
│
▼
ExpressAdapter (from @microsoft/teams.apps)
│ (JWT validated by SDK — D319)
▼
App.on("activity", ...)
│
▼
TeamsAdapter.onInbound handler
│ (normalizes MessageActivity → TeamsMessageEvent — D318)
▼
Agent.create / agent.send (LLM call)
│
▼
adapter.sendMessage → app.send(conversationId, activity) → Teams
│
▼
Recipient (response delivered)Notes on architecture
Why app.send(conversationId, activity) not a ConversationReference
The Hermes Python adapter (microsoft-teams-apps) stored a ConversationReference per chat for proactive sends. The TypeScript v2 SDK simplifies: app.send takes a plain conversation-id string. The SDK manages the underlying reference internally. We just pass event.channel.id back when replying.
This is documented in gateway-teams-sdk-inspection.md (Phase 0 of the plan).
Why no rawBody middleware (unlike WhatsApp)
Teams JWT validation reads the Authorization header. The SDK doesn't HMAC-sign the body. Standard express.json() is sufficient — no verify callback needed.
Troubleshooting
- Bot doesn't see my message → "single-tenant" auth issue. Your Azure AD app might be configured for single-tenant; messages from external tenants are silently rejected by the SDK. Check App registrations → your app → Authentication → Supported account types.
- After restarting the bot, messages don't reach it. When the bot was registered (step 4-5), the messaging endpoint took effect. Restarts don't break that. If they do, check that ngrok is on the same URL and the Bot Service Messaging Endpoint matches.
- "WhatsAppConnectTimeoutError"-style hang on first connect. SDK is verifying credentials with Azure — first call can take 5-10s on cold start. Wait.
- 401 in logs. Token expired (client_secret older than its TTL) or wrong tenant_id. Regenerate the secret in step 2.
v0.1 limits
- 1:1 + group chat + channel posts, text only.
- No Adaptive Cards first-class API (D320) — use
adapter.getApp()escape hatch + the SDK'sAdaptiveCardbuilder for rich UI. - No status receipts (D323) — Teams Bot Framework doesn't emit clean
delivered/readcallbacks. - ConversationReference is managed by the
(README truncated — see full version on GitHub.)
Code
/**
* Microsoft Teams bot example — built on `@microsoft/teams.apps` v2.
*
* The Teams SDK auto-registers `POST /api/messages` on an Express app
* passed via `httpServerAdapter` (D316/D326). We pass it through
* `TeamsAdapter` and let the SDK handle wiring.
*
* Expose this server to the public internet (ngrok / Azure Bot Service
* Messaging Endpoint). See README.md for the 8-step setup.
*/
import express from "express";
import { ExpressAdapter } from "@microsoft/teams.apps";
import {
TeamsAdapter,
type TeamsMessageEvent,
} from "@theokit/gateway-teams";
import { Agent } from "@theokit/sdk";
const CLIENT_ID = required("TEAMS_CLIENT_ID");
const CLIENT_SECRET = required("TEAMS_CLIENT_SECRET");
const TENANT_ID = required("TEAMS_TENANT_ID");
const BOT_DISPLAY_NAME = process.env.TEAMS_BOT_DISPLAY_NAME ?? "Theo";
const PROVIDER_KEY = required("OPENROUTER_API_KEY");
const PORT = Number(process.env.PORT ?? 3978);
function required(name: string): string {
const v = process.env[name];
if (v === undefined || v.length === 0) {
console.error(`[teams-bot] missing env var: ${name}`);
process.exit(1);
}
return v;
}
const expressApp = express();
// EC-6: Teams SDK uses Authorization header for JWT — body is plain JSON.
// `express.json()` is sufficient; no rawBody preservation needed (unlike WhatsApp).
expressApp.use(express.json());
const httpServerAdapter = new ExpressAdapter(expressApp);
const adapter = new TeamsAdapter({
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
tenantId: TENANT_ID,
botDisplayName: BOT_DISPLAY_NAME,
httpServerAdapter,
});
adapter.onInbound(async (event) => {
if (event.platform !== "teams") return;
const tm = event as TeamsMessageEvent;
console.log(
`[teams-bot] inbound from=${tm.sender.id} convType=${tm.teams.conversationType} text=${tm.text.slice(0, 60)}`,
);
const agent = await Agent.create({
apiKey: PROVIDER_KEY,
model: { id: "openai/gpt-4o-mini" },
name: `teams-${tm.channel.id}`,
systemPrompt:
"You are a concise Teams assistant. Reply in one short paragraph using Teams markdown when helpful.",
});
try {
const run = await agent.send(tm.text);
const result = await run.wait();
await adapter.sendMessage({
channel: tm.channel,
text: result.result ?? "(no reply)",
});
} finally {
await agent.dispose();
}
});
await adapter.connect();
// Bind the port — the SDK already registered POST /api/messages on our
// Express app during adapter.connect() → app.initialize().
await httpServerAdapter.start(PORT);
console.log(`[teams-bot] listening on http://localhost:${PORT}`);
console.log(`[teams-bot] webhook URL: http://localhost:${PORT}/api/messages`);
console.log(`[teams-bot] expose via ngrok: \`ngrok http ${PORT}\``);
process.on("SIGINT", async () => {
console.log("\n[teams-bot] shutting down");
await adapter.disconnect();
await httpServerAdapter.stop();
process.exit(0);
});
Run
cd examples/teams-bot
cp .env.example .env # fill in keys
pnpm install
pnpm run run