Events and Filters

Hook into the event bus, transform values via the filter chain, mount HTTP routes.

Events vs filters: pick the right tool

Both event subscribers and filter handlers are functions called by the kernel in response to something happening, but they're shaped for different jobs:

  • Events are fire-and-forget broadcast messages. The kernel publishes an event with a payload; every handler subscribed to that event runs. The publisher does not wait for handlers to complete and does not see their return values — the only way an event affects the call site is by handler side-effects (writing to the database, calling out to a third party, emitting another event). Use events for cache invalidation, analytics, sending email, recomputing derived state, anything where "in the background" is acceptable.
  • Filters are synchronous, ordered, and chained. Each handler in the chain receives a value, returns a value, and the next handler receives the previous handler's output. The original caller sees the final post-chain value. Use filters for transforming HTML, post-processing search results, mutating settings before they're written, computing site-wide string transformations like adding a brand suffix to every page title.

If you need synchronous request/reply semantics where a handler's return value matters to the caller, you want either a filter or one of the kernel's request-style events (email.send, forms:render) which use PublishRequest internally and propagate the first non-nil error back up.

Subscribing to events

scripts/theme.tengo
events := import("core/events")

// Default priority is 50 for events.on, 10 for events.subscribe.
// Lower runs first. Numeric, finite range; siblings non-deterministic.
events.on("node.created",       "./handlers/on_node_created")
events.on("page.served",        "./handlers/on_page_served", 90)
events.subscribe("setting.updated", "./handlers/on_setting")

// Wildcards are supported — a handler subscribed to "node.*" runs for
// every node-domain event (created, updated, deleted, published, etc).
// Use sparingly: a wildcard subscriber that does meaningful work is
// often slower than the rest of the request combined.
events.on("node.*", "./handlers/audit_log", 99)

Inside the handler

The handler script receives a global event object with action (the event name) and payload (a Tengo map matching the event's documented shape). Set response to return a value; for fire-and-forget events the response is ignored, but for request-style events (email.send, forms:render) the kernel collects responses from every subscriber and the first one with non-nil content wins.

scripts/handlers/on_node_created.tengo
log := import("core/log")
log.info("node.created", { id: event.payload.id, type: event.payload.node_type })

// For fire-and-forget events the response is ignored. For
// request-style events (PublishRequest), this is the value the
// publisher sees back:
response = { ok: true }

Standard kernel events

The catalogue below is the surface kernel code emits. Extensions and themes can emit additional events with any name; subscribe to them the same way. Run core.event.emit to fire an event manually for testing.

  • node.created / node.updated / node.deleted — fired after writes via go bus.Publish(...). Asynchronous; the writer doesn't wait. Payload contains the node's id, slug, node_type, language_code, status.
  • setting.updated — fired after core.settings.set with payload {key, language_code, value}.
  • theme.activated / theme.deactivated — synchronous (PublishSync) so subscribers can run before the activation transaction returns. Payload includes {name, slug, path, version, assets, image_sizes}.
  • extension.activated / extension.deactivated — synchronous, payload contains the manifest plus the slug.
  • email.send — request-style (PublishRequest) with error propagation. Provider extensions (smtp-provider, resend-provider) subscribe to deliver the email; the dispatcher in email-manager matches against admin rules to decide which message to send. The kernel does not own the email dispatcher — calls to core.email.send fail with "no provider" if no extension declares email.provider.
  • media.uploaded / media.deleted — fired by media-manager when files change.
  • page.served — fired after every public page render. Useful for hit counters, search indexing.
  • render.body_end — request-style; subscribers can return HTML that the renderer splices in just before </body>. The visual editor uses this to inject its overlay script.

The full set of emit points is grep-able in the source: every call to eventBus.Publish, PublishSync, PublishRequest or PublishCollect across internal/cms and the bundled extensions.

Filter chain

Filters are registered with filters.add(name, script_path, priority=10). Lower priorities run first, higher run later, siblings are non-deterministic. Each handler reads value (and optionally args, an array of additional arguments the caller passed) and sets response; response becomes value for the next handler in the chain.

The kernel does not ship any built-in filters — every filter is theme- or extension-defined. The filter chain is a hook: the kernel exposes the chain machinery and a template helper to invoke it; what you put through the chain is up to you.

scripts/theme.tengo
filters := import("core/filters")

// Global title filter that appends a brand suffix.
filters.add("node.title", "./filters/site_title_suffix", 90)

// Search-result filter that drops drafts.
filters.add("search.results", "./filters/drop_drafts", 50)
scripts/filters/site_title_suffix.tengo
// value is whatever the caller passed; args (if any) follows.
// This filter appends " — Squilla" to the title.
response = string(value) + " — Squilla"

Applying filters from templates

The filter template helper invokes a filter chain by name. The first argument is the chain name, the second is the value to pass through, and any further arguments are forwarded to handlers as the args array.

layouts/default.html
<title>{{ filter "node.title" .node.Title }}</title>

{{/* extra args reach the handler as the global `args` array: */}}
<p>{{ filter "format_date" .node.PublishedAt "long" }}</p>

HTTP routes

routes.register(method, path, script_path) mounts a handler. Path mounting follows a single rule from internal/scripting/handlers.go: paths containing a literal dot mount at the top level (so /sitemap.xml serves at https://your.site/sitemap.xml); other paths mount under /api/theme/ (so /search ends up at /api/theme/search). Extension scripts share the same rule but should prefer declared public_routes in the manifest, which mount at the path you declare without the /api/theme/ prefix.

Inside a route handler, request is the input map ({method, path, query, params, headers, body, ip}) and response is the output map ({status?, headers?, body?, html?, text?, content_type?}). Defaults: status 200, content-type application/json if you set body as a map, text/plain if a string. Use html for rendered HTML, text for plain text, body for everything else.

scripts/
// scripts/theme.tengo
routes := import("core/routes")
routes.register("GET", "/sitemap.xml", "./handlers/sitemap")
routes.register("GET", "/search",       "./handlers/search")  // mounts at /api/theme/search

// scripts/handlers/healthz.tengo (mounted via routes.register)
response = { status: 200, body: "ok", content_type: "text/plain" }
PERF NOTE
Filter handlers and event handlers are loaded once when the entry script registers them; the script body runs on every dispatch. Don't put expensive setup in handler bodies — keep them small and lift one-time work into the entry script's top level. The entry script's globals are not visible inside handlers (they each get a fresh VM scope), so any cross-call state has to live in core/settings or the data store.

Lifetime & ownership

Handlers registered by an entry script are tagged with that script's owner (theme slug or extension slug). Re-activating an extension unloads its handlers and re-registers them in one atomic step; the kernel's filter chain and event bus track ownership so a redeploy never leaves stale handlers behind. Two extensions can subscribe to the same event with no conflict; the priority decides ordering, with the slug recorded on each call so audit logs can trace who handled what.

Wellknown

core/wellknown.register(path, script_path) mounts a handler under /.well-known/<path>. The 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 response using the same shape as routes.register handlers. Use this for protocol metadata that has to live at /.well-known/ by spec; the wellknown router is mounted before all other public routes so it cannot be shadowed by a generic catch-all.