Send a message to Microsoft Teams via Bot Framework

Send a message (plain text or Adaptive Card) to a Microsoft Teams conversation using the Bot Framework REST API. Use this for proactive sends (alerts, replies, threaded notifications) or as the outbound leg of a custom Teams bot. Unlike incoming-webhook MessageCards, the Bot Framework path supports threaded replies, Adaptive Cards 1.4+, and any conversation the bot has been added to (channels, group chats, 1:1). Requirements: - An Azure Bot resource (https://portal.azure.com) with its messaging endpoint pointing at a Windmill HTTP trigger (or wherever your bot logic lives). - A stored ConversationReference object - captured by your bot's installationUpdate / message handler when the bot was first added to the conversation. Stash it in a database, DataTable, or wmill.setState keyed by the conversation you want to reach. - The Azure Bot resource credentials (app_id, app_password) saved as an azure_bot Windmill resource. For a complete Teams to Discord bridge using this script, see the Windmill blog post: https://www.windmill.dev/blog/teams-discord-bridge

Script msteams Verified

by hugo989 ยท 5/21/2026

The script

Submitted by hugo989 Bun
Verified 7 days ago
1
// Send a message (plain text or Adaptive Card) to a Microsoft Teams conversation
2
// using the Bot Framework REST API. Use this for proactive sends or as the
3
// outbound leg of a custom Teams bot.
4

5
type AzureBot = {
6
  app_id: string;
7
  app_password: string;
8
  // Set only when the Azure Bot is configured as Single Tenant. Leave empty
9
  // for Multi Tenant bots so the multi-tenant Bot Framework token endpoint is
10
  // used.
11
  tenant_id?: string;
12
};
13

14
type ConversationReference = {
15
  bot: { id: string; name?: string };
16
  channelId: string;
17
  conversation: {
18
    id: string;
19
    tenantId?: string;
20
    isGroup?: boolean;
21
    conversationType?: string;
22
  };
23
  serviceUrl: string;
24
  user?: { id: string; name?: string };
25
};
26

27
const BF_RESOURCE = "https://api.botframework.com";
28
const BF_TOKEN_ENDPOINT_MULTITENANT =
29
  "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token";
30

31
export async function main(
32
  azure_bot: AzureBot,
33
  conversation_reference: ConversationReference,
34
  text: string,
35
  card?: object,
36
): Promise<{ ok: true; activity_id?: string }> {
37
  const token = await getBotAccessToken(azure_bot);
38

39
  const activity: Record<string, unknown> = {
40
    type: "message",
41
    from: conversation_reference.bot,
42
    recipient: conversation_reference.user ?? { id: "" },
43
    conversation: conversation_reference.conversation,
44
  };
45
  if (card) {
46
    activity.attachments = [
47
      { contentType: "application/vnd.microsoft.card.adaptive", content: card },
48
    ];
49
  } else {
50
    activity.text = text;
51
  }
52

53
  const url = `${conversation_reference.serviceUrl.replace(/\/$/, "")}/v3/conversations/${encodeURIComponent(conversation_reference.conversation.id)}/activities`;
54

55
  const res = await fetch(url, {
56
    method: "POST",
57
    headers: {
58
      authorization: `Bearer ${token}`,
59
      "content-type": "application/json",
60
    },
61
    body: JSON.stringify(activity),
62
  });
63
  if (!res.ok) {
64
    throw new Error(
65
      `Bot Framework send failed: ${res.status} ${await res.text().catch(() => "")}`,
66
    );
67
  }
68
  const body = (await res.json().catch(() => ({}))) as { id?: string };
69
  return { ok: true, activity_id: body.id };
70
}
71

72
async function getBotAccessToken(azure: AzureBot): Promise<string> {
73
  const isSingleTenant = !!azure.tenant_id;
74
  const tokenEndpoint = isSingleTenant
75
    ? `https://login.microsoftonline.com/${azure.tenant_id}/oauth2/token`
76
    : BF_TOKEN_ENDPOINT_MULTITENANT;
77

78
  // Single-tenant bots use the v1 endpoint with `resource`; multi-tenant bots
79
  // use the v2 botframework.com virtual directory with `scope=.../.default`.
80
  const params = isSingleTenant
81
    ? new URLSearchParams({
82
        grant_type: "client_credentials",
83
        client_id: azure.app_id,
84
        client_secret: azure.app_password,
85
        resource: BF_RESOURCE,
86
      })
87
    : new URLSearchParams({
88
        grant_type: "client_credentials",
89
        client_id: azure.app_id,
90
        client_secret: azure.app_password,
91
        scope: `${BF_RESOURCE}/.default`,
92
      });
93

94
  const res = await fetch(tokenEndpoint, {
95
    method: "POST",
96
    headers: { "content-type": "application/x-www-form-urlencoded" },
97
    body: params,
98
  });
99
  if (!res.ok) {
100
    throw new Error(
101
      `AAD token fetch failed: ${res.status} ${await res.text().catch(() => "")}`,
102
    );
103
  }
104
  const json = (await res.json()) as { access_token: string };
105
  return json.access_token;
106
}
107