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 inproto/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 fromwindow.__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.
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.
- Discover — the kernel's filesystem watcher picks up new directories under
extensions/automatically.core.extension.rescanis the explicit, idempotent trigger you'd use from CI scripts; it walks the directory tree and registers everyextension.jsonit finds. - Deploy —
core.extension.deployaccepts a base64-encoded zip up to ~10 MB inline; for anything larger use the presigned-upload pair (core.extension.deploy_initreturns a token-bound PUT URL, thencore.extension.deploy_finalizeroutes through the same install pipeline). The default presigned cap is 200 MB, configurable via SQUILLA_EXTENSION_MAX_MB. Both forms unpack intodata/extensions/<slug>/via an atomic directory swap, register the row, and optionally activate. - Activate —
core.extension.activatedrives the hot-activation pipeline: run any new SQL migrations (the kernel writes(extension_slug, filename)rows intoextension_migrationsso re-activation only runs new files), load Tengo scripts, launch gRPC plugins, register block types and field types, fire theextension.activatedevent. - Deactivate —
core.extension.deactivatereverses every wiring: emitextension.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 inextensionsstays — justis_active=false. - Delete —
core.extension.deleteremoves 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.
/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).
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.