
Teams Migration
- 45 installs
- 6 repo stars
- Updated July 22, 2026
- julianobarbosa/claude-code-skills
Migrate MS Teams chat content to channels or between chats. Use when migrating chat, copying messages, moving chat history, backing up Teams content.
About
Migrate MS Teams chat content to channels or between chats.. Use for Teams migration, chat copying, message moving, history backup.
- intermediate skill
- core: office & documents
Teams Migration by the numbers
- 45 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #377 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/julianobarbosa/claude-code-skills --skill teams-migrationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 45 |
|---|---|
| repo stars | ★ 6 |
| Last updated | July 22, 2026 |
| Repository | julianobarbosa/claude-code-skills ↗ |
What it does
Migrate MS Teams chat content to channels or between chats. Use when migrating chat, copying messages, moving chat history, backing up Teams content.
Files
Customization
Before executing, check for user customizations at: ~/.claude/skills/PAI/USER/SKILLCUSTOMIZATIONS/TeamsMigration/
If this directory exists, load and apply any PREFERENCES.md, configurations, or resources found there. These override default behavior. If the directory does not exist, proceed with skill defaults.
Voice Notification
You MUST send this notification BEFORE doing anything else when this skill is invoked.
1. Send voice notification:
curl -s -X POST http://localhost:8888/notify \
-H "Content-Type: application/json" \
-d '{"message": "Running the WORKFLOWNAME workflow in the TeamsMigration skill to ACTION"}' \
> /dev/null 2>&1 &2. Output text notification:
Running the **WorkflowName** workflow in the **TeamsMigration** skill to ACTION...TeamsMigration
Migrate MS Teams meeting chat or group chat content to team channels, preserving sender attribution, timestamps, images, and attachments. Uses the teams-mcp MCP server for reading and the Microsoft Graph API directly for high-volume batch posting with automatic token refresh and rate limiting.
Workflow Routing
| Workflow | Trigger | File |
|---|---|---|
| MigrateChat | "migrate chat", "copy chat to channel", "move messages" | Workflows/MigrateChat.md |
| ExportChat | "export chat", "backup chat", "download messages" | Workflows/ExportChat.md |
Examples
Example 1: Migrate a meeting chat to a team channel
User: "Migrate the Teams meeting chat to the DevOps channel"
--> Invokes MigrateChat workflow
--> Verifies auth, fetches all messages with pagination
--> Filters system/deleted messages, reverses to chronological order
--> Posts each message with sender + timestamp attribution
--> Verifies count parity and spot-checks contentExample 2: Export chat messages to a local file
User: "Export all messages from our DevOps chat"
--> Invokes ExportChat workflow
--> Fetches all messages with full pagination
--> Saves to JSON file with metadataQuick Reference
- MCP Server:
@floriscornel/teams-mcp(npm) - Auth file:
~/.msgraph-mcp-auth.json - Token cache:
~/.teams-mcp-token-cache.json - Auth command:
npx @floriscornel/teams-mcp@latest authenticate - Migration tool:
Tools/MigrateChat.mjs(Node.js, no dependencies)
Key Learnings
- Graph API `descending: false` not supported -- fetch descending, reverse array
- `download_message_hosted_content` only works for channel messages, not chat messages
- Token auto-refresh via MSAL refresh token in
~/.teams-mcp-token-cache.json - Rate limiting handled by respecting
Retry-Afterheader on 429 responses - For bulk posting (50+ messages) use
Tools/MigrateChat.mjsdirectly via Graph API instead of MCP tool calls - Account must have Teams license --
Chat.*endpoints fail without it (403)
---
Gotchas
- Graph API ignores `descending: false` on chat messages: Endpoint silently returns descending order anyway. Always fetch descending, reverse the array locally — assuming ascending will post messages in reverse chronological order to the destination.
- `download_message_hosted_content` works only for channel messages, not chat messages: Images in meeting/group chats need a different code path through
/chats/{id}/messages/{id}/hostedContents. Calling it on a chat message returns 404 with a misleading "content not found". - MSAL refresh token in `~/.teams-mcp-token-cache.json` expires after 90 days idle: First migration after a long gap fails with
AADSTS700082. Re-runnpx @floriscornel/teams-mcp@latest authenticate— token refresh is silent until it isn't. - *`Chat.
Graph endpoints require a Teams license on the calling account:** Service accounts without a license return 403 with text that mentions permissions, not licensing. Verify withaz ad user show --id <upn> --query assignedLicenses` before debugging Graph scopes. - MCP tool calls hit a per-second rate limit around 50 messages: Bulk migrations through the MCP layer get throttled into hours. For 50+ messages, bypass MCP and use
Tools/MigrateChat.mjsdirectly against Graph withRetry-Afterhandling. - Posting preserves sender name in body text, not metadata: The destination channel shows the migration bot as the author, with the original sender embedded in the message text. Compliance/eDiscovery queries on author will miss migrated content entirely.
#!/usr/bin/env node
/**
* MigrateChat.mjs — Bulk post messages to an MS Teams channel via Graph API.
*
* Usage:
* node MigrateChat.mjs --team TEAM_ID --channel CHANNEL_ID --messages /path/to/messages.json
*
* The messages JSON file should be an array of objects with:
* { from: "Sender Name", createdDateTime: "ISO string", content: "message body" }
*
* Features:
* - Reads MSAL token from ~/.teams-mcp-token-cache.json
* - Automatic token refresh via OAuth2 refresh_token grant
* - Rate limiting (429) with Retry-After
* - Progress tracking to /tmp/teams_migration_progress.json (resumable)
* - HTML formatting with sender attribution and localized timestamps
*/
import { readFileSync, writeFileSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import { parseArgs } from 'util';
// ── CLI Arguments ──────────────────────────────────────────────────
const { values } = parseArgs({
options: {
team: { type: 'string', short: 't' },
channel: { type: 'string', short: 'c' },
messages: { type: 'string', short: 'm' },
progress: { type: 'string', short: 'p' },
timezone: { type: 'string', default: 'America/Sao_Paulo' },
locale: { type: 'string', default: 'pt-BR' },
delay: { type: 'string', default: '100' },
},
});
const TEAM_ID = values.team;
const CHANNEL_ID = values.channel;
const MESSAGES_PATH = values.messages;
const PROGRESS_PATH = values.progress || '/tmp/teams_migration_progress.json';
const TIMEZONE = values.timezone || 'America/Sao_Paulo';
const LOCALE = values.locale || 'pt-BR';
const DELAY_MS = parseInt(values.delay || '100', 10);
if (!TEAM_ID || !CHANNEL_ID || !MESSAGES_PATH) {
console.error('Usage: node MigrateChat.mjs --team TEAM_ID --channel CHANNEL_ID --messages FILE');
console.error('');
console.error('Options:');
console.error(' --team, -t Destination team ID (required)');
console.error(' --channel, -c Destination channel ID (required)');
console.error(' --messages, -m Path to messages JSON file (required)');
console.error(' --progress, -p Path to progress file (default: /tmp/teams_migration_progress.json)');
console.error(' --timezone Timestamp timezone (default: America/Sao_Paulo)');
console.error(' --locale Timestamp locale (default: pt-BR)');
console.error(' --delay Delay between messages in ms (default: 100)');
process.exit(1);
}
// ── Token Management ───────────────────────────────────────────────
const CACHE_PATH = join(homedir(), '.teams-mcp-token-cache.json');
const AUTH_PATH = join(homedir(), '.msgraph-mcp-auth.json');
function getAccessToken() {
const cache = JSON.parse(readFileSync(CACHE_PATH, 'utf8'));
const atEntries = cache.AccessToken || {};
for (const [, val] of Object.entries(atEntries)) {
return val.secret;
}
throw new Error('No access token found in cache');
}
async function refreshToken() {
const cache = JSON.parse(readFileSync(CACHE_PATH, 'utf8'));
const rtEntries = cache.RefreshToken || {};
let refreshSecret = null;
for (const [, val] of Object.entries(rtEntries)) {
refreshSecret = val.secret;
}
if (!refreshSecret) throw new Error('No refresh token found');
const auth = JSON.parse(readFileSync(AUTH_PATH, 'utf8'));
const clientId = auth.clientId;
const acctEntries = cache.Account || {};
let realm = '';
for (const [, val] of Object.entries(acctEntries)) {
realm = val.realm;
}
const body = new URLSearchParams({
client_id: clientId,
grant_type: 'refresh_token',
refresh_token: refreshSecret,
scope: 'https://graph.microsoft.com/.default offline_access',
});
const resp = await fetch(
`https://login.microsoftonline.com/${realm}/oauth2/v2.0/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
}
);
if (!resp.ok) {
const errText = await resp.text();
throw new Error(`Token refresh failed: ${resp.status} ${errText}`);
}
const data = await resp.json();
for (const [, val] of Object.entries(cache.AccessToken)) {
val.secret = data.access_token;
val.expires_on = String(Math.floor(Date.now() / 1000) + data.expires_in);
val.cached_at = String(Math.floor(Date.now() / 1000));
}
if (data.refresh_token) {
for (const [, val] of Object.entries(cache.RefreshToken)) {
val.secret = data.refresh_token;
}
}
writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2));
return data.access_token;
}
// ── Graph API ──────────────────────────────────────────────────────
async function sendMessage(token, message, retryCount = 0) {
const url = `https://graph.microsoft.com/v1.0/teams/${TEAM_ID}/channels/${encodeURIComponent(CHANNEL_ID)}/messages`;
const resp = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
body: { contentType: 'html', content: message },
}),
});
if (resp.status === 429) {
const retryAfter = parseInt(resp.headers.get('Retry-After') || '10');
console.log(` Rate limited. Waiting ${retryAfter}s...`);
await new Promise((r) => setTimeout(r, retryAfter * 1000));
return sendMessage(token, message, retryCount + 1);
}
if (resp.status === 401 && retryCount === 0) {
console.log(' Token expired, refreshing...');
const newToken = await refreshToken();
return sendMessage(newToken, message, retryCount + 1);
}
if (!resp.ok) {
const errText = await resp.text();
throw new Error(`Send failed: ${resp.status} ${errText}`);
}
return resp.json();
}
// ── Formatting ─────────────────────────────────────────────────────
function formatTimestamp(isoDate) {
const d = new Date(isoDate);
return d.toLocaleString(LOCALE, { timeZone: TIMEZONE });
}
function escapeHtml(text) {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
function markdownToHtml(content) {
let html = content;
html = html.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
html = html.replace(/(?<![\/\w])_(.+?)_(?![\/\w])/g, '<i>$1</i>');
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
html = html.replace(/```([^`]+)```/gs, '<pre>$1</pre>');
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.replace(/\n/g, '<br>');
return html;
}
// ── Main ───────────────────────────────────────────────────────────
async function main() {
const messages = JSON.parse(readFileSync(MESSAGES_PATH, 'utf8'));
let startIndex = 0;
try {
const progress = JSON.parse(readFileSync(PROGRESS_PATH, 'utf8'));
startIndex = progress.lastPosted + 1;
console.log(`Resuming from message ${startIndex} (${startIndex} already posted)`);
} catch {
console.log('Starting fresh migration');
}
let token = getAccessToken();
const total = messages.length;
let posted = startIndex;
const errors = [];
console.log(`Total messages to post: ${total - startIndex} (of ${total} total)`);
console.log(`Destination: Team ${TEAM_ID}`);
console.log(`Channel: ${CHANNEL_ID}`);
console.log('');
for (let i = startIndex; i < total; i++) {
const msg = messages[i];
const sender = msg.from;
const timestamp = formatTimestamp(msg.createdDateTime);
const content = msg.content;
const formatted = `<b>${escapeHtml(sender)}</b> \u2014 <i>${timestamp}</i><br><br>${markdownToHtml(content)}`;
try {
await sendMessage(token, formatted);
token = getAccessToken();
posted++;
writeFileSync(
PROGRESS_PATH,
JSON.stringify({ lastPosted: i, total, posted, errors: errors.length })
);
if (posted % 10 === 0 || i === total - 1) {
console.log(`Progress: ${posted}/${total} (${Math.round((posted / total) * 100)}%)`);
}
await new Promise((r) => setTimeout(r, DELAY_MS));
} catch (err) {
console.error(`ERROR on message ${i} (from ${sender}): ${err.message}`);
errors.push({ index: i, sender, error: err.message });
writeFileSync(
PROGRESS_PATH,
JSON.stringify({
lastPosted: i - 1,
total,
posted,
errors: errors.length,
errorDetails: errors,
})
);
if (errors.length > 5) {
console.error('Too many errors, stopping.');
break;
}
}
}
console.log('');
console.log(`Migration complete: ${posted}/${total} messages posted`);
if (errors.length > 0) {
console.log(`Errors: ${errors.length}`);
for (const err of errors) {
console.log(` - Message ${err.index}: ${err.error}`);
}
}
writeFileSync(
PROGRESS_PATH,
JSON.stringify(
{
lastPosted: posted - 1,
total,
posted,
errors: errors.length,
errorDetails: errors,
completed: errors.length === 0,
completedAt: new Date().toISOString(),
},
null,
2
)
);
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});
ExportChat Workflow
Export all messages from an MS Teams chat to a local JSON file for backup or analysis.
Step 1: Gather Parameters
Collect source chat ID from user (parse from Teams URL if provided).
Step 2: Verify Authentication
Same as MigrateChat Step 2.
Step 3: Fetch All Messages
Load tool: mcp__teams-mcp__get_chat_messages
Call with:
chatId: SOURCE_CHAT_ID
fetchAll: true
limit: 2000
descending: true
contentFormat: "markdown"Step 4: Save to File
If result is too large for MCP response, it's already saved to a file automatically. Otherwise, save the JSON response to a user-specified path (default: /tmp/teams_chat_export_YYYYMMDD.json).
Include metadata:
{
"exportDate": "ISO timestamp",
"chatId": "source chat ID",
"totalMessages": N,
"filteredMessages": M,
"senders": ["list of unique senders"],
"dateRange": { "earliest": "ISO", "latest": "ISO" },
"messages": [...]
}Step 5: Summary
Report to user:
- Total messages exported
- Unique senders
- Date range
- File path
- Messages with attachments/images count
MigrateChat Workflow
Migrate all messages from an MS Teams meeting chat (or group chat) to a team channel with full sender attribution and timestamp preservation.
Prerequisites
teams-mcpMCP server connected and authenticated- Account with Teams license (Chat API requires it)
- Source chat ID and destination team/channel IDs
Step 1: Gather Parameters
Use AskUserQuestion to collect:
1. Source chat ID -- Extract from Teams URL: 19:meeting_...@thread.v2 2. Destination team ID -- Extract from Teams URL: groupId=... 3. Destination channel ID -- Either known or discover via list_channels
If user provides Teams URLs, parse them:
- Chat URL:
https://teams.microsoft.com/l/chat/19:meeting_...@thread.v2/conversations?context=... - Team URL:
https://teams.microsoft.com/l/team/19%3A...@thread.tacv2/conversations?groupId=TEAM_ID&tenantId=TENANT_ID
Step 2: Verify Authentication
Load tool: mcp__teams-mcp__auth_status
Call: mcp__teams-mcp__auth_status- Confirm account has Teams license
- If auth fails or wrong account:
npx @floriscornel/teams-mcp@latest authenticate
CRITICAL: If account lacks Teams license, Chat API endpoints return 403. Channel endpoints may still work. Must re-auth with a licensed account.
Step 3: Identify Destination Channel
If channel ID not provided:
Load tool: mcp__teams-mcp__list_channels
Call with: teamId = DESTINATION_TEAM_IDPresent channels to user if multiple exist. Default to "General" if only one.
Step 4: Fetch All Source Messages
Load tool: mcp__teams-mcp__get_chat_messages
Call with:
chatId: SOURCE_CHAT_ID
fetchAll: true
limit: 2000
descending: true # IMPORTANT: ascending NOT supported by Graph API
contentFormat: "markdown"CRITICAL LEARNINGS:
descending: falsereturns error:QueryOptions to order by 'CreatedDateTime' in 'Ascending' direction is not supported- Always fetch descending and reverse the array for chronological posting
- Result may be too large for MCP response -- saved to file automatically
- Parse from saved file using
python3ornode
Step 5: Process Messages
# Filter and prepare messages
filtered = [m for m in messages if m.get('from') and m.get('content','').strip()]
# 'from' field is absent on system messages
# Empty content = system events (join/leave)
filtered.reverse() # Chronological order (oldest first)Filtering rules:
- Remove messages without
fromfield (system messages) - Remove messages with empty
content(system events) - Optionally remove deleted messages (
deletedDateTime != null) - Ask user about system messages if unsure
Step 6: Post Messages via Graph API
For 50+ messages, use `Tools/MigrateChat.mjs` directly instead of individual MCP calls.
# Copy tool to /tmp, configure constants, and run
node /path/to/Tools/MigrateChat.mjsThe tool handles:
- MSAL token from
~/.teams-mcp-token-cache.json - Automatic token refresh via refresh token
- Rate limiting (429 responses with Retry-After)
- Progress tracking to
/tmp/teams_migration_progress.json - Resume from last successful post on restart
Message format (HTML for Graph API):
<b>SENDER_NAME</b> -- <i>TIMESTAMP_LOCALIZED</i><br><br>MESSAGE_CONTENTFor small batches (<50 messages), use MCP directly:
Load tool: mcp__teams-mcp__send_channel_message
Call with:
teamId: DESTINATION_TEAM_ID
channelId: DESTINATION_CHANNEL_ID
format: "markdown"
message: "**Sender** -- _Timestamp_\n\nContent"Step 7: Verify Migration
1. Count check: Source filtered count == destination posted count 2. Spot-check: Read 5-10 messages from destination via get_channel_messages 3. Source integrity: Confirm source chat still readable (read-only operations only)
Edge Cases
| Case | Handling |
|---|---|
| Messages with images | Image URLs preserved in markdown. imageUrl param for MCP, <img> for Graph API |
| OneDrive/SharePoint files | Links preserved as-is (no re-upload) |
| Token expiration mid-migration | Auto-refreshed by MigrateChat.mjs using MSAL refresh token |
| Rate limiting (429) | Respect Retry-After header, retry automatically |
| Very large chats (2000+) | fetchAll: true handles pagination via @odata.nextLink |
| MCP server restart needed | /mcp in Claude Code, then new session for tool re-registration |
| Sensitive content (passwords) | Ask user before posting -- flag potential credentials |
| @mentions in source | Preserved as text in message body (not functional @mentions in destination) |