Extend the platform. Don't fork it. New

A plugin is one folder: a plugin.json manifest plus an optional .NET tier and an optional TypeScript tier. Drop it into the plugins directory and the host discovers and activates it at the next startup — new REST routes, sidebar tools, background workers, and map layer types, with no rebuild of the app.

plugin.json manifest.NET tier (IPlugin)TypeScript / ESM tier /api/plugins/<id>/Drop-in installNo app rebuild
One folder, one manifest

Ship a feature as a self-contained package

Every plugin starts with a plugin.json declaring its id, version, permissions, tools, and tiers. The tiers are optional and independent — a route-only plugin ships a single DLL; a map-widget plugin ships a single ES module. No bundler, no build pipeline: the TypeScript tier is plain ESM served as-is by the in-process Kestrel server.

  • .NET tier — implement IPlugin with a four-hook lifecycle (ConfigureServices, RegisterRoutes, StartAsync, StopAsync); override only what you need.
  • TypeScript tierwindow.Plugins.register(id, { activate, deactivate, layerTypes }) in the map page.
  • Python tier — roadmap (Phase 2): out-of-process FastAPI sidecars, proxied through the in-app server.
  • Versioned contract — the manifest's host.api field gates activation, so API 1.0 plugins keep working as the SDK grows.
plugins/my-plugin/
# one folder = one plugin
my-plugin/
  plugin.json          # required — describes everything
  bin/MyPlugin.dll     # optional — .NET tier
  ui/index.js          # optional — TS/ESM tier

# verify discovery at runtime
GET /api/plugins            → discovered + active
GET /api/plugins/manifests  → web-tier catalog
GET /api/plugins/my-plugin  → full manifest

● [plugins] activated my-plugin v1.0.0
Extension points

Hook into the host where it matters

Each tier plugs into a well-defined chokepoint. Plugin tools appear in the sidebar and search automatically; plugin layer types take precedence in the catalog loader.

🔌

REST routes

Mount minimal-API endpoints under /api/plugins/<id>/… from IPlugin.RegisterRoutes. They register alongside every built-in subsystem on the same in-process server.

🧰

Sidebar tool buttons

Declare tools[] in the manifest and bind each id with host.RegisterToolHandler. The button appears in the native sidebar under your chosen category — no UI code required.

⚙️

Background workers & SSE

IPlugin.StartAsync runs after the server is up — the place for worker loops, sidecar probes, and SSE streams, with StopAsync for cleanup on shutdown or unload.

🗺️

New map layer types

Register a layer-type factory and any catalog item with your cd_itemtype loads through it — the catalog loader consults the plugin registry before its built-in switch.

📦

Artifact registration

host.RegisterCog and host.RegisterSlpk hand outputs to the existing registries, so /api/cog and /api/slpk serve them immediately — no new route needed.

🌉

Native ↔ JS event bridge

host.PostToMapAsync pushes events from C# to JS ctx.onNativeEvent listeners; ctx.postToNative goes the other way. host.ExecAsync centralizes the JSON-encoding rule.

Deployment

Drop the folder in. Restart. Done.

The loader scans the discovery roots at startup and activates every valid manifest it finds. Installing a plugin never means rebuilding or repackaging VoltGrid itself.

  • Bundled plugins<appdir>/plugins/, shipped with the app.
  • User-installed plugins%APPDATA%\VoltGrid\LocalMapServer\plugins\, plus an operator override path via the Plugins:Directory appsetting.
  • Enterprise controlsPlugins:Enabled=false switches the subsystem off; Plugins:AllowedIds allowlists which plugins may load.
  • Declared permissions — manifests declare what they use (map:exec, map:addLayer, …). Phase 1 records them; enforcement, signing, and the audit log are Phase 3 roadmap.
  • Isolated loading — each .NET tier loads into its own collectible AssemblyLoadContext.
http://localhost:5059
# the in-tree hello-world reference plugin
GET /api/plugins/hello-world/ping
{ "pong": "hello-world",
  "id": "hello-world", "version": "1.0.0" }

# sidebar: 👋 Hello from Plugin · 🔔 Ping Plugin (JS)
await window.Plugins.getLayerType('Hello Layer')
→ factory fn — layer-type registry live
For partners & integrators

Start from a working example

You don't start from a blank page. The repository ships a complete reference plugin and a tutorial-flavored developer guide that walks from a one-route plugin to async jobs and map overlays.

👋

In-tree hello-world plugin

  • All the moving parts in miniature: manifest, a 56-line C# tier, an ESM UI tier
  • One ping route, two sidebar tool handlers, a registered Hello Layer type
  • Proves the toast round-trip and the native → JS event bridge end to end
  • Copy the folder, rename the id — a running plugin skeleton
🧭

Established patterns, documented

  • The canonical async-job pattern — fire-and-forget route, pollable job state
  • The 503-on-unavailable contract for plugins wrapping optional external CLIs
  • Config via appsettings.json, matching host conventions
  • Debug traces ([plugins], [plugin-tool]) in native output and DevTools

Tier availability reflects the phased rollout: the .NET and TypeScript tiers and the extension points above ship today; the Python tier, permission enforcement, plugin signing, and a marketplace are roadmap phases. Activation is gated by the manifest's host.api contract version.

Build on the platform your data already lives in.

Whether it's an in-house analysis tool, a partner data connector, or a custom layer type — package it as one folder, drop it in, and it shows up in the sidebar next to everything else.