Plugin Deployment
Martha supports first-party plugins — standalone FastAPI services that register via OpenAPI specs and are accessible through the admin UI's BFF proxy.
This guide covers deploying a new plugin service end-to-end.
Architecture
nginx (443)
/api/* → martha-api:8000
/docs, /redoc → martha-api:8000
/* → slo-pwa:80 (SPA)
martha-api:8000
/api/admin/plugins/{id}/{path} → BFF proxy → plugin:8000/{path}
/api/admin/definitions/integrations → health probe → plugin:8000/health
plugin:8000 (Docker-internal only, not exposed to nginx)
/health, /openapi.json
/rubrics/* (resource endpoints)
/score (action endpoints)Plugins are not exposed directly through nginx. The admin UI reaches them via the BFF proxy, which injects X-Tenant-Id and enforces auth.
Step 1: OpenAPI Extensions
The plugin's OpenAPI spec must include martha extensions at the root level of the schema:
# openapi_overrides.py
def apply_openapi_overrides(app):
original_openapi = app.openapi
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
schema = original_openapi()
# Root level — NOT under schema["info"]
schema["x-martha-integration"] = {
"type": "plugin",
"name": "Scoring Engine",
"icon": "target",
"color": "#8b5cf6",
}
schema["x-martha-plugin"] = {
"resources": [
{"name": "rubrics", "path": "/rubrics"},
],
}
app.openapi_schema = schema
return schema
app.openapi = custom_openapi!!! warning "Extensions must be at the spec root" extract_plugin_manifest() reads from spec.get("x-martha-plugin"). Nesting under schema["info"] causes sync to wipe the manifest.
Action endpoints should include x-martha-action: true so they get imported as Martha functions during sync.
Step 2: Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY . .
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=15s \
CMD curl -f http://localhost:8000/health || exit 1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]curl is required for the Docker HEALTHCHECK command.
Step 3: Docker Compose
Add the service to deployment/docker-compose.yml:
martha-scoring:
image: ghcr.io/colivetree/martha-scoring:latest
container_name: martha-scoring
restart: always
environment:
- SCORING_DB_HOST=${DB_HOST}
- SCORING_DB_PORT=${DB_PORT:-25060}
- SCORING_DB_NAME=martha_scoring
- SCORING_DB_USER=${DB_USER:-martha}
- SCORING_DB_PASSWORD=${DB_PASSWORD}
- SCORING_DB_SSLMODE=${DB_SSLMODE:-require}
networks:
- martha-network
expose:
- "8000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s!!! note "Use expose, not ports" Plugins are internal services. They're only reached via the BFF proxy on martha-network.
The plugin can share the same managed database server as Martha but should use a separate database (e.g. martha_scoring vs martha) to avoid migration conflicts.
Step 4: CI/CD
Add to .github/workflows/deploy.yml:
Test job (runs in parallel with existing tests):
test-scoring:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
repository: colivetree/martha-scoring
token: ${{ secrets.GHCR_PAT }}
- uses: actions/setup-python@v5
with: { python-version: '3.11' }
- run: pip install -r requirements.txt
- run: pytest tests/ -vDocker image build (add to existing docker-images job):
- uses: actions/checkout@v4
with:
repository: colivetree/martha-scoring
token: ${{ secrets.GHCR_PAT }}
path: martha-scoring
- uses: docker/build-push-action@v5
with:
context: martha-scoring
push: true
tags: ghcr.io/colivetree/martha-scoring:latestUpdate the needs array to include the new test job.
Step 5: Deploy Script
Add a stage to deployment/deploy-prod.sh after Martha's migrations:
# Wait for container health
echo "Waiting for scoring container..."
for i in {1..30}; do
if docker inspect --format='{{.State.Health.Status}}' martha-scoring 2>/dev/null \
| grep -q healthy; then
echo "Scoring service healthy"
break
fi
sleep 2
done
# Auto-create database on shared server
docker compose exec -T martha-db psql -U $DB_USER -d postgres \
-tc "SELECT 1 FROM pg_database WHERE datname = 'martha_scoring'" | grep -q 1 \
|| docker compose exec -T martha-db psql -U $DB_USER -d postgres \
-c "CREATE DATABASE martha_scoring OWNER $DB_USER"
# Run migrations
docker compose exec -T martha-scoring alembic upgrade head
# Register plugin in Martha
docker compose exec -T martha-api python scripts/seed_plugins.pyStep 6: Plugin Seed
Add an entry to scripts/seed_plugins.py:
PLUGINS = [
{
"name": "martha-scoring",
"source_url": "http://martha-scoring:8000/openapi.json",
"plugin_manifest": {
"plugin": {"name": "martha-scoring", "version": "0.1.0"},
"integration": {
"name": "Scoring Engine",
"icon": "target",
"color": "#8b5cf6",
"description": "Rubric-based scoring and ranking",
},
"resources": [{"name": "rubrics", "path": "/rubrics"}],
},
},
]The seed is idempotent — it skips if the manifest already matches, and restores it if sync wiped it.
!!! warning "The name field matters" The frontend's PLUGIN_ID must match this name exactly. The BFF proxy does WHERE name = plugin_id to look up the service URL.
Step 7: Frontend Plugin
Register the plugin in martha-admin/src/plugins/:
=== "manifest.ts" ```typescript import { lazy } from "react"; import type { PluginManifest } from "../types";
const RubricsListPage = lazy(() => import("./pages/RubricsListPage"));
export const scoringPlugin: PluginManifest = {
id: "scoring",
name: "Scoring",
icon: "Target",
color: "#8b5cf6",
routes: [
{ path: "plugins/scoring/rubrics", element: RubricsListPage, label: "Rubrics" },
],
};
```
=== "hooks/useRubrics.ts" ```typescript const PLUGIN_ID = "martha-scoring"; // must match DB name, NOT the manifest id
export function useRubrics() {
return useQuery({
queryKey: ["scoring-rubrics", tenantId()],
queryFn: () => scoringAPI.rubrics.list(PLUGIN_ID, tenantId()),
});
}
```
=== "registry.ts" typescript import { scoringPlugin } from "./scoring"; export const plugins: PluginManifest[] = [scoringPlugin];
The id in the manifest ("scoring") is used for frontend routing (/plugins/scoring/...). The PLUGIN_ID in hooks ("martha-scoring") is the DB spec name used for BFF proxy lookups.
Known Gotchas
| Issue | Cause | Fix |
|---|---|---|
| "Plugin not found" 404 | Frontend PLUGIN_ID doesn't match DB openapi_specs.name | Use exact DB name (e.g. "martha-scoring") |
| "Plugin has invalid source URL" | Docker DNS resolves to 172.x.x.x (blocked by SSRF) | SSRF is skipped for specs with plugin_manifest |
| Plugin reverts to "connected" after sync | x-martha-plugin extensions not at spec root | Move extensions to root of OpenAPI schema |
Proxy requests go to /openapi.json/rubrics | source_url includes spec filename | Proxy strips .json suffix before building target URL |
/docs shows frontend instead of API docs | nginx SPA catch-all serves all non-/api/ paths | Add explicit proxy rules for /docs, /redoc, /openapi.json |
| Manifest disappears after sync | import_from_url() overwrites with None | Guard: only overwrite when extract_plugin_manifest() returns non-None |