name: m365-workflows description: >- Microsoft 365 integration patterns for Teams, SharePoint, and Outlook automation. Use when building Teams bots, sending Teams channel notifications, creating SharePoint document workflows, handling Adaptive Cards, automating Outlook email, or working with Microsoft Graph API. Covers MCP server conventions, compound site IDs, channel ID management, Adaptive Card size limits, and Korea Standard Time handling. Activate whenever Microsoft 365, Teams, SharePoint, Outlook, or Graph API is mentioned. license: SEE LICENSE IN ../../LICENSE metadata: author: parandurume-labs version: "1.0.0" license: GM-Social-v2.0
Microsoft 365 Workflow Patterns
This skill provides rules for building reliable Microsoft 365 integrations with Teams, SharePoint, and Outlook. Rules are ordered by impact.
Learned Patterns (Auto-Updated)
Before applying the rules below, check if LESSONS.md exists in the project root. If it does, read the section tagged with m365-workflows and apply those project-specific lessons alongside the rules below.
MCP Server Configuration
When using the M365 MCP server, configure the endpoint:
Server URL: ms365-mcp.delightfulbeach-79de09ed.koreacentral.azurecontainerapps.io/mcp
Category 1: SharePoint Integration (CRITICAL)
Rule 1: Use Compound Site ID Format
Impact: CRITICAL — Simple site IDs fail in Graph API calls. The compound format is required.
❌ Wrong:
// Using site ID alone — this will fail
const siteId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
const response = await graphClient
.api(`/sites/${siteId}/lists`)
.get();
✅ Correct:
// Compound site ID: hostname:/path:/siteId
const siteId = "contoso.sharepoint.com:/sites/project-alpha:/a1b2c3d4-e5f6-7890-abcd-ef1234567890";
const response = await graphClient
.api(`/sites/${siteId}/lists`)
.get();
Format: {hostname}:/{server-relative-path}:/{site-id}
Rule 2: SharePoint Internal Field Names
Impact: CRITICAL — Display names and internal names are different. Using display names causes silent failures.
| Display Name | Internal Name | Type |
|---|---|---|
| Title | Title | Text |
| Created | Created | DateTime |
| Created By | Author | Person |
| Modified | Modified | DateTime |
| Modified By | Editor | Person |
| File Name | FileLeafRef | Text |
| File Size | FileSizeDisplay | Text |
| Content Type | ContentTypeId | ContentType |
| ID | ID | Counter |
| Checked Out To | CheckoutUser | Person |
❌ Wrong:
await graphClient
.api(`/sites/${siteId}/lists/${listId}/items`)
.select("Created By, Modified By, File Name")
.get();
✅ Correct:
await graphClient
.api(`/sites/${siteId}/lists/${listId}/items`)
.expand("fields($select=Author,Editor,FileLeafRef)")
.get();
Rule 3: Handle Large Document Libraries with Pagination
Impact: HIGH — SharePoint returns max 200 items per page by default. Missing pagination means missing data.
❌ Wrong:
// Only gets first page (up to 200 items)
const items = await graphClient
.api(`/sites/${siteId}/lists/${listId}/items`)
.get();
✅ Correct:
let allItems = [];
let response = await graphClient
.api(`/sites/${siteId}/lists/${listId}/items`)
.top(200)
.get();
allItems.push(...response.value);
while (response["@odata.nextLink"]) {
response = await graphClient
.api(response["@odata.nextLink"])
.get();
allItems.push(...response.value);
}
Category 2: Teams Channels & Messages (CRITICAL)
Rule 4: Channel IDs in Environment Variables
Impact: CRITICAL — Hardcoded channel IDs break when channels are recreated or when moving between environments.
❌ Wrong:
const channelId = "19:abc123def456@thread.tacv2"; // Hardcoded
await graphClient
.api(`/teams/${teamId}/channels/${channelId}/messages`)
.post(message);
✅ Correct:
const channelId = process.env.TEAMS_CHANNEL_ID;
if (!channelId) throw new Error("TEAMS_CHANNEL_ID environment variable is required");
await graphClient
.api(`/teams/${teamId}/channels/${channelId}/messages`)
.post(message);
Rule 5: Teams Message Size Limit
Impact: HIGH — Messages over 28KB are silently truncated or rejected.
❌ Wrong:
// Dumping entire report as a Teams message
const message = { body: { content: entireReport } }; // Could be 100KB+
✅ Correct:
// Summary in message, full report as file attachment or link
const message = {
body: {
contentType: "html",
content: `<p><strong>Report Summary</strong></p>
<p>${summary}</p>
<p><a href="${reportUrl}">View full report</a></p>`
}
};
Rule 6: Use HTML Content Type for Rich Messages
Impact: MEDIUM — Plain text messages cannot include formatting, links, or mentions.
❌ Wrong:
const message = {
body: {
contentType: "text",
content: "Build #42 passed. See results at https://..."
}
};
✅ Correct:
const message = {
body: {
contentType: "html",
content: `<p>Build <strong>#42</strong> passed ✅</p>
<p><a href="https://...">View results</a></p>`
}
};
Category 3: Adaptive Cards (HIGH)
Rule 7: Adaptive Card 28KB Size Limit
Impact: HIGH — Cards exceeding 28KB fail to render with no useful error message.
❌ Wrong:
{
"type": "AdaptiveCard",
"body": [
{
"type": "TextBlock",
"text": "... (embedding 50KB of data in the card)"
}
]
}
✅ Correct:
{
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"text": "Report Summary",
"weight": "Bolder",
"size": "Medium"
},
{
"type": "TextBlock",
"text": "Top 5 items shown. Click below for full details.",
"wrap": true
}
],
"actions": [
{
"type": "Action.OpenUrl",
"title": "View Full Report",
"url": "https://..."
}
]
}
Best practice: Keep card payload under 20KB; use links for detailed data.
Rule 8: Always Set Card Version
Impact: MEDIUM — Missing version defaults to 1.0, which lacks many features.
❌ Wrong:
{
"type": "AdaptiveCard",
"body": [...]
}
✅ Correct:
{
"type": "AdaptiveCard",
"version": "1.5",
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"body": [...]
}
Rule 9: Wrap Long Text in Cards
Impact: MEDIUM — Text without wrap: true is clipped on mobile devices.
❌ Wrong:
{ "type": "TextBlock", "text": "This is a very long description that..." }
✅ Correct:
{ "type": "TextBlock", "text": "This is a very long description that...", "wrap": true }
Category 4: Time & Locale (HIGH)
Rule 10: Default to Korea Standard Time
Impact: HIGH — Scheduled events, notifications, and timestamps are wrong if timezone is not set explicitly.
❌ Wrong:
// No timezone specified — defaults to UTC
const event = {
start: { dateTime: "2025-03-15T09:00:00" },
end: { dateTime: "2025-03-15T10:00:00" }
};
✅ Correct:
const event = {
start: { dateTime: "2025-03-15T09:00:00", timeZone: "Korea Standard Time" },
end: { dateTime: "2025-03-15T10:00:00", timeZone: "Korea Standard Time" }
};
Note: Microsoft Graph uses Windows timezone IDs (Korea Standard Time), not IANA (Asia/Seoul).
Rule 11: Date Format for Korean Locale
Impact: MEDIUM — Korean users expect YYYY년 MM월 DD일 format.
❌ Wrong:
const dateStr = date.toLocaleDateString("en-US"); // "3/15/2025"
✅ Correct:
const dateStr = date.toLocaleDateString("ko-KR", {
year: "numeric",
month: "long",
day: "numeric"
}); // "2025년 3월 15일"
Category 5: Environment Variable Conventions
All M365-related environment variables should follow this naming convention:
| Variable | Purpose | Example |
|---|---|---|
M365_TENANT_ID | Azure AD tenant ID | a1b2c3d4-... |
M365_CLIENT_ID | App registration client ID | e5f6a7b8-... |
TEAMS_TEAM_ID | Target team ID | 19:abc...@thread.tacv2 |
TEAMS_CHANNEL_ID | Target channel ID | 19:def...@thread.tacv2 |
TEAMS_WEBHOOK_URL | Incoming webhook URL | https://...webhook.office.com/... |
SHAREPOINT_SITE_ID | Compound site ID | contoso.sharepoint.com:/sites/... |
SHAREPOINT_LIST_ID | Target list/library ID | a1b2c3d4-... |
GRAPH_API_SCOPE | Graph API permission scope | https://graph.microsoft.com/.default |
Rules:
- Never hardcode any of these values
- Use
.envfiles for local development (add.envto.gitignore) - Use Azure Key Vault or platform secrets for production
- Validate all required variables at startup — fail fast with clear error messages
Category 6: Microsoft Graph Advanced (HIGH)
Rule 12: Graph API Batch Requests
Impact: HIGH — Making individual Graph API calls in a loop causes throttling (HTTP 429) and poor performance.
❌ Wrong:
// N+1 problem — one API call per user
const users = ["user1@contoso.com", "user2@contoso.com", "user3@contoso.com"];
for (const email of users) {
const profile = await graphClient.api(`/users/${email}`).get();
results.push(profile);
}
✅ Correct:
// Batch up to 20 requests in a single call
const batchContent = {
requests: users.map((email, i) => ({
id: String(i + 1),
method: "GET",
url: `/users/${email}?$select=displayName,mail,department`
}))
};
const batchResponse = await graphClient.api("/$batch").post(batchContent);
for (const response of batchResponse.responses) {
if (response.status === 200) {
results.push(response.body);
} else if (response.status === 429) {
// Individual request throttled — retry after delay
const retryAfter = response.headers["Retry-After"] || 5;
await delay(retryAfter * 1000);
// Retry individual request
}
}
Why: Graph API batch endpoint accepts up to 20 requests per batch. Each request in the batch can succeed or fail independently. Always handle per-request errors, especially 429 (throttling).
Rule 13: Teams Tab SSO Authentication
Impact: HIGH — Prompting users to log in separately in a Teams tab creates friction and confusion.
❌ Wrong:
// Redirecting to a login page inside Teams tab
window.location.href = "/login?redirect=/tab";
✅ Correct:
import * as microsoftTeams from "@microsoft/teams-js";
async function getToken() {
await microsoftTeams.app.initialize();
try {
// Silent SSO — no user interaction needed
const token = await microsoftTeams.authentication.getAuthToken();
// Exchange Teams token for your API token (server-side)
const apiToken = await fetch("/api/auth/teams-exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ teamsToken: token })
}).then(r => r.json());
return apiToken.accessToken;
} catch (error) {
// Fallback: interactive consent popup
if (error.message === "resourceRequiresConsent") {
const token = await microsoftTeams.authentication.authenticate({
url: `${window.location.origin}/auth-start`,
width: 600,
height: 535
});
return token;
}
throw error;
}
}
# Server-side: exchange Teams token using On-Behalf-Of flow
from msal import ConfidentialClientApplication
app = ConfidentialClientApplication(
client_id=os.environ["M365_CLIENT_ID"],
client_credential=os.environ["M365_CLIENT_SECRET"],
authority=f"https://login.microsoftonline.com/{os.environ['M365_TENANT_ID']}"
)
result = app.acquire_token_on_behalf_of(
user_assertion=teams_token,
scopes=["User.Read", "Mail.Read"]
)
Why: Teams SSO provides a seamless experience — the user is already authenticated in Teams. Use the On-Behalf-Of (OBO) flow server-side to exchange the Teams token for Graph API permissions.
Rule 14: SharePoint Framework (SPFx) Web Part Patterns
Impact: HIGH — Custom SPFx web parts with incorrect patterns cause rendering failures and memory leaks.
❌ Wrong:
// Direct DOM manipulation in SPFx
export default class MyWebPart extends BaseClientSideWebPart<IMyProps> {
public render(): void {
this.domElement.innerHTML = `
<div>${this.properties.title}</div>
<div id="content"></div>
`;
// Direct fetch without error handling
fetch("/api/data").then(r => r.json()).then(data => {
document.getElementById("content")!.innerHTML = data.html;
});
}
}
✅ Correct:
import * as React from "react";
import * as ReactDom from "react-dom";
import { BaseClientSideWebPart } from "@microsoft/sp-webpart-base";
import { SPHttpClient } from "@microsoft/sp-http";
export default class MyWebPart extends BaseClientSideWebPart<IMyProps> {
public render(): void {
const element = React.createElement(MyComponent, {
title: this.properties.title,
spHttpClient: this.context.spHttpClient,
siteUrl: this.context.pageContext.web.absoluteUrl,
displayMode: this.displayMode
});
ReactDom.render(element, this.domElement);
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement); // Prevent memory leaks
}
}
Why: Use React for SPFx rendering (not direct DOM manipulation). Always unmount components in onDispose(). Use SPHttpClient for authenticated SharePoint API calls — it handles token management automatically.
Category 7: Outlook & Planner Integration (MEDIUM)
Rule 15: Outlook Actionable Messages
Impact: MEDIUM — Sending plain notifications when actionable cards are available misses engagement opportunities.
❌ Wrong:
# Plain text email notification
send_email(
to="manager@contoso.com",
subject="Expense Report Pending Approval",
body="Employee Kim submitted an expense report for ₩150,000. Please approve in the system."
)
✅ Correct:
# Actionable Message with Adaptive Card (MessageCard format is deprecated)
card_payload = {
"type": "AdaptiveCard",
"$schema": "https://adaptivecards.io/schemas/adaptive-card.json",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"text": "경비 보고서 승인 요청",
"weight": "bolder",
"size": "medium"
},
{
"type": "FactSet",
"facts": [
{"title": "제출자", "value": "김철수"},
{"title": "금액", "value": "₩150,000"},
{"title": "카테고리", "value": "출장비"},
{"title": "날짜", "value": "2026년 3월 31일"}
]
}
],
"actions": [
{
"type": "Action.Http",
"title": "승인",
"method": "POST",
"url": "https://api.contoso.com/expenses/123/approve",
"body": "{\"action\": \"approve\", \"expenseId\": \"123\"}",
"headers": [{"name": "Content-Type", "value": "application/json"}]
},
{
"type": "Action.Http",
"title": "반려",
"method": "POST",
"url": "https://api.contoso.com/expenses/123/reject",
"body": "{\"action\": \"reject\", \"expenseId\": \"123\"}"
},
{
"type": "Action.OpenUrl",
"title": "상세 보기",
"url": "https://expenses.contoso.com/123"
}
]
}
send_email(
to="manager@contoso.com",
subject="경비 보고서 승인 요청 - 김철수 (₩150,000)",
body="<html><body>경비 보고서가 제출되었습니다.</body></html>",
actionable_card=card_payload
)
Why: Actionable Messages let users approve, reject, or respond directly from Outlook without opening another app. Register your service at https://aka.ms/publishactionablecard for production use.
Rule 16: OneDrive File Operations via Graph
Impact: MEDIUM — Using SharePoint API for simple file operations is unnecessarily complex.
❌ Wrong:
// Using SharePoint REST API for file upload
await fetch(`${siteUrl}/_api/web/GetFolderByServerRelativeUrl('/Documents')/Files/add(url='report.pdf',overwrite=true)`, {
method: "POST",
headers: { "Authorization": `Bearer ${token}` },
body: fileContent
});
✅ Correct:
// Upload file to OneDrive/SharePoint via Graph API
// Small files (< 4MB) — simple upload
await graphClient
.api(`/drives/${driveId}/items/root:/Reports/report.pdf:/content`)
.put(fileContent);
// Large files (> 4MB) — resumable upload session
const session = await graphClient
.api(`/drives/${driveId}/items/root:/Reports/large-report.pdf:/createUploadSession`)
.post({
item: {
"@microsoft.graph.conflictBehavior": "rename",
name: "large-report.pdf"
}
});
// Upload in chunks (recommended: 5-10MB chunks)
const CHUNK_SIZE = 5 * 1024 * 1024;
for (let offset = 0; offset < fileSize; offset += CHUNK_SIZE) {
const chunk = fileContent.slice(offset, offset + CHUNK_SIZE);
const end = Math.min(offset + CHUNK_SIZE, fileSize) - 1;
await fetch(session.uploadUrl, {
method: "PUT",
headers: {
"Content-Range": `bytes ${offset}-${end}/${fileSize}`,
"Content-Length": chunk.length
},
body: chunk
});
}
Why: Graph API's drive endpoint works for both OneDrive and SharePoint document libraries. Use simple upload for files < 4MB, resumable upload for larger files. The upload session URL is pre-authenticated — no Bearer token needed.
Rule 17: Planner Task Management via Graph
Impact: MEDIUM — Manual task tracking when Graph API can automate Planner integration.
✅ Correct:
// Create a Planner task
const task = await graphClient.api("/planner/tasks").post({
planId: planId,
bucketId: bucketId,
title: "리뷰: 3월 경비 보고서",
assignments: {
[assigneeId]: {
"@odata.type": "#microsoft.graph.plannerAssignment",
orderHint: " !"
}
},
dueDateTime: "2026-04-07T09:00:00Z",
priority: 3 // 1=Urgent, 3=Important, 5=Medium, 9=Low
});
// Update task details (description, checklist)
await graphClient
.api(`/planner/tasks/${task.id}/details`)
.header("If-Match", task["@odata.etag"])
.patch({
description: "3월 팀 경비 보고서를 검토하고 승인해 주세요.",
checklist: {
[guid()]: {
title: "금액 확인",
isChecked: false
},
[guid()]: {
title: "영수증 첨부 확인",
isChecked: false
}
}
});
Why: Planner tasks require If-Match etag header for updates (optimistic concurrency). Priority uses a 1-9 scale. Assignments use the user's ID as the key. Always include orderHint with a space-bang pattern.
Rule 18: Power Automate Cloud Flow Triggers from Code
Impact: MEDIUM — Building custom automation when Power Automate flows can handle it is over-engineering.
✅ Correct:
import httpx
# Trigger a Power Automate flow via HTTP request trigger
FLOW_TRIGGER_URL = os.environ["POWER_AUTOMATE_WEBHOOK_URL"]
async def trigger_approval_flow(expense_data: dict):
"""Trigger Power Automate approval flow from application code."""
async with httpx.AsyncClient() as client:
response = await client.post(
FLOW_TRIGGER_URL,
json={
"submitter": expense_data["submitter_name"],
"amount": expense_data["amount"],
"category": expense_data["category"],
"approver_email": expense_data["approver_email"],
"callback_url": f"{API_BASE}/api/expenses/{expense_data['id']}/status"
},
timeout=30.0
)
response.raise_for_status()
return response.json()
Why: Power Automate excels at approval workflows, multi-step notifications, and M365 orchestration. Use HTTP request triggers to invoke flows from your code. Use HTTP response actions to return results. This avoids reimplementing approval logic, Teams notifications, and Outlook email in your application.
Rule 19: Teams Meeting Transcript Access
Impact: MEDIUM — Accessing meeting transcripts requires specific Graph API permissions and timing considerations.
✅ Correct:
// 1. List transcripts for a meeting (available after meeting ends)
const transcripts = await graphClient
.api(`/me/onlineMeetings/${meetingId}/transcripts`)
.get();
// 2. Get transcript content
if (transcripts.value.length > 0) {
const transcriptId = transcripts.value[0].id;
// Get as VTT format (includes timestamps)
const vttContent = await graphClient
.api(`/me/onlineMeetings/${meetingId}/transcripts/${transcriptId}/content`)
.header("Accept", "text/vtt")
.get();
// Or get as plain text (no timestamps)
const textContent = await graphClient
.api(`/me/onlineMeetings/${meetingId}/transcripts/${transcriptId}/content`)
.header("Accept", "text/plain")
.get();
}
Required permissions: OnlineMeetingTranscript.Read.All (application) or OnlineMeetingTranscript.Read (delegated).
Why: Transcripts are only available after the meeting ends and transcription was enabled. Use VTT format when you need speaker attribution and timestamps. Use plain text for summarization or search indexing.
Pre-Deployment Checklist (M365 Integrations)
Authentication:
- App registration has minimum required Graph API permissions
- Teams SSO configured for tabs (no separate login)
- OAuth 2.0 used for API plugins (not API keys)
SharePoint:
- Compound site ID format used
- Internal field names used (not display names)
- SPFx web parts unmount on dispose
Teams:
- Adaptive Cards under 28KB
- Channel IDs retrieved dynamically (not hardcoded)
- Webhook URLs stored in Key Vault
Graph API:
- Batch requests used for bulk operations (≤20 per batch)
- Retry logic for HTTP 429 with Retry-After header
- Delta queries for incremental sync
Localization:
- Korean date format (YYYY년 MM월 DD일)
- Korea Standard Time timezone ID used
- Korean error messages in 합니다체