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.

  1. 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's SquillaHost service; wrap that in coreapi.NewClient(conn) and you have a typed Go interface to all 56 CoreAPI methods.
  2. GetSubscriptions(Empty) returns (SubscriptionList) — declarative event subscriptions. Returning {name: "node.published"} tells the kernel to call HandleEvent whenever that event fires. Wildcards are supported (* for all events, node.* for a domain prefix).
  3. HandleEvent(EventRequest) returns (EventResponse) — dispatch on action and act on the JSON payload. The response can carry HTML for template-injection events (forms:render being the canonical example) or be an empty success.
  4. HandleHTTPRequest(PluginHTTPRequest) returns (PluginHTTPResponse) — serve admin proxy and public-route requests. See below for what's in the request struct.
  5. 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.
cmd/plugin/main.go
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.

plugin/main.go
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
}
GUARDED
Every CoreAPI call your plugin makes is wrapped by 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 from path so your handler sees the suffix only; user_id is the authenticated user; sensitive headers (cookie, authorization, x-forwarded-for, x-real-ip) are stripped before forwarding. If the manifest declares admin_routes with capability gates, those run in the kernel before the proxy hits your plugin.
  • Public — exact paths declared in public_routes. user_id is 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.

$ shell
# 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.