Skip to content

Authoring Plugins

Doug edited this page Jul 16, 2026 · 4 revisions

Authoring Plugins

How to build a GDX third-party module. A plugin is a pip package that exports a PluginManifest under the gdx.modules entry-point group. The reference plugin gdx-plugin-example (in the separate gdx_dispatch_plugins repo) exercises everything below end to end — copy it as a starting point.

1. Package layout

gdx-plugin-foo/
  pyproject.toml
  gdx_plugin_foo/
    __init__.py      # exports `manifest`
    models.py        # tables on PluginBase, named plug_foo_*
    router.py        # APIRouter; routes relative, mounted at /api/plugins/foo
    ui.py            # the declarative UI manifest

2. Register the entry point — pyproject.toml

[project]
name = "gdx-plugin-foo"
version = "0.1.0"

[project.entry-points."gdx.modules"]
foo = "gdx_plugin_foo:manifest"

pip install makes this discoverable; the plugin-host's discover_plugins() finds it via the gdx.modules group.

3. Export a manifest — __init__.py

from gdx_dispatch.plugin_api import PluginManifest
from gdx_plugin_foo import models      # noqa: F401 — registers tables on PluginBase
from gdx_plugin_foo.router import router
from gdx_plugin_foo.ui import UI

manifest = PluginManifest(
    key="foo",                 # lowercase, stable; becomes the module key + /api/plugins/foo
    name="Foo",
    tier="professional",       # starter | professional | business
    requires="gdx>=1.0",       # host-version gate; "" = any
    router=router,
    ui=UI,
)

Validation is enforced at construction (key lowercase/non-empty, valid tier). The version gate requires accepts "gdx>=X.Y[.Z]" or ""; anything else fails closed (won't load).

The manifest also takes optional fields:

  • permissions — elevated capabilities the plugin needs, each consent-gated by the owner at install (ADR-014). Currently only "browser": the plugin-host image is browser-capable (Playwright/Chromium), so a consented plugin can drive a streamed headless browser the operator watches — e.g. to capture pricing from a site with no API. Unknown permission names fail validation.
  • migrations_path — path to the plugin's Alembic version dir, if it manages schema beyond create_all.
  • catalog_types / pricing_strategies — Catalog Pack contributions (ADR-015): catalog types shipped as data (field schema + a code-free pricing strategy) that appear in the core New Catalog dialog with no pack code needed at run time.

4. Models — models.py

from sqlalchemy import Column, Integer, String
from gdx_dispatch.plugin_api.base import PluginBase

class FooItem(PluginBase):                 # MUST inherit PluginBase
    __tablename__ = "plug_foo_items"       # namespace plug_<key>_*
    id = Column(Integer, primary_key=True)
    company_id = Column(String(64), nullable=False, index=True)  # scope to tenant
    name = Column(String(200), nullable=False)

The plugin-host creates these tables at boot (PluginBase.metadata.create_all).

5. Routes — router.py

from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from gdx_dispatch.plugin_api.context import PluginContext, get_plugin_db, require_module

router = APIRouter()  # mounted under /api/plugins/foo by the host

@router.get("/items")
def list_items(ctx: PluginContext = Depends(require_module("foo")),
               db: Session = Depends(get_plugin_db)):
    # ctx.tenant_id / user_id / role come from the core proxy's forwarded identity.
    # require_module 403s unless 'foo' is enabled for the tenant.
    ...

require_module("foo") gates the route on the tenant's enabled modules (forwarded by the core proxy — no DB round trip). get_plugin_db is a session on the shared DB.

6. UI — ui.py (no JavaScript)

The host renders these declarative screens with its own PrimeVue components. v0 vocabulary: a list screen (table over endpoint with columns) plus an optional inline create form.

UI = {
  "screens": [{
    "type": "list",
    "title": "Foo Items",
    "endpoint": "/api/plugins/foo/items",
    "columns": [{"field": "id", "label": "ID"}, {"field": "name", "label": "Name"}],
    "create": {
      "endpoint": "/api/plugins/foo/items",
      "fields": [{"name": "name", "label": "Name", "type": "text", "required": True}],
    },
  }]
}

UI the manifest can't express is out of scope by design — the escape hatch is contributing the capability to the host, not shipping browser JS.

How the host loads you

discover_plugins() loads every gdx.modules entry point, keeps the ones that return a valid PluginManifest and pass the version gate, and skips (with a log line) — never crashes on — a plugin that errors on import, returns the wrong type, or is incompatible. The plugin-host then creates your tables and mounts your router under /api/plugins/<key>.

Persisting captured items into a catalog (#51)

A plugin that captures items (e.g. the CHI pricing plugin scrapes a door's spec

  • price) can persist them into a browsable, reusable catalog — not just an estimate line — via the host helper upsert_catalog_items:
from gdx_dispatch.plugin_api.catalog import upsert_catalog_items

@router.post("/capture-to-catalog")
def capture(ctx = Depends(require_module("chi")), db = Depends(get_plugin_db)):
    res = upsert_catalog_items(db, target_catalog_id, [
        {"sku": "CHI-2216", "name": "CHI Door 16x7", "cost": 1850,
         "vendor": "CHI", "attributes": {"width": 16, "height": 7}},
    ], source="chi-pricing")
    return {"created": res.created, "updated": res.updated}
  • Items land through the same pricing-strategy / vendor / custom-attribute path as the UI and CSV import — a plugin can't accidentally store retail=cost.
  • Dedupes by SKU within the catalog (re-capturing updates + reprices).
  • vendor defaults to source so captured items are traceable to the plugin.
  • The db session from get_plugin_db IS the tenant connection, so writes are tenant-scoped automatically.
  • Raises CatalogUpsertError for a missing/deleted/virtual catalog.

Reference

  • Architecture: ADR-013
  • SDK source: gdx_dispatch/plugin_api/ (catalog upsert: plugin_api/catalog.py)
  • Working example: gdx-plugin-example in the gdx_dispatch_plugins repo

Clone this wiki locally