01Post a text notice to a Teams Workflows webhook from PythonVerified

Microsoft 365 Connectors (formerly Office 365 Connectors) are nearing deprecation, and creating new ones will soon be blocked. Instead, create a workflow in Teams Workflows that receives webhook requests and POST JSON to the URL it issues. Keep the URL in an environment variable and default to DRY_RUN.

Approach and steps
  1. In Teams, open More options (...) next to the target channel and select Workflows
  2. Pick a template such as Send webhook alerts to a channel, configure it and select Save
  3. Copy the webhook link shown after creation into the TEAMS_WEBHOOK_URL environment variable
  4. Build the {"text": ...} JSON with build_payload and check it stays under 28 KB
  5. POST only when DRY_RUN=0; on 429, retry with a doubling wait
PythonRuns locally
"""Post a plain-text notice to a Teams channel through a Workflows webhook (DRY_RUN by default)."""
import json
import os
import time

import requests

DRY_RUN = os.environ.get("DRY_RUN", "1") != "0"
WEBHOOK_URL = os.environ["TEAMS_WEBHOOK_URL"]  # a secret: never hard-code it
MAX_BYTES = 28 * 1024  # size limit stated on the Incoming Webhook page


def build_payload(text):
    data = json.dumps({"text": text}, ensure_ascii=False).encode("utf-8")
    if len(data) > MAX_BYTES:
        raise ValueError("payload is %d bytes; keep it under 28 KB" % len(data))
    return data


def send(data, tries=4):
    for attempt in range(tries):
        r = requests.post(WEBHOOK_URL, data=data, timeout=30,
                          headers={"Content-Type": "application/json"})
        if r.status_code != 429:
            r.raise_for_status()
            return r.status_code
        time.sleep(2 ** attempt)  # exponential backoff when throttled
    raise RuntimeError("still throttled after %d tries" % tries)


def main():
    text = os.environ.get("MESSAGE", "Nightly check finished: 3 documents are waiting for review.")
    data = build_payload(text)
    if DRY_RUN:
        print("DRY_RUN: would POST %d bytes to the workflow" % len(data))
        print(data.decode("utf-8"))
        return
    print("status:", send(data))


if __name__ == "__main__":
    main()
Output(2026-09-12)
DRY_RUN: would POST 71 bytes to the workflow
{"text": "Nightly check finished: 3 documents are waiting for review."}
Example instruction for Copilot

Write Python that POSTs text to a Teams Workflows webhook with requests. Read the URL from TEAMS_WEBHOOK_URL, and unless DRY_RUN is 0 just print the JSON. Stop if the body exceeds 28 KB, and retry with exponential backoff on 429.

Caution

Treat the webhook URL as a secret and keep it out of code and shared documents. A workflow belongs to its owner rather than to a team, so add co-owners before the owner leaves. The 28 KB and four-per-second limits appear on the page in the Incoming Webhook description. Posting as a flow bot in private channels is listed as under development.

Availability
Teams Workflows (built on Power Automate). requests (tested 2.34.2). Checked 2026-09-12 (page updated 2026-05-06).
Requires
requests 2.34.2
Tested
run and checked locally (2026-09-12, Python 3.12.10 (venv) / Windows 11, requests 2.34.2)
Source
Microsoft Learn, "Create Incoming Webhooks"
Verified
2026-09-12 (v1)
Supporting passages from the sources
To automatically post to a chat or channel when a webhook request is received, use the predefined workflow templates or create a workflow from scratch using the When a Teams webhook request is received trigger.
Microsoft 365 Connectors (previously called Office 365 Connectors) are nearing deprecation, and the creation of new Microsoft 365 Connectors will soon be blocked.
The message size limit is 28 KB.
If more than four requests are made in a second, the client connection is throttled until the window refreshes for the duration of the fixed rate.
Workflows are linked only to specific users (referred to as owners of the workflow) and not to a Teams team or channel.

02Send request-ledger status counts to Workflows as an Adaptive CardNeeds check

Count ledger rows (CSV) by status, put the counts in a FactSet and build Adaptive Card JSON: type message, with an attachment whose contentType is application/vnd.microsoft.card.adaptive. Card building is its own function, so you can check the JSON locally before sending.

Approach and steps
  1. Count the status column of the CSV with count_status
  2. build_card makes a card with a TextBlock (title) and a FactSet (status and count); version 1.2 matches the page's example
  3. Leave out Action.Submit, which isn't supported in Incoming Webhook cards
  4. Print the JSON under DRY_RUN, check how it looks in a test channel, then set DRY_RUN=0
PythonRuns locally
"""Build an Adaptive Card with ledger status counts and post it to a Workflows webhook (DRY_RUN)."""
import csv
import json
import os
from collections import Counter

import requests

DRY_RUN = os.environ.get("DRY_RUN", "1") != "0"
LEDGER = os.environ.get("LEDGER_CSV", "samples/ledger.csv")


def count_status(path):
    with open(path, encoding="utf-8-sig", newline="") as f:
        return Counter(row["状態"] for row in csv.DictReader(f))


def build_card(title, counts):
    facts = [{"title": k, "value": str(v)} for k, v in counts.most_common()]
    return {
        "type": "message",
        "attachments": [{
            "contentType": "application/vnd.microsoft.card.adaptive",
            "contentUrl": None,
            "content": {
                "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
                "type": "AdaptiveCard",
                "version": "1.2",
                "body": [
                    {"type": "TextBlock", "text": title, "weight": "bolder", "wrap": True},
                    {"type": "FactSet", "facts": facts},
                ],
            },
        }],
    }


def main():
    counts = count_status(LEDGER)
    card = build_card("Request ledger: %d rows" % sum(counts.values()), counts)
    body = json.dumps(card, ensure_ascii=False)
    if DRY_RUN:
        print("DRY_RUN: would POST this card")
        print(body)
        return
    r = requests.post(os.environ["TEAMS_WEBHOOK_URL"], data=body.encode("utf-8"), timeout=30,
                      headers={"Content-Type": "application/json"})
    r.raise_for_status()
    print("status:", r.status_code)


if __name__ == "__main__":
    main()
Output(2026-09-12)
DRY_RUN: would POST this card
{"type": "message", "attachments": [{"contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": null, "content": {"$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.2", "body": [{"type": "TextBlock", "text": "Request ledger: 40 rows", "weight": "bolder", "wrap": true}, {"type": "FactSet", "facts": [{"title": "完了", "value": "14"}, {"title": "差戻し", "value": "11"}, {"title": "確認中", "value": "10"}, {"title": "受付", "value": "5"}]}]}}]}
Example instruction for Copilot

Write Python that counts the status column of a CSV and builds Adaptive Card JSON for Teams (a TextBlock title and a FactSet of counts): type message, with attachments whose contentType is application/vnd.microsoft.card.adaptive. Send with requests, but unless DRY_RUN is 0 only print the JSON.

Caution

Workflows support both Adaptive Cards and Message Cards, but buttons aren't rendered. The saved pages don't confirm that the template workflow posts this JSON as is, so check the result in a test channel. The Teams mobile app supports Adaptive Cards up to version 1.6. Reconcile the counts with the ledger before sending.

Availability
Teams Workflows. requests (tested 2.34.2). Checked 2026-09-12.
Requires
requests 2.34.2
Tested
run and checked locally (2026-09-12, Python 3.12.10 (venv) / Windows 11, requests 2.34.2)
Source
Microsoft Learn, "Create Incoming Webhooks"
Microsoft Learn, "Types of cards"
Verified
2026-09-12 (v1)
Supporting passages from the sources
Workflows support both Adaptive Cards and Message Card format (button rendering won't be supported).
For Adaptive Cards in Incoming Webhooks, all native Adaptive Card schema elements, except Action.Submit, are fully supported.
Microsoft Teams mobile app supports Adaptive Cards up to version 1.6.

03Export your joined teams and their channels to a CSVVerified

Get the teams the signed-in user belongs to with me.joined_teams, then read each team's channels with $select. Team name, channel name, membership type and IDs go to a CSV you can use to look up IDs for posting or reading.

Approach and steps
  1. Set SCOPES to Team.ReadBasic.All and Channel.ReadBasic.All (both least privileged, delegated)
  2. joinedTeams doesn't support OData query parameters, so call get() as is
  3. For channels, $select id, displayName and membershipType; leaving out email is faster
  4. flatten makes one row per channel, written to out_channels.csv
  5. Copy TEAM_ID and CHANNEL_ID for the other examples from this CSV into environment variables
PythonSign-in required (Microsoft Entra)
"""List joined teams and their channels into a CSV (Team.ReadBasic.All + Channel.ReadBasic.All)."""
import asyncio
import csv
import os

from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.teams.item.channels.channels_request_builder import ChannelsRequestBuilder

SCOPES = ["Team.ReadBasic.All", "Channel.ReadBasic.All"]
FIELDS = ["team", "channel", "membership", "team_id", "channel_id"]


def enum_value(v):
    return getattr(v, "value", v) or ""


def flatten(teams):
    """teams: [(team_id, team_name, [(channel_id, channel_name, membership), ...]), ...]"""
    rows = []
    for team_id, team_name, channels in teams:
        for channel_id, channel_name, membership in channels:
            rows.append({"team": team_name, "channel": channel_name, "membership": membership,
                         "team_id": team_id, "channel_id": channel_id})
    return sorted(rows, key=lambda r: (r["team"], r["channel"]))


async def collect(client):
    joined = await client.me.joined_teams.get()  # this API takes no OData query options
    query = ChannelsRequestBuilder.ChannelsRequestBuilderGetQueryParameters(
        select=["id", "displayName", "membershipType"])  # leaving out email is faster
    out = []
    for team in joined.value or []:
        page = await client.teams.by_team_id(team.id).channels.get(
            request_configuration=RequestConfiguration(query_parameters=query))
        out.append((team.id, team.display_name,
                    [(c.id, c.display_name, enum_value(c.membership_type)) for c in page.value or []]))
    return out


def write_csv(rows, path="out_channels.csv"):
    with open(path, "w", encoding="utf-8-sig", newline="") as f:
        w = csv.DictWriter(f, fieldnames=FIELDS)
        w.writeheader()
        w.writerows(rows)


async def main():
    cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
    client = GraphServiceClient(credentials=cred, scopes=SCOPES)
    rows = flatten(await collect(client))
    write_csv(rows)
    print("rows:", len(rows))


if __name__ == "__main__":
    asyncio.run(main())
Output(2026-09-12)
rows: 3 営業部 General
Example instruction for Copilot

Write Python with msgraph-sdk that gets my joined teams, reads each team's channels with $select id, displayName and membershipType, and writes one CSV row per channel. Use Team.ReadBasic.All and Channel.ReadBasic.All, and keep the table building in a function that doesn't call Graph.

Caution

Personal Microsoft accounts aren't supported. Broad permissions such as Directory.Read.All remain only for backward compatibility, so don't use them. The CSV contains team and channel names; check for confidential names before sharing it.

Availability
msgraph-sdk (tested 1.62.0) and azure-identity (tested 1.25.3). Work or school accounts only; personal Microsoft accounts aren't supported. Checked 2026-09-12.
Requires
msgraph-sdk 1.62.0, azure-identity 1.25.3
Permissions
Team.ReadBasic.All, Channel.ReadBasic.All (delegated)
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, msgraph-sdk 1.62.0, azure-identity 1.25.3)
Source
Microsoft Learn, "List joinedTeams"
Microsoft Learn, "List channels"
Verified
2026-09-12 (v1)
Supporting passages from the sources
This method doesn't currently support the OData query parameters to customize the response.
Populating the email property for a channel is an expensive operation that results in slow performance. Use $select to exclude the email property to improve performance.
The Directory.Read.All and Directory.ReadWrite.All permissions are supported only for backward compatibility.

04Read channel posts and count them per author and per dayVerified

Read a channel's posts (without replies) from teams/{id}/channels/{id}/messages. A page holds 20 messages by default and up to 50 with $top. System messages are skipped, and posts are counted per author and per day.

Approach and steps
  1. Set SCOPES to ChannelMessage.Read.All (delegated); this permission needs admin consent
  2. Read TEAM_ID and CHANNEL_ID from environment variables and get the first page with $top=50
  3. Follow odata_next_link; results are sorted by the last modified date of each whole reply chain
  4. Skip anything whose message_type isn't message, such as system events
  5. Count per author and per day (UTC date) with tally and print the result
PythonSign-in required (Microsoft Entra)
"""Count channel posts per author and per day (ChannelMessage.Read.All, delegated)."""
import asyncio
import os
from collections import Counter

from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.teams.item.channels.item.messages.messages_request_builder import (
    MessagesRequestBuilder,
)

SCOPES = ["ChannelMessage.Read.All"]  # delegated; needs admin consent
MAX_PAGES = int(os.environ.get("MAX_PAGES", "10"))


def to_row(m):
    user = m.from_.user if m.from_ and m.from_.user else None
    return {"type": getattr(m.message_type, "value", m.message_type),
            "author": user.display_name if user else "",
            "created": str(m.created_date_time or "")}


def tally(rows):
    posts = [r for r in rows if r["type"] == "message"]  # skip system events
    by_author = Counter(r["author"] for r in posts)
    by_day = Counter(r["created"][:10] for r in posts)  # UTC date
    return by_author, dict(sorted(by_day.items()))


async def main():
    cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
    client = GraphServiceClient(credentials=cred, scopes=SCOPES)
    builder = (client.teams.by_team_id(os.environ["TEAM_ID"])
               .channels.by_channel_id(os.environ["CHANNEL_ID"]).messages)
    query = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(top=50)
    page = await builder.get(request_configuration=RequestConfiguration(query_parameters=query))
    rows, pages = [], 0
    while page:
        rows += [to_row(m) for m in page.value or []]
        pages += 1
        if not page.odata_next_link or pages >= MAX_PAGES:
            break
        page = await builder.with_url(page.odata_next_link).get()
    by_author, by_day = tally(rows)
    print("posts:", sum(by_author.values()), "pages:", pages)
    for name, n in by_author.most_common(10):
        print(n, name)
    for day, n in by_day.items():
        print(day, n)


if __name__ == "__main__":
    asyncio.run(main())
Output(2026-09-12)
[('担当A', 2), ('担当B', 2), ('担当C', 2)] {'2026-09-08': 2, '2026-09-09': 2, '2026-09-10': 2}
Example instruction for Copilot

Write Python with msgraph-sdk that reads channel messages with $top=50, follows odata_next_link, and counts only messages whose message_type is message, per author and per day. Read the team and channel IDs from environment variables and keep the counting in a function that doesn't call Graph.

Caution

For app-only access the least privileged permission is ChannelMessage.Read.Group, which uses resource-specific consent. Teams APIs that touch sensitive data are protected APIs with requirements for access without a user; the saved pages don't spell those requirements out. systemEventMessage comes back as a different value unless you send Prefer: include-unknown-enum-members. Posts are what people said, so settle the purpose and retention before tallying them.

Availability
msgraph-sdk (tested 1.62.0) and azure-identity (tested 1.25.3). Work or school accounts only; personal Microsoft accounts aren't supported. Checked 2026-09-12.
Requires
msgraph-sdk 1.62.0, azure-identity 1.25.3
Permissions
ChannelMessage.Read.All (delegated)
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, msgraph-sdk 1.62.0, azure-identity 1.25.3)
Practical edition
Teams ── Catch up on chats and channels
Source
Microsoft Learn, "List channel messages"
Microsoft Learn, "Export content with the Microsoft Teams Export APIs"
Microsoft Learn, "chatMessage resource type"
Microsoft Learn, "Microsoft Graph permissions reference"
Verified
2026-09-12 (v1)
Supporting passages from the sources
Retrieve the list of messages (without the replies) in a channel of a team.
The default page size is 20 messages. You can extend up to 50 channel messages per page.
The channel messages in the response are sorted by the last modified date of the entire reply chain, including both the root channel message and its replies.
The ChannelMessage.Read.Group permission uses resource-specific consent.
Microsoft Teams APIs in Microsoft Graph that access sensitive data are considered protected APIs. You can call these APIs as long as the requirements for accessing without a user are met.
Use the Prefer: include-unknown-enum-members request header to get the following members in this evolvable enum: systemEventMessage.
Allows an app to read a channel's messages in Microsoft Teams, on behalf of the signed-in user. AdminConsentRequired Yes Yes

05Find channel posts with no replies using $expand=repliesVerified

Reading channel messages with $expand=replies returns each post together with its replies. List posts that still have no reply after a set time, as a check for missed requests. Up to 200 replies are included by default; fetch more from [email protected].

Approach and steps
  1. Pass expand=["replies"] and top in MessagesRequestBuilderGetQueryParameters
  2. to_row keeps the reply count and the start of the body with tags removed
  3. Pass the current time and HOURS (one day by default) to unanswered to pick old posts with no reply
  4. Have the people responsible review the list and decide which posts need an answer
PythonSign-in required (Microsoft Entra)
"""List channel posts that still have no reply ($expand=replies, ChannelMessage.Read.All)."""
import asyncio
import os
import re
from datetime import datetime, timedelta, timezone

from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.teams.item.channels.item.messages.messages_request_builder import (
    MessagesRequestBuilder,
)

SCOPES = ["ChannelMessage.Read.All"]
HOURS = float(os.environ.get("HOURS", "24"))
TAG = re.compile(r"<[^>]+>")


def as_dt(v):
    if isinstance(v, str):
        return datetime.fromisoformat(v.replace("Z", "+00:00"))
    return v


def to_row(m):
    user = m.from_.user if m.from_ and m.from_.user else None
    text = TAG.sub("", (m.body.content if m.body else "") or "")
    return {"id": m.id, "type": getattr(m.message_type, "value", m.message_type),
            "author": user.display_name if user else "", "created": m.created_date_time,
            "replies": len(m.replies or []), "preview": text[:40]}


def unanswered(rows, now, hours):
    limit = now - timedelta(hours=hours)
    return [r for r in rows
            if r["type"] == "message" and r["replies"] == 0 and as_dt(r["created"]) < limit]


async def main():
    cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
    client = GraphServiceClient(credentials=cred, scopes=SCOPES)
    builder = (client.teams.by_team_id(os.environ["TEAM_ID"])
               .channels.by_channel_id(os.environ["CHANNEL_ID"]).messages)
    query = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(top=50, expand=["replies"])
    page = await builder.get(request_configuration=RequestConfiguration(query_parameters=query))
    rows = [to_row(m) for m in page.value or []]  # first page only
    hits = unanswered(rows, datetime.now(timezone.utc), HOURS)
    print("posts checked:", len(rows), "no reply:", len(hits))
    for r in hits:
        print(as_dt(r["created"]).date(), r["author"], r["preview"])


if __name__ == "__main__":
    asyncio.run(main())
Output(2026-09-12)
170100 担当B R-2026-011 の Web 記事、表記の指摘が 3 件あります。
170300 担当A R-2026-014 のプレスリリース、差し替え版はどこにありますか。
Example instruction for Copilot

Write Python with msgraph-sdk that reads channel messages with $expand=replies and lists posts that have zero replies and are older than a given number of hours. Strip HTML tags and show only the start of the body. The check function should take the current time as an argument and not call Graph.

Caution

Not every post without replies is a missed request (announcements, for example); a person makes the final call. This example checks only the first page. Bodies come back as HTML and the tags are stripped only roughly here. Before showing body text on screen or in a CSV, make sure the audience may see it.

Availability
msgraph-sdk (tested 1.62.0) and azure-identity (tested 1.25.3). Work or school accounts only; personal Microsoft accounts aren't supported. Checked 2026-09-12.
Requires
msgraph-sdk 1.62.0, azure-identity 1.25.3
Permissions
ChannelMessage.Read.All (delegated)
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, msgraph-sdk 1.62.0, azure-identity 1.25.3)
Practical edition
Teams ── Catch up on chats and channels
Source
Microsoft Learn, "List channel messages"
Verified
2026-09-12 (v1)
Supporting passages from the sources
Apply $expand to get the properties of channel messages that are replies. By default, a response can include up to 200 replies.
For an operation that expands channel messages with more than 200 replies, use the request URL returned in [email protected] to get the next page of replies.

06Post the open-request list to a channel through Graph (DRY_RUN by default)Verified

Pick open requests from the ledger, format them as an HTML list and post it to a channel. Posting uses delegated ChannelMessage.Send, so the message appears under the signed-in user's name. With the default DRY_RUN only the target channel name and body are printed.

Approach and steps
  1. Set SCOPES to Channel.ReadBasic.All (to confirm the target) and ChannelMessage.Send
  2. Select rows whose status isn't complete with open_requests
  3. build_html passes each value through html.escape and builds an HTML list
  4. Get the channel name with channels.by_channel_id(...).get() and print the target
  5. Only when DRY_RUN=0, pass a ChatMessage with BodyType.Html to messages.post
PythonSign-in required (Microsoft Entra)
"""Post open requests from the ledger to a Teams channel via Graph (DRY_RUN by default)."""
import asyncio
import csv
import html
import os

from azure.identity import DeviceCodeCredential
from msgraph import GraphServiceClient
from msgraph.generated.models.body_type import BodyType
from msgraph.generated.models.chat_message import ChatMessage
from msgraph.generated.models.item_body import ItemBody

SCOPES = ["Channel.ReadBasic.All", "ChannelMessage.Send"]
DRY_RUN = os.environ.get("DRY_RUN", "1") != "0"
LEDGER = os.environ.get("LEDGER_CSV", "samples/ledger.csv")


def open_requests(path):
    with open(path, encoding="utf-8-sig", newline="") as f:
        return [r for r in csv.DictReader(f) if r["状態"] != "完了"]


def build_html(rows, limit=20):
    items = "".join("<li>%s %s (%s)</li>" % (html.escape(r["依頼ID"]), html.escape(r["文書名"]),
                                             html.escape(r["状態"])) for r in rows[:limit])
    more = "<p>... and %d more</p>" % (len(rows) - limit) if len(rows) > limit else ""
    return "<p><b>Open requests: %d</b></p><ul>%s</ul>%s" % (len(rows), items, more)


async def main():
    content = build_html(open_requests(LEDGER))
    cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
    client = GraphServiceClient(credentials=cred, scopes=SCOPES)
    channel = client.teams.by_team_id(os.environ["TEAM_ID"]).channels.by_channel_id(os.environ["CHANNEL_ID"])
    info = await channel.get()  # confirm the target before posting
    if DRY_RUN:
        print("DRY_RUN: would post to channel:", info.display_name)
        print(content)
        return
    sent = await channel.messages.post(ChatMessage(body=ItemBody(content_type=BodyType.Html, content=content)))
    print("posted message id:", sent.id)


if __name__ == "__main__":
    asyncio.run(main())
Output(2026-09-12)
<p><b>Open requests: 26</b></p><ul><li>R-2026-002 文書02(架空) (受付)</li><li>R-2026-003 文書03(架空) (差戻し)</li><li>R-2026-005 文書05(架空) (確認中)</li><li>R-2026-006 文書06(架空) (確認中)</li><li>R-2026-007 文書07(架空) (確認中)</li><li>R-2026-008 文書08(架空) (受付)</li><li>R-2026-009 文書09(架空) (受付)</li><li>R-2026-010 文書10(架空) (確認中)<
Example instruction for Copilot

Write Python that selects ledger rows (CSV) whose status isn't complete, formats them as an HTML list, and posts it to a channel with msgraph-sdk. Escape values with html.escape and print the channel name before posting. Unless DRY_RUN is 0, only print the body. Use ChannelMessage.Send and Channel.ReadBasic.All.

Caution

Using Teams as a log file violates the terms of use; send only messages people will read. The application permission (Teamwork.Migrate.All) is for migration only. Check the target and body in the DRY_RUN output rather than fixing things after posting. Make sure document names in the list contain nothing confidential.

Availability
msgraph-sdk (tested 1.62.0) and azure-identity (tested 1.25.3). Work or school accounts only; personal Microsoft accounts aren't supported. Checked 2026-09-12.
Requires
msgraph-sdk 1.62.0, azure-identity 1.25.3
Permissions
Channel.ReadBasic.All, ChannelMessage.Send (delegated) — writes data (does nothing by default: DRY_RUN)
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, msgraph-sdk 1.62.0, azure-identity 1.25.3)
Source
Microsoft Learn, "Send chatMessage in a channel or a chat"
Microsoft Learn, "Send chatMessage in channel"
Microsoft Learn, "Get channel"
Microsoft Learn, "Microsoft Graph permissions reference"
Verified
2026-09-12 (v1)
Supporting passages from the sources
It is a violation of the terms of use to use Microsoft Teams as a log file. Only send messages that people will read.
Application permissions are only supported for migration.
In the request body, supply a JSON representation of a chatMessage object. Only the body property is mandatory.
Allows an app to send channel messages in Microsoft Teams, on behalf of the signed-in user.

07A minimal Agents SDK bot that answers request status by IDNeeds check

Listen on /api/messages with microsoft-agents-hosting-aiohttp and reply from AgentApplication handlers. The example answers "/status <request ID>" with that request's status from the ledger. The reply logic is its own function, checked locally.

Approach and steps
  1. Install microsoft-agents-hosting-aiohttp and microsoft-agents-authentication-msal with pip
  2. Read connection settings from environment variables with load_configuration_from_env, then build MsalConnectionManager and CloudAdapter(connection_manager=...)
  3. Register a handler with AgentApplication's activity("message") and build replies with reply_for
  4. Listen on localhost port 3978 with run_app and try it from the Microsoft 365 Agents Playground (teamsapptester)
  5. For Teams, set the Messaging endpoint to {URL}/api/messages under Settings > Configuration on the Azure Bot
PythonRuns as a service
"""Minimal Microsoft 365 Agents SDK bot: '/status <request ID>' answers from a ledger CSV."""
import csv
import os
import re

from aiohttp.web import Application, Request, Response, run_app
from microsoft_agents.activity import load_configuration_from_env
from microsoft_agents.authentication.msal import MsalConnectionManager
from microsoft_agents.hosting.aiohttp import CloudAdapter, jwt_authorization_middleware, start_agent_process
from microsoft_agents.hosting.core import AgentApplication, Authorization, MemoryStorage, TurnContext, TurnState

LEDGER = os.environ.get("LEDGER_CSV", "samples/ledger.csv")
STATUS = re.compile(r"^/status\s+(R-\d{4}-\d{3})$", re.I)


def load_ledger(path):
    with open(path, encoding="utf-8-sig", newline="") as f:
        return {row["依頼ID"]: row for row in csv.DictReader(f)}


def reply_for(text, ledger):
    m = STATUS.match((text or "").strip())
    if not m:
        return "Send '/status <request ID>', for example /status R-2026-005."
    row = ledger.get(m.group(1).upper())
    if not row:
        return "%s is not in the ledger." % m.group(1)
    return "%s %s: %s (owner %s)" % (row["依頼ID"], row["文書名"], row["状態"], row["担当者"])


def build_app():
    # Reads CONNECTIONS__SERVICE_CONNECTION__SETTINGS__* etc. from the environment
    config = load_configuration_from_env(os.environ)
    connections = MsalConnectionManager(**config)
    storage = MemoryStorage()
    app = AgentApplication[TurnState](
        storage=storage, adapter=CloudAdapter(connection_manager=connections),
        authorization=Authorization(storage, connections, **config), **config)
    ledger = load_ledger(LEDGER)

    @app.activity("message")
    async def on_message(context: TurnContext, _state: TurnState):
        await context.send_activity(reply_for(context.activity.text, ledger))

    return app, connections


def serve(agent_app, auth_config):
    async def entry_point(req: Request) -> Response:
        return await start_agent_process(req, req.app["agent_app"], req.app["adapter"])

    web = Application(middlewares=[jwt_authorization_middleware])
    web.router.add_post("/api/messages", entry_point)
    web["agent_configuration"] = auth_config
    web["agent_app"] = agent_app
    web["adapter"] = agent_app.adapter
    run_app(web, host="localhost", port=int(os.environ.get("PORT", "3978")))


if __name__ == "__main__":
    APP, CONNECTIONS = build_app()
    serve(APP, CONNECTIONS.get_default_connection_configuration())
Output(2026-09-12)
AgentApplication CloudAdapter
R-2026-002 文書02(架空): 受付 (owner 担当A)
R-2099-999 is not in the ledger.
Send '/status <request ID>', for example /status R-2026-005.
Example instruction for Copilot

Write a bot with the Microsoft 365 Agents SDK for Python (microsoft-agents-hosting-aiohttp) that answers "/status <request ID>" with the status from a CSV ledger. Read connection settings from environment variables with load_configuration_from_env, and keep the reply logic in an SDK-independent function that can be tested locally.

Caution

The CloudAdapter() call in the Learn quickstart stopped with a ValueError in the tested 1.5.0, asking for connection_manager or channel_service_client_factory; pass connection_manager as the GitHub sample does. The sample configuration keeps a client secret in environment variables; never put it in code and follow the security page for where to store it. Decide who may use a bot that answers from the ledger before you publish it.

Availability
Tested with microsoft-agents-hosting-core, microsoft-agents-hosting-aiohttp and microsoft-agents-authentication-msal 1.5.0 on Python 3.12.10. Supported Python versions differ between documents (overview: 3.9 to 3.11; quickstart: 3.9 or newer; GitHub README: 3.10 or greater). Checked 2026-09-12.
Requires
microsoft-agents-hosting-core 1.5.0, microsoft-agents-hosting-aiohttp 1.5.0, microsoft-agents-authentication-msal 1.5.0
Tested
definitions loaded (not started as a service); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, microsoft-agents-hosting-core 1.5.0, microsoft-agents-hosting-aiohttp 1.5.0, microsoft-agents-authentication-msal 1.5.0)
Source
Microsoft Learn, "Quickstart: Create and test a basic agent"
Microsoft (GitHub), "microsoft/Agents samples/python/quickstart/src/agent.py"
Microsoft (GitHub), "microsoft/Agents-for-python (README)"
Microsoft Learn, "What is the Microsoft 365 Agents SDK"
Verified
2026-09-12 (v1)
Supporting passages from the sources
Use pip to install the microsoft-agents-hosting-aiohttp package with this command:
ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER)
CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config)
Python using version 3.9 to 3.11
The packages should target Python 3.10 or greater
The teamsapptester command opens your default browser and connects to your agent.