Tengo-only Extensions

Lightweight extensions written purely in Tengo, no Go binary required.

When to skip Go

If your extension only needs to react to events, register filters, expose simple HTTP routes, mount a /.well-known/ path, or seed/transform content, you can ship it as a pure Tengo package — no Go binary, no protobuf, no go-plugin handshake, no cross-compilation. The reference implementation is resend-provider, which handles transactional email through a third-party API in roughly twenty lines of Tengo.

Pick gRPC instead when you need the Go ecosystem (real libraries with C dependencies, structured concurrency primitives, long-lived in-memory state, image or audio processing, or anything that has to outlive a single request). Tengo is for scripting; gRPC is for programming.

Folder layout

filesystem
extensions/<slug>/
  extension.json         # manifest, declares capabilities[] for script use
  scripts/
    extension.tengo      # entry point, runs once on activation
    handlers/*.tengo     # event handlers, route handlers (one file per handler)
    filters/*.tengo      # filter handlers
    wellknown/*.tengo    # /.well-known/<path> handlers if you need them
  blocks/<slug>/         # content-block templates the extension owns
  assets/                # bundled images imported into media on activation

What the engine exposes

From inside Tengo, the kernel exposes one module per CoreAPI domain plus a handful of utility modules. Each module is a regular Tengo import, scoped to the extension's declared capabilities:

  • events.on(action, script_path[, priority=50]) and events.subscribe(action, script_path[, priority=10]) — register an event handler whose body lives at script_path. The two functions differ only in their default priority. Lower priority runs first. Higher numeric priority runs later. Events fire in priority order, with siblings non-deterministic.
  • events.emit(action, payload) — fire your own event into the bus. Useful for cross-extension coordination.
  • filters.add(name, script_path[, priority=10]) — register a filter handler. Filters run synchronously and chain values; lower priority runs first.
  • routes.register(method, path, script_path) — mount an HTTP handler on a public-route path. The path must be one declared in the manifest's public_routes.
  • wellknown.register(path, script_path) — mount a handler under /.well-known/<path>. path is the suffix (e.g. "security.txt", "nodeinfo/2.0", "acme-challenge/*"); a trailing * registers a prefix handler. The script receives request and writes to response using the same shape as routes.register handlers.
  • core/nodes, core/nodetypes, core/taxonomies, core/terms, core/menus, core/media, core/users, core/settings, core/theme_settings, core/data, core/files, core/email — each maps to its CoreAPI domain.
  • core/http — outbound HTTP via http.get(url[, opts]), http.post(url[, opts]), etc. The only sanctioned way to call out of the sandbox.
  • core/log — leveled logging that lands in the kernel's structured log.
  • core/assets — read-only access to files inside the extension's directory.
SANDBOX LIMITS
Tengo extensions cannot open arbitrary network connections (use core/http), read arbitrary files (use core/assets, scoped to the extension's directory), or spawn subprocesses. Each script invocation is bounded by SetMaxAllocs(50000) and a 10-second context.WithTimeout in internal/scripting/engine.go — hit either limit and the call returns an error. If you need any of those things, ship a gRPC plugin.

Entry point

scripts/extension.tengo runs once on activation. Top-level statements execute immediately — there is no exported main function and no init lifecycle. The pattern is: import the modules you need, register handlers, and exit. The engine remembers what you registered (keyed by extension slug) so deactivation can unregister them precisely.

scripts/extension.tengo
events  := import("core/events")
filters := import("core/filters")
routes  := import("core/routes")
log     := import("core/log")

log.info("resend-provider starting")

// Subscribe to the dispatcher's normalised email send event.
// Handler body lives in scripts/handlers/send_via_resend.tengo and
// reads `event` (the payload) plus the imported core/* modules.
events.on("email.send", "./handlers/send_via_resend", 100)

// Mutate the subject before the dispatcher renders the template.
filters.add("email.subject", "./filters/prefix_subject", 90)

// Public route, declared in extension.json's public_routes:
//   { "method": "GET", "path": "/healthz/resend" }
routes.register("GET", "/healthz/resend", "./handlers/healthz")

Handler files

Each script_path argument names a file the engine loads on demand. Inside a handler script you have access to a small set of injected globals depending on the handler kind:

  • Event handlerevent (the payload as a Tengo map) plus all core/* modules. Returning a map under response.html or response.body lets template-injection events (e.g. forms:render) splice content into the public render.
  • Filter handlervalue (the current value being filtered) and args (the call's extra arguments). Return the new value.
  • Route / wellknown handlerrequest (method, path, headers, query_params, body) and a response map you populate with status, headers, body.

Capabilities

Tengo handlers run with the capabilities the manifest declares. The capability guard sits at the CoreAPI boundary, not at the Tengo VM boundary — calling core/nodes.create from a script whose extension does not declare nodes:write returns an error string from the module. The script keeps running; it just won't get the side-effect.

Reload behaviour

Activation loads the entry script and every file it references through events.on / filters.add / routes.register. Deactivation unloads them and unregisters every handler the entry script registered. Ownership is tracked by extension slug, so two extensions can register handlers for the same event without colliding — they fire in priority order with the slug recorded for audit.