From miro-pack
Migrates Miro boards between teams/orgs, exports content to JSON, imports data via REST API v2, and re-platforms from Lucidchart/FigJam whiteboard tools.
npx claudepluginhub jeremylongshore/claude-code-plugins-plus-skills --plugin miro-packThis skill is limited to using the following tools:
Comprehensive migration strategies for Miro REST API v2: export entire board content, import structured data into boards, migrate between teams/organizations, and re-platform from competing whiteboard tools.
Detects PII in Miro board content via regex, exports data using REST API v2 for DSAR requests, implements retention policies and GDPR/CCPA compliance patterns.
Automates Miro whiteboard tasks: create/manage boards, sticky notes, frames, items, sharing, connectors via Rube MCP (Composio). Requires active Miro connection and tool schema search.
Automates Miro whiteboard operations like listing/creating boards, sticky notes, frames, sharing, connectors via Composio's Rube MCP toolkit. Use for programmatic Miro workflows.
Share bugs, ideas, or general feedback.
Comprehensive migration strategies for Miro REST API v2: export entire board content, import structured data into boards, migrate between teams/organizations, and re-platform from competing whiteboard tools.
| Type | Complexity | Duration | Approach |
|---|---|---|---|
| Export board content | Low | Minutes | Read all items, save as JSON |
| Import data into board | Medium | Minutes | Batch create items via API |
| Move boards between teams | Medium | Hours | Copy + re-share |
| Re-platform (Lucidchart/FigJam) | High | Days–Weeks | Export → transform → import |
| Full org migration | High | Weeks | SCIM + board migration + member mapping |
Export every item on a board to a structured JSON file:
interface BoardExport {
exportedAt: string;
board: {
id: string;
name: string;
description: string;
owner: { id: string; name: string };
};
items: ExportedItem[];
connectors: ExportedConnector[];
tags: ExportedTag[];
members: ExportedMember[];
}
async function exportBoard(boardId: string): Promise<BoardExport> {
// Get board metadata
const board = await miroFetch(`/v2/boards/${boardId}`);
// Get all items (cursor-paginated)
const items: any[] = [];
let cursor: string | undefined;
do {
const params = new URLSearchParams({ limit: '50' });
if (cursor) params.set('cursor', cursor);
const page = await miroFetch(`/v2/boards/${boardId}/items?${params}`);
items.push(...page.data);
cursor = page.cursor;
} while (cursor);
// Get all connectors
const connectors: any[] = [];
cursor = undefined;
do {
const params = new URLSearchParams({ limit: '50' });
if (cursor) params.set('cursor', cursor);
const page = await miroFetch(`/v2/boards/${boardId}/connectors?${params}`);
connectors.push(...page.data);
cursor = page.cursor;
} while (cursor);
// Get all tags
const tags = await miroFetch(`/v2/boards/${boardId}/tags`);
// Get board members
const members = await miroFetch(`/v2/boards/${boardId}/members?limit=100`);
return {
exportedAt: new Date().toISOString(),
board: {
id: board.id,
name: board.name,
description: board.description ?? '',
owner: { id: board.owner?.id, name: board.owner?.name },
},
items: items.map(normalizeItem),
connectors: connectors.map(normalizeConnector),
tags: tags.data ?? [],
members: members.data ?? [],
};
}
function normalizeItem(item: any) {
return {
id: item.id,
type: item.type,
data: item.data,
style: item.style,
position: item.position,
geometry: item.geometry,
parentId: item.parent?.id,
createdAt: item.createdAt,
createdBy: item.createdBy?.id,
};
}
Recreate exported items on a new board:
import PQueue from 'p-queue';
interface ImportResult {
created: number;
failed: number;
errors: Array<{ item: any; error: string }>;
idMap: Map<string, string>; // Old ID → New ID
}
async function importToBoard(
targetBoardId: string,
exportData: BoardExport,
options: { offsetX?: number; offsetY?: number } = {}
): Promise<ImportResult> {
const queue = new PQueue({ concurrency: 3, interval: 1000, intervalCap: 8 });
const result: ImportResult = { created: 0, failed: 0, errors: [], idMap: new Map() };
// Phase 1: Create items (excluding frames first, then frames)
const frames = exportData.items.filter(i => i.type === 'frame');
const nonFrames = exportData.items.filter(i => i.type !== 'frame');
// Create frames first (they contain other items)
for (const frame of frames) {
await queue.add(async () => {
try {
const newItem = await createItemByType(targetBoardId, frame, options);
result.idMap.set(frame.id, newItem.id);
result.created++;
} catch (err: any) {
result.failed++;
result.errors.push({ item: frame, error: err.message });
}
});
}
await queue.onIdle();
// Then create other items
for (const item of nonFrames) {
await queue.add(async () => {
try {
const newItem = await createItemByType(targetBoardId, item, options);
result.idMap.set(item.id, newItem.id);
result.created++;
} catch (err: any) {
result.failed++;
result.errors.push({ item, error: err.message });
}
});
}
await queue.onIdle();
// Phase 2: Recreate connectors using new IDs
for (const connector of exportData.connectors) {
const newStartId = result.idMap.get(connector.startItem?.id);
const newEndId = result.idMap.get(connector.endItem?.id);
if (!newStartId || !newEndId) continue;
await queue.add(async () => {
try {
await miroFetch(`/v2/boards/${targetBoardId}/connectors`, 'POST', {
startItem: { id: newStartId },
endItem: { id: newEndId },
captions: connector.captions,
style: connector.style,
shape: connector.shape,
});
result.created++;
} catch (err: any) {
result.errors.push({ item: connector, error: err.message });
}
});
}
await queue.onIdle();
// Phase 3: Recreate tags
for (const tag of exportData.tags) {
await queue.add(async () => {
try {
await miroFetch(`/v2/boards/${targetBoardId}/tags`, 'POST', {
title: tag.title,
fillColor: tag.fillColor,
});
} catch (err: any) {
// Duplicate tag titles return 409 — safe to ignore
if (!err.message?.includes('409')) {
result.errors.push({ item: tag, error: err.message });
}
}
});
}
await queue.onIdle();
return result;
}
async function createItemByType(
boardId: string,
item: any,
options: { offsetX?: number; offsetY?: number }
) {
const position = {
x: (item.position?.x ?? 0) + (options.offsetX ?? 0),
y: (item.position?.y ?? 0) + (options.offsetY ?? 0),
};
const endpointMap: Record<string, string> = {
sticky_note: 'sticky_notes',
shape: 'shapes',
card: 'cards',
text: 'texts',
frame: 'frames',
image: 'images',
document: 'documents',
embed: 'embeds',
app_card: 'app_cards',
};
const endpoint = endpointMap[item.type];
if (!endpoint) throw new Error(`Unsupported item type: ${item.type}`);
return miroFetch(`/v2/boards/${boardId}/${endpoint}`, 'POST', {
data: item.data,
style: item.style,
position,
geometry: item.geometry,
});
}
async function duplicateBoard(
sourceBoardId: string,
targetTeamId: string,
newName: string,
): Promise<{ newBoardId: string; importResult: ImportResult }> {
// Step 1: Export source board
console.log('Exporting source board...');
const exportData = await exportBoard(sourceBoardId);
// Step 2: Create new board in target team
console.log('Creating target board...');
const newBoard = await miroFetch('/v2/boards', 'POST', {
name: newName,
description: exportData.board.description,
teamId: targetTeamId,
});
// Step 3: Import all content
console.log('Importing items...');
const importResult = await importToBoard(newBoard.id, exportData);
console.log(`Done! Created ${importResult.created} items, ${importResult.failed} failed`);
return { newBoardId: newBoard.id, importResult };
}
Import structured data (from spreadsheets, Jira, etc.) as Miro items:
interface CsvRow {
title: string;
description?: string;
category?: string;
priority?: string;
}
async function importCsvAsCards(
boardId: string,
rows: CsvRow[],
layout: 'grid' | 'column' = 'grid'
): Promise<ImportResult> {
const result: ImportResult = { created: 0, failed: 0, errors: [], idMap: new Map() };
const queue = new PQueue({ concurrency: 3, interval: 1000, intervalCap: 8 });
// Color mapping for categories
const categoryColors: Record<string, string> = {
bug: '#ff6b6b',
feature: '#2d9bf0',
improvement: '#51cf66',
default: '#868e96',
};
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const x = layout === 'grid' ? (i % 5) * 300 : 0;
const y = layout === 'grid' ? Math.floor(i / 5) * 200 : i * 200;
await queue.add(async () => {
try {
const card = await miroFetch(`/v2/boards/${boardId}/cards`, 'POST', {
data: {
title: row.title,
description: row.description ?? '',
},
style: {
cardTheme: categoryColors[row.category?.toLowerCase() ?? 'default'] ?? categoryColors.default,
},
position: { x, y },
});
// Add priority as tag
if (row.priority) {
try {
const tag = await miroFetch(`/v2/boards/${boardId}/tags`, 'POST', {
title: row.priority,
fillColor: row.priority === 'High' ? 'red' : 'yellow',
});
await miroFetch(`/v2/boards/${boardId}/items/${card.id}/tags`, 'POST', {
tagId: tag.id,
});
} catch {
// Tag might already exist — acceptable
}
}
result.created++;
} catch (err: any) {
result.failed++;
result.errors.push({ item: row, error: err.message });
}
});
}
await queue.onIdle();
return result;
}
async function validateMigration(
sourceBoardId: string,
targetBoardId: string,
): Promise<ValidationReport> {
const sourceItems = await fetchAllItems(sourceBoardId);
const targetItems = await fetchAllItems(targetBoardId);
const sourceConnectors = await fetchAllConnectors(sourceBoardId);
const targetConnectors = await fetchAllConnectors(targetBoardId);
const checks = [
{
name: 'Item count match',
pass: targetItems.length >= sourceItems.length * 0.95, // 95% threshold
detail: `Source: ${sourceItems.length}, Target: ${targetItems.length}`,
},
{
name: 'Item types match',
pass: compareTypeCounts(sourceItems, targetItems),
detail: getTypeCountDiff(sourceItems, targetItems),
},
{
name: 'Connectors migrated',
pass: targetConnectors.length >= sourceConnectors.length * 0.9,
detail: `Source: ${sourceConnectors.length}, Target: ${targetConnectors.length}`,
},
];
return {
passed: checks.every(c => c.pass),
checks,
summary: `${checks.filter(c => c.pass).length}/${checks.length} checks passed`,
};
}
async function rollbackMigration(
targetBoardId: string,
importResult: ImportResult,
): Promise<void> {
console.log(`Rolling back: deleting ${importResult.created} items from ${targetBoardId}`);
const queue = new PQueue({ concurrency: 5 });
for (const [, newId] of importResult.idMap) {
queue.add(async () => {
await miroFetch(`/v2/boards/${targetBoardId}/items/${newId}`, 'DELETE').catch(() => {});
});
}
await queue.onIdle();
console.log('Rollback complete');
}
| Issue | Cause | Solution |
|---|---|---|
| Rate limited during import | Too many items | Reduce concurrency, increase interval |
| Connector fails | Referenced item wasn't created | Check idMap for missing mappings |
| Image URL 404 | External image no longer available | Skip or replace with placeholder |
| Position overlap | No offset applied | Use offsetX/offsetY options |
| Tag duplicate | Tag title already exists | Catch 409, reuse existing tag |
This is the final Flagship skill. For starting a new integration from scratch, see miro-install-auth.