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
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])andevents.subscribe(action, script_path[, priority=10])— register an event handler whose body lives atscript_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'spublic_routes.wellknown.register(path, script_path)— mount a handler under/.well-known/<path>.pathis the suffix (e.g."security.txt","nodeinfo/2.0","acme-challenge/*"); a trailing*registers a prefix handler. The script receivesrequestand writes toresponseusing the same shape asroutes.registerhandlers.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 viahttp.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.
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.
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 handler —
event(the payload as a Tengo map) plus allcore/*modules. Returning a map underresponse.htmlorresponse.bodylets template-injection events (e.g.forms:render) splice content into the public render. - Filter handler —
value(the current value being filtered) andargs(the call's extra arguments). Return the new value. - Route / wellknown handler —
request(method, path, headers, query_params, body) and aresponsemap you populate withstatus,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.