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
| Field | Required | Description |
|---|---|---|
id | ✓ | Unique slug. Lowercase letters, numbers, and hyphens only (e.g. my-forum). Used as the folder name and as the prefix for all DB tables. |
name | ✓ | Human-readable display name shown in the admin panel. |
version | ✓ | Semver string (MAJOR.MINOR.PATCH). |
description | Short description shown in the admin panel. | |
author | Author name or contact email. | |
min_app_version | Minimum Polyphony version required. Install is blocked if the running app is older. | |
backend_entry | Path inside the ZIP to the Python module that exposes router: APIRouter. Omit if the plugin has no backend code. | |
frontend_entry | Path inside the ZIP to the compiled JS bundle. Omit if the plugin has no UI. | |
menu_items | List of nav links to inject. role must be annotator, researcher, or admin. | |
migrations | Path inside the ZIP to the folder containing Alembic migration scripts. Omit if no DB tables. | |
config_schema | JSON 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.jsonmust be at the root of the ZIP (not in a subdirectory).- The
idinplugin.jsonmust 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
| Table | Columns |
|---|---|
plugin_forum_threads | id, title, body, author_user_id, created_at |
plugin_forum_replies | id, thread_id, body, author_user_id, created_at |
Backend endpoints (mounted at /api/v1/plugins/forum/)
| Method | Path | Description |
|---|---|---|
GET | /threads | List all threads |
POST | /threads | Create a new thread |
GET | /threads/{id} | Get a thread with its replies |
POST | /threads/{id}/replies | Post 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"'},
)