← writing

Anatomy of a Squilla extension

A guided tour through the files that make up a Squilla extension, using a small fictional comments extension as the example. One gRPC plugin, one React micro-frontend, a handful of SQL migrations, a few Tengo scripts, and a manifest that ties it all together.

When I tell people that Squilla extensions own their full stack, they nod politely and then ask what that actually means on disk. Fair question. The README phrasing is that an extension is a Debian package, which sounds catchy until you have to point at a folder. So let me point at a folder. Here is a small fictional comments extension, the kind of thing you would build to let readers leave threaded comments under a blog post.

extensions/comments/
  extension.json              # manifest
  cmd/plugin/
    main.go                   # gRPC plugin entry
    handler.go                # HandleHTTPRequest implementation
  admin-ui/
    src/
      App.tsx                 # micro-frontend root
      routes/list.tsx
      routes/thread.tsx
    vite.config.ts            # builds one ES module
    package.json
  migrations/
    001_create_comments.sql
    002_add_thread_idx.sql
  scripts/
    on_comment_created.tgo    # Tengo event hook
    routes_public.tgo         # public HTTP route

That is the whole extension. Every piece in that tree has a job, and the manifest at the top is what makes the kernel agree to load any of it. Let me walk through the pieces one at a time, because once you have seen the shape once you have seen it for every extension Squilla ships.

extension.json, the contract with the kernel

The manifest is the first thing the loader reads, and it is the only file that is technically mandatory. It declares two things that matter for the rest of this post. The first is the list of capabilities the extension wants. The second is the list of slots the extension provides. Capabilities are what the extension is allowed to do, slots are what the extension claims to be.

{
  "slug": "comments",
  "name": "Comments",
  "version": "0.1.0",
  "capabilities": ["nodes:read", "data:read", "data:write", "events:subscribe"],
  "provides": ["comments"],
  "plugins": [{ "name": "comments", "binary": "cmd/plugin/comments" }],
  "admin_ui": {
    "entry": "admin-ui/dist/comments.js",
    "routes": [
      { "path": "/comments", "component": "List", "label": "Comments" },
      { "path": "/comments/:id", "component": "Thread" }
    ],
    "menu": [{ "label": "Comments", "icon": "message-square", "path": "/comments" }]
  }
}

The CoreAPI checks every capability at the call site. If the extension calls DataExec but forgot to ask for data:write, it gets a guard error, not a silent execution. The provides array is the other half of the puzzle. Other extensions or the theme can look up whoever currently provides the comments slot, which means I can swap my homegrown comments extension for an Akismet-flavoured replacement later and the theme never has to change. Slots are how Squilla stays generic at the core while still giving themes a stable thing to call.

The gRPC plugin, isolated by design

The plugin is a real Go binary that runs in its own process. The kernel uses HashiCorp go-plugin to fork it on activation, hand it a SquillaHost client over gRPC, and from there the two processes talk over a bidirectional channel. The reason for the extra hop is failure isolation. If the comments plugin segfaults because someone wrote a regex that backtracks for forty seconds, the kernel does not die with it. The supervisor logs the crash, marks the extension unhealthy, and serves a friendly placeholder for any HTTP routes that pointed at it.

Most of the plugin code is a thin wrapper. The kernel proxies anything matching /admin/api/ext/comments/* to the plugin, which receives a structured request and returns a structured response. Here is the shape of the handler stub:

func (p *commentsPlugin) HandleHTTPRequest(
    ctx context.Context, req *pb.HTTPRequest,
) (*pb.HTTPResponse, error) {
    switch {
    case req.Method == "GET" && req.Path == "/threads":
        return p.listThreads(ctx, req)
    case req.Method == "POST" && req.Path == "/threads":
        return p.createThread(ctx, req)
    default:
        return &pb.HTTPResponse{Status: 404}, nil
    }
}

Inside listThreads I call back into the host with p.host.DataQuery, which is the same CoreAPI the kernel uses, just speaking over gRPC instead of in-process. Every call is capability-guarded on the other end, which is exactly the property I want.

The admin micro-frontend, no React in the bundle

The admin UI for the comments extension lives in admin-ui/ and is built by Vite into a single ES module. The interesting trick is what is not in that bundle. React is not. shadcn primitives are not. The icon set is not. The Squilla API client is not. All of those come from the host SPA through an import map, and the extension grabs them at runtime via window.__SQUILLA_SHARED__.

The reason matters. If every extension shipped its own React, a site with ten extensions would load ten copies, the admin would be slow, and shared context like the toast system would not work across extensions because each one would have its own provider. Pointing everything at the shared globals means one React tree, one router, one set of primitives, and an extension that weighs maybe twenty kilobytes instead of two hundred. The extension still uses normal imports in source, the Vite config just rewrites them to external lookups during the build.

Migrations, owned tables, clean uninstalls

The migrations folder is a flat list of numbered SQL files. On activation, the kernel runs whatever has not been applied yet, in order, inside a transaction. The comments extension creates a comments table, a thread_idx, maybe a moderation_state column in the second migration. Those tables belong to the extension. Nothing in core knows they exist, and nothing else should write to them.

This is the part that makes uninstalls clean. When you deactivate the extension, the kernel knows exactly which tables came in with it, because they were declared by the migrations the extension shipped. There is no leftover comments_meta floating around in some shared schema. The Debian package analogy holds: install adds files, uninstall removes them, no shared state to untangle.

Tengo scripts, the glue layer

The scripts folder is where the extension wires itself into the kernel without compiling new Go. Each .tgo file is loaded into a sandboxed Tengo VM with the core/* modules available. Three things tend to live in here: event subscriptions, public route registrations, and filter hooks.

The wiring and the handler live in separate files. A small entry script binds an event name to a path, and the engine resolves that path to a .tgo handler file at dispatch time. So the comments extension ends up with one script that registers the subscription, and a second script that contains the actual handler body.

// scripts/register.tgo — wiring only
events := import("core/events")
routes := import("core/routes")

events.on("comment.created", "./hooks/on_comment_created")
routes.register("POST", "/api/comments", "./routes/create_comment")
// scripts/hooks/on_comment_created.tgo — the handler body
log := import("core/log")
email := import("core/email")

log.info("new comment on node " + ev.payload.node_id)
email.send({
    to: "author@example.com",
    template: "new-comment-notification",
    vars: { node_id: ev.payload.node_id, body: ev.payload.body },
})

The signature is events.on(action, script_path[, priority]), with the path given as a string relative to the script root and without the .tgo extension. routes.register and filters.add follow the same shape. Each handler file runs in its own VM invocation with the event payload bound as ev, which keeps the wiring file small and the handler files focused on one job each. Compiled plugin for heavy lifting, Tengo for the wiring. Two tools, clean split.

Why all this matters

The reason I keep coming back to the Debian package analogy is the hard rule that sits behind it. If disabling an extension would leave dead code in core, that code belongs in the extension instead. The whole point of this architecture is that the kernel stays generic forever. Media routing, email dispatch, comment moderation, sitemaps, none of those live in core. They are all extensions with their own deps, their own tables, their own admin UI, their own lifecycle. The kernel is the bus and the registry, nothing more. 🧩

Once you see the pattern in one extension you have seen it in all of them. Open up extensions/media-manager/ or extensions/email-manager/ in the Squilla repo and the shape is identical. Same manifest, same plugin process, same micro-frontend trick, same migrations folder, same Tengo glue. That repeatability is the real win. New feature equals new folder. No core changes, no shared schema, no merge conflicts with the next extension you write next month.

Want more like this?

Occasional, opinionated, no listicles.
all writing →