Authoring Plugins

This guide walks through writing a Polyphony plugin from scratch: creating the manifest, structuring the ZIP, writing a backend router, defining database models, and shipping a frontend bundle.

Plugin Manifest (plugin.json)

Every plugin ZIP must contain a plugin.json at the root (not inside a subdirectory). This file declares everything the app needs to load, mount, and display the plugin.

{
  "id": "my-forum",
  "name": "Discussion Forum",
  "version": "1.0.0",
  "description": "A discussion forum for annotation teams.",
  "author": "Jane Doe",
  "min_app_version": "0.1.0",
  "backend_entry": "backend/main.py",
  "frontend_entry": "frontend/index.js",
  "menu_items": [
    { "role": "researcher", "label": "Forum", "path": "/plugins/my-forum" },
    { "role": "annotator",  "label": "Forum", "path": "/plugins/my-forum" }
  ],
  "migrations": "backend/migrations/",
  "config_schema": {
    "type": "object",
    "properties": {
      "max_threads": { "type": "integer", "default": 100 }
    }
  }
}

Field Reference

FieldRequiredDescription
idUnique slug. Lowercase letters, numbers, and hyphens only (e.g. my-forum). Used as the folder name and as the prefix for all DB tables.
nameHuman-readable display name shown in the admin panel.
versionSemver string (MAJOR.MINOR.PATCH).
descriptionShort description shown in the admin panel.
authorAuthor name or contact email.
min_app_versionMinimum Polyphony version required. Install is blocked if the running app is older.
backend_entryPath inside the ZIP to the Python module that exposes router: APIRouter. Omit if the plugin has no backend code.
frontend_entryPath inside the ZIP to the compiled JS bundle. Omit if the plugin has no UI.
menu_itemsList of nav links to inject. role must be annotator, researcher, or admin.
migrationsPath inside the ZIP to the folder containing Alembic migration scripts. Omit if no DB tables.
config_schemaJSON Schema describing admin-configurable settings. Omit if no settings.

ZIP File Layout

my-forum-1.0.0.zip
├── plugin.json                      ← required, must be at root
├── backend/
│   ├── main.py                      ← exposes router: APIRouter
│   ├── models.py                    ← SQLAlchemy models
│   ├── schemas.py                   ← Pydantic DTOs
│   ├── service.py
│   └── migrations/
│       └── 0001_initial.py          ← Alembic migration script
└── frontend/
    └── index.js                     ← compiled ESM/UMD bundle

Rules:

  • plugin.json must be at the root of the ZIP (not in a subdirectory).
  • The id in plugin.json must match the ZIP filename prefix: my-forum-1.0.0.zip"id": "my-forum".
  • All DB table names must be prefixed plugin_{id}_ (underscores, not hyphens). Example: plugin_my_forum_threads. The installer validates this.

Writing the Backend

The file named in backend_entry must expose a module-level variable named router of type fastapi.APIRouter. The app mounts it at:

/api/v1/plugins/{plugin-id}/
# backend/main.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from src.api.deps import get_db, get_current_user

router = APIRouter()

@router.get("/threads")
def list_threads(db: Session = Depends(get_db)):
    from . import models
    return db.query(models.Thread).all()

Database Models

Define SQLAlchemy models in the plugin package. All table names must be prefixed plugin_{id}_ where {id} uses underscores (e.g. plugin_my_forum_):

# backend/models.py
from sqlalchemy import String, Text, Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from src.db.base import Base

class Thread(Base):
    __tablename__ = "plugin_my_forum_threads"

    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    title: Mapped[str] = mapped_column(String(200), nullable=False)
    body: Mapped[str] = mapped_column(Text, nullable=False)
    author_user_id: Mapped[int] = mapped_column(Integer, nullable=False)

Using Core Services

Plugin code can import any core service directly:

from src.services.team import TeamService
from src.services.auth import UserService

There is no API boundary between plugin code and core code. Plugins run with full application privileges.

Reading Plugin Configuration

Admin-configurable settings (declared in config_schema) are stored in the core plugin_config table. Read them in your backend code with:

from src.services.plugin import get_plugin_config

@router.get("/settings")
def get_settings(db: Session = Depends(get_db)):
    config = get_plugin_config("my-forum", db)
    max_threads = config.get("max_threads", 100)
    return {"max_threads": max_threads}

Error Isolation

Plugin routers are wrapped with a generic exception handler. An unhandled exception in a plugin endpoint returns HTTP 500 without crashing the core application.

Writing the Frontend

The file named in frontend_entry must be a self-contained compiled bundle (ESM or UMD). It may not load external modules at runtime. The bundle must expose a default export conforming to the PluginDefinition interface:

interface PluginRoute {
  path: string;                      // e.g. "/plugins/my-forum"
  component: React.ComponentType;
}

interface PluginDefinition {
  routes: PluginRoute[];
}

Example using a bundler such as Vite or esbuild:

// src/index.tsx
import ForumPage from "./ForumPage";

const plugin: PluginDefinition = {
  routes: [
    { path: "/plugins/my-forum", component: ForumPage },
  ],
};

export default plugin;

Route components are rendered inside the Polyphony app shell and have access to the auth context (current user, role). Each plugin route tree is wrapped in a React ErrorBoundary so a plugin crash cannot affect the rest of the application.

Loading Behaviour

At startup the frontend fetches /api/v1/plugins/active, which returns all active plugins with their menu_items and bundle URLs. Plugin bundles are loaded lazily via import() the first time the user navigates to a plugin route. Navigation works even before any bundle is loaded because menu items come from the API response, not from the bundle itself.

Migrations

Plugin migrations are Alembic scripts stored in the folder named in "migrations". They run automatically on install. On uninstall, all tables prefixed plugin_{id}_ are dropped directly — the migration rollback scripts are not used.

A minimal migration:

# backend/migrations/0001_initial.py
from alembic import op
import sqlalchemy as sa

def upgrade():
    op.create_table(
        "plugin_my_forum_threads",
        sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
        sa.Column("title", sa.String(200), nullable=False),
        sa.Column("body", sa.Text, nullable=False),
        sa.Column("author_user_id", sa.Integer, nullable=False),
    )

def downgrade():
    op.drop_table("plugin_my_forum_threads")

Example: Discussion Forum Plugin

A standalone plugin with no dependency on core annotation data.

plugin.json

{
  "id": "forum",
  "name": "Discussion Forum",
  "version": "1.0.0",
  "backend_entry": "backend/main.py",
  "frontend_entry": "frontend/index.js",
  "menu_items": [
    { "role": "researcher", "label": "Forum", "path": "/plugins/forum" },
    { "role": "annotator",  "label": "Forum", "path": "/plugins/forum" }
  ],
  "migrations": "backend/migrations/"
}

DB tables

TableColumns
plugin_forum_threadsid, title, body, author_user_id, created_at
plugin_forum_repliesid, thread_id, body, author_user_id, created_at

Backend endpoints (mounted at /api/v1/plugins/forum/)

MethodPathDescription
GET/threadsList all threads
POST/threadsCreate a new thread
GET/threads/{id}Get a thread with its replies
POST/threads/{id}/repliesPost a reply

Example: CSV Export Plugin

A plugin that integrates with core annotation data to add a download feature for researchers.

plugin.json

{
  "id": "csv-export",
  "name": "CSV Export",
  "version": "1.0.0",
  "backend_entry": "backend/main.py",
  "frontend_entry": "frontend/index.js",
  "menu_items": [
    { "role": "researcher", "label": "Export CSV", "path": "/plugins/csv-export" }
  ]
}

No migrations key — this plugin defines no DB tables.

Backend

The backend imports AnnotationService and ProjectService from core, fetches all annotations for a project, and streams a CSV response:

from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from src.api.deps import get_db, require_researcher
from src.services.annotation import AnnotationService
import csv, io

router = APIRouter()

@router.get("/download")
def download_csv(
    project_id: int = Query(...),
    db=Depends(get_db),
    user=Depends(require_researcher),
):
    rows = AnnotationService.export_for_project(project_id, db)
    output = io.StringIO()
    writer = csv.DictWriter(output, fieldnames=["document", "variable", "value", "annotator"])
    writer.writeheader()
    writer.writerows(rows)
    output.seek(0)
    return StreamingResponse(
        output,
        media_type="text/csv",
        headers={"Content-Disposition": f'attachment; filename="project_{project_id}.csv"'},
    )