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.
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.
IPlugin with a four-hook lifecycle (ConfigureServices, RegisterRoutes, StartAsync, StopAsync); override only what you need.window.Plugins.register(id, { activate, deactivate, layerTypes }) in the map page.host.api field gates activation, so API 1.0 plugins keep working as the SDK grows.# 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
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.
Mount minimal-API endpoints under /api/plugins/<id>/… from IPlugin.RegisterRoutes. They register alongside every built-in subsystem on the same in-process server.
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.
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.
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.
host.RegisterCog and host.RegisterSlpk hand outputs to the existing registries, so /api/cog and /api/slpk serve them immediately — no new route needed.
host.PostToMapAsync pushes events from C# to JS ctx.onNativeEvent listeners; ctx.postToNative goes the other way. host.ExecAsync centralizes the JSON-encoding rule.
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.
<appdir>/plugins/, shipped with the app.%APPDATA%\VoltGrid\LocalMapServer\plugins\, plus an operator override path via the Plugins:Directory appsetting.Plugins:Enabled=false switches the subsystem off; Plugins:AllowedIds allowlists which plugins may load.map:exec, map:addLayer, …). Phase 1 records them; enforcement, signing, and the audit log are Phase 3 roadmap.AssemblyLoadContext.# 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
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.
ping route, two sidebar tool handlers, a registered Hello Layer typeappsettings.json, matching host conventions[plugins], [plugin-tool]) in native output and DevToolsTier 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.
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.