Octarq is extended by plugins. A plugin consists of:
- A Go module implementing
plugin.Plugin - A JS package implementing
UIPlugin(from@octarq/plugin-sdk)
[!NOTE] For comprehensive architectural boundaries, security invariants, route scoping, and i18n glossaries, refer to the Developer Conventions.
1. Directory Structure & Scaffolding
You can scaffold a new plugin repository using octarq plugin new <name> or create a minimal repository matching this layout:
your-plugin/
├── go.mod # Go module (e.g., github.com/you/octarq-plugin-hello)
├── hello.go # Implements plugin.Plugin (+ optional MenuProvider, MCPProvider)
└── web/
├── package.json # JS package with @octarq/plugin-sdk peerDependency
├── index.ts # Implements UIPlugin (@octarq/plugin-sdk)
└── Page.tsx # React UI page2. Backend Implementation (plugin.Plugin)
Every plugin implements plugin.Plugin and optionally registers routes, models, menus, or MCP tools.
package hello
import (
"net/http"
"github.com/octarq-org/octarq/server/plugin"
)
type Plugin struct{}
// Name returns a unique, stable ID for the plugin.
func (Plugin) Name() string { return "hello" }
// Models returns GORM model structs owned by this plugin.
func (Plugin) Models() []any { return nil }
// Mount registers HTTP endpoints on the host router.
func (Plugin) Mount(mux plugin.Mux, ctx *plugin.Context) {
mux.Handle("GET /api/x/hello/ping", ctx.Guard(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message": "pong"}`))
},
)))
}
// Menus defines sidebar entries provided by this plugin.
func (Plugin) Menus() []plugin.MenuItem {
return []plugin.MenuItem{
{ID: "hello", Label: "Hello", Path: "/hello", Icon: "wave", Category: "Workspace"},
}
}
// Compile-time interface assertions
var (
_ plugin.Plugin = Plugin{}
_ plugin.MenuProvider = Plugin{}
)Key backend rules:
- Every route is auto-gated. The host wraps your mux so a feature disabled for the caller’s workspace answers
404before your handler runs. - Feature-gate tiered routes with
402. Return402 Payment Requiredwhen an organization lacks the required tier or entitlement; the frontendPluginGateturns it into an upgrade or feature prompt. - Never import
internal/*. Everything a plugin needs is onplugin.Context:DB,Guard,Encrypt/Decrypt(AES-256-GCM),Audit,Notify,SendMail,OnEmail,DNS,UserID/OrgID,GetWorkspaceSetting/SetWorkspaceSetting. See Developer Conventions — Package Boundaries. - Pair every optional interface with a compile-time assertion (
var _ plugin.MenuProvider = Plugin{}). Optional capabilities are detected by runtime type assertion, so a typo’d method silently never runs without these. - Own your tables. Every model (core + plugins) is AutoMigrated once at startup; a preflight fails if two plugin model types claim the same table. Mirror an existing core table with a local struct (
TableName()override) to read core data without importinginternal/models.
3. Frontend Implementation (UIPlugin)
The frontend uses @octarq/plugin-sdk to register routes, sidebar items, widgets, and translation dictionaries.
import { lazy } from "react";
import type { UIPlugin } from "@octarq/plugin-sdk";
export const helloPlugin: UIPlugin = {
name: "hello",
routes: [
{
path: "/hello",
Component: lazy(() => import("./Page")),
},
],
};Key frontend rules:
- Wrap every page in
React.lazy. An uncomposed build ships none of your bytes; each page gets its own async chunk. - Build your UI from
@octarq/plugin-sdk. It re-exports the shared library (GlassCard,Button,Field,Modal,Toggle,LockedFeature,useTranslation, …). Import by name, never reach into app-internal paths. PluginGatewraps every plugin route.402→ upsell (lockedFallbackor the SDK’sLockedFeature),403→ access-denied note,404/chunk failure → neutral “not in this build” note. Degrade declaratively viausePluginGate().degrade(err.status); the gate is the safety net, never a raw error.categoryequals the sidebar group label it joins. Acategorywith no matching group creates one viaareaForCategory. A plugin can declare a new top-level area (areas) and point menu categories at its id or group labels — never at itstitle(display text).- A settings page’s
Pathis/settings/<menu id>. The last segment is the menu’sID, not itsLabel. The GoPathand frontendUIRoute.pathmust match exactly —PluginGatecompares them to tell “operator disabled this” apart from “this build doesn’t have it”. requiredRole/requiredTierare advisory UX only. The host hides menu entries and pre-renders access-denied belowrequiredRole— but the server stays authoritative: enforce with403/402in your backend.- i18n namespace = your
name.i18n.en/i18n.zhmerge under"<name>", so apageTitlekey is read ast("hello.pageTitle").
4. Instance vs Tenant Scope
Octarq runs at two scopes, and a plugin page belongs to exactly one of them:
- Tenant scope — one per workspace. The UI lives in the
/adminshell; the sidebar entry comes fromplugin.MenuProvider(server/plugin/plugin.go), is served byGET /api/menus, and is gated per workspace: a workspace that disables the feature stops seeing it. The frontend page is aUIPlugin.routesentry. - Instance scope — one per deployment. The UI lives in the
/instanceconsole; the rail entry comes fromplugin.InstanceMenuProvider(server/plugin/plugin.go), is served by the instance-admin-gatedGET /api/instance/menusendpoint, and has no per-workspace toggle — the entry is announced by the deployment or it isn’t. The frontend page is aUIPlugin.instanceRoutesentry.
Pick the scope with the deciding question:
A config that exists once per deployment → instance scope; one that exists per workspace → tenant scope. The same page must never be reachable from both shells.
To pair the two halves of an instance-scope page, implement
InstanceMenuProvider on the Go side and register instanceRoutes on the JS
side, with matching Path/path values:
var _ plugin.InstanceMenuProvider = (*Plugin)(nil)
func (p *Plugin) InstanceMenus() []plugin.MenuItem {
return []plugin.MenuItem{{ID: "sso", Label: "SSO", Path: "/sso", Icon: "key-round"}}
}const myPlugin: UIPlugin = {
name: "my-plugin",
instanceRoutes: [{ path: "/sso", Component: lazy(() => import("./SsoPage")) }],
};The console renders an entry only when the backend’s /api/instance/menus
announces it and the frontend registers an instanceRoutes entry for the
same path — mirroring how the tenant sidebar trusts /api/menus (see the
sidebar merge in web/src/App.tsx). The rail ignores Category and
RequiredRole: it is flat, and instance admin is the only gate.
5. Route Namespace & Idempotent Writes
Namespace
http.ServeMux panics on a duplicate pattern, so two plugins claiming the
same path is a boot crash. Octarq catches that before the mux does and refuses
to start with an error naming both plugins — but the only way to be sure your
routes never collide with a future core route is to stay inside the
namespace reserved for out-of-tree plugins:
/api/x/{your-plugin-name}/...This is enforced for external plugins: an out-of-tree plugin that
registers an /api/... route outside /api/x/{name}/ is refused at startup.
In-tree paths that predate the rule (/api/domains, /api/emails,
/api/products) are deliberately left alone; the bare top-level nouns are
already spoken for, and this is what keeps them from becoming a moving target
for you.
Routes outside /api/ (a public landing page, an OAuth callback) are not
subject to the rule.
mux.Handle("POST /api/x/hello/greetings", ctx.Guard(createGreeting))Idempotent writes
Any endpoint that creates a resource, sends a message, or moves money should
accept an Idempotency-Key. A client whose request times out mid-flight will
retry, and without a key the retry is a second side effect.
The host provides the mechanism through the service registry; resolve it lazily and wrap the handlers that need it:
import "github.com/octarq-org/octarq/server/idempotency"
type middleware = func(http.Handler) http.Handler
func (p *Plugin) Mount(mux plugin.Mux, ctx *plugin.Context) {
idem := func(h http.Handler) http.Handler { return h } // no-op fallback
if m, ok := plugin.LookupAs[middleware](ctx, idempotency.ServiceName); ok {
idem = m
}
mux.Handle("POST /api/x/hello/greetings", idem(ctx.Guard(createGreeting)))
}Behaviour, once wrapped:
- Requests without the header are unaffected — adoption never changes existing clients.
- The first request runs; its response is stored per
(workspace, endpoint, key)and replayed for repeats within 24h withIdempotency-Replayed: true. - A repeat arriving while the first is still running gets
409+Retry-After. - The same key with a different body gets
422— never someone else’s response. 5xx,429and panics release the key, so the client’s retry is still possible.- A response too large (>1 MiB) or streamed cannot be stored; the repeat gets
409withIdempotency-Original-Statusrather than a fabricated empty body. The handler still never runs twice.
6. Inter-Plugin Service Registry
Plugins communicate through an in-memory service registry provided on plugin.Context.
- Provide a service:
ctx.Provide("hello.service", myServiceInstance) - Lookup a service safely:
if svc, ok := plugin.LookupAs[MyService](ctx, "hello.service"); ok { svc.DoSomething() }
Start(ctx context.Context)from optionalStarterinterface runs in a background goroutine after all plugins have mounted, providing an entry point for inter-plugin initialization.
Services are resolved lazily — in Start or per-request, never in your own Mount — and degrade gracefully when absent. Providing the same service name twice is a startup error.
7. Ship In-App Help Docs
Ship documentation as a docs/ directory, embedded and served under /help/<slug>:
//go:embed docs
var docs embed.FS
func (p *Plugin) HelpDocsFS() fs.FS { return docs }The naming is the contract: docs/webhooks.mdx is a page whose slug is webhooks; docs/webhooks.zh.mdx is its Chinese translation and never a page of its own. Everything else — title, category, order — is YAML frontmatter in the file, and category must be one of the six keys from plugin.HelpCategories(). Subdirectories are walked and don’t affect slugs. For pages built at runtime, implement HelpProvider (HelpDocs() []plugin.HelpDoc) instead — a plugin may implement both, and the two are concatenated.
8. Composition & Building
Octarq plugins are composed at build time (similar to xcaddy):
# Build custom binary with your Go and JS plugin modules
OCTARQ_PLUGINS='[{"go":"github.com/you/octarq-plugin-hello","npm":"@you/octarq-plugin-hello"}]' make plugin-build- Routes Auto-Gate: Endpoints return
404when disabled in workspace settings. - AutoMigrate Preflight: Database tables are resolved and migrated at startup.
9. Trust model
There is no runtime plugin loading — a compiled-in plugin runs in-process with full access (DB, secrets, network). This fits a curated / operator-opt-in ecosystem: the operator chooses which plugins to build in, and you review what you ship. It is not a sandbox for untrusted third-party code; untrusted plugins would need process/WASM isolation.
Full network access is part of that trust, so outbound requests to a URL your
users supply are your responsibility. Any fetch whose destination comes from a
tenant, an org admin, a webhook payload or an OIDC discovery document must go
through plugin/safehttp — safehttp.NewClient(timeout) and
safehttp.Get(ctx, client, url, ua). It validates at dial time, so it also
catches DNS rebinding and redirects into 169.254.169.254; checking the URL
before you send it does not. See the package documentation for the details.
10. Distribution
A plugin is one repo with the two halves: the Go module is go get-able, and
the web/ package publishes to npm with @octarq/plugin-sdk and react as
peer dependencies. The working reference is
examples/plugin-hello.
For publishing the SDK itself, see Publishing the SDK.
11. Checklist
-
Plugin.Name()andUIPlugin.nameare identical. - A compile-time
var _ plugin.X = Plugin{}assert for every interface (Plugin + each optional one). - Backend
/apiroutes live under/api/x/<name>/; write endpoints acceptIdempotency-Key. - Backend routes registered on the passed
Mux; secrets viactx.Encrypt; cross-plugin services viactx.Provide/ lazyplugin.LookupAs. - Every outbound fetch of a user-supplied URL goes through
server/plugin/safehttp, not a barehttp.Client. - Tiered routes return 402 when gated; rely on the host’s auto-404 for the disabled-feature case.
- Pages are
React.lazy; UI built from@octarq/plugin-sdk; 402/404 handled. - i18n keys live under your
namenamespace. - Help pages live in
docs/<slug>.mdxwith a<slug>.zh.mdxtranslation; title/category/order are frontmatter. - Conforms to all rules in Developer Conventions.
-
go build ./...andpnpm buildare green;go:embedproduces one binary.