NYC

miro-api

SKILL.md

Miro API Skill

Master Miro whiteboard automation using the REST API v2 and Python SDK. This skill covers board creation, widget management, frames, shapes, connectors, templates, and real-time collaboration patterns for building automated visual workflows.

When to Use This Skill

USE when:

  • Automating sprint retrospective board creation
  • Building visual project status dashboards
  • Creating automated architecture diagrams
  • Setting up templated workshop boards
  • Integrating Miro with CI/CD pipelines
  • Automating user story mapping workflows
  • Creating visual incident response boards
  • Building automated onboarding boards
  • Generating meeting facilitation templates
  • Syncing data from external systems to Miro

DON'T USE when:

  • Simple text-based collaboration (use Slack or Teams)
  • Document-focused workflows (use Notion or Confluence)
  • Code-focused diagramming (use Mermaid or PlantUML)
  • Real-time whiteboarding without persistence
  • Personal note-taking (use Obsidian)

Prerequisites

Miro App Setup

# 1. Create a Miro App at https://miro.com/app/settings/user-profile/apps
# 2. Choose REST API 2.0
# 3. Configure OAuth 2.0 scopes

# Required OAuth Scopes:
# - boards:read          - Read board data
# - boards:write         - Create and modify boards
# - team:read            - Read team information
# - identity:read        - Read user identity
# - microphone:read      - Read board audio (optional)
# - screen_recording     - Screen recording (optional)

# App Permissions for different use cases:
# Team App:   boards:read, boards:write, team:read
# User App:   boards:read, boards:write, identity:read
# Admin App:  All scopes + organization management

# Get Access Token via OAuth 2.0:
# 1. Redirect user to: https://miro.com/oauth/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=REDIRECT_URI
# 2. Exchange code for token at: https://api.miro.com/v1/oauth/token

Python Environment Setup

# Create virtual environment
python -m venv miro-env
source miro-env/bin/activate  # Linux/macOS
# miro-env\Scripts\activate   # Windows

# Install Miro API SDK
pip install miro-api

# Install additional dependencies
pip install python-dotenv requests httpx aiohttp

# Create requirements.txt
cat > requirements.txt << 'EOF'
miro-api>=2.0.0
python-dotenv>=1.0.0
requests>=2.31.0
httpx>=0.25.0
aiohttp>=3.9.0
Pillow>=10.0.0
EOF

# Environment variables
cat > .env << 'EOF'
MIRO_ACCESS_TOKEN=your-access-token
MIRO_CLIENT_ID=your-client-id
MIRO_CLIENT_SECRET=your-client-secret
MIRO_TEAM_ID=your-team-id
EOF

API Authentication

# auth.py
# ABOUTME: Miro API authentication utilities
# ABOUTME: Handles OAuth2 token management and refresh

import os
from dotenv import load_dotenv
import requests
from datetime import datetime, timedelta

load_dotenv()

class MiroAuth:
    """Miro OAuth2 authentication handler"""

    BASE_URL = "https://api.miro.com"
    AUTH_URL = "https://miro.com/oauth/authorize"
    TOKEN_URL = "https://api.miro.com/v1/oauth/token"

    def __init__(self):
        self.client_id = os.environ.get("MIRO_CLIENT_ID")
        self.client_secret = os.environ.get("MIRO_CLIENT_SECRET")
        self.access_token = os.environ.get("MIRO_ACCESS_TOKEN")
        self.refresh_token = os.environ.get("MIRO_REFRESH_TOKEN")
        self.token_expires = None

    def get_authorization_url(self, redirect_uri: str, state: str = None) -> str:
        """Generate OAuth2 authorization URL"""
        params = {
            "response_type": "code",
            "client_id": self.client_id,
            "redirect_uri": redirect_uri,
        }
        if state:
            params["state"] = state

        query = "&".join(f"{k}={v}" for k, v in params.items())
        return f"{self.AUTH_URL}?{query}"

    def exchange_code_for_token(self, code: str, redirect_uri: str) -> dict:
        """Exchange authorization code for access token"""
        response = requests.post(
            self.TOKEN_URL,
            data={
                "grant_type": "authorization_code",
                "client_id": self.client_id,
                "client_secret": self.client_secret,
                "code": code,
                "redirect_uri": redirect_uri,
            },
        )
        response.raise_for_status()
        token_data = response.json()

        self.access_token = token_data["access_token"]
        self.refresh_token = token_data.get("refresh_token")
        self.token_expires = datetime.now() + timedelta(
            seconds=token_data.get("expires_in", 3600)
        )

        return token_data

    def refresh_access_token(self) -> dict:
        """Refresh the access token"""
        response = requests.post(
            self.TOKEN_URL,
            data={
                "grant_type": "refresh_token",
                "client_id": self.client_id,
                "client_secret": self.client_secret,
                "refresh_token": self.refresh_token,
            },
        )
        response.raise_for_status()
        token_data = response.json()

        self.access_token = token_data["access_token"]
        self.refresh_token = token_data.get("refresh_token", self.refresh_token)

        return token_data

    def get_headers(self) -> dict:
        """Get authorization headers for API requests"""
        return {
            "Authorization": f"Bearer {self.access_token}",
            "Content-Type": "application/json",
        }

Core Capabilities

1. Board Management

# boards.py
# ABOUTME: Miro board management operations
# ABOUTME: Create, read, update, delete boards

import os
from miro_api import Miro
from dotenv import load_dotenv

load_dotenv()

# Initialize Miro client
miro = Miro(access_token=os.environ.get("MIRO_ACCESS_TOKEN"))


def create_board(name: str, description: str = "", team_id: str = None) -> dict:
    """Create a new Miro board"""
    team_id = team_id or os.environ.get("MIRO_TEAM_ID")

    board = miro.boards.create(
        name=name,
        description=description,
        team_id=team_id,
        policy={
            "permissionsPolicy": {
                "collaborationToolsStartAccess": "all_editors",
                "copyAccess": "anyone",
                "sharingAccess": "team_members_with_editing_rights",
            },
            "sharingPolicy": {
                "access": "private",
                "inviteToAccountAndBoardLinkAccess": "editor",
                "organizationAccess": "private",
                "teamAccess": "edit",
            },
        },
    )

    return {
        "id": board.id,
        "name": board.name,
        "view_link": board.view_link,
        "created_at": board.created_at,
    }


def get_board(board_id: str) -> dict:
    """Get board details"""
    board = miro.boards.get(board_id)

    return {
        "id": board.id,
        "name": board.name,
        "description": board.description,
        "view_link": board.view_link,
        "created_at": board.created_at,
        "modified_at": board.modified_at,
    }


def list_boards(team_id: str = None, limit: int = 50) -> list:
    """List all boards in a team"""
    team_id = team_id or os.environ.get("MIRO_TEAM_ID")

    boards = miro.boards.get_all(team_id=team_id, limit=limit)

    return [
        {
            "id": board.id,
            "name": board.name,
            "view_link": board.view_link,
        }
        for board in boards
    ]


def update_board(board_id: str, name: str = None, description: str = None) -> dict:
    """Update board properties"""
    update_data = {}
    if name:
        update_data["name"] = name
    if description:
        update_data["description"] = description

    board = miro.boards.update(board_id, **update_data)

    return {"id": board.id, "name": board.name, "description": board.description}


def delete_board(board_id: str) -> bool:
    """Delete a board"""
    miro.boards.delete(board_id)
    return True


def copy_board(board_id: str, new_name: str, team_id: str = None) -> dict:
    """Copy an existing board"""
    team_id = team_id or os.environ.get("MIRO_TEAM_ID")

    board = miro.boards.copy(board_id, name=new_name, team_id=team_id)

    return {
        "id": board.id,
        "name": board.name,
        "view_link": board.view_link,
    }


# Usage example
if __name__ == "__main__":
    # Create a board
    board = create_board(
        name="Sprint Retrospective - Q1 2026",
        description="Team retrospective for Q1 sprint",
    )
    print(f"Created board: {board['view_link']}")

    # List boards
    boards = list_boards(limit=10)
    for b in boards:
        print(f"- {b['name']}: {b['view_link']}")

2. Sticky Notes and Cards

# sticky_notes.py
# ABOUTME: Sticky note and card creation
# ABOUTME: Create, position, and style sticky notes

from miro_api import Miro
import os

miro = Miro(access_token=os.environ.get("MIRO_ACCESS_TOKEN"))


def create_sticky_note(
    board_id: str,
    content: str,
    x: float = 0,
    y: float = 0,
    color: str = "yellow",
    width: float = 200,
) -> dict:
    """Create a sticky note on a board"""

    # Color mapping
    colors = {
        "yellow": "yellow",
        "green": "green",
        "blue": "blue",
        "pink": "pink",
        "orange": "orange",
        "purple": "violet",
        "gray": "gray",
        "cyan": "cyan",
        "red": "red",
        "light_yellow": "light_yellow",
        "light_green": "light_green",
        "light_blue": "light_blue",
        "light_pink": "light_pink",
    }

    sticky = miro.sticky_notes.create(
        board_id=board_id,
        data={"content": content, "shape": "square"},
        style={"fillColor": colors.get(color, "yellow")},
        position={"x": x, "y": y, "origin": "center"},
        geometry={"width": width},
    )

    return {
        "id": sticky.id,
        "content": sticky.data.content,
        "position": {"x": sticky.position.x, "y": sticky.position.y},
        "color": sticky.style.fill_color,
    }


def create_sticky_grid(
    board_id: str,
    items: list,
    start_x: float = 0,
    start_y: float = 0,
    columns: int = 4,
    spacing: float = 250,
    color: str = "yellow",
) -> list:
    """Create a grid of sticky notes"""
    created = []

    for i, item in enumerate(items):
        row = i // columns
        col = i % columns

        x = start_x + (col * spacing)
        y = start_y + (row * spacing)

        sticky = create_sticky_note(
            board_id=board_id, content=item, x=x, y=y, color=color
        )
        created.append(sticky)

    return created


def create_card(
    board_id: str,
    title: str,
    description: str = "",
    x: float = 0,
    y: float = 0,
    assignee_id: str = None,
    due_date: str = None,
    tags: list = None,
) -> dict:
    """Create a card widget"""

    card_data = {"title": title, "description": description}

    if assignee_id:
        card_data["assigneeId"] = assignee_id
    if due_date:
        card_data["dueDate"] = due_date
    if tags:
        card_data["tagIds"] = tags

    card = miro.cards.create(
        board_id=board_id,
        data=card_data,
        position={"x": x, "y": y, "origin": "center"},
        geometry={"width": 320, "height": 200},
    )

    return {
        "id": card.id,
        "title": card.data.title,
        "position": {"x": card.position.x, "y": card.position.y},
    }


def update_sticky_note(
    board_id: str, sticky_id: str, content: str = None, color: str = None
) -> dict:
    """Update a sticky note"""
    update_data = {}

    if content:
        update_data["data"] = {"content": content}
    if color:
        update_data["style"] = {"fillColor": color}

    sticky = miro.sticky_notes.update(board_id, sticky_id, **update_data)

    return {"id": sticky.id, "content": sticky.data.content}


def delete_sticky_note(board_id: str, sticky_id: str) -> bool:
    """Delete a sticky note"""
    miro.sticky_notes.delete(board_id, sticky_id)
    return True


# Retrospective board example
def create_retro_board(board_id: str) -> dict:
    """Create a retrospective board layout"""

    # Create category headers
    categories = [
        {"title": "What went well", "color": "green", "x": 0},
        {"title": "What to improve", "color": "pink", "x": 500},
        {"title": "Action items", "color": "blue", "x": 1000},
    ]

    created_stickies = {}

    for cat in categories:
        # Create header sticky
        header = create_sticky_note(
            board_id=board_id,
            content=f"<strong>{cat['title']}</strong>",
            x=cat["x"],
            y=-200,
            color=cat["color"],
            width=400,
        )
        created_stickies[cat["title"]] = [header]

        # Create placeholder stickies
        for i in range(3):
            placeholder = create_sticky_note(
                board_id=board_id,
                content="Add your thoughts here...",
                x=cat["x"],
                y=i * 150,
                color=cat["color"],
            )
            created_stickies[cat["title"]].append(placeholder)

    return created_stickies


if __name__ == "__main__":
    board_id = "YOUR_BOARD_ID"

    # Create grid of stickies
    items = ["Task 1", "Task 2", "Task 3", "Task 4", "Task 5", "Task 6"]
    stickies = create_sticky_grid(
        board_id=board_id, items=items, columns=3, color="blue"
    )
    print(f"Created {len(stickies)} sticky notes")

3. Shapes and Drawing

# shapes.py
# ABOUTME: Shape creation and manipulation
# ABOUTME: Rectangles, circles, lines, and custom shapes

from miro_api import Miro
import os

miro = Miro(access_token=os.environ.get("MIRO_ACCESS_TOKEN"))


def create_shape(
    board_id: str,
    shape_type: str,
    content: str = "",
    x: float = 0,
    y: float = 0,
    width: float = 200,
    height: float = 100,
    fill_color: str = "#ffffff",
    border_color: str = "#000000",
    border_width: int = 2,
) -> dict:
    """Create a shape on the board

    Shape types: rectangle, circle, triangle, rhombus, parallelogram,
                 trapezoid, pentagon, hexagon, octagon, wedge_round_rectangle_callout,
                 round_rectangle, star, flow_chart_process, flow_chart_decision,
                 flow_chart_terminator, flow_chart_data, flow_chart_document
    """

    shape = miro.shapes.create(
        board_id=board_id,
        data={"content": content, "shape": shape_type},
        style={
            "fillColor": fill_color,
            "borderColor": border_color,
            "borderWidth": str(border_width),
            "borderStyle": "normal",
            "fontFamily": "arial",
            "fontSize": "14",
            "textAlign": "center",
            "textAlignVertical": "middle",
        },
        position={"x": x, "y": y, "origin": "center"},
        geometry={"width": width, "height": height},
    )

    return {
        "id": shape.id,
        "type": shape.data.shape,
        "position": {"x": shape.position.x, "y": shape.position.y},
    }


def create_rectangle(
    board_id: str,
    content: str = "",
    x: float = 0,
    y: float = 0,
    width: float = 200,
    height: float = 100,
    fill_color: str = "#ffffff",
) -> dict:
    """Create a rectangle"""
    return create_shape(
        board_id=board_id,
        shape_type="rectangle",
        content=content,
        x=x,
        y=y,
        width=width,
        height=height,
        fill_color=fill_color,
    )


def create_circle(
    board_id: str,
    content: str = "",
    x: float = 0,
    y: float = 0,
    diameter: float = 100,
    fill_color: str = "#ffffff",
) -> dict:
    """Create a circle"""
    return create_shape(
        board_id=board_id,
        shape_type="circle",
        content=content,
        x=x,
        y=y,
        width=diameter,
        height=diameter,
        fill_color=fill_color,
    )


def create_flowchart_shape(
    board_id: str,
    shape_type: str,
    content: str = "",
    x: float = 0,
    y: float = 0,
    width: float = 150,
    height: float = 80,
) -> dict:
    """Create a flowchart shape

    Types: flow_chart_process, flow_chart_decision, flow_chart_terminator,
           flow_chart_data, flow_chart_document, flow_chart_predefined_process,
           flow_chart_manual_input, flow_chart_display, flow_chart_preparation
    """

    colors = {
        "flow_chart_process": "#e3f2fd",
        "flow_chart_decision": "#fff3e0",
        "flow_chart_terminator": "#f3e5f5",
        "flow_chart_data": "#e8f5e9",
        "flow_chart_document": "#fce4ec",
    }

    return create_shape(
        board_id=board_id,
        shape_type=shape_type,
        content=content,
        x=x,
        y=y,
        width=width,
        height=height,
        fill_color=colors.get(shape_type, "#ffffff"),
    )


def create_flowchart(board_id: str, steps: list, start_x: float = 0, start_y: float = 0) -> list:
    """Create a flowchart from a list of steps

    Each step: {"type": "process|decision|terminator", "content": "text"}
    """
    created = []
    current_y = start_y

    for step in steps:
        shape_type = f"flow_chart_{step.get('type', 'process')}"
        height = 100 if step.get("type") == "decision" else 80

        shape = create_flowchart_shape(
            board_id=board_id,
            shape_type=shape_type,
            content=step["content"],
            x=start_x,
            y=current_y,
            height=height,
        )
        created.append(shape)

        current_y += height + 80  # Add spacing for connectors

    return created


def update_shape(
    board_id: str,
    shape_id: str,
    content: str = None,
    fill_color: str = None,
    x: float = None,
    y: float = None,
) -> dict:
    """Update a shape"""
    update_data = {}

    if content is not None:
        update_data["data"] = {"content": content}
    if fill_color:
        update_data["style"] = {"fillColor": fill_color}
    if x is not None or y is not None:
        position = {}
        if x is not None:
            position["x"] = x
        if y is not None:
            position["y"] = y
        update_data["position"] = position

    shape = miro.shapes.update(board_id, shape_id, **update_data)
    return {"id": shape.id}


if __name__ == "__main__":
    board_id = "YOUR_BOARD_ID"

    # Create a simple flowchart
    steps = [
        {"type": "terminator", "content": "Start"},
        {"type": "process", "content": "Initialize system"},
        {"type": "decision", "content": "Valid input?"},
        {"type": "process", "content": "Process data"},
        {"type": "terminator", "content": "End"},
    ]

    flowchart = create_flowchart(board_id, steps)
    print(f"Created flowchart with {len(flowchart)} shapes")

4. Connectors and Lines

# connectors.py
# ABOUTME: Connector and line creation
# ABOUTME: Connect shapes, create arrows, and diagram flows

from miro_api import Miro
import os

miro = Miro(access_token=os.environ.get("MIRO_ACCESS_TOKEN"))


def create_connector(
    board_id: str,
    start_item_id: str,
    end_item_id: str,
    start_position: str = "right",
    end_position: str = "left",
    stroke_color: str = "#000000",
    stroke_width: int = 2,
    stroke_style: str = "normal",
    start_cap: str = "none",
    end_cap: str = "stealth",
    caption: str = None,
) -> dict:
    """Create a connector between two items

    Positions: top, right, bottom, left, auto
    Caps: none, stealth, rounded_stealth, diamond, diamond_filled, oval, oval_filled,
          arrow, triangle, triangle_filled, erd_one, erd_many, erd_one_or_many, erd_only_one,
          erd_zero_or_one, erd_zero_or_many
    Stroke styles: normal, dashed, dotted
    """

    connector_data = {
        "startItem": {"id": start_item_id, "position": {"x": start_position}},
        "endItem": {"id": end_item_id, "position": {"x": end_position}},
    }

    connector_style = {
        "strokeColor": stroke_color,
        "strokeWidth": str(stroke_width),
        "strokeStyle": stroke_style,
        "startStrokeCap": start_cap,
        "endStrokeCap": end_cap,
    }

    if caption:
        connector_data["captions"] = [{"content": caption, "position": "50%"}]

    connector = miro.connectors.create(
        board_id=board_id,
        data=connector_data,
        style=connector_style,
    )

    return {
        "id": connector.id,
        "start_item": connector.start_item.id,
        "end_item": connector.end_item.id,
    }


def create_line(
    board_id: str,
    start_x: float,
    start_y: float,
    end_x: float,
    end_y: float,
    stroke_color: str = "#000000",
    stroke_width: int = 2,
    end_cap: str = "none",
) -> dict:
    """Create a standalone line"""

    # For lines without connected items, we use absolute coordinates
    connector = miro.connectors.create(
        board_id=board_id,
        data={
            "startItem": {"position": {"x": start_x, "y": start_y}},
            "endItem": {"position": {"x": end_x, "y": end_y}},
        },
        style={
            "strokeColor": stroke_color,
            "strokeWidth": str(stroke_width),
            "startStrokeCap": "none",
            "endStrokeCap": end_cap,
        },
    )

    return {"id": connector.id}


def connect_flowchart_shapes(board_id: str, shape_ids: list, labels: list = None) -> list:
    """Connect a list of shapes in sequence"""
    created = []

    for i in range(len(shape_ids) - 1):
        label = labels[i] if labels and i < len(labels) else None

        connector = create_connector(
            board_id=board_id,
            start_item_id=shape_ids[i],
            end_item_id=shape_ids[i + 1],
            start_position="bottom",
            end_position="top",
            end_cap="stealth",
            caption=label,
        )
        created.append(connector)

    return created


def create_decision_branches(
    board_id: str,
    decision_shape_id: str,
    yes_shape_id: str,
    no_shape_id: str,
) -> list:
    """Create Yes/No branches from a decision diamond"""

    yes_connector = create_connector(
        board_id=board_id,
        start_item_id=decision_shape_id,
        end_item_id=yes_shape_id,
        start_position="bottom",
        end_position="top",
        end_cap="stealth",
        caption="Yes",
        stroke_color="#4caf50",
    )

    no_connector = create_connector(
        board_id=board_id,
        start_item_id=decision_shape_id,
        end_item_id=no_shape_id,
        start_position="right",
        end_position="left",
        end_cap="stealth",
        caption="No",
        stroke_color="#f44336",
    )

    return [yes_connector, no_connector]


def create_erd_relationship(
    board_id: str,
    entity1_id: str,
    entity2_id: str,
    cardinality_start: str = "erd_one",
    cardinality_end: str = "erd_many",
    label: str = None,
) -> dict:
    """Create an ERD relationship line

    Cardinalities: erd_one, erd_many, erd_one_or_many, erd_only_one,
                   erd_zero_or_one, erd_zero_or_many
    """

    return create_connector(
        board_id=board_id,
        start_item_id=entity1_id,
        end_item_id=entity2_id,
        start_cap=cardinality_start,
        end_cap=cardinality_end,
        caption=label,
    )


def update_connector(
    board_id: str,
    connector_id: str,
    stroke_color: str = None,
    caption: str = None,
) -> dict:
    """Update a connector"""
    update_data = {}

    if stroke_color:
        update_data["style"] = {"strokeColor": stroke_color}
    if caption:
        update_data["captions"] = [{"content": caption, "position": "50%"}]

    connector = miro.connectors.update(board_id, connector_id, **update_data)
    return {"id": connector.id}


def delete_connector(board_id: str, connector_id: str) -> bool:
    """Delete a connector"""
    miro.connectors.delete(board_id, connector_id)
    return True


if __name__ == "__main__":
    board_id = "YOUR_BOARD_ID"

    # Example: Connect shapes
    shape_ids = ["shape1_id", "shape2_id", "shape3_id"]
    connectors = connect_flowchart_shapes(board_id, shape_ids)
    print(f"Created {len(connectors)} connectors")

5. Frames and Organization

# frames.py
# ABOUTME: Frame creation for board organization
# ABOUTME: Group items, create sections, and manage layout

from miro_api import Miro
import os
from typing import List, Optional

miro = Miro(access_token=os.environ.get("MIRO_ACCESS_TOKEN"))


def create_frame(
    board_id: str,
    title: str,
    x: float = 0,
    y: float = 0,
    width: float = 800,
    height: float = 600,
    fill_color: str = "#f5f5f5",
) -> dict:
    """Create a frame to organize board content"""

    frame = miro.frames.create(
        board_id=board_id,
        data={"title": title, "format": "custom"},
        style={"fillColor": fill_color},
        position={"x": x, "y": y, "origin": "center"},
        geometry={"width": width, "height": height},
    )

    return {
        "id": frame.id,
        "title": frame.data.title,
        "position": {"x": frame.position.x, "y": frame.position.y},
        "geometry": {"width": frame.geometry.width, "height": frame.geometry.height},
    }


def create_frame_grid(
    board_id: str,
    titles: list,
    columns: int = 3,
    frame_width: float = 600,
    frame_height: float = 400,
    spacing: float = 50,
    start_x: float = 0,
    start_y: float = 0,
) -> list:
    """Create a grid of frames"""
    created = []

    for i, title in enumerate(titles):
        row = i // columns
        col = i % columns

        x = start_x + col * (frame_width + spacing)
        y = start_y + row * (frame_height + spacing)

        frame = create_frame(
            board_id=board_id,
            title=title,
            x=x,
            y=y,
            width=frame_width,
            height=frame_height,
        )
        created.append(frame)

    return created


def create_kanban_board(
    board_id: str,
    columns: list = None,
    frame_width: float = 400,
    frame_height: float = 800,
) -> list:
    """Create a Kanban-style board with columns"""

    if columns is None:
        columns = ["To Do", "In Progress", "Review", "Done"]

    colors = {
        "To Do": "#f5f5f5",
        "In Progress": "#fff3e0",
        "Review": "#e8f5e9",
        "Done": "#e3f2fd",
    }

    frames = []
    start_x = 0

    for i, column in enumerate(columns):
        frame = create_frame(
            board_id=board_id,
            title=column,
            x=start_x + i * (frame_width + 50),
            y=0,
            width=frame_width,
            height=frame_height,
            fill_color=colors.get(column, "#f5f5f5"),
        )
        frames.append(frame)

    return frames


def create_workshop_layout(
    board_id: str, sections: list, section_width: float = 1000, section_height: float = 600
) -> dict:
    """Create a workshop board layout

    sections: list of {"title": "name", "description": "desc", "color": "#hex"}
    """
    created = {"frames": [], "headers": []}

    for i, section in enumerate(sections):
        # Create frame for section
        frame = create_frame(
            board_id=board_id,
            title=section["title"],
            x=0,
            y=i * (section_height + 100),
            width=section_width,
            height=section_height,
            fill_color=section.get("color", "#f5f5f5"),
        )
        created["frames"].append(frame)

    return created


def update_frame(
    board_id: str,
    frame_id: str,
    title: str = None,
    width: float = None,
    height: float = None,
) -> dict:
    """Update a frame"""
    update_data = {}

    if title:
        update_data["data"] = {"title": title}
    if width or height:
        geometry = {}
        if width:
            geometry["width"] = width
        if height:
            geometry["height"] = height
        update_data["geometry"] = geometry

    frame = miro.frames.update(board_id, frame_id, **update_data)
    return {"id": frame.id, "title": frame.data.title}


def get_items_in_frame(board_id: str, frame_id: str) -> list:
    """Get all items contained within a frame"""
    frame = miro.frames.get(board_id, frame_id)

    # Get child items
    children = miro.frames.get_children(board_id, frame_id)

    return [
        {"id": child.id, "type": child.type}
        for child in children
    ]


def add_items_to_frame(board_id: str, frame_id: str, item_ids: list) -> bool:
    """Add items to a frame by updating their parent"""
    for item_id in item_ids:
        # Items inside a frame are managed by their position
        # They need to be within the frame's boundaries
        pass
    return True


def delete_frame(board_id: str, frame_id: str) -> bool:
    """Delete a frame"""
    miro.frames.delete(board_id, frame_id)
    return True


if __name__ == "__main__":
    board_id = "YOUR_BOARD_ID"

    # Create Kanban board
    kanban = create_kanban_board(board_id)
    print(f"Created Kanban with {len(kanban)} columns")

    # Create workshop layout
    sections = [
        {"title": "Introduction", "color": "#e3f2fd"},
        {"title": "Brainstorming", "color": "#fff3e0"},
        {"title": "Voting", "color": "#e8f5e9"},
        {"title": "Action Items", "color": "#f3e5f5"},
    ]
    workshop = create_workshop_layout(board_id, sections)
    print(f"Created workshop with {len(workshop['frames'])} sections")

6. Text and Images

# text_images.py
# ABOUTME: Text elements and image handling
# ABOUTME: Create text boxes, embed images, and manage media

from miro_api import Miro
import os
import requests
from io import BytesIO

miro = Miro(access_token=os.environ.get("MIRO_ACCESS_TOKEN"))


def create_text(
    board_id: str,
    content: str,
    x: float = 0,
    y: float = 0,
    width: float = 200,
    font_size: int = 14,
    font_family: str = "arial",
    text_align: str = "left",
    color: str = "#000000",
) -> dict:
    """Create a text element"""

    text = miro.texts.create(
        board_id=board_id,
        data={"content": content},
        style={
            "color": color,
            "fillOpacity": "1.0",
            "fontFamily": font_family,
            "fontSize": str(font_size),
            "textAlign": text_align,
        },
        position={"x": x, "y": y, "origin": "center"},
        geometry={"width": width},
    )

    return {
        "id": text.id,
        "content": text.data.content,
        "position": {"x": text.position.x, "y": text.position.y},
    }


def create_heading(
    board_id: str,
    content: str,
    x: float = 0,
    y: float = 0,
    level: int = 1,
) -> dict:
    """Create a heading text element"""

    font_sizes = {1: 36, 2: 28, 3: 22, 4: 18}
    font_size = font_sizes.get(level, 14)

    return create_text(
        board_id=board_id,
        content=f"<strong>{content}</strong>",
        x=x,
        y=y,
        width=500,
        font_size=font_size,
    )


def create_bullet_list(
    board_id: str,
    items: list,
    x: float = 0,
    y: float = 0,
) -> dict:
    """Create a bulleted list"""

    content = "<ul>" + "".join(f"<li>{item}</li>" for item in items) + "</ul>"

    return create_text(
        board_id=board_id,
        content=content,
        x=x,
        y=y,
        width=400,
    )


def upload_image_from_url(
    board_id: str,
    image_url: str,
    x: float = 0,
    y: float = 0,
    width: float = None,
    title: str = None,
) -> dict:
    """Create an image from a URL"""

    image_data = {"url": image_url}
    if title:
        image_data["title"] = title

    geometry = {}
    if width:
        geometry["width"] = width

    image = miro.images.create(
        board_id=board_id,
        data=image_data,
        position={"x": x, "y": y, "origin": "center"},
        geometry=geometry if geometry else None,
    )

    return {
        "id": image.id,
        "position": {"x": image.position.x, "y": image.position.y},
    }


def upload_image_from_file(
    board_id: str,
    file_path: str,
    x: float = 0,
    y: float = 0,
    width: float = None,
) -> dict:
    """Upload an image from a local file"""

    with open(file_path, "rb") as f:
        image_data = f.read()

    # Use the image upload endpoint
    headers = {
        "Authorization": f"Bearer {os.environ.get('MIRO_ACCESS_TOKEN')}",
    }

    files = {"resource": (os.path.basename(file_path), image_data)}
    data = {"position": f'{{"x": {x}, "y": {y}, "origin": "center"}}'}

    if width:
        data["geometry"] = f'{{"width": {width}}}'

    response = requests.post(
        f"https://api.miro.com/v2/boards/{board_id}/images",
        headers=headers,
        files=files,
        data=data,
    )
    response.raise_for_status()

    return response.json()


def create_embed(
    board_id: str,
    url: str,
    x: float = 0,
    y: float = 0,
    width: float = 400,
    height: float = 300,
    mode: str = "modal",
) -> dict:
    """Create an embedded content (web page, video, etc.)

    Modes: inline, modal
    """

    embed = miro.embeds.create(
        board_id=board_id,
        data={"url": url, "mode": mode},
        position={"x": x, "y": y, "origin": "center"},
        geometry={"width": width, "height": height},
    )

    return {
        "id": embed.id,
        "url": embed.data.url,
        "position": {"x": embed.position.x, "y": embed.position.y},
    }


def create_document_section(
    board_id: str,
    title: str,
    description: str,
    items: list,
    x: float = 0,
    y: float = 0,
) -> dict:
    """Create a document-like section with title, description, and bullet points"""

    created = {}

    # Create title
    heading = create_heading(board_id, title, x=x, y=y, level=2)
    created["title"] = heading

    # Create description
    desc = create_text(board_id, description, x=x, y=y + 60, width=500)
    created["description"] = desc

    # Create bullet list
    bullets = create_bullet_list(board_id, items, x=x, y=y + 150)
    created["items"] = bullets

    return created


if __name__ == "__main__":
    board_id = "YOUR_BOARD_ID"

    # Create a document section
    section = create_document_section(
        board_id=board_id,
        title="Project Overview",
        description="This project aims to improve team collaboration through automated Miro workflows.",
        items=[
            "Automated board creation",
            "Template-based layouts",
            "Integration with CI/CD",
        ],
    )
    print(f"Created section with {len(section)} elements")

Integration Examples

GitHub Actions Integration

# .github/workflows/miro-sync.yml
name: Sync to Miro

on:
  issues:
    types: [opened, labeled]
  pull_request:
    types: [opened, closed, merged]

jobs:
  sync-miro:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: |
          pip install miro-api requests

      - name: Create issue card in Miro
        if: github.event_name == 'issues'
        env:
          MIRO_ACCESS_TOKEN: ${{ secrets.MIRO_ACCESS_TOKEN }}
          MIRO_BOARD_ID: ${{ secrets.MIRO_BOARD_ID }}
        run: |
          python << 'EOF'
          import os
          from miro_api import Miro

          miro = Miro(access_token=os.environ["MIRO_ACCESS_TOKEN"])
          board_id = os.environ["MIRO_BOARD_ID"]

          # Create sticky note for new issue
          issue_title = "${{ github.event.issue.title }}"
          issue_number = "${{ github.event.issue.number }}"
          issue_url = "${{ github.event.issue.html_url }}"

          miro.sticky_notes.create(
              board_id=board_id,
              data={
                  "content": f"#{issue_number}: {issue_title}\n\n{issue_url}"
              },
              style={"fillColor": "yellow"},
              position={"x": 0, "y": 0, "origin": "center"},
          )
          print(f"Created sticky note for issue #{issue_number}")
          EOF

      - name: Update PR status in Miro
        if: github.event_name == 'pull_request'
        env:
          MIRO_ACCESS_TOKEN: ${{ secrets.MIRO_ACCESS_TOKEN }}
          MIRO_BOARD_ID: ${{ secrets.MIRO_BOARD_ID }}
        run: |
          python << 'EOF'
          import os
          from miro_api import Miro

          miro = Miro(access_token=os.environ["MIRO_ACCESS_TOKEN"])
          board_id = os.environ["MIRO_BOARD_ID"]

          pr_title = "${{ github.event.pull_request.title }}"
          pr_state = "${{ github.event.action }}"

          colors = {
              "opened": "light_blue",
              "closed": "red",
              "merged": "green",
          }

          miro.sticky_notes.create(
              board_id=board_id,
              data={"content": f"PR: {pr_title}\nStatus: {pr_state}"},
              style={"fillColor": colors.get(pr_state, "gray")},
              position={"x": 500, "y": 0, "origin": "center"},
          )
          EOF

Sprint Retrospective Automation

# retro_automation.py
# ABOUTME: Automated sprint retrospective board creation
# ABOUTME: Creates templated retro board with categories

from miro_api import Miro
import os
from datetime import datetime

miro = Miro(access_token=os.environ.get("MIRO_ACCESS_TOKEN"))


def create_retrospective_board(
    sprint_number: int,
    team_name: str,
    team_id: str = None,
) -> dict:
    """Create a complete retrospective board for a sprint"""

    team_id = team_id or os.environ.get("MIRO_TEAM_ID")

    # Create board
    board_name = f"Sprint {sprint_number} Retrospective - {team_name}"
    board = miro.boards.create(
        name=board_name,
        description=f"Retrospective for Sprint {sprint_number}",
        team_id=team_id,
    )
    board_id = board.id

    # Create frames for categories
    categories = [
        {"title": "What Went Well", "color": "#c8e6c9", "x": 0},
        {"title": "What Could Be Improved", "color": "#ffcdd2", "x": 700},
        {"title": "Action Items", "color": "#bbdefb", "x": 1400},
    ]

    frames = []
    for cat in categories:
        frame = miro.frames.create(
            board_id=board_id,
            data={"title": cat["title"], "format": "custom"},
            style={"fillColor": cat["color"]},
            position={"x": cat["x"], "y": 0, "origin": "center"},
            geometry={"width": 600, "height": 800},
        )
        frames.append({"id": frame.id, "title": cat["title"]})

        # Add placeholder stickies
        for i in range(3):
            miro.sticky_notes.create(
                board_id=board_id,
                data={"content": "Add your thoughts here..."},
                style={"fillColor": cat["color"]},
                position={
                    "x": cat["x"],
                    "y": -200 + (i * 150),
                    "origin": "center",
                },
            )

    # Create header
    miro.texts.create(
        board_id=board_id,
        data={
            "content": f"<strong>Sprint {sprint_number} Retrospective</strong><br>{datetime.now().strftime('%B %d, %Y')}"
        },
        style={"fontSize": "36", "textAlign": "center"},
        position={"x": 700, "y": -500, "origin": "center"},
        geometry={"width": 800},
    )

    # Add voting instructions
    miro.texts.create(
        board_id=board_id,
        data={
            "content": "Instructions:<br>1. Add sticky notes to each category<br>2. Vote on items using dots<br>3. Discuss top voted items<br>4. Create action items"
        },
        style={"fontSize": "14"},
        position={"x": -400, "y": 0, "origin": "center"},
        geometry={"width": 300},
    )

    return {
        "board_id": board_id,
        "view_link": board.view_link,
        "frames": frames,
    }


def create_sprint_planning_board(
    sprint_number: int,
    team_name: str,
    stories: list,
) -> dict:
    """Create a sprint planning board with user stories"""

    board = miro.boards.create(
        name=f"Sprint {sprint_number} Planning - {team_name}",
        description=f"Planning board for Sprint {sprint_number}",
        team_id=os.environ.get("MIRO_TEAM_ID"),
    )
    board_id = board.id

    # Create Kanban columns
    columns = ["Backlog", "To Do", "In Progress", "Review", "Done"]
    col_width = 350
    col_height = 1000

    for i, col in enumerate(columns):
        miro.frames.create(
            board_id=board_id,
            data={"title": col, "format": "custom"},
            style={"fillColor": "#f5f5f5"},
            position={"x": i * (col_width + 30), "y": 0, "origin": "center"},
            geometry={"width": col_width, "height": col_height},
        )

    # Add stories to backlog
    for j, story in enumerate(stories):
        miro.cards.create(
            board_id=board_id,
            data={
                "title": story.get("title", "User Story"),
                "description": story.get("description", ""),
            },
            position={"x": 0, "y": -300 + (j * 150), "origin": "center"},
            geometry={"width": 300, "height": 120},
        )

    return {"board_id": board_id, "view_link": board.view_link}


if __name__ == "__main__":
    # Create retrospective board
    retro = create_retrospective_board(sprint_number=15, team_name="Platform Team")
    print(f"Retro board: {retro['view_link']}")

    # Create planning board
    stories = [
        {"title": "As a user, I want to login with SSO", "description": "Implement SSO authentication"},
        {"title": "As a user, I want dark mode", "description": "Add dark mode support"},
    ]
    planning = create_sprint_planning_board(sprint_number=16, team_name="Platform Team", stories=stories)
    print(f"Planning board: {planning['view_link']}")

Best Practices

1. Rate Limiting

# Rate limit handling
import time
from functools import wraps

def rate_limit_handler(max_retries=3, base_delay=1):
    """Decorator for handling Miro rate limits"""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_retries):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if "429" in str(e) or "rate" in str(e).lower():
                        delay = base_delay * (2 ** attempt)
                        print(f"Rate limited, waiting {delay}s...")
                        time.sleep(delay)
                    else:
                        raise
            raise Exception("Max retries exceeded")
        return wrapper
    return decorator

@rate_limit_handler(max_retries=3)
def safe_create_sticky(board_id, content, x, y):
    return miro.sticky_notes.create(
        board_id=board_id,
        data={"content": content},
        position={"x": x, "y": y, "origin": "center"},
    )

2. Batch Operations

# Batch creation for better performance
def batch_create_stickies(board_id: str, items: list, batch_size: int = 10):
    """Create stickies in batches to avoid rate limits"""
    created = []

    for i in range(0, len(items), batch_size):
        batch = items[i:i + batch_size]

        for item in batch:
            sticky = miro.sticky_notes.create(
                board_id=board_id,
                data={"content": item["content"]},
                style={"fillColor": item.get("color", "yellow")},
                position={"x": item["x"], "y": item["y"], "origin": "center"},
            )
            created.append(sticky)

        # Small delay between batches
        if i + batch_size < len(items):
            time.sleep(0.5)

    return created

3. Error Handling

# Comprehensive error handling
from miro_api.exceptions import MiroApiException

def safe_api_call(func, *args, **kwargs):
    """Wrapper for safe API calls"""
    try:
        return func(*args, **kwargs)
    except MiroApiException as e:
        if e.status_code == 404:
            print(f"Resource not found: {e}")
            return None
        elif e.status_code == 401:
            print("Authentication failed - check token")
            raise
        elif e.status_code == 403:
            print("Permission denied - check scopes")
            raise
        elif e.status_code == 429:
            print("Rate limited - implement backoff")
            time.sleep(60)
            return func(*args, **kwargs)
        else:
            raise

4. Position Calculations

# Helper functions for positioning
def calculate_grid_position(index: int, columns: int, spacing: float = 250):
    """Calculate x, y for grid layout"""
    row = index // columns
    col = index % columns
    return {
        "x": col * spacing,
        "y": row * spacing,
    }

def calculate_circle_position(index: int, total: int, radius: float = 300, center_x: float = 0, center_y: float = 0):
    """Calculate x, y for circular layout"""
    import math
    angle = (2 * math.pi * index) / total
    return {
        "x": center_x + radius * math.cos(angle),
        "y": center_y + radius * math.sin(angle),
    }

Troubleshooting

Common Issues

Issue: 401 Unauthorized

# Verify token is valid
import requests

def verify_token(token: str) -> bool:
    response = requests.get(
        "https://api.miro.com/v1/users/me",
        headers={"Authorization": f"Bearer {token}"}
    )
    return response.status_code == 200

# Check token scopes
def get_token_scopes(token: str) -> list:
    response = requests.get(
        "https://api.miro.com/v1/oauth-token",
        headers={"Authorization": f"Bearer {token}"}
    )
    return response.json().get("scopes", [])

Issue: Items not appearing on board

# Check board permissions
def check_board_access(board_id: str) -> dict:
    board = miro.boards.get(board_id)
    return {
        "id": board.id,
        "permissions": board.policy.permissions_policy,
        "sharing": board.policy.sharing_policy,
    }

Issue: Rate limiting

# Implement exponential backoff
import time

def with_backoff(func, max_retries=5):
    for i in range(max_retries):
        try:
            return func()
        except Exception as e:
            if "429" in str(e):
                wait = (2 ** i) + (random.random() * 0.1)
                time.sleep(wait)
            else:
                raise
    raise Exception("Max retries exceeded")

Debug Commands

# Test API authentication
curl -X GET "https://api.miro.com/v1/users/me" \
  -H "Authorization: Bearer $MIRO_ACCESS_TOKEN"

# List boards
curl -X GET "https://api.miro.com/v2/boards?team_id=$MIRO_TEAM_ID" \
  -H "Authorization: Bearer $MIRO_ACCESS_TOKEN"

# Get board details
curl -X GET "https://api.miro.com/v2/boards/$BOARD_ID" \
  -H "Authorization: Bearer $MIRO_ACCESS_TOKEN"

# Create sticky note
curl -X POST "https://api.miro.com/v2/boards/$BOARD_ID/sticky_notes" \
  -H "Authorization: Bearer $MIRO_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"data": {"content": "Test note"}, "position": {"x": 0, "y": 0}}'

Version History

Version Date Changes
1.0.0 2026-01-17 Initial release with comprehensive Miro API v2 patterns

Resources


This skill provides production-ready patterns for Miro whiteboard automation, enabling powerful visual collaboration workflows and team productivity tools.

Weekly Installs
14
First Seen
Jan 24, 2026
Installed on
claude-code13
codex12
cursor12
opencode12
gemini-cli11
antigravity11