Building a gRPC Extension
Author a Go extension as a HashiCorp go-plugin gRPC binary.
The five RPC methods
The plugin interface is defined in proto/plugin/squilla_plugin.proto and re-exported through pkg/plugin. A plugin must implement five RPCs; in practice most extensions do real work in one or two of them and stub the rest.
Initialize(InitializeRequest) returns (Empty)— called by the kernel right after the plugin process starts. The request carries a gRPC client connection back to the kernel'sSquillaHostservice; wrap that incoreapi.NewClient(conn)and you have a typed Go interface to all 56 CoreAPI methods.GetSubscriptions(Empty) returns (SubscriptionList)— declarative event subscriptions. Returning{name: "node.published"}tells the kernel to callHandleEventwhenever that event fires. Wildcards are supported (*for all events,node.*for a domain prefix).HandleEvent(EventRequest) returns (EventResponse)— dispatch onactionand act on the JSONpayload. The response can carry HTML for template-injection events (forms:renderbeing the canonical example) or be an empty success.HandleHTTPRequest(PluginHTTPRequest) returns (PluginHTTPResponse)— serve admin proxy and public-route requests. See below for what's in the request struct.Shutdown(Empty) returns (Empty)— flush state and exit cleanly on deactivation. The supervisor kills plugins that block too long here, so do not put anything slow in this path.
package main
import (
"github.com/hashicorp/go-plugin"
ext "github.com/erikkubica/squilla/pkg/plugin"
)
func main() {
plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: ext.Handshake,
Plugins: map[string]plugin.Plugin{
"extension": &ext.ExtensionPluginGRPC{Impl: &Impl{}},
},
GRPCServer: plugin.DefaultGRPCServer,
})
}
The host client
Inside Initialize you receive a gRPC ClientConn wired to the kernel's SquillaHost service. Wrap it in coreapi.NewClient(conn) to get a typed Go handle covering every CoreAPI domain — nodes, node types, taxonomies, settings, events, menus, media, users, HTTP, logging, the data store, and file storage — 56 methods in total at the time of writing.
func (p *Impl) Initialize(conn *grpc.ClientConn) error {
p.host = coreapi.NewClient(conn)
return nil
}
// later, in HandleHTTPRequest:
list, err := p.host.QueryNodes(ctx, coreapi.NodeQuery{
NodeType: "recipe",
Status: "published",
Limit: 10,
})
if err != nil {
return nil, err
}
capabilityGuard. The guard rejects calls outside the declared capability set with no override flag — declare what you need in capabilities, or expect permission denied. The data-store methods add a second check: the table must appear in data_owned_tables, and the kernel-private tables (users, sessions, content_nodes, etc.) are denied unconditionally. DataExec (raw parameterised SQL) is intentionally not exposed through the gRPC client at all — it's an internal-only entrypoint. Authoring a write the typed methods can't express? Ship it as a kernel-side migration or rethink the schema; do not try to escape the guard.HandleHTTPRequest
The kernel proxies two flavours of request to your plugin:
- Admin — anything under
/admin/api/ext/<slug>/*, authenticated by the SPA session. The proxy strips the prefix frompathso your handler sees the suffix only;user_idis the authenticated user; sensitive headers (cookie,authorization,x-forwarded-for,x-real-ip) are stripped before forwarding. If the manifest declaresadmin_routeswith capability gates, those run in the kernel before the proxy hits your plugin. - Public — exact paths declared in
public_routes.user_idis always 0 (anonymous from the plugin's perspective). Reserved kernel paths are refused at registration time.
Both share a single PluginHTTPRequest shape: method, path, headers, body, query_params, path_params, user_id. Return a PluginHTTPResponse with status, headers, body. Nothing else — if you want streaming, write to a separate file the kernel can serve.
Build & deploy
Compile the plugin for the host's OS/arch. The default deploy targets in the bundled Makefile are linux/arm64; override GOOS/GOARCH for amd64 hosts.
# Cross-compile for the running kernel's architecture
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \
go build -o extensions/forms/bin/forms ./extensions/forms/cmd/plugin
# Hot-deploy through MCP. Inline base64 path is simplest under ~5–10 MB:
core.extension.deploy({ body_base64: "<zip-of-extension-dir>", activate: true })
# For larger archives use the presigned-upload pair (default 200 MB cap,
# override via SQUILLA_EXTENSION_MAX_MB):
core.extension.deploy_init({ filename: "forms-2.0.0.zip" })
# -> { upload_url, upload_token, expires_at, max_bytes }
PUT <upload_url> # raw zip bytes, no Authorization header (token IS the auth)
core.extension.deploy_finalize({ upload_token: "...", activate: true })
Migrations
SQL files in migrations/ run on activation in lexicographic order. The recommended naming is NNNNNNNNNNNN_description.sql (e.g. 20260101_init.sql) so chronological filenames sort correctly. The kernel records each applied file in extension_migrations as (extension_slug, filename); re-activation only runs files not already recorded. There are no down-migrations. Write idempotent up-migrations — wrap CREATE TABLE with IF NOT EXISTS, guard ALTER TABLE with PL/pgSQL DO $$ blocks that check information_schema.columns, expect to restore from backup if you need to roll back.
Hot-deploy mechanics
The deploy tools (whether inline or via the presigned pair) unpack into data/extensions/<slug>/ using an atomic directory swap so a partially-extracted archive can't be observed by the running kernel. The row in extensions is upserted, migrations run, the plugin process is started, and — if activate=true — the activation pipeline runs in the same call. Plugin binaries must already be compiled for the host's OS/arch before you zip the package; the deploy step does not cross-compile.