--- name: developing-blockninja-plugins description: 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/pluginsdk SDK usage in standalone plugin repos. --- # Developing BlockNinja Plugins ## Overview > **⚠️ If you remember `.so` instructions, they are obsolete.** Standalone plugins are now > **wasm** (wazero, WASI reactor mode) — the big-bang migration landed 2026-07-03 and the > `.so` loader/builder were deleted from cms (`so_loader.go`, `internal/builder/`, cms > commit a04277ee2). There is NO `make build-so`, NO `plugin.Open`, NO CGO, NO in-container > compile, NO host/plugin Go-version lock-step, NO `copy-plugin-source` deploy, and NO > go:embed'd web bundle. Instances **download** a prebuilt `.bnp` artifact — 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 the Go pluginsdk is a convenience, not a dependency of the model. The canonical plugin-need → ABI-mechanism map is `~/src/blockninja/cms/docs/abi/abi-capability-surface.md`. Never write plugin code from memory — every SDK symbol is locally verifiable. Truth lives at: - **SDK source:** `~/src/blockninja/pluginsdk` (module `git.dev.alexdunmow.com/block/pluginsdk`) — since the proto-first SDK program (P3, 2026-07), plugins import ONLY `block/pluginsdk/...`; `block/core/...` and `block/cms/...` imports fail check-safety. Codeless plugins import NOTHING (no Go at all). The ABI protos live in `pluginsdk/abi/proto/v1` (Go bindings `pluginsdk/abi/v1`). - **ABI contract (authoritative):** `~/src/blockninja/cms/docs/abi/wasm-abi.md` — reactor mode, hook catalog, capability dispositions, error codes, `plugin.mod` `data_dir`; capability matrix: `cms/docs/abi/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 Go 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: `cms/docs/abi/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: `cms/docs/abi/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 //go:build wasip1 package main import "git.dev.alexdunmow.com/block/pluginsdk/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//.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](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](html-blocks.md)**. Custom blocks that hit the DB have a wasm-specific gotcha — see "Per-instance state" below. ## Theme font size hooks (`bn-*` / `--fs-*`) Admin-set per-element font sizes (cms ADR 0029) reach templates through class hooks (`bn-hero-title`, `bn-post-title`, `bn-post-lede`, `bn-post-card-title`, `bn-nav-link`, `bn-hero-subtitle`) and `var(--fs-, )` consumption in CSS. Re-skins must preserve the built-in's `bn-*` hooks; custom templates add them to equivalent elements; hardcoded `font-size` on a mapped element wraps in `var(--fs-, )`. Full key table and rules: **[theme-overrides.md](theme-overrides.md)**. ## 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_`) granted only on schema ``. `public.*` is **permission denied (SQLSTATE 42501) by design** — core data goes through `CoreServices` capability 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.go` is committed; regenerate with `sqlc 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 with `auth.CaptchaVerified(r.Header)` — the host consumes the posted `cap-token` and stamps the unforgeable `X-Bn-Verified-Captcha` trusted header before dispatch. Fail closed when it returns false. Reference: calcomblock `HandleCreateBooking`. - `AppURL`/`MediaPath` — delivered once in `LoadRequest.host_config`. - **`deps.Provisioner` is the seeding path** — `EnsurePage`, `EnsureMenuItem`, `EnsureMedia`, `EnsureSetting`, `MergeSiteSettings`, data tables, embeds, job schedules, custom colors — call it from **`Load`**. **`RegisterWithProvisioner` is 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 to `Load`. (`EnsureEmbed` crosses template embeds only; a `RenderFunc` embed errors — functions can't serialize.) - **`deps.ContentAuthor`** — imperative authoring: `CreatePage`, `SetPageBlocks`, `PublishPage`, `SetPageSEO`, `UpsertPost` (blog_posts table). - `Bridge.GetService` still returns `nil` across the sandbox (typed values can't cross — by design), but **cross-plugin calls now work via `Bridge.Invoke`** (opaque payloads, JSON by convention): the provider's service value implements `plugin.BridgeInvokable` and answers over `HOOK_BRIDGE_CALL`. - **AI tools execute**: `deps.ToolRegistry.Register` handlers run via `HOOK_AI_TOOL_CALL`. Register tools in `Register` (see per-instance state) or they exist on one pooled instance only. - **Job progress crosses**: `JobHandlerFunc`'s `progress(current, total, msg)` reaches the job system as `jobs.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.UpdatePluginSettings` writes 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 via `HOOK_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): 1. **Stash deps in every deps-receiving entry point** (`Load`, `HTTPHandler`, `JobHandlers`) so deps-less hooks (media hooks, `Unload`) can reach the DB — testplugin's `pkgDeps`/`stashDeps`. Instances are single-threaded, so a plain global is safe. 2. **DB-backed blocks:** `HOOK_RENDER_BLOCK` receives no services. Get the pool inside `Register` (which runs on every instance) via `wasmguest.HostServices().Pool` — **nil-check it**: in native/DESCRIBE builds it's the zero value. See symposium `register.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 ambient network, no env** in the guest. Outbound HTTP has ONE opt-in path — the `allowed_hosts` egress grant + `deps.OutboundHTTP` (see §Outbound HTTP egress below); there is no other way to reach the network. One opt-in filesystem exception: `data_dir = true` in `plugin.mod` grants a read-write preopen at guest `/data` — a per-plugin directory on a persistent volume (`instance--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 (testplugin `onLoad`). - **Buffered HTTP only** — no SSE/WebSocket/streaming from plugin endpoints. Realtime features use the core realtime system (`@block-ninja/ui` hooks / 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.Getenv` for secrets (nothing is there anyway) — plugin settings or `CoreServices`. ## Outbound HTTP egress — `allowed_hosts` + `deps.OutboundHTTP` (ADR 0023) A guest has no ambient network. To call a third-party API (Pexels, Cal.com, any webhook target) the plugin declares an egress allowlist and rides a host-mediated transport. Exemplar: `plugins/calcomblock` (`client.go`, `handler.go`). The whole surface lives in `pluginsdk` — `pluginsdk/egress` (policy), `pluginsdk/plugin` (`deps.OutboundHTTP`, `plugin.mod` fields), cms host `plugin/wasmhost/caps/net.go` (`http.request`). **Declare it in `plugin.mod`** (first-class keys — a hand-added TOML key not in the struct is dropped on the next `ninja plugin init`/`bump`): ```toml allowed_hosts = ["api.pexels.com", "*.cal.com"] # empty ⇒ no egress at all max_response_mb = 25 # optional; default cap is 10 MB ``` `ninja plugin build` validates the patterns and stamps them into the manifest (`PluginManifest.allowed_hosts` / `max_response_mb`). **Host-pattern grammar** (`pluginsdk/egress.ParsePattern`): a bare hostname (`api.pexels.com`) or a single **left-anchored** multi-label wildcard (`*.cal.com`) whose base must be a registrable domain. Each entry is **https/443 only** unless it names an explicit port (`host:8443`). Rejected: bare `*`, public-suffix wildcards (`*.com`, `*.co.uk`), embedded schemes/paths/userinfo, a URL instead of a host. A request to a non-matching host, a non-https scheme, or (post-DNS) a private/SSRF IP fails with `ABI_ERROR_CODE_EGRESS_DENIED` (`egress.ErrDenied`) — catch it and degrade; it's distinct from a transport failure. **Make requests through `deps.OutboundHTTP`** (an `http.RoundTripper`), never a bare `http.DefaultTransport`: ```go client := &http.Client{Transport: deps.OutboundHTTP, Timeout: 30 * time.Second} ``` The host enforces the grant, the SSRF guard, the platform denylist, and the response-size cap, performs the buffered request, and logs it (buffered only — no streaming/SSE/WebSocket; request AND response bodies are capped, oversize fails rather than truncates). `deps.OutboundHTTP` is **nil when the plugin declared no `allowed_hosts`** — and at DESCRIBE/native/test time. Follow calcomblock's `NewCalcomClient(apiKey, rt http.RoundTripper)` shape: pass `deps.OutboundHTTP` as the transport, and fall back to `http.DefaultTransport` when it's nil so DESCRIBE probes and `httptest` paths still work (tests reach a fake server by overriding the base URL). **API keys** for the upstream service are the plugin's OWN secret: store them in plugin settings (encrypted at rest via the settings capability, as calcomblock's `settings_store.go` does), NOT in `allowed_hosts` and NOT in env. The allowlist authorizes the *destination*, not the *credential*. **Install-time consent (ADR 0023 D5/D9):** declaring `allowed_hosts` (or a raised `max_response_mb`) makes the plugin **egress-granted** — the CMS gates it behind an install-time consent dialog (`GetPluginEgressPlan` reports declared vs granted with the widening diff; approval is all-or-nothing, no per-host selection). A version bump that **widens** the allowlist or cap re-gates the update. So keep the allowlist as tight as the integration truly needs. ## Public site-root routes — `public_routes` + `sitemap` (ADR 0026) By default a plugin's HTTP handler is reachable ONLY under the namespaced mount `/api/plugins/`. To serve top-level public URLs (SEO pages, public APIs) the plugin claims them in `plugin.mod` (first-class keys, same round-trip rule as `allowed_hosts` — a hand-added key not in the struct is dropped on the next `init`/`bump`): ```toml public_routes = [{ path = "/area", prefix = true }, { path = "/api/semantic-search" }] sitemap = true # host fetches entries from the plugin's well-known sitemap endpoint ``` `ninja plugin build` validates and stamps both into the manifest (`PluginManifest.public_routes` / `sitemap`; validator: `pluginsdk/plugin.ValidatePublicRoutes`). Build-rejected: relative paths, the site root `/`, trailing slashes (set `prefix = true` to claim the subtree), traversal or malformed segments, duplicates, anything equal to or under the reserved prefixes `/admin`, `/api/plugins`, `/ws`, and a prefix claim that would cover one (a `/api` prefix claim covers `/api/plugins`). Routes and the sitemap flag require an HTTP handler; codeless plugins cannot declare either. The guest ABI is unchanged: matching requests dispatch to the SAME `HTTPHandler` hook, which already receives full unstripped request paths, so one handler routes both mounts by `r.URL.Path`. Conflict rules are host-side at install/load: core routes always win, first-installed plugin wins between plugins, and losing claims surface in the admin plugin UI (never silently dropped). **Status (2026-07-12):** manifest + CLI shipped (pluginsdk v0.2.7, cli 71d005f). Host-side mounting and the sitemap merge land with WO-PX-002 — until that cms release is deployed, claims are stamped into the `.bnp` but not yet mounted. First consumer: perthplaygrounds (`/area`, `/badges`, `/age`, `/api/directory`). ## 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/pluginsdk`; exact-version lock-step with the CMS is dead (testplugin builds on v0.2.5 while v0.2.7 is current) — compatibility is gated by ABI major (`abi_version`, currently 1). ```bash ninja plugin build --dir . # wasip1 compile + DESCRIBE probe → -.bnp ninja plugin verify -.bnp # loader-identical checks: layout, abi_version, name match cd ~/src/blockninja/check-safety && go run . # 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 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//` — **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/pluginsdk/plugin CoreServices` from the plugin dir; (2) the capability disposition table in `cms/docs/abi/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 `cms/docs/abi/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` or `block/core` 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 | `cms/docs/abi/wasm-abi.md` | | Which ABI mechanism serves a plugin need (matrix; codeless equivalents) | `cms/docs/abi/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](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](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` | | Top-level site-root URL claims (`public_routes`, `sitemap`) | `cms/docs/adr/0026-plugin-public-routes-manifest-capability.md` + this skill §Public site-root routes | | 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 publish` **builds the .bnp itself and ships it** — the source-archive path is gone (CLI feeb311). A wasm plugin whose Go `Registration.Version` drifts from plugin.mod fails the publish ("manifest version != plugin.mod version") — sync the constant when bumping. `--bnp ` ships a prebuilt artifact instead of building. - **`kind` is frozen at first publish.** The registry stores `kind` (`plugin`|`theme`) on first `CreatePlugin` and never updates it; publish only compares. A theme MUST declare `kind = "theme"` BEFORE its first publish. Dev-only remedy for a misregistered row: `UPDATE registry_plugins SET kind='theme'` on `orchestrator-db`, then republish. - `ninja plugin publish` builds the artifact from the **working directory** and warns on a dirty tree (`--strict` aborts) — keep the tree committed at the bumped version so the artifact matches the tag; `make archive-check` proves HEAD compiles. - `ninja plugin init` is interactive and DROPS unknown `plugin.mod` keys on rewrite — re-check `plugin.mod` after running it (`data_dir` is a known key and survives). - **Stale-CLI manifest drop:** a `ninja` binary older than the field you rely on builds a `.bnp` whose manifest silently omits it. `allowed_hosts` needs CLI >= 7cfc371; `public_routes`/`sitemap` need CLI >= 71d005f (pluginsdk v0.2.7). Rebuild/reinstall the CLI from `~/src/blockninja/cli` before publishing a plugin that uses a new manifest field. New plugin: `ninja plugin init`, copy `main.go` + layout from testplugin and the Makefile from symposium, minimal registration per PLUGIN_DEVELOPMENT.md.