Themes used to call out to shell scripts. Yikes. Or they were PHP. Also yikes. I wanted a third option for Squilla, something that runs in-process, cannot escape its sandbox, and starts in roughly five milliseconds. That is Tengo, and after a year of living with it I am quietly in love.

What Tengo actually is

Tengo is a small scripting language written in Go. Lua-ish syntax, compiled to bytecode, sandboxed by default, and embeddable as a library. No FFI. No filesystem unless I hand it one. No network unless I hand it one. It is the opposite of "throw a whole runtime at the problem."

In Squilla I use it for the layer that sits between the kernel and your theme. Event subscribers, HTTP route handlers, filters, and small per-site hooks all live as .tgo files. Everything reaches the CMS through the core/* module namespace: core/nodes, core/settings, core/events, core/http, core/log, and friends. That is the entire API surface a script ever sees.

How registration actually works

One thing worth saying up front, because every tutorial I tried to write got it wrong the first time: events.on, routes.register, and filters.add do not take inline anonymous functions. They take a script path — a relative path (no extension) pointing at another .tgo file that holds the actual handler body. The entry script wires things up; the handler scripts do the work. Two files, always.

A subscriber, in full

Here is a webhook ping that fires every time a node gets published. First the entry point, scripts/theme.tengo, which just registers the subscription:

events := import("core/events")

events.on("node.published", "./handlers/on_node_published")

And the handler itself, scripts/handlers/on_node_published.tgo, where the work happens:

http := import("core/http")
log  := import("core/log")

ev := payload

res := http.post("https://hooks.example.com/squilla", {
    headers: {"Content-Type": "application/json"},
    body: {
        id:    ev.node.id,
        slug:  ev.node.slug,
        title: ev.node.title,
    },
})
if res.status_code >= 400 {
    log.warn("webhook failed", {status: res.status_code})
}

That is the whole integration. No build step, no plugin binary, no restart. Drop both files in the theme's scripts folder, the engine picks them up, and the next publish fires it.

A one-line route handler

Need a JSON sitemap fragment for a crawler that does not understand XML? Register a route in the entry script:

routes := import("core/routes")

routes.register("GET", "/sitemap.json", "./routes/sitemap_json")

Then the handler at scripts/routes/sitemap_json.tgo:

nodes := import("core/nodes")

response = {json: nodes.query({status: "published", select: ["slug", "updated_at"]})}

That is genuinely it. The handler script sets response, the engine serializes it, and the route is live the moment the entry script loads.

A filter that touches the head tag

Filters are how Tengo participates in the rendering chain. Same two-file pattern. Entry script:

filters := import("core/filters")

filters.add("head_html", "./filters/built_with_meta", 90)

Handler at scripts/filters/built_with_meta.tgo:

response = payload + `<meta name="x-built-with" content="squilla">`

Filters are just scripts that read payload and write response. No reflection, no decorator magic, no class registry. The kernel calls them in priority order and moves on.

The gotchas (because there always are some)

Tengo is not Python. I have to remind myself of this often.

  • There are no exceptions. Functions return errors as values, and you check them. If you forget to check, the script keeps running with whatever weird value you got back. This is fine once you internalize it, and miserable for the first afternoon.
  • error is a reserved token in the Tengo parser and sometimes trips it up as a method selector in certain contexts. To keep scripts working everywhere, the engine exposes log.err as a safe alias for log.error — both exist, both go to the same logger, and I default to log.err() in scripts so I never have to think about which form the parser is happy with today.
  • No imports beyond the modules the host registers. You cannot pull in arbitrary packages from the internet. This is a feature, not a bug, but worth knowing if you are coming from Node.
  • Numbers are int or float, not BigInt. If you are doing anything with large counters you handle the overflow yourself.

What it is not for

Tengo is not where I put heavy compute. If a feature needs goroutines, gRPC clients, real concurrency, or a sustained CPU loop, that is a job for a Squilla extension, which is a proper Go binary loaded through HashiCorp's go-plugin. Tengo is for glue, for hooks, for the small bits of policy a site owner wants to express without rebuilding the world.

Roughly: if you would have written it in PHP in a WordPress functions.php, write it in Tengo. If you would have written it as a microservice, write it as an extension.

Why I keep coming back to it

Tengo is the right size for a hooks language. A scripting layer should be the kind of thing you can read in one sitting, learn in an afternoon, and trust to stay out of your way. Big languages have big footguns. They tempt you into building entire applications inside what was supposed to be a thin policy layer, and then you have two codebases pretending to be one.

Tengo never lets me do that. It is small enough that the moment I feel the urge to write something complicated, I notice the friction and remember I should be writing Go instead. That guardrail, more than any feature on the spec sheet, is the thing I value most.

Small languages stay friendly. That is the whole pitch.