On this page
MSO Cloud is a core platform plus plugins. Foundational capabilities (identity, tenancy org > brand, row-level security, RBAC, the bronze/silver/gold data lake, connectors, i18n, billing, audit) are always-on core. Everything optional (chat, decision intelligence, audiences, activation, service recovery, the live-commerce report, every data-source connector) ships as a plugin that a super admin enables per organization in /admin/orgs/<slug>/plugins.
This guide is the contract for building and shipping a plugin. The runtime lives in @yng/plugin-host; packages/plugin-host/README.md is the terse API reference.
1. Mental model#
A plugin is declarative metadata (a manifest) plus the feature code it describes. The manifest says what the plugin is, which navigation items it owns, how it gates per org, and what it requires. The platform reads the manifest to:
- render the plugin in the
/admin/orgs/<slug>/pluginscatalog, - resolve whether it is enabled for an org (the gate),
- hide its nav items when disabled (and hide the generic items it replaces when enabled), and
- redirect its pages when disabled, via the page guard you add.
The catalog is a single compiled array: MANIFESTS in packages/plugin-host/src/registry.ts, exported from that file as PLUGIN_REGISTRY. Everything else in the platform reads PLUGIN_REGISTRY; you edit MANIFESTS. Adding a plugin means appending one manifest and wiring its feature code. There is no dynamic or remote plugin loading: plugins are compiled into the build and toggled at runtime per org.
What a plugin can and cannot own
MSO Cloud is a Next.js App Router application over a centralized Drizzle monorepo, so framework artifacts must live in their framework homes:
| Artifact | Lives in | Why |
|---|---|---|
| Manifest | packages/plugin-host/src/registry.ts |
the compiled catalog |
| tRPC router | apps/web/src/server/routers/<feature>.ts |
composed in root.ts |
| Pages | apps/web/src/app/(dashboard)/<feature>/ |
Next App Router |
| API routes | apps/web/src/app/api/<feature>/ |
Next route handlers |
| Tables | packages/schema/src/tables/ |
Drizzle generator and RLS policies |
| i18n | packages/i18n/src/messages/{vi,en}.ts |
shared catalog |
| Pure logic / shared UI | a workspace package (optional) | reuse |
A plugin is therefore modular by convention plus manifest, not by physical package isolation: the framework owns those directories, and a page or a table cannot be registered from anywhere else. The manifest is what makes the scattered pieces one addressable, toggleable feature.
2. The manifest contract#
Defined and Zod-validated in packages/plugin-host/src/manifest.ts. The schema is .strict(): an unknown key fails the parse, and every manifest is pluginManifestSchema.parse(...)d at module load, so a malformed manifest fails the build rather than shipping.
| Field | Type | Required | Meaning |
|---|---|---|---|
id |
kebab-case string (max 128) | yes | Stable id, also org_plugin_installations.plugin_id. Never rename. |
version |
string (max 32) | yes | Manifest version, stored on each install row as plugin_version. |
nameKey |
i18n key | yes | Catalog card title. Must resolve in vi and en. |
descriptionKey |
i18n key | yes | Catalog card description. vi and en. |
category |
enum | yes | ai_decision | analytics | growth | operations | market_intelligence | data_source. Connector plugins must use data_source. |
navItemIds |
string[] | yes | ORG_MENU_ITEMS ids this plugin owns. [] = headless. Hidden from the sidebar when the plugin is disabled. |
replacesNavItemIds |
string[] | no (default []) |
Core or generic nav ids this plugin replaces. Hidden when the plugin is enabled, so a tailored dashboard pack does not sit next to the generic report it stands in for. Hides from nav only; the routes stay reachable by URL. |
defaultEnabled |
boolean | yes | Enablement when the org has no installation row and no matching legacy flag. true = opt-out (a formerly always-on feature stays on when it is pluginized). false = opt-in (net-new gated features). |
legacyOrgFlag |
enum | null | yes | The single legacy flag, chat's pre-plugin boolean column on organizations, to fall back to when no installation row exists. null for new plugins. |
prerequisite |
enum | yes | ai_config (the org needs an ai_configs row with a saved key) | none. |
dependsOn |
plugin id[] | no (default []) |
Plugins that must be enabled first. The gate treats this plugin as disabled whenever any dependency is disabled, transitively, with no cascade writes. Must be acyclic. |
kind |
feature | connector |
no (default feature) |
A connector is a data-source integration; it must also declare connectorFamily and transport. |
connectorFamily |
enum | connectors only | marketplace (sales channels) | marketing (ad and analytics platforms) | market_intel (third-party market estimates) | content_intel (first-party mention-level conversation corpora a client supplies). |
transport |
enum | connectors only | api (direct sync) | file (upload plus mapping) | both. |
thirdPartyData |
boolean | no (default false) |
The data describes the market rather than the org's own operations. Renders the third-party badge on connector surfaces. |
providerId |
string (max 64) | connectors only | Links the plugin to its implementation: the worker connectorRegistry id, or the market-data parser id. |
Cross-field rules enforced by the same schema: a manifest with kind: 'connector' must carry connectorFamily, transport, and category: 'data_source'.
3. The gate API#
From @yng/plugin-host (or @yng/plugin-host/gate):
isPluginEnabledForOrg(orgId, pluginId): Promise<boolean>
getEnabledPluginIdsForOrg(orgId): Promise<Set<string>> // whole registry, 1 query
getDisabledNavItemsForOrg(orgId): Promise<Set<string>> // nav ids to hide
getReplacedNavItemsForOrg(orgId): Promise<Set<string>> // nav ids superseded by an enabled plugin
orgHasConnectorFamily(orgId, family): Promise<boolean> // does this org run a data pillar at all
Resolution order (per plugin):
- an
org_plugin_installationsrow:enabled && is_activewins; - else the manifest's
legacyOrgFlagcolumn onorganizations; - else the manifest's
defaultEnabled; - then the dependency gate: the result flips to disabled if anything in
dependsOnresolves to disabled.
The gate filters by org_id explicitly and relies on the same RLS-context mechanism every other org-scoped read in the dashboard uses. Callers own the super-admin bypass and any role or prerequisite checks; the gate answers only "is this plugin turned on for this org".
4. Entitlement model, read this before gating#
Optional-plugin entitlement is enforced at two layers:
- Page: call
requirePlugin(pluginId)(from@/server/plugin-guard) as the first statement of a plugin's server component. It redirects to/when the plugin is disabled (super admins bypass). This runs before any data fetch, so a disabled plugin's page never renders or queries. - Nav: the dashboard layout hides a disabled plugin's
navItemIdsand thereplacesNavItemIdsof the plugins that are enabled.
Feature routers whose data is shared are deliberately not gated at the tRPC-procedure layer. The optional-feature routers are interconnected: core workspace pages read briefing.byUser, actions.queue and playbooks.list, and /activation reads audiences.list. Gating those routers wholesale would fail core and cross-plugin surfaces whenever one plugin is off.
Build a new plugin so this is not a question. Give it a dedicated, non-shared router and gate that router with a dedicated base procedure, so entitlement is enforced server-side on the data path as well as the page. That is what chat-with-data does with chatProcedure (org gate, role check, and AI-config prerequisite, all server-side). Keep the plugin's read models out of shared routers and you get page, nav, and API entitlement from one manifest.
5. Add a plugin, step by step#
Scaffold the snippets:
pnpm tsx scripts/scaffold-plugin.ts <id> <category> [navItemId ...]
# e.g. pnpm tsx scripts/scaffold-plugin.ts price-intelligence analytics price_alerts
The scaffold prints copy-paste-ready blocks and mutates nothing. It covers feature plugins (ai_decision, analytics, growth, operations); a connector manifest carries the taxonomy fields and is written by hand against the table in section 2.
Then:
- Manifest: paste the printed block into the
MANIFESTSarray inpackages/plugin-host/src/registry.ts. ChoosedefaultEnabled(opt-out for formerly always-on features, opt-in for net-new) andprerequisite. - i18n: add
plugins.<camelId>.nameand.descriptionto bothpackages/i18n/src/messages/vi.tsanden.ts(vi is the source of truth;i18n-key-syncenforces parity). A newcategoryalso needsplugins.category.<camel>in both locales and an entry in the label map inapps/web/src/app/admin/orgs/[slug]/plugins/page.tsx; a new connector family needsplugins.family.<camel>. - Nav: every
navItemIdmust exist inORG_MENU_ITEMS(apps/web/src/lib/rbac.ts). If the item should be renamable in/admin/orgs/<slug>/menu, add it toMENU_ITEM_LABEL_KEYSwith its sidebar i18n key. - Backfill: seed installation rows for all orgs with
pnpm tsx scripts/migrations/backfill-plugin-installations.ts. It iterates every org, is idempotent, and preserves anenabledvalue an admin has already set. - Page guard: add
await requirePlugin('<id>')as the first statement of each dedicated page's server component. Skip for headless plugins. - Verify:
pnpm tsx scripts/audit/plugin-manifest-validity.ts pnpm --filter @yng/web typecheck && pnpm --filter @yng/web lint
/admin/orgs/<slug>/plugins picks the plugin up automatically; the catalog is registry-driven.
6. Per-org enablement and admin surfaces#
org_plugin_installations, unique on(org_id, plugin_id): one row per org and plugin, carryingenabled,is_active,configjsonb andplugin_version. Org-scoped RLS./admin/orgs/<slug>/plugins: a super admin toggles each plugin per org. Feature plugins group by category; connectors group byconnectorFamily, with a badge on third-party data. The toggle is blocked with a hint whenprerequisite === 'ai_config'and the org has no saved key, and when adependsOnplugin is off.admin.togglePluginupserts the install row and mirrors the legacy flag, so the org drawer's chat checkbox stays consistent./admin/orgs/<slug>/menu: a related super-admin surface that renames any sidebar nav item per org (organizations.menu_labels); the sidebar rendersmenuLabels[id] ?? t(defaultKey).
7. Testing and CI#
| Gate | What it checks |
|---|---|
scripts/audit/plugin-manifest-validity.ts |
ids unique; nameKey, descriptionKey, category label and connector-family label resolve in vi and en; navItemIds exist in ORG_MENU_ITEMS; connector plugins declare providerId; dependsOn targets exist and the dependency graph is acyclic. Wired into pre-push. |
scripts/audit/i18n-key-sync.ts |
vi and en key parity. |
scripts/audit/i18n-hardcoded-vi.py |
no new hardcoded Vietnamese; use t(). |
pnpm --filter @yng/web build |
production build, which catches what a cached typecheck cannot. |
Manual: enable and disable the plugin for a test org in /admin/orgs/<slug>/plugins, then confirm the nav item appears and disappears and that the page redirects when the plugin is off.
8. Worked example: decision-intelligence#
A plugin that owns three nav items (action_queue, playbooks, weekly_memo) and whose data comes from routers (actions, playbooks, memos) that are also read by core workspace pages.
- Manifest:
navItemIds: ['action_queue','playbooks','weekly_memo'],defaultEnabled: false,category: 'ai_decision'. - Entitlement: each dedicated page (
/action-queue,/playbooks,/decisions/weekly-memo) callsrequirePlugin('decision-intelligence'), and nav hides all three items unless a super admin has enabled the plugin for the org. briefingis deliberately not owned by this plugin. The Morning Brief is a core surface whose only gate is the pillar set it declares on its ownsurface.ts(ADR 0035 D1), so making the decision surfaces opt-in on 2026-08-29 could not switch the brief off for an org that has no installation row. A surface with its own gate does not also belong to an entitlement it does not need.- The routers stay on
orgProcedureandbrandProcedurerather than a plugin-gated base, because the role workspaces read them and gating the routers would break those core pages when the plugin is off.
This is the pattern for a plugin whose data is shared with core surfaces. A plugin with its own data should instead follow the dedicated-router pattern in section 4.
9. Gotchas#
- RLS context: gate reads target the RLS-protected
org_plugin_installations. They work wherever the dashboard's existing org-scoped reads work; do not add ad-hocsetOrgContextcalls inside request handlers, becauseorgProcedureand the page context already pin it. Maintenance scripts must pin the org context per org, as the backfill script does. - Never rename a plugin
id: it is the persisted key. Bumpversionfor changes, and add a new plugin for a rename. defaultEnabledis load-bearing: opt-out (true) keeps a formerly core feature on for every org; opt-in (false) is for net-new gated features.- Legacy flags are deprecated once every org has an install row, which the backfill guarantees. Do not add new ones.
dependsOnis a gate, not a cascade: enabling a dependent plugin never writes rows for its dependencies, so the catalog blocks the toggle until the dependency is on.
10. Future: per-package plugins and build SKUs#
When a plugin grows heavy isolated logic, or is authored by a third party, it can graduate to its own workspace package that exports its manifest, with a build-time catalog composed per customer tier so unused plugins tree-shake out of the bundle. This is deliberately deferred: one compiled catalog keeps the build and the gate simple, and every plugin today is authored in this repository.