Tutorial 2 — Design plugin with tables
You'll build panel-designer, an EXPERIMENT_DESIGN plugin that owns a database table for drug-response panels. It uses mint_sdk.migrations to evolve its schema, exposes CRUD routes for panels, and saves the experiment-level design summary to DesignData.
Time: ~60 minutes Prereqs: Tutorial 1 recommended; you should be comfortable with mint init and mint dev
1. Scaffold
mint init panel-designer
cd panel-designerOr non-interactively:
mint init panel-designer \
--type experiment-design \
--description "Drug-response panel design" \
--no-frontend \
--yesNote: mint init doesn't have a --with-migrations flag — every experiment-design plugin scaffold includes a migrations/ directory by default, since design plugins typically own database tables.
The scaffolder produces:
panel-designer/
├── pyproject.toml
├── src/
│ └── panel_designer/
│ ├── __init__.py
│ ├── plugin.py
│ ├── models.py # ← SQLAlchemy models
│ ├── routes.py
│ └── migrations/
│ ├── __init__.py
│ └── 001_initial.py
└── tests/
└── test_plugin.py2. Define the panel model
Replace models.py:
# src/panel_designer/models.py
from datetime import datetime
from typing import Any
from uuid import UUID, uuid4
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Panel(Base):
__tablename__ = "panels"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
experiment_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
drugs: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False)
notes: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)The drugs column stores a list like [{"name": "Cisplatin", "doses_uM": [0.1, 1, 10]}, ...]. Schema-flexible, queryable enough for our needs.
3. Initial migration
Replace the scaffolded 001_initial.py:
# src/panel_designer/migrations/001_initial.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("created_at", sa.DateTime, nullable=False),
sa.Column("updated_at", sa.DateTime, nullable=False),
)
await op.create_index("idx_panels_experiment", "panels", ["experiment_id"])PluginMigration requires version: int and name: str class attributes. Columns are constructed with sa.Column(...) from SQLAlchemy directly. For Postgres-specific types like JSONB or UUID, import from sqlalchemy.dialects.postgresql; they map to TEXT/JSON on SQLite.
4. Wire the plugin
# src/panel_designer/plugin.py
from mint_sdk import (
AnalysisPlugin,
PluginCapabilities,
PluginMetadata,
PluginType,
)
from panel_designer.routes import router
class PanelDesignerPlugin(AnalysisPlugin):
@property
def metadata(self) -> PluginMetadata:
return PluginMetadata(
name="panel-designer",
version="0.1.0",
description="Drug-response panel design",
analysis_type="oncology",
routes_prefix="/panel-designer",
plugin_type=PluginType.EXPERIMENT_DESIGN,
capabilities=PluginCapabilities(
requires_auth=True,
requires_experiments=True,
requires_database=True,
requires_shared_database=True, # we own a table
),
schema_version="1.0",
)
def get_routers(self):
return [(router, "")]
async def initialize(self, context=None):
self._context = context
if context is None:
self._setup_standalone_db() # base-class helper; uses LocalDatabase
async def shutdown(self):
if self.is_standalone:
self._teardown_standalone_db()
def get_migrations_package(self) -> str:
return "panel_designer.migrations"requires_shared_database=True is what unlocks context.get_shared_db_session() in integrated mode. Standalone mode falls through to LocalDatabase; the base class wires this when you call _setup_standalone_db().
5. CRUD routes
# src/panel_designer/routes.py
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from pydantic import BaseModel
from sqlalchemy import select
from panel_designer.models import Panel
router = APIRouter()
class PanelIn(BaseModel):
experiment_id: int
name: str
drugs: list[dict[str, Any]]
notes: str | None = None
class PanelOut(BaseModel):
id: UUID
experiment_id: int
name: str
drugs: list[dict[str, Any]]
notes: str | None
def _get_plugin():
from panel_designer import plugin as _pkg
return _pkg._INSTANCE
@router.get("/panels/{experiment_id}", response_model=list[PanelOut])
async def list_panels(experiment_id: int):
plugin = _get_plugin()
async with plugin.get_plugin_db_session() as session:
result = await session.execute(
select(Panel).where(Panel.experiment_id == experiment_id)
)
panels = result.scalars().all()
return [PanelOut(id=p.id, experiment_id=p.experiment_id, name=p.name,
drugs=p.drugs, notes=p.notes) for p in panels]
@router.post("/panels", response_model=PanelOut, status_code=201)
async def create_panel(body: PanelIn):
plugin = _get_plugin()
async with plugin.get_plugin_db_session() as session:
panel = Panel(
experiment_id=body.experiment_id,
name=body.name,
drugs=body.drugs,
notes=body.notes,
)
session.add(panel)
await session.commit()
await session.refresh(panel)
# Update the experiment's design data summary so the platform can show panel counts
await plugin.save_design(body.experiment_id, {
"panel_count": await _count_panels(plugin, body.experiment_id),
})
return PanelOut(id=panel.id, experiment_id=panel.experiment_id,
name=panel.name, drugs=panel.drugs, notes=panel.notes)
async def _count_panels(plugin, experiment_id: int) -> int:
from sqlalchemy import func
async with plugin.get_plugin_db_session() as session:
result = await session.execute(
select(func.count(Panel.id)).where(Panel.experiment_id == experiment_id)
)
return result.scalar_one()# src/panel_designer/__init__.py
_INSTANCE = None# src/panel_designer/plugin.py — update initialize
import panel_designer as _pkg
class PanelDesignerPlugin(AnalysisPlugin):
# ... metadata + get_routers + shutdown as above ...
async def initialize(self, context=None):
self._context = context
if context is None:
self._setup_standalone_db()
_pkg._INSTANCE = selfsave_design is the convenience wrapper around PluginDataRepository.save_experiment_data. It writes to the platform's DesignData table when integrated, no-ops when standalone.
6. Run, migrate, and test
# Apply the migration ahead of starting (optional — the platform does it automatically)
mint dev
# In another terminal:
curl -X POST http://127.0.0.1:8005/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]}]
}'
# → {"id":"...","experiment_id":1,"name":"Cisplatin dose-response", ...}
curl http://127.0.0.1:8005/api/panel-designer/panels/1
# → [{"id":"...", "experiment_id":1, "name":"Cisplatin dose-response", ...}]7. Add a migration that adds a column
Suppose later you want a tags column on panels.
# src/panel_designer/migrations/002_add_tags.py
import sqlalchemy as sa
from mint_sdk.migrations import MigrationOps, PluginMigration
class AddPanelTagsColumn(PluginMigration):
version = 2
name = "add_panel_tags_column"
async def upgrade(self, op: MigrationOps) -> None:
await op.add_column(
"panels",
sa.Column("tags", sa.JSON, nullable=True),
)
await op.create_index("idx_panels_tags", "panels", ["tags"])Update the model:
# src/panel_designer/models.py — add to Panel
tags: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)Restart mint dev. The migration runner sees 002 as pending, applies it, and your CRUD routes can now read panel.tags.
Don't edit applied migrations
001_initial.py was already applied to your dev database. Editing it now would trigger MigrationChecksumError on next startup. To change 001 retroactively, write 003. See Concepts → Migrations for the discipline.
8. Validate
mint doctorExpected: every check passes. mint doctor validates the entry point, plugin metadata, migration discovery, and dependency alignment.
9. Package
mint build
# → dist/panel-designer-0.1.0.mldThe bundle includes the migrations package — installs apply them automatically.
Where you've landed
You have an EXPERIMENT_DESIGN plugin that:
- Owns a real database table (
panels) - Evolves schema with two versioned migrations
- Exposes CRUD routes that read/write the table
- Mirrors the experiment-level summary into
DesignDataso the platform UI sees it - Works in standalone (SQLite) and integrated (Postgres) modes
Next
→ Tutorial 3 — Adding a frontend — give panel-designer a UI → Tutorial 4 — Plugin roles — gate panel deletion on a plugin role → Recipes → Backfill migrations — chunked data backfill patterns → Recipes → Querying plugin data — heavier query patterns on plugin tables