Skip to main content

Adding an Addon

An addon is an optional module that implements the Module interface. It lives in your fork's tree (backend/internal/addons/<name>/), registers into the catalog, and is toggled per internal tenant at /admin/modules.

:::note Core-only base Per ADR-0006 Orkestra ships no addons and the base is a single Go module. There is no separate addon go.mod, no go.work, no replace directive, and no published module — an addon is just a package under backend/internal/addons/ that compiles into the one binary. (The older multi-repo "publish an addon repo" model was reverted.) :::

This page is the full walkthrough. For the architectural shape, see Module lifecycle. For the SDK boundary rules (what an addon can and can't import), see Shared interfaces.

Step 1 — Scaffold the addon

internal/addons/ doesn't exist in the base — create it. Mirror the layout of a core module under backend/internal/core/:

mkdir -p backend/internal/addons/youraddon
# Copy the shape of e.g. internal/core/logging/ — keep the layout, gut the logic.

Layout every addon follows (no go.mod — it's part of backend/go.mod):

internal/addons/youraddon/
├── module.go # implements Module — Init, RegisterRoutes, Start, Stop, HealthCheck, metadata
├── handlers/ # Huma v2 route handlers
├── services/ # business logic — pure Go, no HTTP
├── repository/ # MongoDB access via shared/tenantrepo
└── models/ # request/response DTOs + persisted documents

Step 2 — Implement the Module interface

module.go is the only file the registry calls into. The full surface is documented in Module interface; the minimum is:

package youraddon

import (
"context"

"github.com/orkestra/backend/pkg/sdk/module"
)

type Module struct {
svc *Service
}

func NewModule() *Module { return &Module{} }

func (m *Module) Name() string { return "youraddon" }
func (m *Module) DisplayName() string { return "Your Addon" }
func (m *Module) Description() string { return "What this addon does in one sentence." }
func (m *Module) Category() string { return "business" } // core | business | ai | infra

func (m *Module) Dependencies() module.Dependencies {
return module.Dependencies{
Required: []string{}, // e.g. []string{"documents"} — registry topo-sorts
Optional: []string{},
}
}

func (m *Module) Collections() []module.CollectionSpec { /* see Step 3 */ }
func (m *Module) NavItems() []module.NavItem { /* see Step 4 */ }
func (m *Module) ConfigSchema() module.ConfigSchema { /* see Step 5 */ }

func (m *Module) ProvidedServices() []module.ServiceKey { return nil }
func (m *Module) RequiredServices() []module.ServiceKey { return nil }
func (m *Module) OptionalServices() []module.ServiceKey { return nil }

func (m *Module) Init(ctx context.Context, deps module.Deps) error {
m.svc = NewService(deps.Mongo, deps.Logger)
return nil
}

func (m *Module) RegisterRoutes(api module.API, mw module.RoleMiddleware) {
// huma.Register(api, ..., m.handler.Foo) — mw.RequireRole(...) for RBAC
}

func (m *Module) Start(ctx context.Context) error { return nil }
func (m *Module) Stop(ctx context.Context) error { return nil }
func (m *Module) HealthCheck(ctx context.Context) error { return nil }
func (m *Module) Enabled() bool { return true } // gated by ModuleConfigService at runtime

Rules the registry enforces:

  • Never import another addon's services/ or repository/ package from module.go. Use shared/iface interfaces and pull dependencies through deps.Services.GetTyped[T]. See Service registry.
  • All Mongo writes go through shared/tenantrepo with an orgId scope. The tenantscope static analyzer (make ci-backend) fails CI if you bypass it.

Step 3 — Declare collections

func (m *Module) Collections() []module.CollectionSpec {
return []module.CollectionSpec{
{
Name: "youraddon_widgets", // see mongo-collection-naming
Indexes: []module.IndexSpec{
{Keys: bson.D{{"orgId", 1}, {"createdAt", -1}}},
{Keys: bson.D{{"orgId", 1}, {"slug", 1}}, Unique: true},
},
},
}
}

The registry auto-creates the collection and indexes at boot. Don't run createIndex yourself.

Naming: if your addon has 2+ collections, prefix all of them with the addon directory name (singular). youraddon_widgets, youraddon_events, etc.

Step 4 — Declare nav items

func (m *Module) NavItems() []module.NavItem {
return []module.NavItem{
{
Group: "Business", // sidebar section
Label: "Your Addon",
Path: "/youraddon",
Icon: "Sparkle", // Phosphor name
MinRole: "operator", // operator | manager | developer | administrator | super_admin
},
}
}

The navigation core module aggregates these at runtime — no frontend code needed for the sidebar entry. The actual /youraddon page is a separate frontend PR in frontend-admin/.

Step 5 — Declare config schema

func (m *Module) ConfigSchema() module.ConfigSchema {
return module.ConfigSchema{
Fields: []module.ConfigField{
{
Key: "apiToken",
Type: module.FieldSecret, // AES-256-GCM at rest
EnvVar: "YOURADDON_API_TOKEN", // first-boot seed
Description: "Bearer token for the upstream API.",
Required: true,
},
{
Key: "syncIntervalSeconds",
Type: module.FieldInt,
Default: 300,
},
},
}
}

ConfigService persists this in the module_configs MongoDB collection and caches it in Redis (30s TTL). Admins edit values at /admin/modules/youraddon. Secret fields never appear in API responses or logs.

Step 6 — Register in the catalog

Create backend/cmd/server/catalog_youraddon.go — one init():

package main

import (
"github.com/orkestra/backend/internal/addons/youraddon"
"github.com/orkestra/backend/pkg/sdk/module"
)

func init() {
optionalModules["youraddon"] = func() module.Module { return youraddon.NewModule() }
}

No build tags, no go.mod — it compiles into the one binary. The registry topologically sorts at boot by Dependencies(), so a module whose transitive dependency isn't registered fails loudly at boot, not at request time.

Step 7 — Run it locally

cd backend && go mod tidy # pull your addon's third-party deps into the module
cd ../docker
docker compose -f docker-compose.infra.yml up -d
docker compose -f docker-compose.dev.yml --env-file .env up -d

Then enable the addon:

  1. Mint an admin token: ORKESTRA_API_URL=http://localhost:3000 ./scripts/devtoken.sh administrator
  2. Open /admin/modules, find your addon, toggle it on. The registry hot-loads it — no restart.
  3. Configure it at /admin/modules/youraddon.

If Start() fails, the toggle returns the error inline and the module stays in error state — you don't have to scrape logs.

Checklist before opening the PR

  • module.go implements every method on Modulemake ci-backend will compile-check this
  • All Mongo writes go through shared/tenantrepomake backend-tenantscope passes
  • Every protected route declares an MinRole and uses mw.RequireRole(...)make backend-policycoverage passes
  • Collections declared in Collections() follow the naming rule
  • If new routes were added, regenerate the canonical OpenAPI dump: (cd backend && make openapi-dump) and commit backend/openapi/enterprise.json
  • If new cross-module permissions were added, update backend/tools/policycoverage/baseline.txt

See also: Module lifecycle · Customizing an archived addon · Build your first addon (SDK)