← writing

CoreAPI tour: 35 methods, three callers

The CoreAPI is the spine of Squilla. One Go interface, three adapters, and an MCP surface for AI agents. Here is the guided tour of what each domain does and why the boundaries land where they do.

If you remember one thing about Squilla, make it this. The CoreAPI is the spine. Everything else, the themes, the extensions, the admin UI, the MCP tools you call from Claude, is just a different mouth attached to the same backbone. Once that clicks, the whole architecture stops feeling like a pile of moving parts and starts feeling like a single small contract with a lot of polite implementations around it.

So let me walk you through that spine. One Go interface, three adapters, around fifty methods spread across sixteen domains, and a capability check sitting in front of every single call. That is the whole story.

One interface, three callers

The CoreAPI lives in internal/coreapi/. It is a plain Go interface. Everything the kernel can do for a caller is defined there, in one file you can read in a sitting. The trick is that the same interface is exposed three ways, because three very different kinds of code need to call it.

The first caller is the kernel itself. When core code wants to fetch a node or send an event, it just calls the interface directly. No serialization, no proxy, no permission dance. It is a Go method call. Capability checks are bypassed for internal callers, because the kernel is the source of authority. If you cannot trust the kernel, you have bigger problems than your CMS.

The second caller is a compiled extension. Extensions are HashiCorp go-plugin processes, separate binaries that talk to the host over gRPC. The host exposes a SquillaHost gRPC service, the plugin holds a client to it, and every CoreAPI call from a plugin is an RPC. The bidirectional broker means the host can call the plugin too, which is how HTTP request handling works without the kernel ever knowing what the plugin actually does inside.

The third caller is a Tengo script. Tengo is the embedded sandboxed scripting VM. Themes and lightweight extensions write .tgo files that import modules like core/nodes, core/settings, core/events, and so on. Under the hood each module is a thin shim that translates Tengo values into Go calls against the same CoreAPI interface.

And then there is the fourth caller, which is not really an adapter so much as a presentation. The MCP server in front of Squilla exposes the same CoreAPI as tools to AI agents. When Claude calls core.node.create, it is the same code path a Tengo script would hit, with the same capability checks, the same validation, the same audit trail. The agent just happens to be writing the call in natural language instead of code.

The sixteen domains, briefly

Here is the whole surface area, grouped the way it lives in the codebase.

Nodes are content. CRUD plus query. A node has a type, a slug, a language, a status, blocks data, fields data, SEO settings. Pages, posts, case studies, anything that lives in a URL is a node.

Node types are the schemas for nodes. Register a new type, get it back, list them all, update or delete. Extensions register their own types on activation. A type defines fields, taxonomies, URL prefixes per language, and whether it supports blocks.

Taxonomies are how nodes get classified. Extensions register taxonomies the same way they register node types, the built-in ones being categories and tags, but you can declare whatever you need, say destination on a trip node type or difficulty on a recipe. Terms are scoped to the triple (node_type, taxonomy, slug), so a vietnam term on trips is a different row from a vietnam term on posts, which keeps counts and translations clean. The node query API accepts a tax_query map so you can filter nodes by one or more terms in a single call, which is how archive pages and related-content blocks stay fast.

Settings are schema-driven and per-language. The kernel ships a few groups (general, SEO, advanced, languages, security). Each field declares whether it is translatable, and the store reads and writes per-locale rows with a default-language fallback. Extensions register their own setting groups the same way.

Events are the nervous system. Subscribe to an event, publish to it, or use the request and reply variant when you need an answer back. There is Subscribe, SubscribeResult, SubscribeErr on the listening side, and Publish, PublishSync, PublishCollect, PublishRequest on the calling side. More on this in a moment.

Email is a single method, send. It does not deliver anything itself. The kernel emits an event, and whichever extension declares provides:["email.provider"] picks it up and delivers. The email-manager extension owns the templates, the rules engine, and the logs. The SMTP and Resend extensions are just provider implementations of the same slot.

Menus are CRUD. Site navigation, footer links, anything menu-shaped lives here.

Routes let Tengo scripts register HTTP endpoints. Useful for ad hoc admin tooling or theme-specific endpoints.

Filters are the WordPress-style hook chain. Register a filter, apply it elsewhere in the codebase, and any number of extensions can transform the value as it passes through.

Media is where the kernel deliberately steps back. There is no bytes-on-disk fallback inside core. Upload, get, query, and delete all route through whichever extension declares provides:["media-provider"]. The bundled media-manager fills the slot, but you can hot-swap it for an S3 provider or an R2 provider by activating one with a higher priority and deactivating the default. No code changes, no migration ceremony.

Users is read-only at the CoreAPI level. Get a user, query the user list. Writes happen through the auth subsystem with proper hashing and session invalidation.

HTTP is outbound fetch. A controlled way for an extension to talk to the outside world, with the capability check enforcing whether it is allowed to.

Log is leveled logging with caller prefix, so every line in production tells you which extension or script wrote it.

Data is the low-level escape hatch. Extensions that own their own tables use DataGet, DataQuery, DataCreate, DataUpdate, DataDelete, and the raw DataExec. The capability gate is tight here because this is direct table access.

File storage is StoreFile and DeleteFile for blobs that are not media. Backups, exports, generated PDFs, that kind of thing.

Two domains worth a closer look

Events. The interesting one is PublishRequest. Most pub/sub systems are fire and forget, you publish and hope. PublishRequest is a synchronous request and reply. The caller publishes a payload, listeners respond, and the caller gets a typed answer or a typed error back. This is how the kernel asks an extension a question. Password reset is the cleanest example. The auth subsystem does not know which email provider is installed. It does not need to. It publishes a request, the email-manager picks up the request, renders the template, hands it off to the provider, and replies with success or a structured error. If no provider is active, the reset flow gracefully degrades and the admin sees a clear message. No hard coupling, no if provider == "smtp" branching in the kernel.

Media. The hard rule in Squilla is that the kernel keeps no file bytes itself. Upload a hero image to a node, and the bytes never touch core. They go straight to whatever media-provider extension is active. This is the rule that makes the swap painless. Want to move from local disk to S3? Activate an S3 extension with a higher priority. The next upload lands in S3, the existing references keep working because the media object carries its own URL. No data migration, no rewriting nodes. The kernel never had a stake in where the bytes lived.

The capability gate

Every CoreAPI call routes through a capability guard. Extensions declare what they need in extension.json, things like nodes:read, data:write, files:write, email:send. The guard checks on every call. Internal kernel callers bypass it. Tengo and gRPC callers do not. That single layer is what makes it safe to install an extension you did not write.

The contract

The CoreAPI is the contract. Everything else is implementation detail you can swap. Switch the email provider, switch the media backend, replace the admin UI, point a different MCP server at the same interface. As long as the spine is intact, the body can change shape. That is the whole reason it is shaped this way. ✨

Want more like this?

Occasional, opinionated, no listicles.
all writing →