Scripting with Tengo

The embedded Tengo scripting engine — when, why, and how.

Why Tengo

Tengo is a small, sandboxed scripting language with a syntax that looks like a cleaned-up JavaScript. Squilla embeds a Tengo VM (github.com/d5/tengo) so themes and Tengo-only extensions can ship behaviour without a Go compile step. Memory and execution time are bounded by the kernel before every invocation, the sandbox blocks filesystem and network access except through whitelisted modules, and capability checks fire on every CoreAPI call.

The two limits worth remembering: 50,000 allocations and 10 seconds wall-clock per invocation (SetMaxAllocs(50000) and context.WithTimeout(10*time.Second) in internal/scripting/engine.go). Hit either and the call returns an error to whoever invoked the handler. The kernel keeps running; only the misbehaving script is interrupted.

Where Tengo runs

  • Theme entry scriptscripts/theme.tengo, executed once on theme activation. Top-level statements run synchronously and block activation until they complete; there is no exported main function. Used for seeding nodes, registering node types and taxonomies, declaring settings registries, building the menu structure, and wiring filters/event handlers.
  • Extension entry scriptscripts/extension.tengo, executed once on extension activation. Same pattern as the theme entry script. Used by Tengo-only extensions for everything they want to do; gRPC extensions can ship Tengo too if they want lightweight scripted behaviour alongside the Go plugin.
  • Handler scripts — referenced by event/filter/route registrations. Loaded once when the entry script registers them, but the script body runs on every dispatch. Receive their input via implicit globals (event for event handlers, value for filters, request for routes), set response to return a value.
  • Block-renderer hooksfilter/event template helpers run during a render. Called from inside templates with the current page context available; lighter sandbox limits the same way as standalone handlers.

Imports

Tengo supports two import styles. core/* imports load built-in modules from the kernel's adapter; relative imports (./setup/nodetypes) load source modules from the theme's or extension's scripts/ directory. Imports are evaluated at parse time, so circular references are caught immediately.

scripts/theme.tengo
// Built-in modules (always available; capability checks at call time)
log      := import("core/log")
nodes    := import("core/nodes")
events   := import("core/events")
filters  := import("core/filters")

// Local modules (relative to scripts/)
setup_nt := import("./setup/nodetypes")
seeds    := import("./seeds/pages")

log.info("squilla theme starting")
setup_nt.run()
seeds.run()

filters.add("node.title", "./filters/site_title_suffix", 90)
events.on("node.published", "./handlers/notify_slack")

Idempotency

The theme entry script runs on every activation, including after every server restart that re-activates the active theme on boot. Seeds must be idempotent: query by slug before creating, never duplicate-insert. The pattern across the in-tree themes (squilla, hello-vietnam, curriculum-vitae) is a seeds/*.tengo file with helper functions that check existence first.

scripts/seeds/pages.tengo
nodes := import("core/nodes")
log   := import("core/log")

ensure_page := func(slug, title, layout, blocks) {
    res := nodes.query({ node_type: "page", slug: slug, limit: 1 })
    if !is_error(res) && res.total > 0 {
        log.debug("page exists; skipping", { slug: slug })
        return
    }
    nodes.create({
        title:        title,
        slug:         slug,
        node_type:    "page",
        status:       "published",
        layout_slug:  layout,
        blocks_data:  blocks
    })
    log.info("seeded page", { slug: slug })
}

export { run: func() {
    ensure_page("home",  "Home",  "default", [])
    ensure_page("about", "About", "default", [])
} }
log.err, NOT log.error
Use log.err, not log.errorerror is reserved in Tengo and the parser refuses to match it as a method name. The other levels (info, warn, debug) work as expected. Caller info (theme/extension slug, script path) is auto-prefixed onto every log line, so you don't need to add it yourself.

Capabilities

Theme scripts run with a fixed default capability set defined in internal/scripting/capabilities.go. As of the current build it grants nodes:read/write/delete, nodetypes:read/write, menus:read/write/delete, settings:read/write, users:read, events:emit/subscribe, filters:register/apply, routes:register, log:write, http:fetch, files:write/delete, and email:send. Themes do not get raw data-store access (data:*) or media management (media:*); the latter is the responsibility of the media-manager extension, and the former is reserved for kernel-side migrations.

Extension scripts inherit whatever the manifest's capabilities array declares — nothing more, nothing less. Capability checks happen at the CoreAPI boundary, not at the Tengo VM boundary: a Tengo call to core/data.exec from a theme script returns an error rather than executing the SQL, the script keeps running, and you can branch on is_error to handle the failure gracefully.

What you can't do

  • Open arbitrary network connections — use core/http, which routes through the capability-checked FetchRequest path with a configurable timeout.
  • Read arbitrary files — use core/assets, scoped to the theme/extension root with traversal escapes rejected at resolution time.
  • Spawn subprocesses or shell out — the sandbox does not expose os.exec or any equivalent.
  • Write files outside the storage path conventions of core/files — the kernel rejects absolute paths and rooted relative paths.
  • Re-enter the script engine — a handler invoking another handler that invokes another handler ad infinitum is bounded by Tengo's call-stack limit; effectively, recursion depth is in the dozens, not the millions.