The first time I tried to give a Squilla theme some dynamic behavior, I caught myself sketching out a shell script. A theme calling out to bash on every page render. I closed the editor and went for a walk.
The other option, the one most CMS people reach for, is embedding a big language. Drop PHP into the templates. Or Lua. Or a JS runtime. Each of those comes with a whole ecosystem of footguns I did not want to inherit, especially from anonymous theme authors uploading code into a tenant.
So I went looking for a third option. Small. Compiled. In-process. Sandboxed by default. Fast to start. And I found Tengo.
What Tengo actually is
Tengo is a tiny scripting language written in Go. The syntax sits somewhere between Lua and Go itself, so if you have written either you can read it on day one. Scripts compile to bytecode and run on a small VM that you embed directly into your Go binary. There is no os.Exec, no network, no filesystem unless the host hands it to you. The sandbox is the default, not an afterthought.
For Squilla that turned out to be exactly the right size. Big languages have big footguns. A theme should not need a package manager.
How it shows up in Squilla
Every Tengo script in a theme or extension talks to the CMS through the core/* modules. core/nodes for content, core/settings for config, core/events for the event bus, plus core/routes, core/filters, core/media, and friends. The same surface area a gRPC extension would get, just from a script that loads in milliseconds.
Here is a tiny event subscriber. It listens for node.published and logs a friendly note.
events := import("core/events")
log := import("core/log")
events.on("node.published", func(payload) {
log.info("published node: " + payload.title)
})
That is the whole file. No build step, no manifest dance, just drop it into the theme's scripts folder and it gets loaded on boot.
Routes are just as short. This one answers /hello with a JSON payload.
routes := import("core/routes")
routes.register("GET", "/hello", func(req) {
return { status: 200, json: { hello: "world" } }
})
Filters are how you nudge core behavior without forking it. Here is one that adds an extra meta tag to every public page.
filters := import("core/filters")
filters.add("render.head_tags", func(tags, ctx) {
tags = append(tags, ``)
return tags
})
Three different extension points, three tiny files. Each one starts in about 5ms because the VM is already warm and the bytecode is cached.
The one gotcha you will hit
Tengo reserves error as a selector on objects. That sounds harmless until you write the most natural log call in the world.
log := import("core/log")
// Looks fine. Will not compile.
log.error("something broke")
The compiler sees .error and refuses. I lost a real hour to this the first time. The fix is to name your method anything else. In Squilla the logger exposes log.warn and log.err, both of which compile and both of which read fine.
log.err("something broke")
log.warn("something is suspicious")
Once you know about it, it stops being a problem. But it is the kind of papercut that deserves a sign on the door.
What Tengo is not for
This is the part I want to be honest about. Tengo is wonderful for hooks. It is not the place to do heavy lifting. If you need to resize a thousand images, talk to S3, run a regex over a gigabyte of logs, or hold a long-lived TCP connection, you do not want a sandboxed bytecode VM in the hot path. You want a real binary.
That is what gRPC extensions are for in Squilla. They run as separate processes, get the full Go toolchain, and can do whatever a normal Go program can do. Tengo glues things together. gRPC extensions do the work.
The split feels right in practice. Most of what a theme or a small site needs is glue. A nudge here, a tag there, a side effect when something publishes. Tengo handles all of that without ever asking the operator to think about supply chains or sandbox escapes.
Why the small size is the feature
I think the reason I keep coming back to Tengo is that it knows what it is. The language fits in a coffee break. The standard library is small on purpose. The runtime cannot reach out and surprise you. When a theme author writes a hook, the worst they can do is write a slow hook, and even that gets caught by the per-script timeout.
Big languages give you everything. Tengo gives you just enough. For a hooks language, just enough turns out to be exactly right.
If you are building anything that needs to run untrusted-ish code in-process, take a look. It is one of those tools that disappears into the background, which is the highest compliment I can pay a runtime.