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:
mint init panel-designer \
--name "Panel Designer" \
--description "Drug-response panel design" \
--type experiment-design \
--template analysis-basic \
--no-frontend \
--yes
cd panel-designerAdd the local database dependencies used by standalone mode:
uv add sqlmodel aiosqlite greenletgreenlet 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:
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.pyCLAUDE.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:
mint doctor --fix
uv run pytest -q2. Add the table model
Create src/mint_plugin_panel_designer/models.py:
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:
[
{"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:
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 = None4. Add the panel service
Create src/mint_plugin_panel_designer/services/panel_service.py:
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:
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:
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 PanelServiceChange the metadata to declare table ownership:
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:
class PanelDesignerPlugin(AnalysisPlugin):
# keep the generated __new__ singleton method
def __init__(self) -> None:
if hasattr(self, "_initialized"):
return
super().__init__()
self._initialized = TrueThen update lifecycle and router registration:
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:
mint devIf port 8003 is already in use, choose another port:
mint dev --port 8014In another terminal:
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/1Change 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:
mkdir -p src/mint_plugin_panel_designer/migrations
touch src/mint_plugin_panel_designer/migrations/__init__.pyCreate src/mint_plugin_panel_designer/migrations/v001_create_panels_table.py:
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:
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:
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:
uv run pytest -qIf 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
mint doctor --fix
mint build
# dist/*.mintThe 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
panelstable - Uses the current
routers/,schemas/, andservices/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
- Tutorial 4 - Plugin roles - gate panel deletion on a plugin role
- Tutorial 2 - Adding a frontend - reuse the Vue pattern when you add a UI to panel-designer
- Recipes - Backfill migrations - chunked data backfill patterns
- Recipes - Querying plugin data - heavier query patterns on plugin tables