Skip to content

Tutorial 3 - Design plugin with tables

You'll build panel-designer, an EXPERIMENT_DESIGN plugin that owns a panels table for drug-response panel designs. This tutorial uses the current mint init scaffold, adds SQLModel models for local development, exposes CRUD routes through PluginDependency, and adds a migration package for installed platform deployments.

Time: ~60 minutes Prereqs: Tutorial 1; comfort with mint init, FastAPI routes, and async SQLAlchemy sessions

1. Scaffold

Create an experiment-design plugin:

bash
mint init panel-designer \
  --name "Panel Designer" \
  --description "Drug-response panel design" \
  --type experiment-design \
  --template analysis-basic \
  --no-frontend \
  --yes
cd panel-designer

Add the local database dependencies used by standalone mode:

bash
uv add sqlmodel aiosqlite greenlet

greenlet is required by SQLAlchemy's async SQLite engine. Without it, migration tests that use sqlite+aiosqlite:// fail before your migration code runs.

The generated module is mint_plugin_panel_designer:

text
panel-designer/
├── .github/
│   └── workflows/
│       ├── ci.yml
│       └── release.yml
├── .gitignore
├── CHANGELOG.md
├── CLAUDE.md
├── pyproject.toml
├── README.md
├── scripts/
│   ├── build.sh
│   ├── dev.sh
│   └── release.sh
├── src/
│   └── mint_plugin_panel_designer/
│       ├── __init__.py
│       ├── plugin.py
│       ├── routers/
│       │   └── analysis.py
│       ├── schemas/
│       └── services/
└── tests/
    ├── __init__.py
    └── test_plugin.py

CLAUDE.md is generated by the default --yes scaffold. If mint doctor says the file is missing current SDK guidance, run mint doctor --fix once to refresh it. If you use --ai-assistant none, the current SDK scaffolds no assistant file and mint doctor reports that as a failing check; mint doctor --fix creates AGENTS.md.

Checkpoint:

bash
mint doctor --fix
uv run pytest -q

2. Add the table model

Create src/mint_plugin_panel_designer/models.py:

python
from datetime import UTC, datetime
from typing import Any

from sqlalchemy import JSON, Column
from sqlmodel import Field, SQLModel


def utc_now() -> datetime:
    return datetime.now(UTC)


class Panel(SQLModel, table=True):
    __tablename__ = "panels"

    id: int | None = Field(default=None, primary_key=True)
    experiment_id: int = Field(index=True)
    name: str = Field(max_length=200)
    drugs: list[dict[str, Any]] = Field(sa_column=Column(JSON, nullable=False))
    notes: str | None = None
    tags: list[str] | None = Field(default=None, sa_column=Column(JSON, nullable=True))
    created_at: datetime = Field(default_factory=utc_now)
    updated_at: datetime = Field(default_factory=utc_now)

Panel.drugs stores values such as:

json
[
  {"name": "Cisplatin", "doses_uM": [0.1, 1, 10, 100]}
]

The SQLModel class does two jobs: it is the ORM model used in route handlers, and it gives standalone mint dev enough table metadata for the plugin's local SQLite database.

3. Add request and response schemas

Create src/mint_plugin_panel_designer/schemas/panels.py:

python
from typing import Any

from pydantic import BaseModel, ConfigDict


class PanelIn(BaseModel):
    experiment_id: int
    name: str
    drugs: list[dict[str, Any]]
    notes: str | None = None
    tags: list[str] | None = None


class PanelOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    experiment_id: int
    name: str
    drugs: list[dict[str, Any]]
    notes: str | None = None
    tags: list[str] | None = None

4. Add the panel service

Create src/mint_plugin_panel_designer/services/panel_service.py:

python
from typing import TYPE_CHECKING

from sqlalchemy import func
from sqlmodel import select

from mint_plugin_panel_designer.models import Panel, utc_now
from mint_plugin_panel_designer.schemas.panels import PanelIn, PanelOut

if TYPE_CHECKING:
    from mint_plugin_panel_designer.plugin import PanelDesignerPlugin


class PanelService:
    def __init__(self, plugin: "PanelDesignerPlugin") -> None:
        self.plugin = plugin

    async def list_panels(self, experiment_id: int) -> list[PanelOut]:
        async with self.plugin.get_plugin_db_session() as session:
            result = await session.execute(
                select(Panel).where(Panel.experiment_id == experiment_id)
            )
            return [
                PanelOut.model_validate(panel)
                for panel in result.scalars().all()
            ]

    async def create_panel(self, body: PanelIn) -> PanelOut:
        now = utc_now()
        panel = Panel(
            experiment_id=body.experiment_id,
            name=body.name,
            drugs=body.drugs,
            notes=body.notes,
            tags=body.tags,
            created_at=now,
            updated_at=now,
        )

        async with self.plugin.get_plugin_db_session() as session:
            session.add(panel)
            await session.commit()
            await session.refresh(panel)

        panel_count = await self.count_panels(body.experiment_id)
        await self.plugin.save_design(
            body.experiment_id,
            {"panel_count": panel_count},
        )
        return PanelOut.model_validate(panel)

    async def count_panels(self, experiment_id: int) -> int:
        async with self.plugin.get_plugin_db_session() as session:
            result = await session.execute(
                select(func.count(Panel.id)).where(Panel.experiment_id == experiment_id)
            )
            return int(result.scalar_one())

get_plugin_db_session() is the mode-portable session helper. It uses the plugin's local SQLite database in standalone mode and the platform-scoped Postgres schema when the plugin is loaded in-process by MINT.

5. Add the router

Create src/mint_plugin_panel_designer/routers/panels.py:

python
from fastapi import APIRouter, Depends, status
from mint_sdk.app import PluginDependency

from mint_plugin_panel_designer.schemas.panels import PanelIn, PanelOut
from mint_plugin_panel_designer.services.panel_service import PanelService

router = APIRouter(tags=["panels"])
panel_service_dep = PluginDependency[PanelService]("PanelService")


@router.get("/panels/{experiment_id}", response_model=list[PanelOut])
async def list_panels(
    experiment_id: int,
    service: PanelService = Depends(panel_service_dep),
) -> list[PanelOut]:
    return await service.list_panels(experiment_id)


@router.post("/panels", response_model=PanelOut, status_code=status.HTTP_201_CREATED)
async def create_panel(
    body: PanelIn,
    service: PanelService = Depends(panel_service_dep),
) -> PanelOut:
    return await service.create_panel(body)

6. Wire the plugin

Update src/mint_plugin_panel_designer/plugin.py:

python
from mint_plugin_panel_designer.models import Panel
from mint_plugin_panel_designer.routers import analysis, panels
from mint_plugin_panel_designer.routers.panels import panel_service_dep
from mint_plugin_panel_designer.services.panel_service import PanelService

Change the metadata to declare table ownership:

python
plugin_type=PluginType.EXPERIMENT_DESIGN,
capabilities=PluginCapabilities(
    requires_auth=True,
    requires_database=True,
    requires_experiments=True,
    requires_shared_database=True,
),
schema_version="1.0",

Confirm the generated __init__() calls super().__init__() before _setup_standalone_db() can run. Current mint init scaffolds this already, so keep it. If you are updating an older plugin that lacks the base call, add this version:

python
class PanelDesignerPlugin(AnalysisPlugin):
    # keep the generated __new__ singleton method

    def __init__(self) -> None:
        if hasattr(self, "_initialized"):
            return
        super().__init__()
        self._initialized = True

Then update lifecycle and router registration:

python
class PanelDesignerPlugin(AnalysisPlugin):
    # ... __new__, __init__, and metadata as above ...

    async def initialize(self, context: PlatformContext | None = None) -> None:
        self._context = context
        if context is None:
            self._setup_standalone_db()
        panel_service_dep.set(PanelService(self))
        mode = "integrated" if context else "standalone"
        print(f"[{PLUGIN_NAME}] Initialized in {mode} mode")

    async def shutdown(self) -> None:
        panel_service_dep.reset()
        if self.is_standalone:
            self._teardown_standalone_db()
        print(f"[{PLUGIN_NAME}] Shutting down...")

    def get_routers(self) -> list[tuple[APIRouter, str]]:
        return [
            (analysis.router, ""),
            (panels.router, ""),
        ]

    def get_shared_models(self) -> list[type]:
        return [Panel]

Why get_shared_models() is still useful: the standalone dev server calls _setup_standalone_db() and creates the SQLite table from this model. Installed platform deployments can use migrations for schema history, which you add next.

7. Run locally

Start the backend:

bash
mint dev

If port 8003 is already in use, choose another port:

bash
mint dev --port 8014

In another terminal:

bash
curl -X POST http://127.0.0.1:8003/api/panel-designer/panels \
  -H "Content-Type: application/json" \
  -d '{
    "experiment_id": 1,
    "name": "Cisplatin dose-response",
    "drugs": [{"name": "Cisplatin", "doses_uM": [0.1, 1, 10, 100]}],
    "tags": ["dose-response"]
  }'

curl http://127.0.0.1:8003/api/panel-designer/panels/1

Change 8003 to the port you selected. A successful POST returns the created panel with an integer id; the GET returns a one-item list for experiment 1.

In standalone mode save_design() returns None, so the route still works even though no platform is available.

8. Add production migrations

Create a migrations package:

bash
mkdir -p src/mint_plugin_panel_designer/migrations
touch src/mint_plugin_panel_designer/migrations/__init__.py

Create src/mint_plugin_panel_designer/migrations/v001_create_panels_table.py:

python
import sqlalchemy as sa
from mint_sdk.migrations import MigrationOps, PluginMigration


class CreatePanelsTable(PluginMigration):
    version = 1
    name = "create_panels_table"

    async def upgrade(self, op: MigrationOps) -> None:
        await op.create_table(
            "panels",
            sa.Column("id", sa.Integer, primary_key=True),
            sa.Column("experiment_id", sa.Integer, nullable=False),
            sa.Column("name", sa.String(200), nullable=False),
            sa.Column("drugs", sa.JSON, nullable=False),
            sa.Column("notes", sa.String, nullable=True),
            sa.Column("tags", sa.JSON, nullable=True),
            sa.Column("created_at", sa.DateTime, nullable=False),
            sa.Column("updated_at", sa.DateTime, nullable=False),
        )
        await op.create_index("idx_panels_experiment", "panels", ["experiment_id"])

Add this method to PanelDesignerPlugin:

python
def get_migrations_package(self) -> str:
    return "mint_plugin_panel_designer.migrations"

When MINT loads the installed plugin from its mint.plugins entry point, the platform discovers this package and applies pending migrations before it mounts the plugin routes. The local mint dev --platform proxy registers the plugin in the platform UI, but it does not install the plugin into the platform process or apply migrations for it; use the migration test below or a disposable installed platform to validate migration behavior.

9. Test the migration

Create tests/test_migrations.py:

python
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from mint_sdk.migrations import MigrationRunner

from mint_plugin_panel_designer.migrations.v001_create_panels_table import (
    CreatePanelsTable,
)


@pytest.mark.asyncio
async def test_migrations_apply_cleanly(tmp_path):
    db_path = tmp_path / "plugin.db"
    engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
    try:
        runner = MigrationRunner(
            engine,
            plugin_name="panel-designer",
            dialect="sqlite",
        )
        result = await runner.run([CreatePanelsTable()])
        assert result.applied == [1]

        async with engine.connect() as conn:
            rows = await conn.execute(text('PRAGMA table_info("panels")'))
            columns = {row[1] for row in rows.fetchall()}
        assert {"id", "experiment_id", "name", "drugs", "tags"} <= columns
    finally:
        await engine.dispose()

Run:

bash
uv run pytest -q

If this fails with No module named 'greenlet', add the missing async SQLAlchemy dependency with uv add greenlet and run the test again.

10. Validate and package

bash
mint doctor --fix
mint build
# dist/*.mint

The bundle includes the migrations package because it lives under src/mint_plugin_panel_designer/.

Where you've landed

You have an EXPERIMENT_DESIGN plugin that:

  • Owns a queryable panels table
  • Uses the current routers/, schemas/, and services/ scaffold
  • Works locally with standalone SQLite via get_shared_models()
  • Saves an experiment-level design summary through save_design()
  • Ships an append-only migration package for installed platform deployments
  • Has a migration test that proves the schema applies cleanly

Next

MINT is open source. Made by the Morscher Lab.