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/orrepository/package frommodule.go. Useshared/ifaceinterfaces and pull dependencies throughdeps.Services.GetTyped[T]. See Service registry. - All Mongo writes go through
shared/tenantrepowith an orgId scope. Thetenantscopestatic 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:
- Mint an admin token:
ORKESTRA_API_URL=http://localhost:3000 ./scripts/devtoken.sh administrator - Open
/admin/modules, find your addon, toggle it on. The registry hot-loads it — no restart. - 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.goimplements every method onModule—make ci-backendwill compile-check this - All Mongo writes go through
shared/tenantrepo—make backend-tenantscopepasses - Every protected route declares an
MinRoleand usesmw.RequireRole(...)—make backend-policycoveragepasses - Collections declared in
Collections()follow the naming rule - If new routes were added, regenerate the canonical OpenAPI dump:
(cd backend && make openapi-dump)and commitbackend/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)