Extensions Overview

What extensions are, how they extend the kernel, and the three authoring surfaces.

Sovereign packages

An extension is a self-contained package that ships its full stack. Each one owns its database tables (and the SQL migrations that create them), its business logic, its admin micro-frontend, its scripts, and — if it needs Go — its own gRPC plugin binary. The kernel never reaches inside; it only speaks to extensions through CoreAPI calls (extension → kernel) and a five-method gRPC interface (kernel → extension).

That isolation is the point. A panicking gRPC plugin cannot take down the kernel — plugins are out-of-process, supervised, and the dispatcher has independent recovery semantics. A buggy Tengo handler cannot escape its sandbox: each call is capped at 50,000 allocations and a 10-second wall-clock deadline (internal/scripting/engine.go). A half-broken admin micro-frontend cannot crash the SPA shell because it's loaded as a separate ES module behind an import-map shim.

The reference implementation ships ten extensions in-tree under extensions/ in the repository. Eight are real features and two are scaffolding: media-manager, email-manager, forms, seo-extension, sitemap-generator, smtp-provider, resend-provider, content-blocks, visual-editor, plus hello-extension as the kit-tour skeleton.

The three authoring surfaces

An extension can mix and match three surfaces. You don't need all three — the smallest viable extension is a single extension.json file. The bundled extensions are a deliberate sampler:

  • gRPC plugin — a Go binary launched by the kernel via HashiCorp go-plugin. Implements the five RPCs in proto/plugin/squilla_plugin.proto. Reach for this when you need the Go ecosystem (real libraries, native concurrency, in-process state, CPU-bound work). media-manager is the canonical example: image decoding/encoding, WebP conversion, multi-size generation — all of it would be excruciating to express in Tengo.
  • Tengo scripts — sandboxed scripts under scripts/. No compilation, no plugin process to launch, hot-reload on activation. Reach for this when you only need to react to events, register filters, or expose a small HTTP endpoint. resend-provider is the canonical example: roughly twenty lines of Tengo, zero Go.
  • Admin UI micro-frontend — an isolated Vite build under admin-ui/ that emits an ES module. The SPA shell loads it on demand using import-map shims so shared deps (react, @squilla/ui, @squilla/api, @squilla/icons) come from window.__SQUILLA_SHARED__ at runtime. The micro-frontend owns admin routes, sidebar entries, slot injections, and any extension-specific field-type editors.

An extension can ship all three (forms, email-manager), only a plugin and a UI (media-manager), only Tengo and a UI (sitemap-generator), or only Tengo (resend-provider, smtp-provider). The manifest declares what's there; the loader wires what's declared.

Anatomy

A complete extension under extensions/<slug>/ can include any of the following. Only extension.json is mandatory.

filesystem
extensions/<slug>/
  extension.json            # manifest (required)
  cmd/plugin/main.go        # gRPC plugin entry (optional)
  bin/<slug>                # built plugin binary, GOOS=linux GOARCH=arm64
  migrations/*.sql          # schema, applied on activation, recorded in extension_migrations
  admin-ui/                 # React micro-frontend (optional)
    src/index.tsx           # named exports per route + slot
    dist/index.js           # build output, referenced by manifest entry
  editor-ui/                # extra public-site bundles, e.g. visual-editor's overlay
    dist/editor.js
  scripts/                  # Tengo scripts (optional)
    extension.tengo         # entry point, runs once on activation
    handlers/*.tengo        # event/route handler bodies
    filters/*.tengo         # filter handler bodies
  blocks/<slug>/            # content blocks owned by this extension
    block.json              # block_types schema + view.html resolver
    view.html               # template body, reads .fields and .node
  layouts/, partials/, templates/   # if the extension ships theme-asset overrides
  assets/                   # bundled images imported into media library on activation

Lifecycle

An extension moves through a predictable set of stages. Every transition is idempotent and every transition is driven from MCP tools or the admin UI — nothing requires a kernel restart.

  1. Discover — the kernel's filesystem watcher picks up new directories under extensions/ automatically. core.extension.rescan is the explicit, idempotent trigger you'd use from CI scripts; it walks the directory tree and registers every extension.json it finds.
  2. Deploycore.extension.deploy accepts a base64-encoded zip up to ~10 MB inline; for anything larger use the presigned-upload pair (core.extension.deploy_init returns a token-bound PUT URL, then core.extension.deploy_finalize routes through the same install pipeline). The default presigned cap is 200 MB, configurable via SQUILLA_EXTENSION_MAX_MB. Both forms unpack into data/extensions/<slug>/ via an atomic directory swap, register the row, and optionally activate.
  3. Activatecore.extension.activate drives the hot-activation pipeline: run any new SQL migrations (the kernel writes (extension_slug, filename) rows into extension_migrations so re-activation only runs new files), load Tengo scripts, launch gRPC plugins, register block types and field types, fire the extension.activated event.
  4. Deactivatecore.extension.deactivate reverses every wiring: emit extension.deactivated, unload scripts, kill plugin subprocesses, drop block types and field types from the in-memory registries. Database tables stay untouched, data persists, and the row in extensions stays — just is_active=false.
  5. Deletecore.extension.delete removes the directory and the registry row. Tables are not dropped: there are no down-migrations and the kernel will not destroy persistent data on your behalf. Drop tables yourself if you want a true uninstall.
THE HARD RULE
If disabling an extension would leave dead code in the kernel, that code belongs in the extension. Image optimisation, email templates, sitemap generation, public form submit endpoints, the /robots.txt route, the visual editor overlay — all extensions, never core. The hard rule is what keeps the kernel small enough to audit in an afternoon.

Capabilities and the guard

Every extension declares the kernel capabilities it intends to use in its manifest. Each CoreAPI call from a plugin or Tengo script passes through the capabilityGuard wrapper in internal/coreapi/capability.go; calls outside the declared set return an error with no override. Internal kernel callers bypass the guard entirely, so kernel code can call any CoreAPI method without declaring anything.

The capability namespace uses <domain>:<verb> with 27 capability strings:

  • nodes: nodes:read, nodes:write, nodes:delete
  • node types: nodetypes:read, nodetypes:write
  • settings: settings:read, settings:write
  • events: events:emit, events:subscribe
  • email: email:send
  • menus: menus:read, menus:write, menus:delete
  • routes / filters: routes:register, filters:register, filters:apply
  • media: media:read, media:write, media:delete
  • users: users:read
  • HTTP: http:fetch
  • logging: log:write
  • data store: data:read, data:write, data:delete
  • file storage: files:write, files:delete

Plus the meta capability admin_access used by role definitions. The full per-method matrix is in the CoreAPI Reference; you can inspect any installed extension's declared set with core.extension.get(slug).

ALSO CHECKED
Capabilities are necessary but not sufficient. The data-store CoreAPI also enforces table ownership: an extension granted data:read can only read tables it lists in data_owned_tables (and never the kernel-private list — users, sessions, content_nodes, and friends). That's why an extension can't pivot from data:read on its own table into reading users.

Public routes and the proxy

Extensions claim public URL paths by listing them in public_routes. The kernel proxies matching requests to HandleHTTPRequest on the gRPC plugin. Reserved kernel paths are refused at registration time so a hostile or careless manifest cannot shadow auth-critical handlers — the deny list (in internal/cms/public_proxy.go) covers /admin, /auth, /api/v1, /me, /login, /logout, /register, /forgot-password, and /reset-password, with prefix matching that lets /auth/login stay reserved while /auth-callback is free for an extension to claim.

Admin proxy routes are simpler and don't require declaration: any path under /admin/api/ext/<slug>/* is automatically forwarded to the plugin. Sensitive request headers (cookie, authorization, x-forwarded-for, x-real-ip) are stripped before forwarding so a plugin can't accidentally exfiltrate session state; the authenticated user id is passed in the typed user_id field of PluginHTTPRequest instead. A manifest's admin_routes array can attach per-capability gates to specific admin paths so that, for example, an editor with admin_access but not data:write can GET a forms list endpoint but cannot POST to its create endpoint — enforced before the proxy even forwards the request.