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
*.jsonfiles ARE MeshNodes, verbatim.*.csfiles becomeCodenodes keyed by path.- A child NodeType may equally live as
Widget/index.jsoninside its own folder (the shapePublish/Slideuses) instead of the siblingWidget.jsonabove — both are valid. Only the plugin root must beindex.jsoninside the folder. manifest.lockis generated by the plugins repo'sscripts/gen-manifests.pyand verified in CI. ItsmoduleVersion— a hash over the module's sorted (path, file-hash) pairs — is the content identity every installation compares, so an unchanged module is never re-installed. See Plugin Update on Green Build.- The root
index.jsonisnodeType: "Store/Plugin"with aPluginContent— the cover (Body/Video/Poster), price,EntryPoint,MarketingPath,SetupPathandInstallPaths. TheStore/Plugintype gives the plugin its store card, its gated children and its cover page. (ASpaceroot is correct only when its content is a partition-levelNodeTypeDefinitioncompiling a sharedSource/model, e.g.UWDeepfield; aPluginManifestroot is the deprecated pre-"Store retype" form. Canonical reference:MeshWeaver.Plugins/AGENTS.md.) 🚨Store/PluginandPluginContentare themselves a dynamic node type shipped by theStoreplugin inSystemorph/MeshWeaver.Plugins— not framework code in this repository, so they do not appear in anysrc/search here. - The NodeType node carries the type's configuration, e.g.
"configuration": "config => config.WithContentType<WidgetContent>().AddDefaultLayoutAreas().AddLayout(l => l.AddWidgetLayoutAreas())". - The mesh compiles
Source/live on install (Roslyn) — no app rebuild, no NuGet. Version = the node's mesh-tracked version.
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
- Lay out the folder as above, in a working copy of the plugins repo.
- Write the content record (
Source/WidgetContent.cs) — a plain record with the fields your type carries. Attributes like[Required],[DisplayName],[MeshNodeProperty]shape the editor. - Write the layout areas (
Source/WidgetLayoutAreas.cs) — the views. Compose framework controls (Controls.Stack,Controls.Markdown,Controls.DataGrid…), never hand-built HTML. - Write in-node tests (
Test/WidgetTests.cs) — plain C# that asserts by throwing (the runtime compile references only the platform assemblies; no xUnit). - 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).
- explicit
- 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
- Push your folder to a branch of
Systemorph/MeshWeaver.Pluginsand open a PR. - CI (
Plugin Catalog CI) runsscripts/validate-repos.py— every node.jsonmust parse, carryid+nodeType, every NodeType must shipSource/*.cs, declared dependencies must resolve, and every module'smanifest.lockmust be current — then compiles and renders the affected modules in a real portal image. - 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/pluginsfor every instance whosePluginGrantcovers 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:
- GUI: the Space's GitHub Sync settings → repository URL + branch + subfolder → Import.
- Code / script:
GitHubSyncService.ImportFromGitHub(repoUrl, ref, spaceId, spaceName, subdir, userId).
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:
- 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.
- Install the App on the org and grant it the plugin repo(s).
- Generate a private key on the App page (downloads a
.pem). - Configure the instance (the
GitHub:Appsection; 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) |
- Point the instance's catalog sources at your plugins repo — a
PluginCatalog:Sourceslist of{ Name, RepoPath, Ref, Subdir, Format }entries (the legacy singlePluginCatalog:SourceRepoPathstill 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:RegistryTokensallowlist 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 isPluginCatalog: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)
- The grant is TWO steps: the App's Permissions & events (Contents: Read [& Write]) and the installation's Repository access (which repos it can see). Either missing → API calls 404 (not 403) on the repo. A permission change on an installed App may also sit pending approval on the installation page until an org admin approves it.
- Installation tokens fix their permissions at mint. Granting repos/permissions does NOT upgrade already-minted tokens — and the platform caches its token until near expiry. After changing the grant, restart the portal to drop the cached token.
- Client-id prefixes:
Iv23li…= GitHub App,Ov23li…= OAuth App. A client secret generated on one never matches the other's client id ("client_id and/or client_secret passed are incorrect" at token exchange while the authorize step succeeds). - Verify from outside (no portal involved): sign an RS256 JWT with the PEM (
iss= client id), thenGET /app/installations→POST /app/installations/{id}/access_tokens→ check the responsepermissions, thenGET /installation/repositorieswith the token — the granted repos must list the plugin repo.
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:
- As the App (default for server/registry instances): exports authenticate with the
installation token and commits are authored as
meshweaver-app[bot]. Requires the App's Contents permission to be Read & Write. - As a user: someone who connected their GitHub account (
/connect/github/me) exports under their own credential; commits are authored as them.
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. |