Meridian handbook
Extending

Extensions

How to write a Meridian extension, a Go plugin built against github.com/wrux/meridian-extend that adds template globals and filters, HTTP routes, commands, an asset resolver and a dev server; every interface it offers, how to build it so the engine will load it, how to name it in engine.yaml, and every error the loader gives.

An extension is a Go plugin (a .so file) that the engine opens at boot when the project’s engine.yaml names it. It adds template globals and filters, HTTP routes, commands, the asset helpers’ resolver and a dev server to the project, without your needing the engine’s source: you build it against one small module, github.com/wrux/meridian-extend, which uses the Go standard library only.

Use an extension when templates and configuration cannot express what you need. For logic behind a declared endpoint, a project plugin runs out of process and is easier to deploy; for wiring Vite, use the first-party vite extension.

A minimal extension

// shout.go
package main

import (
	"context"
	"strings"

	extend "github.com/wrux/meridian-extend"
)

type shout struct{}

func (shout) Manifest() extend.Manifest {
	return extend.Manifest{ID: "shout", Name: "Shout", Version: "1.0.0"}
}

// Register declares the filter, so engine-dmo check admits it in templates.
func (shout) Register(r extend.Registrar) error {
	return r.Filter("shout", "value|shout: the value in capitals")
}

// Init attaches the filter's function once the engine is running.
func (shout) Init(_ context.Context, h extend.Host) error {
	return h.Filter("shout", func(_ extend.Render, in, _ extend.Value) (any, error) {
		return strings.ToUpper(in.String()), nil
	})
}

// Extension is the symbol the engine looks up.
var Extension extend.Extension = shout{}

func main() {}

Build it for the engine it will load into (see Building for the exact rules), put the file in the project and name it:

go build -trimpath -buildmode=plugin -o extensions/shout.so .
# config/engine.yaml
extensions:
  - extensions/shout.so
<h2>{{ entry.title|shout }}</h2>

engine-dmo check --project . now admits |shout, and engine-dmo version --project . lists shout among the modules.

How the engine uses an extension

This is the whole path from your source to a template calling your filter: one build on your side, then four stages in every engine process that names the file.

flowchart TD
  src["your extension<br/>(package main)"] -->|"go build -buildmode=plugin"| so["greet.so"]
  so --> yaml["engine.yaml<br/>extensions: [extensions/greet.so]"]
  yaml --> cgo{"engine built<br/>with cgo?"}
  cgo -->|no| refuse["refused: pure Go build"]
  cgo -->|yes| info{"build info matches<br/>the engine's?"}
  info -->|no| mismatch["refused, naming<br/>the difference"]
  info -->|yes| reg["Load and Register<br/>(every command, check included)"]
  reg -->|"serve only"| init["Init: attach functions,<br/>mount routes"]
  init -->|"a declared name<br/>not attached"| stop["boot stops"]
  init --> call["Call: each template use<br/>of a global or filter"]

Load. When the engine reads engine.yaml (on serve, check, and every other command that loads the project), it opens each file under extensions:, after checking the file’s build information against its own (see Building). A file that does not match is refused by name, before it is opened.

Register. It calls Manifest() and Register(r). Register declares names only: globals, filters, a dev server, commands. It must do no I/O and read no configuration, because engine-dmo check and every command run it.

Init. When the engine boots to serve, it calls Init(ctx, h) on an extension that implements extend.Initer, after the engine’s services exist and before the server takes requests. Init attaches the function of every global and filter Register declared, mounts routes and sets the asset resolver. An error from Init stops the boot with the extension named. A declared global or filter that Init did not attach also stops the boot: the template global cn is declared but Init did not attach it (Host.Global).

Call. Each time a template calls a global or applies a filter, the engine calls your function with the page’s render context. A panic in any of your functions is recovered and becomes that render’s error, naming the global or filter; it does not stop the process. A goroutine your extension starts itself is not protected.

The interfaces

Your plugin’s main package exports one variable, Extension, of type extend.Extension. You implement Extension and, optionally, Initer. Every other interface below is implemented by the engine; you call it and never implement it. The engine may add methods to those interfaces in a minor release of the module.

Extension and Initer

type Extension interface {
	Manifest() Manifest
	Register(r Registrar) error
}

type Initer interface {
	Init(ctx context.Context, h Host) error
}

Manifest

FieldTypeMeaning
IDstringThe extension’s handle: lower case letters, digits and underscores, starting with a letter. It may not be core, engine or meridian, nor the id of a module the engine compiles (engine-dmo version lists them), nor another extension’s.
NamestringWhat the boot log and engine-dmo check show.
VersionstringYour extension’s own version.
Requires[]stringIds of modules the engine must compile (crm, portal) or of other extensions the project loads. A missing one refuses the load: module x requires module y, which neither bundle dmo nor the extensions have. Extensions are registered after the bundle’s modules and after the extensions they require.

Registrar

What Register receives.

MethodWhat it declares
Global(name, doc string, keywords ...string) errorA template global: a function every page of the public site may call by name. doc is its one-line documentation for the generated template API and must not be empty. keywords are the keyword arguments it takes: none, their names, or extend.AnyKeyword ("*") to admit any keyword. engine-dmo check admits a call to it, holds the call’s keywords to this list, and refuses a call to a name nothing declares. A name core or a module already has is refused.
Filter(name, doc string) errorA template filter, used as `value
DevServer(fn DevServerFunc) errorA dev server that engine-dmo serve --dev starts as a child before the engine boots and stops when it stops. Its origin is admitted by the development content security policy for scripts, styles, images, fonts and its socket. One per extension.
Command(c Command) errorA command on the engine’s command line, present from the first run because extensions load before the command line is built.

Host

What Init receives.

MethodMeaning
ProjectDir() stringThe project’s directory, under which an extension reads its own files.
Production() boolWhether engine.environment is production, where an extension may cache what it reads.
Log() *slog.LoggerThe engine’s logger, with your extension’s id on each line.
Global(name string, fn GlobalFunc) errorAttaches the function of a global declared in Register. An undeclared name is refused: declare it in Register with Registrar.Global.
Filter(name string, fn FilterFunc) errorAttaches the function of a declared filter.
Route(pattern string, h http.Handler)Mounts a handler on the engine’s top-level mux, ahead of the admin and the public site, for a Go net/http pattern such as "GET /ui/health". The handler secures itself: no session, permission check, CSRF protection or rate limit of the engine’s applies.
Dev() boolWhether the engine runs under serve --dev.
PublicDir() stringThe project’s public/ directory, which the engine serves and a frontend build writes under.
Assets(fn AssetsFunc) errorSets the resolver of the asset helpers’ bundles. The engine holds one at most; a second is refused.

Functions you write

// Called for every call a template makes.
type GlobalFunc func(r Render, args Args) (any, error)

// in is the value the filter is applied to; arg IsNil when the template wrote none.
type FilterFunc func(r Render, in, arg Value) (any, error)

// ok false leaves the bundle to the engine's own fallback.
type AssetsFunc func(r Render, req AssetRequest) (markup HTML, ok bool)

// Returns the zero DevServer, and no error, when there is nothing to serve.
type DevServerFunc func(ctx context.Context, d Dev) (DevServer, error)

A global or filter returns a string, a number, a bool, []any, []string, map[string]any, extend.HTML, an extend.Value, or nil. A string is escaped where the template prints it. extend.HTML is printed unescaped: return it only for markup you built from escaped parts. An error fails the render with the global or filter named (greeting(): ..., |shout: ...).

Render

The page render a function runs in.

MethodMeaning
Site() SiteThe page’s site.
Locale() stringThe page’s locale, a BCP 47 tag such as en-GB.
Request() *http.RequestThe request the page renders for. Read it; do not change it.
ProjectDir() stringThe project’s directory.
Once(key string) boolTrue the first time it is asked for key in this render, false after: for markup a page needs once, such as a client script.

Site

FieldMeaning
KeyThe site’s key.
TemplateChainThe site’s template roots in lookup order, its own first: directories under templates/.
PathPrefixThe path the site is served under on a shared host, "" at its root. A link to a public file starts with it.
Bundlessite.yaml’s assets.bundles: an entry file relative to the project, by bundle name. A name not listed builds from src/entries/<site>.js (main) or src/entries/<site>-<name>.js.
Manifestsite.yaml’s assets.manifest, a build manifest relative to the project for a site built by its own toolchain; "" for the project’s build.
Budgetssite.yaml’s assets.budgets: a size by kind (css, js, legacy_css, legacy_js), as written (120KB).

Args and Value

Args are a call’s arguments: positional ones, then keywords written name=value.

MethodMeaning
Len() intThe number of positional arguments.
At(i int) ValueThe positional argument at i; past the end it IsNil.
Positional() []ValueThe positional arguments in order.
Keywords() []stringThe keywords’ names in call order.
Keyword(name string) (Value, bool)A keyword’s value and whether it was passed.
Only(names ...string) errorRefuses a keyword not among names. The engine already does this for the keywords you declared, unless you declared AnyKeyword.

Value is a template value as your function receives it: IsNil, IsString, IsNumber, IsInteger, IsBool, IsList, IsMap, IsTrue (its truth as {% if %} reads it), String() (as a template prints it, "" when nil), Int(), Float(), Bool() (false unless it is a boolean), Len() (of a string, list or map), Index(i) (a list’s item, nil out of range) and Interface() (the Go value: strings, numbers, booleans, []any and map[string]any are what they say; any other type is the engine’s own and opaque to you).

Assets

The asset helpers (assets.css('main'), assets.js('main'), assets.js('legacy', false), preload()) ask your resolver for each bundle:

AssetRequest fieldMeaning
BundleThe bundle’s name: main, legacy.
Kindcss, js or preload.
ModuleFor js: a module script rather than a classic deferred one.

Return ok false to leave a bundle to the engine, which prints css/<name>.css or js/<name>.js under public/ for css and js, and nothing for preload. The frontend build describes the helpers.

Dev server

TypeFieldMeaning
DevProjectDirThe project’s directory.
EngineThe running engine binary’s path, for a toolchain that calls the engine back (engine-dmo assets manifest).
LogThe engine’s log, with your extension’s id on each line.
DevServerOriginIts scheme, host and port: http://127.0.0.1:5173.
StopStops it; nil when there is nothing to stop.
PolicyFurther sources the development content security policy admits, by directive, beyond the origin: "style-src": {"'unsafe-inline'"} for a server that injects <style> elements. Development only: the production policy never sees them.

Commands

Command fieldMeaning
PathWhere the command goes: "ui" for a top-level command, or a parent’s path and a name ("assets budget") beside the engine’s own.
ShortIts one-line description for the help.
FlagsDeclares its flags on a standard library *flag.FlagSet, which the engine parses with its own global flags.
RunRuns it. An error exits with status 1, printed. A panic is recovered into an error.

Run receives a CommandContext:

MethodMeaning
Args() []stringThe arguments after the command and its flags.
Flags() *flag.FlagSetThe flag set Flags declared, parsed.
ProjectDir(), ConfigDir()The global --project and --config.
JSON(), Quiet()The global --json and --quiet.
Stdout(), Stderr()Where to write.
Sites() ([]Site, error)The project’s sites, from its configuration, loaded and checked as for any other command.
TemplateDir() stringThe project’s templates directory (template.dir, templates by default).
PublicDir() stringThe project’s public/ directory.

A complete extension

This extension declares a global that takes a keyword and reads the page’s locale, a filter with an optional argument, an asset resolver for one bundle, a dev server and a command.

// greet.go
package main

import (
	"context"
	"flag"
	"fmt"
	"html"
	"net"
	"net/http"
	"strings"

	extend "github.com/wrux/meridian-extend"
)

type greet struct{}

var Extension extend.Extension = greet{}

func (greet) Manifest() extend.Manifest {
	return extend.Manifest{ID: "greet", Name: "Greetings", Version: "0.1.0"}
}

func (greet) Register(r extend.Registrar) error {
	if err := r.Global("greeting", "greeting(name, punct='.'): a greeting for name in the page's language, ending in punct", "punct"); err != nil {
		return err
	}
	if err := r.Filter("shout", "value|shout or value|shout:'!': the value in capitals, with the argument after it"); err != nil {
		return err
	}
	if err := r.DevServer(devServer); err != nil {
		return err
	}
	return r.Command(extend.Command{
		Path:  "greet hello",
		Short: "Print a greeting, to show an extension's command",
		Flags: func(fs *flag.FlagSet) { fs.String("name", "world", "who to greet") },
		Run: func(_ context.Context, c extend.CommandContext) error {
			_, err := fmt.Fprintf(c.Stdout(), "Hello, %s, from %s\n", c.Flags().Lookup("name").Value.String(), c.ProjectDir())
			return err
		},
	})
}

func (greet) Init(_ context.Context, h extend.Host) error {
	if err := h.Global("greeting", greeting); err != nil {
		return err
	}
	if err := h.Assets(assets); err != nil {
		return err
	}
	return h.Filter("shout", func(_ extend.Render, in, arg extend.Value) (any, error) {
		return strings.ToUpper(in.String()) + arg.String(), nil
	})
}

var hello = map[string]string{"en": "Hello", "ja": "こんにちは", "th": "สวัสดี"}

func greeting(r extend.Render, a extend.Args) (any, error) {
	word, ok := hello[strings.SplitN(r.Locale(), "-", 2)[0]]
	if !ok {
		word = hello["en"]
	}
	punct := "."
	if p, ok := a.Keyword("punct"); ok {
		punct = p.String()
	}
	return word + ", " + a.At(0).String() + punct, nil
}

// assets resolves the greet bundle and leaves every other one to the engine.
func assets(r extend.Render, req extend.AssetRequest) (extend.HTML, bool) {
	if req.Bundle != "greet" || req.Kind != "js" {
		return "", false
	}
	var b strings.Builder
	if r.Once("greet-client") {
		b.WriteString(`<script type="module" src="` + html.EscapeString(r.Site().PathPrefix+"/greet/client.js") + `"></script>`)
	}
	b.WriteString(`<script type="module" src="` + html.EscapeString(r.Site().PathPrefix+"/greet/"+r.Site().Key+".js") + `"></script>`)
	return extend.HTML(b.String()), true
}

// devServer serves a greeting on a free loopback port under serve --dev.
func devServer(_ context.Context, d extend.Dev) (extend.DevServer, error) {
	l, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		return extend.DevServer{}, err
	}
	srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		fmt.Fprintln(w, "hello from the greet dev server")
	})}
	go func() { _ = srv.Serve(l) }()
	d.Log.Info("greet dev server", "addr", l.Addr().String())
	return extend.DevServer{Origin: "http://" + l.Addr().String(), Stop: func() { _ = srv.Close() }}, nil
}

func main() {}

In a template:

<p>{{ greeting("visitor", punct="!") }}</p>
<h2>{{ entry.title|shout:"!" }}</h2>
{{ assets.js('greet') }}

On an en-GB page the first line prints Hello, visitor!; on a ja-JP page, こんにちは, visitor!. From the shell:

engine-dmo greet hello --name Sian --project .

Mounting a route

A route is mounted in Init and secures itself:

func (greet) Init(_ context.Context, h extend.Host) error {
	h.Route("GET /greet/health", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{"ok":true}`))
	}))
	// attach globals and filters as above
	return nil
}

Building

A Go plugin shares the Go runtime and every common package with the program that opens it, so it loads only into the exact build it was made for. The engine reads your plugin’s build information before it opens it and refuses any difference, naming it and saying what to change. What your plugin must match:

Must matchWhere to read it
The Go toolchain, exactly (go1.27.3)engine-dmo version, the extensions: line
Operating system and architecture (linux/amd64, darwin/arm64), and the architecture level when the engine records one (GOARM64=v8.0)The same line
Built with cgo (CGO_ENABLED=1)The same line says cgo
Built with -trimpathThe same line says -trimpath
github.com/wrux/meridian-extend at exactly the engine’s versionThe same line, meridian-extend <version>
Every module your plugin shares with the engine, at the engine’s versionengine-dmo version --json, extensions.modules
Built outside a Go workspace (GOWORK=off)
engine-dmo version
meridian v0.8.0 (vips) go1.27.3
  bundle dmo: modules ai directory forms crm ...
  extensions: built with go1.27.3 linux/amd64, cgo, -trimpath, meridian-extend v0.0.0-00010101000000-000000000000

The values above are an example; read your own engine’s.

engine-dmo version --json gives the same under extensions: supported, go_version, os, arch, level, cgo, trimpath, meridian_extend and modules (every module the engine links, by path and version).

The module file

// go.mod
module example.com/greet

go 1.26.0

toolchain go1.27.3

require github.com/wrux/meridian-extend v0.0.0-00010101000000-000000000000

replace github.com/wrux/meridian-extend => ../meridian-extend

Require meridian-extend at exactly the version engine-dmo version prints. While the module is not published to a Go module proxy, that version is the zero pseudo-version shown above and your go.mod points a replace directive at your copy of the module’s source.

Getting the module's source

A release does not carry meridian-extend: it is not on a Go module proxy, and neither the binary nor the container images contain its source. Today the only copy is in the Meridian source repository (libs/extend), so building an extension needs that source from Cherwell Studio, at the commit your engine was built from. With the binary alone, an extension cannot be built. Configuration, templates and declared endpoints need nothing beyond the binary.

Keep your extension’s own dependencies to the standard library where you can: any module the engine also links must be required at the engine’s version, or the plugin will not open.

The build command

For the container image (linux/amd64), build with the official Go image of the engine’s toolchain:

docker run --rm --platform linux/amd64 -v "$PWD":/src -w /src \
  -e GOWORK=off -e CGO_ENABLED=1 golang:1.27.3-trixie \
  go build -trimpath -buildmode=plugin -o greet.so .

For an engine binary on your own machine, with the same Go version installed:

GOWORK=off CGO_ENABLED=1 go build -trimpath -buildmode=plugin -o greet.so .

If your replace points outside the directory you mount into the container, mount that directory too.

Which engines load extensions

Extensions load only in an engine built with cgo, on Linux, macOS or FreeBSD. The -vips container image is one; an engine built without cgo refuses every extension (this engine is a pure Go build, which cannot load extensions: run the -vips image, a cgo build). Windows cannot load Go plugins. See Container images.

Rebuild for every engine release

There is no compatibility window between releases: a plugin built for one engine build opens in that build only. A new engine release may change the Go toolchain, the meridian-extend version or a shared module. Rebuild every extension against each release before you deploy it, and run engine-dmo check with the new binary: it opens every extension and reports a mismatch at its line in engine.yaml.

Naming extensions in engine.yaml

# config/engine.yaml
extensions:
  - vite                     # a first-party extension, by name
  - extensions/greet.so      # your own, by path relative to the project
EntryResolves to
A bare name (no /, no .so, not starting with .)A first-party extension shipped beside the engine binary: extensions/<name>.so next to the executable (/app/extensions/<name>.so in the image).
A pathThe file at that path, relative to the project directory unless absolute.

Each file loads once per process, in the order listed, then extensions are registered in the order their Requires allow. Every problem across the list is reported together, each at its line.

Errors

The loader’s messages, each reported by engine-dmo check at the entry’s line in engine.yaml with error code MRD-1006, and by serve before it boots:

MessageCause and fix
no extension at <path>The file is not there. Paths are relative to the project directory.
this engine has no extension named <name> (looked for ...)A bare name that is not a first-party extension of this engine. Write a path for your own.
this engine is a pure Go build, which cannot load extensionsRun the -vips image or another cgo build.
extensions load on Linux, macOS and FreeBSD, not <os>Go plugins do not exist on that system.
this engine was built in a Go workspace, which no extension can matchAn engine you built yourself in a workspace. Use a release binary or image.
<path> is not a Go plugin with build informationNot a Go plugin, or built without module information.
<path> does not match this engine (...) followed by each differenceOne or more of: it is not a Go plugin (built with -buildmode=...); it was built with go1.x and the engine with go1.y: build it with go1.y (the golang:1.y-trixie image); it was built for os/arch and the engine runs on os/arch; a different architecture level; it was built against meridian-extend x and the engine links y: require github.com/wrux/meridian-extend y; build it with -trimpath, as the engine was; it requires <module> x and the engine links y.
<path> shares the package <pkg> with the engine at another versionA shared package differs although the versions above match: build with -trimpath, against the engine’s meridian-extend, outside a workspace, with the engine’s version of every module it also links.
<path> exports no Extension: declare var Extension extend.Extension = ...The symbol is missing.
<path> exports Extension as <type>, not an extend.ExtensionDeclare it with the interface type: var Extension extend.Extension = greet{}.
module id "X" is not lower case letters, digits and underscores starting with a letter, module id "core" is reserved, module x is in the bundle alreadyChange Manifest().ID.
the template global x has no documentationPass a non-empty doc to Global or Filter.
the template global x is not declared: declare it in Register with Registrar.GlobalInit attached a name Register did not declare.
the command "x" has no RunGive the command a Run function.

Security

An extension runs inside the engine process with the engine’s privileges, its memory and its environment, secrets included. It is trusted code, like the engine itself: review it as you would the engine, build it yourself from source you control, and never load a .so you did not build. A route it mounts has no authentication unless the handler adds it.