Plugin Manual — Author, Publish, Install

The practical companion to Plugins (the architecture) and Plugin Registry (the distribution model). This page is the how-to: create a new plugin, publish it, install it anywhere, run your own registry, and push changes back to git.

1. Anatomy of a plugin

A plugin is a folder of mesh nodes in a git repo — nothing else. The on-disk shape:

MyPlugin/                           the plugin folder — the unit of import
  index.json                        the plugin ROOT — nodeType "Store/Plugin", content PluginContent
  Widget.json                       a NodeType node (Content = NodeTypeDefinition{ configuration })
  Widget/
    Source/
      WidgetContent.cs              the content record
      WidgetLayoutAreas.cs          the layout areas (views)
    Test/
      WidgetTests.cs                in-node tests — compiled together with Source
  Guide.md                          a Markdown node documenting the plugin
  manifest.lock                     CI-maintained content fingerprint — do NOT hand-edit

Real examples to copy from: Publish/ (one compiled type — Publish/Slide) and Edu/ (Lesson, Module, Exercise, Quiz, CourseInvite, CourseCatalog, incl. cross-type reads) in Systemorph/MeshWeaver.Plugins; LinkedIn/ (a dozen types + CSV loaders) lives in the separate Systemorph/MeshWeaver.SocialMedia repo, alongside X/ and YouTube/.

2. Author a new plugin, step by step

  1. Lay out the folder as above, in a working copy of the plugins repo.
  2. Write the content record (Source/WidgetContent.cs) — a plain record with the fields your type carries. Attributes like [Required], [DisplayName], [MeshNodeProperty] shape the editor.
  3. Write the layout areas (Source/WidgetLayoutAreas.cs) — the views. Compose framework controls (Controls.Stack, Controls.Markdown, Controls.DataGrid…), never hand-built HTML.
  4. Write in-node tests (Test/WidgetTests.cs) — plain C# that asserts by throwing (the runtime compile references only the platform assemblies; no xUnit).
  5. Mind the runtime-compile rules (each one is load-bearing — details in Plugins):
    • explicit usings in every file (no implicit usings);
    • only public framework surface (internal helpers are invisible — inline an equivalent);
    • a type's identity is its install path (MyPlugin/Widget) — derive "my type" from the node, never hardcode a name;
    • read foreign content untyped (a sibling type from another assembly resolves to JsonElement — read fields off the JSON).
  6. Add Guide.md — how the plugin works, for the people installing it.

Test locally

🚨 No build or test in Systemorph/MeshWeaver compiles plugin Source/ — it is in-mesh C#, so dotnet build and the whole test suite are blind to it. The gate lives in the plugins repo's own Plugin Catalog CI, and you run the same two scripts locally:

python3 scripts/validate-repos.py    # JSON shape, id+nodeType, NodeType has Source/, manifest.lock current
python3 scripts/compile-check.py     # compile every module's Source/ against the framework assemblies
                                     #   (defaults to a sibling ../MeshWeaver build; CI passes --refs)

CI then imports each affected repo into a real portal image (mw-plugin-test) and compiles, renders and runs the in-node Test/*.cs there. Any MeshWeaver dev instance works the same way by hand: import the folder with GitSync from your fork/branch, then confirm the NodeType's compilationStatus is Ok and open the type's page.

3. Publish to the plugins repository

  1. Push your folder to a branch of Systemorph/MeshWeaver.Plugins and open a PR.
  2. CI (Plugin Catalog CI) runs scripts/validate-repos.py — every node .json must parse, carry id + nodeType, every NodeType must ship Source/*.cs, declared dependencies must resolve, and every module's manifest.lock must be current — then compiles and renders the affected modules in a real portal image.
  3. On merge, the plugin is published: the registry (memex) reads the repo — as the GitHub App, never a person — and the plugin appears in GET /api/plugins for every instance whose PluginGrant covers it.

4. Install a plugin

From the registry (any instance — no GitHub access needed)

Browsing and installing is the Store's job — the PluginCatalog node's Catalog area, which lists each configured registry's modules with Install / Update / Installed status and installs on click. (The old platform-admin Settings ▸ Administration ▸ Plugin Catalog tab was retired; what remains under global settings is the read-only installed inventory on the About tab.) The consumer pulls the package over HTTP and compiles it locally; no GitHub credential is involved.

The catalog reads PluginCatalog:RegistryUrl (or the multi-registry PluginCatalog:Registries list) plus the instance key the registry issued this installation (PluginCatalog:RegistryToken, or the per-registry Registries:N:Token), sent as Authorization: Bearer:

GET  {registry}/api/plugins             → the catalog { packages:[…] }    (Bearer mwi_ instance key)
POST {registry}/api/plugins/files {id}  → a package's files { files:[…] } (Bearer mwi_ instance key)

A fresh installation does not need the click at all: PluginCatalog:InstallPreInstalledPackages (default true) reconciles the platform baseline on every boot, and PluginCatalog:InstallByDefault (e.g. ["Plugins/*"]) seeds an installation that has no install records yet.

The types compile on first import; re-running is an upsert. See Plugin Registry for the payload shapes and the install flow.

Straight from git (your own instance, own repos)

If your instance has git access (its own GitHub App, or a connected user), import directly — GitSync fetches the folder and parses every file into nodes:

Authentication resolves user-credential-first, then the App installation token — so a headless server instance imports with no personal login at all.

Into an EXISTING Space (the partition already has content): ImportFromGitHub is create-only — it fails with Node already exists. Configure the source and reimport instead:

sync.SaveConfig(spacePath, repoUrl, branch, subdir, false, false)   // register the source
sync.ReimportAtCommit(spacePath, branch, userId)                    // mirror add/update/prune

Two operational notes: a failed ImportFromGitHub (e.g. auth error) leaves an empty orphan Space behind — inspect (version:1, no children) and delete it before retrying; and running SaveConfig + ReimportAtCommit back-to-back can race the config read — a retry reads the settled config. Re-running a reimport is idempotent (fingerprint-matched).

5. Set up your own instance as a registry

Any MeshWeaver instance can be the distribution point for its own plugins. One-time setup:

  1. Create a GitHub App on your org (Settings → Developer settings → GitHub Apps → New):
    • Permissions: Contents: Read (Read & Write if you want push-back, §6);
    • no webhooks/callback needed for sync alone.
  2. Install the App on the org and grant it the plugin repo(s).
  3. Generate a private key on the App page (downloads a .pem).
  4. Configure the instance (the GitHub:App section; ship secrets via KeyVault/env):
Key Value
GitHub__App__ClientId the App's client id (Iv23li…)
GitHub__App__PrivateKey the PEM text
GitHub__App__InstallationOwner your org login (picks the installation; or pin GitHub__App__InstallationId)
  1. Point the instance's catalog sources at your plugins repo — a PluginCatalog:Sources list of { Name, RepoPath, Ref, Subdir, Format } entries (the legacy single PluginCatalog:SourceRepoPath still works). The App credential above lets it read a private repo.

The instance now serves GET /api/plugins, and the surface fails closed: PluginCatalog:RequireInstanceKey defaults to true, so a caller must present a registered instance key (Authorization: Bearer mwi_…) that resolves to a MeshWeaverInstance node and its admin-owned PluginGrant — the grant, not the key, decides which (source, package) pairs it may read. Consumers set PluginCatalog:RegistryUrl to this instance and their issued key in PluginCatalog:RegistryToken, then install from the Store's catalog.

🚨 The flat PluginCatalog:RegistryTokens allowlist is obsolete and no longer read. It was open when unset, which is how this registry served its private sources to anonymous callers until 2026-08-06. Setting it today gates nothing. The only way to open the surface now is PluginCatalog:RequireInstanceKey=false (local dev / the e2e stub), which warns on every request.

The App credential lives on this ONE instance; every consumer pulls over HTTP — that's the whole point (Plugin Registry → credential encapsulation).

App-grant gotchas (each one bites)

6. Push back (mesh → git)

Editing plugin content on the instance and syncing it back to the repo is the same GitSync export ("sync back") — and the same identity rules:

Trigger it from the Space's GitHub Sync settings (Sync back) or GitHubSyncService.SyncToGitHub(spacePath, userId) — token resolution is automatic (user credential first, else the App).

7. Troubleshooting

Symptom Cause / fix
Connect your GitHub account first, or configure the GitHub App Neither identity available — connect a user, or set GitHub:App:ClientId + PrivateKey (§5).
The GitHub App has no installations The App exists but isn't installed on the org — App page → Install App, grant the repo.
Import succeeds but the type doesn't render Compile failed — check the NodeType's diagnostics (get_diagnostics/the node's Configuration tab); usual causes are the runtime-compile rules in §2.5.
Push-back 403 The App's Contents permission is Read-only — set Read & Write and re-approve the installation.
Octokit.NotFoundException (404) on a repo that exists The installation can't SEE the repo — repo missing from the installation's Repository access, or the token was minted before the grant (restart the portal to drop the cached token).
Node already exists: {space} on import ImportFromGitHub is create-only. For an existing Space use SaveConfig + ReimportAtCommit (§4). If the existing node is an empty orphan from a previously failed import, delete it and retry.
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.