23 KiB
| name | description |
|---|---|
| developing-blockninja-plugins | Use when creating, modifying, building, or publishing BlockNinja CMS plugins or themes — work in plugins/* or sites/* repos, codeless .bnp artifacts (blocks.yaml, seed.json, manifest.yaml), plugin registration, blocks, templates, plugin Connect services, migrations, ninja plugin commands, check-safety for plugins, or block/core SDK usage in standalone plugin repos. |
Developing BlockNinja Plugins
Overview
⚠️ If you remember
.soinstructions, they are obsolete. Standalone plugins are now wasm (wazero, WASI reactor mode) — the big-bang migration landed 2026-07-03 and the.soloader/builder were deleted from cms (so_loader.go,internal/builder/, cms commit a04277ee2). There is NOmake build-so, NOplugin.Open, NO CGO, NO in-container compile, NO host/plugin Go-version lock-step, NOcopy-plugin-sourcedeploy, and NO go:embed'd web bundle. Instances download a prebuilt.bnpartifact — they never compile. A registry version published as a source archive is rejected at install ("legacy source archive; republish as .bnp").
The ABI is the capability model; the Go SDK is only its ergonomic front-end.
Since WO-WZ-019, every host interaction is reachable purely over the ABI — a
manifest declaration, a blockninja.host_call method, or a host-invoked hook —
so a plugin in any language gets 100% of the surface; linking Go core is a
convenience, not a dependency of the model. The canonical plugin-need → ABI-
mechanism map is ~/src/blockninja/core/docs/abi-capability-surface.md.
Never write plugin code from memory — every SDK symbol is locally verifiable. Truth lives at:
- SDK source:
~/src/blockninja/core— for WASM plugins, import prefixgit.dev.alexdunmow.com/block/core/...is the ONLY one allowed; neverblock/cms/.... Codeless plugins import NOTHING (no Go at all). - ABI contract (authoritative):
~/src/blockninja/core/docs/wasm-abi.md— reactor mode, hook catalog, capability dispositions, error codes,plugin.moddata_dir; capability matrix:core/docs/abi-capability-surface.md - Architecture spec:
~/src/blockninja/cms/docs/superpowers/specs/2026-07-03-wasm-plugin-migration-design.md - Canonical guide (registration concepts, blocks, provisioning):
~/src/blockninja/cms/docs/PLUGIN_DEVELOPMENT.md - Exemplars:
plugins/testplugin— THE reference wasm plugin, every surface + failure endpoints (see its README surface table);plugins/symposium— the largest real port (service-heavy: RPC, jobs, AI, templ blocks; its Makefile is the build/publish convention)
Core-vs-plugin: platform-wide behavior → core; domain-specific or owns its own data/UI → plugin (decision table in PLUGIN_DEVELOPMENT.md).
Codeless plugins (WO-WZ-020): a repo with NO Go source builds a codeless
.bnp — blocks as blocks/blocks.yaml definitions (host-rendered via data
providers), seed/seed.json, manifest.yaml for presets/fonts/master pages.
No wasm, no block/core dependency at all. Prefer this form for themes,
content sites, and template-only block packs; write Go only for genuine logic
(services, jobs, computing tags, custom fetch). Contract:
core/docs/codeless-bnp.md.
Choose the artifact kind FIRST: codeless vs wasm
ninja plugin build classifies by repo shape: no Go source → codeless
(declarative .bnp, no plugin.wasm); Go source → wasm. Start every new
plugin/theme by asking whether it needs code AT ALL — themes, content sites,
and template-only block packs should be codeless (blocks as blocks.yaml
definitions + providers, seed/seed.json, manifest.yaml; contract:
core/docs/codeless-bnp.md). Write Go only for genuine logic: Connect
services, jobs, computing tags/filters, custom fetch, HTTP handlers, media
hooks, RAG fetchers. --codeless asserts the expectation. Mixed form is
legal: a wasm plugin may ALSO ship blocks/ + seed/ and keep Go only for
its logic (the WZ-021 "reduced wasm" target).
Anatomy of a wasm plugin
PluginRegistration is unchanged from the .so era — same fields, same Register,
same CoreServices. What changed is how it's served. Boilerplate main.go
(testplugin/main.go verbatim):
//go:build wasip1
package main
import "git.dev.alexdunmow.com/block/core/plugin/wasmguest"
func init() { wasmguest.Serve(Registration) }
func main() {} // never called — reactor mode
Compile: GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .
(Go ≥ 1.24; reactor mode — plain go build command mode does NOT work, the exports are
never callable). You never run this by hand: ninja plugin build does it.
At publish time a DESCRIBE probe instantiates the module once and captures every static
registration surface into manifest.pb; the CMS loader reads that without instantiating
the module. Dynamic work (block render, HTTP, jobs, Load/Unload, RAG fetch, media hooks)
crosses the ABI as protobuf hooks into a pool of instances (default 4, 512 MiB memory
cap each, 30 s per-call deadline). Full hook catalog + manifest↔Registration field map:
wasm-abi.md.
DESCRIBE constraint: host functions are stubbed to fail during the probe, so
Register (and anything reached from it) must not call capabilities or the DB — it gets a
named error at build time. Load-time work belongs in Load.
Worked example — plugins/testplugin
| Surface | File |
|---|---|
Blocks (rendercontext, content) + template (testplugin_page) |
registration.go |
Connect service via plugin.NewConnectServiceBinding (RBAC roles in manifest) |
service.go |
Raw chi routes + failure endpoints (/panic, /slow, /forbidden-sql, /alloc) |
httphandler.go |
| Job handler, media hooks, Load/Unload, RAG fetcher, deps stashing | registration.go |
| Goose migration + sqlc (pgx/v5) queries | migrations/, sql/queries/, db/ |
| Settings schema, theme preset (go:embed'd JSON) | assets/ |
data_dir = true grant + /data round-trip |
plugin.mod, onLoad |
Blocks: prefer declarative forms — definitions first, then html, custom Go LAST
For a plugin-owned block type, the preferred form is a block definition:
an entry in blocks/blocks.yaml (schema + .ninjatpl template + declared
data providers — posts, site, menus, authors, …), rendered host-side
by the definition engine with layer fallback. Zero Go, works in codeless AND
wasm artifacts, admin-forkable. Register a Go BlockFunc only when no
declared provider can produce the data (then return blocks.PoweredBlock so
the template still renders host-side; final-HTML string building is the last
resort).
For one-off page sections on a SITE (not a reusable type), the built-in
html block below still applies.
Themes re-skin built-ins via template OVERRIDES, not new blocks. A codeless theme ships
templates/overrides/<theme>/<key>.ninjatpl (+ a template_overrides entry in
manifest.yaml) for each built-in it restyles, plus its own page templates, presets, fonts,
email wrapper, and demo seed. The override dispatch model (definition-backed built-ins render
WITH their providers' data via RenderDefinition; the four compiled auth/404 built-ins
dispatch via the compiled path), the legacy field renames (button href→link etc.), the
full ninjatpl engine gotcha list, seed page-slug rules, the git -C commit-hook trap, and
the screenshot/preview pipeline are all in theme-overrides.md — read
it BEFORE writing any theme override or .ninjatpl.
The default reflex — register a custom block type (blocks.BlockMeta + a func(ctx, content) string
render func + a Module Federation editor) — is usually the wrong altitude for a section/content
block. Most sections (hero, features, nav, footer, CTA, legal) are built-in html blocks: a
pongo2 template rendered into _html_content, BlockKey: "html", zero Go render code and zero
custom editor. Live html blocks even drive admin-editable menus and use context.*. Register a
custom block only when you need a structured field-form editor or render logic a template can't
express. Custom keys also risk red block-fallback boxes when a key isn't registered; built-in
html never does. Decision guide, render modes, menu wiring, and gotchas: html-blocks.md.
Custom blocks that hit the DB have a wasm-specific gotcha — see "Per-instance state" below.
Database — sqlc unchanged, sandboxed role
sqlc-generated code (sql_package: "pgx/v5") works without source edits: at runtime
deps.Pool is a bnwasm.Pool (pgx-shaped Pool/Tx over the ABI's db.* messages).
Autocommit: type-assert the pool to your db.DBTX (registration.go openQueries).
Transactions: deps.Pool.Begin(ctx) → db.New(tx) → Commit/Rollback (httphandler.go
/db-tx). Errors surface as *pgconn.PgError with real SQLSTATEs.
Hard rules and limits:
- Own schema ONLY. Queries run under a per-plugin Postgres role (
plugin_<name>) granted only on schema<name>.public.*is permission denied (SQLSTATE 42501) by design — core data goes throughCoreServicescapability interfaces, never SQL. - No raw SQL — sqlc for static, Bob for dynamic (workspace rule, applies to plugins).
- Savepoints/nested transactions and named args (
pgx.NamedArgs) are rejected with clear errors. text[]values cannot carry NULL elements (collapse to""); the whole array can still be NULL.- A tx handle dies with the call chain's deadline:
errors.Is(err, bnwasm.ErrTxExpired)is retryable — re-run the unit of work in a fresh transaction. - Migrations are plain Goose SQL in
migrations/— run by the host (never cross the boundary).db/*.sql.gois committed; regenerate withsqlc generate.
Capabilities — CoreServices works as before, know the dispositions
Plugin code compiles against the same Go interfaces (deps.Content, deps.Settings,
deps.Crypto, deps.Menus, deps.RAGService, …) — the guest SDK ships them as stubs
marshaling to host functions. Full family/method table + disposition of every
CoreServices member: wasm-abi.md. The ones that behave differently:
Interceptors,CoreServiceBindings— host-side; RBAC method roles merge from the manifest, auth context arrives via forwarded HTTP headers (host interceptors already ran).- Captcha is host-verified (pluginsdk ≥ v0.2.2): a guest cannot hold the host's
stateful captcha server, so never import
block/core/captcha(any core import/require fails check-safety). Render the widget with the ninjatpl captcha tag; enforce withauth.CaptchaVerified(r.Header)— the host consumes the postedcap-tokenand stamps the unforgeableX-Bn-Verified-Captchatrusted header before dispatch. Fail closed when it returns false. Reference: calcomblockHandleCreateBooking. AppURL/MediaPath— delivered once inLoadRequest.host_config.deps.Provisioner(core ≥ v0.17.x) is the seeding path —EnsurePage,EnsureMenuItem,EnsureMedia,EnsureSetting,MergeSiteSettings, data tables, embeds, job schedules, custom colors — call it fromLoad.RegisterWithProvisioneris INERT under wasm: Register runs at DESCRIBE where host functions are stubbed, so its provisioner is a no-op — seed logic living there silently does nothing. Move it toLoad. (EnsureEmbedcrosses template embeds only; aRenderFuncembed errors — functions can't serialize.)deps.ContentAuthor— imperative authoring:CreatePage,SetPageBlocks,PublishPage,SetPageSEO,UpsertPost(blog_posts table).Bridge.GetServicestill returnsnilacross the sandbox (typed values can't cross — by design), but cross-plugin calls now work viaBridge.Invoke(opaque payloads, JSON by convention): the provider's service value implementsplugin.BridgeInvokableand answers overHOOK_BRIDGE_CALL.- AI tools execute:
deps.ToolRegistry.Registerhandlers run viaHOOK_AI_TOOL_CALL. Register tools inRegister(see per-instance state) or they exist on one pooled instance only. - Job progress crosses:
JobHandlerFunc'sprogress(current, total, msg)reaches the job system asjobs.progress(correlated via the call context). - Directory extensions work: panel sections / pin decorators are invoked via their own hooks from the manifest-captured counts.
SettingsUpdater.UpdatePluginSettingswrites the plugin's OWN settings (the host pins the name to the caller; another plugin's name is rejected).RAGService.RegisterContentFetcher— records guest-side + a manifest declaration; the host calls back viaHOOK_RAG_FETCH.- Methods without an error channel (
Slugify,EvaluateAccess,Bridge.RegisterService, …) degrade to zero values on transport failure.
Per-instance state (the #1 wasm gotcha)
Concurrency = an instance pool; each instance is a separate Go runtime with its own
globals. Load fires once, on one instance — globals it sets do NOT exist on the
others. Two sanctioned patterns (both in testplugin/symposium):
- Stash deps in every deps-receiving entry point (
Load,HTTPHandler,JobHandlers) so deps-less hooks (media hooks,Unload) can reach the DB — testplugin'spkgDeps/stashDeps. Instances are single-threaded, so a plain global is safe. - DB-backed blocks:
HOOK_RENDER_BLOCKreceives no services. Get the pool insideRegister(which runs on every instance) viawasmguest.HostServices().Pool— nil-check it: in native/DESCRIBE builds it's the zero value. See symposiumregister.go.
The same rule governs the callback hooks: AI tool handlers and bridge service
values must be registered in Register (via wasmguest.HostServices()), not
Load — HOOK_AI_TOOL_CALL / HOOK_BRIDGE_CALL can land on ANY pooled
instance, and only Register runs on all of them.
Sandbox constraints — write reload-safe code
- No filesystem, no network, no env in the guest. One opt-in exception:
data_dir = trueinplugin.modgrants a read-write preopen at guest/data— a per-plugin directory on a persistent volume (instance-<slug>-plugin-data), shared across the plugin's pool instances, deleted on uninstall. Off by default; the grant is visible at publish time. Absent at DESCRIBE time — tolerate the write failure (testpluginonLoad). - Buffered HTTP only — no SSE/WebSocket/streaming from plugin endpoints. Realtime
features use the core realtime system (
@block-ninja/uihooks / core topics). - Panics recover to errors: an in-hook panic → INTERNAL error → 5xx; the host and other
plugins survive. Deadline overruns and OOM discard the instance; the pool refills.
Error-code table (INTERNAL/DECODE/UNIMPLEMENTED/DEADLINE/PERMISSION/TX_EXPIRED):
wasm-abi.md. - Hot swap: plugin updates swap live (
SwapPlugin) with no restart — never assume an instance lives long, or that in-memory state survives an update. Persist in the DB or/data. - No
os.Getenvfor secrets (nothing is there anyway) — plugin settings orCoreServices.
Build → verify → publish
plugin.mod is still the manifest (name/version/kind/scope/categories/tags, plus the new
first-class data_dir bool). kind is still frozen at first publish. Pin a recent
block/core; exact-version lock-step with the CMS is dead (testplugin builds on v0.15.1
while cms runs v0.15.2) — compatibility is gated by ABI major (abi_version, currently 1).
ninja plugin build --dir . # wasip1 compile + DESCRIBE probe → <name>-<version>.bnp
ninja plugin verify <name>-<ver>.bnp # loader-identical checks: layout, abi_version, name match
cd ~/src/blockninja/check-safety && go run . <plugin-path> # MUST exit 0 (unchanged mandate)
ninja plugin bump patch # commits plugin.mod — does NOT git-tag
git tag vX.Y.Z && git push origin main vX.Y.Z # --follow-tags skips lightweight tags
ninja plugin publish # builds the .bnp itself at the bumped version and
# uploads it — the .bnp is the ONLY publish form
# (--bnp <file> ships a prebuilt artifact instead)
The .bnp (tar.zst) packs plugin.mod + manifest.pb, plus — when present —
blocks/ (definition manifest), templates/, seed/, migrations/,
schemas/, assets/, and web/dist (flattened under web/); wasm artifacts
add plugin.wasm, codeless ones don't (manifest.codeless is set, and
verify/reader reject a mismatch in either direction). Bundling is a fixed
allowlist (cli/internal/bnp/build.go), not config or git: each optional dir
ships only if it exists with ≥1 regular file (symlinks skipped), read straight
from the working directory — an untracked file under assets/ ships; Go
source, Makefile, README, or anything outside the listed dirs never does
(README/CHANGELOG reach the registry as publish metadata, not artifact
contents). manifest.pb is never read from disk: wasm builds DESCRIBE-probe
the module, then plugin.mod's data_dir and any root manifest.yaml
declarative surfaces are folded in, with referenced JSON (presets.json etc.)
embedded INTO manifest.pb rather than packed as files. Packing is
deterministic (sorted entries, fixed modes) — same tree, byte-identical
artifact. Statics are
consumed host-side: Goose runs migrations, assets are served directly, and the Module
Federation admin bundle is served from the artifact at /plugins/<name>/ — no longer
go:embed'd, but web/dist must be built (cd web && pnpm run build) before
ninja plugin build. Makefile convention: copy plugins/symposium/Makefile
(build / build-frontend / verify / archive-check / publish — archive-check now
proves git archive HEAD wasm-compiles). Generated Go (*_templ.go, db/*.sql.go,
*connect.go) and web/dist stay committed; .gitignore adds *.bnp and *.wasm.
Dev loop: publish to the DEV orchestrator (ninja --host https://my.localdev.blockninjacms.com plugin publish — my.blockninjacms.com
without localdev is PRODUCTION). Install via Admin → Plugins → Browse Registry
(InstallFromRegistry): the instance downloads the .bnp, checksum-verifies, and
hot-loads without restart; updates hot-swap the same way. First publish lands
private; review via the orchestrator dashboard. Private account-scoped plugins:
--private on init/publish.
Verifying SDK symbols
Before using an unfamiliar SDK call, check: (1) the pinned SDK — go doc git.dev.alexdunmow.com/block/core/plugin CoreServices from the plugin dir; (2) the
capability disposition table in core/docs/wasm-abi.md (does it cross the ABI, and how?);
(3) nearest exemplar usage in testplugin/symposium; (4) the CMS-side implementation in
cms/backend for semantics.
Missing capability ⇒ first check core/docs/abi-capability-surface.md — the surface
is complete for every known plugin need since WO-WZ-019. A genuinely new capability is an
ABI extension (manifest field, host_call method pair, or hook — additive within ABI
major 1), landed in the ABI proto + cms host first; the Go guest stub is then the
ergonomic wrapper, not the capability itself. Alternatively vendor the cms-internal
package into the plugin's internal/ with a provenance header. NEVER replace
directives; NEVER block/cms imports. Remember the wasm boundary: a value that carries
functions/handlers can't cross — it needs a hook or a manifest declaration instead.
Doc routing
| Working on | Read first |
|---|---|
ABI, hooks, capabilities, error codes, data_dir, build/pack internals |
core/docs/wasm-abi.md |
| Which ABI mechanism serves a plugin need (matrix; codeless equivalents) | core/docs/abi-capability-surface.md |
Scaffold, registration, custom block types, CoreServices concepts |
cms/docs/PLUGIN_DEVELOPMENT.md |
| Section/content blocks — html block vs custom; menus; live vs pre-render | html-blocks.md (in this skill) — read BEFORE writing a custom block type |
| Codeless THEMES — template overrides, ninjatpl gotchas, override dispatch, seed slugs, screenshots | theme-overrides.md (in this skill) — read BEFORE writing any theme override or .ninjatpl |
Depositing media (seed EnsureMedia / runtime CoreServices.Media) |
PLUGIN_DEVELOPMENT.md §Depositing Media |
| Plugin migrations / DB tables — own schema, per-plugin role | PLUGIN_DEVELOPMENT.md §Migrations + this skill §Database |
Platform data tables (Provisioner.EnsureDataTable, View-rootable JSONB store) |
PLUGIN_DEVELOPMENT.md §Provisioning → Data tables |
| Public HTTP routes (webhooks, widgets) — buffered only | cms/docs/PLUGIN_HTTP_HANDLERS.md |
| Load/Unload, runtime state | cms/docs/PLUGIN_LIFECYCLE_HOOKS.md + this skill §Per-instance state |
| Themes, templates, master pages, CSS | cms/docs/TEMPLATE_PLUGINS.md |
Theme preview screenshots (ninja theme screenshot, registry previewImageUrl) |
cms/docs/theme-previews.md |
| Block editor / settings UI (Module Federation) | cms/docs/PLUGIN_EDITOR_SDK.md |
| Migration rationale, what stayed/died | the 2026-07-03 wasm migration spec (cms/docs/superpowers/specs) |
Stale docs — do NOT follow: cms/docs/compiled-plugin-architecture.md (.so pipeline,
dead) and any plugins/CLAUDE.md / memory notes describing make build-so, in-container
compiles, go:embed'd web bundles, or core-version skew.
Release traps
ninja plugin publishbuilds the .bnp itself and ships it — the source-archive path is gone (CLI feeb311). A wasm plugin whose GoRegistration.Versiondrifts from plugin.mod fails the publish ("manifest version != plugin.mod version") — sync the constant when bumping.--bnp <file>ships a prebuilt artifact instead of building.kindis frozen at first publish. The registry storeskind(plugin|theme) on firstCreatePluginand never updates it; publish only compares. A theme MUST declarekind = "theme"BEFORE its first publish. Dev-only remedy for a misregistered row:UPDATE registry_plugins SET kind='theme'onorchestrator-db, then republish.ninja plugin publishbuilds the artifact from the working directory and warns on a dirty tree (--strictaborts) — keep the tree committed at the bumped version so the artifact matches the tag;make archive-checkproves HEAD compiles.ninja plugin initis interactive and DROPS unknownplugin.modkeys on rewrite — re-checkplugin.modafter running it (data_diris a known key and survives).
New plugin: ninja plugin init, copy main.go + layout from testplugin and the Makefile
from symposium, minimal registration per PLUGIN_DEVELOPMENT.md.