MSO Cloud · Documentation

Plugin Development Guide

Source: docs/architecture/reference/plugin-development-guide.md Updated 2026-09-21
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>/plugins catalog,
  • 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):

  1. an org_plugin_installations row: enabled && is_active wins;
  2. else the manifest's legacyOrgFlag column on organizations;
  3. else the manifest's defaultEnabled;
  4. then the dependency gate: the result flips to disabled if anything in dependsOn resolves 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:

  1. 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.
  2. Nav: the dashboard layout hides a disabled plugin's navItemIds and the replacesNavItemIds of 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:

  1. Manifest: paste the printed block into the MANIFESTS array in packages/plugin-host/src/registry.ts. Choose defaultEnabled (opt-out for formerly always-on features, opt-in for net-new) and prerequisite.
  2. i18n: add plugins.<camelId>.name and .description to both packages/i18n/src/messages/vi.ts and en.ts (vi is the source of truth; i18n-key-sync enforces parity). A new category also needs plugins.category.<camel> in both locales and an entry in the label map in apps/web/src/app/admin/orgs/[slug]/plugins/page.tsx; a new connector family needs plugins.family.<camel>.
  3. Nav: every navItemId must exist in ORG_MENU_ITEMS (apps/web/src/lib/rbac.ts). If the item should be renamable in /admin/orgs/<slug>/menu, add it to MENU_ITEM_LABEL_KEYS with its sidebar i18n key.
  4. Backfill: seed installation rows for all orgs with pnpm tsx scripts/migrations/backfill-plugin-installations.ts. It iterates every org, is idempotent, and preserves an enabled value an admin has already set.
  5. Page guard: add await requirePlugin('<id>') as the first statement of each dedicated page's server component. Skip for headless plugins.
  6. 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, carrying enabled, is_active, config jsonb and plugin_version. Org-scoped RLS.
  • /admin/orgs/<slug>/plugins: a super admin toggles each plugin per org. Feature plugins group by category; connectors group by connectorFamily, with a badge on third-party data. The toggle is blocked with a hint when prerequisite === 'ai_config' and the org has no saved key, and when a dependsOn plugin is off.
  • admin.togglePlugin upserts 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 renders menuLabels[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) calls requirePlugin('decision-intelligence'), and nav hides all three items unless a super admin has enabled the plugin for the org.
  • briefing is deliberately not owned by this plugin. The Morning Brief is a core surface whose only gate is the pillar set it declares on its own surface.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 orgProcedure and brandProcedure rather 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-hoc setOrgContext calls inside request handlers, because orgProcedure and 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. Bump version for changes, and add a new plugin for a rename.
  • defaultEnabled is 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.
  • dependsOn is 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.