Meridian handbook
For agentsRecipes

Extension

When an agent should write a Meridian extension (a Go plugin adding template filters, globals, routes or commands), the minimal filter extension, how it must be built to load, how to name it in engine.yaml and how to verify it.

An extension is a Go plugin (.so) the engine opens at start when engine.yaml names it. It can add template filters and globals, HTTP routes, commands, an asset resolver and a development server. Reach for one only when configuration and templates cannot express the need: most sites never have one.

Before you write one

  • Check it is needed. A filter for formatting, a component for markup, a declared endpoint for a form, and a webhook for telling another system all exist already. Search the handbook first: https://docs.cherwell.studio/llms-full.txt.
  • Check you can build it. A plugin loads only into the exact build it was made for. You need the Go toolchain version the engine names, the same OS and architecture, cgo, and the github.com/wrux/meridian-extend module source at the engine’s version. Read the requirements from the binary:
engine-dmo version
  extensions: built with go1.27.3 darwin/arm64, GOARM64=v8.0, cgo, -trimpath, meridian-extend v0.0.0-00010101000000-000000000000

If the line says the engine is a pure Go build, or you do not have the toolchain or the module source, stop: tell the person what the extension would do and that it needs a cgo engine (the -vips image) and a build environment. Extensions do not load on Windows.

The module source is the hard part. github.com/wrux/meridian-extend is not published to a Go module proxy: the engine records it at the zero version v0.0.0-00010101000000-000000000000, so go get cannot fetch it, and nothing in the binary or the images carries it. An extension can be built only where the person has the module’s source at the engine’s exact version (from the Meridian source repository) to point a replace at. Without it, stop and say so. Every engine release means rebuilding every extension against the new version; there is no compatibility window between releases.

A filter

// extensions/shout/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() {}
// extensions/shout/go.mod
module example.com/shout

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

Use the toolchain and meridian-extend version your engine-dmo version prints, not the ones above, and point replace at wherever the person keeps the module source. Keep the extension to the standard library: any module it shares with the engine must match the engine’s version exactly.

Build and name it

cd extensions/shout
GOWORK=off CGO_ENABLED=1 go build -trimpath -buildmode=plugin -o ../shout.so .

For a Linux container image, build in the official Go image of the engine’s toolchain (see Extensions).

# config/engine.yaml
extensions:
  - extensions/shout.so
<h2>{{ entry.title|shout }}</h2>

Verify

engine-dmo check
engine-dmo version
engine-dmo render / --json

check opens the plugin and reports any mismatch at its line in engine.yaml (does not match this engine (...), followed by each difference and what to change), and admits |shout in templates. version lists shout among the modules. Init runs only when the engine serves, so render is the test that the filter works.

Rebuild every extension for every engine release, and never load a .so you did not build from source you control: an extension runs inside the engine with all its privileges. First-party extensions, such as vite, are named by bare name (extensions: [vite]) and ship with the engine; see First-party extensions.