Project plugins (experimental)
How to give a declared endpoint logic YAML cannot express with a project plugin: an ordinary program the engine starts, supervises and calls over a Unix socket; plugins.yaml with every option, default and limit, the plugin step, writing one in Go with github.com/wrux/meridian-plugin, protocol version 0 on the wire, building, pinning and failure behaviour.
Experimental
engine-dmo check and at start. engine-dmo version prints the protocol it speaks (plugin protocol 0 (experimental)).A project plugin is an ordinary program, usually Go, that the engine starts, supervises and calls when a declared endpoint reaches a plugin step. It is for the logic an endpoint’s YAML cannot express: a rule with branches, a calculation, a partner’s API that needs an SDK. The endpoint keeps everything else: its access rule, input validation, CSRF, rate limits, audit, caching and response shape. The plugin receives the endpoint’s validated input and returns data; it never sees the request.
A plugin is not an extension. It runs out of process, so it needs no matching Go toolchain, and a crash costs the endpoints that use it rather than the site. It cannot add template functions or routes.
A minimal plugin
A market calendar whose rules have branches in them: every Thursday except over Christmas, plus the last Saturday of every month but December.
// plugin/main.go
package main
import (
"fmt"
"time"
plugin "github.com/wrux/meridian-plugin"
)
func main() {
plugin.Serve(plugin.Handlers{
Endpoints: map[string]plugin.EndpointFunc{"market-days": marketDays},
})
}
type Day struct {
Date string `json:"date"`
Kind string `json:"kind"`
}
func marketDays(ctx plugin.Context, r plugin.Request) (plugin.Response, error) {
from := time.Now().UTC().Truncate(24 * time.Hour)
if s := r.Input.String("from"); s != "" {
d, err := time.Parse(time.DateOnly, s)
if err != nil {
return plugin.Response{}, fmt.Errorf("from is a date such as 2026-10-08, got %q", s)
}
from = d
}
count := 4
if f, ok := r.Input.Float("count"); ok {
count = min(max(int(f), 1), 12)
}
var out []Day
for d := from; len(out) < count; d = d.AddDate(0, 0, 1) {
christmas := (d.Month() == time.December && d.Day() >= 24) || (d.Month() == time.January && d.Day() == 1)
switch {
case d.Weekday() == time.Thursday && !christmas:
out = append(out, Day{d.Format(time.DateOnly), "market"})
case d.Weekday() == time.Saturday && d.Month() != time.December && d.AddDate(0, 0, 7).Month() != d.Month():
out = append(out, Day{d.Format(time.DateOnly), "farmers-market"})
}
}
ctx.Log.Info("market days", "from", from.Format(time.DateOnly), "count", len(out))
return plugin.Data(out), nil
} Declare it for the site, with the handlers an endpoint may call:
# config/sites/<site>/plugins.yaml
plugins:
markets:
command: bin/markets # the binary, relative to the project
build: plugin # the Go program engine-dmo plugin build compiles to it
endpoints: [market-days] # the handlers a plugin step may call
timeout: 2s
concurrency: 4
memory: 256MB
circuit_breaker: { failures: 5, window: 1m, cooldown: 30s } Call it from an endpoint:
# config/sites/<site>/endpoints.yaml
endpoints:
- key: market-days
path: /api/market-days.json
methods: [get]
access: public
input:
from: { type: date, from: query }
count: { type: number, from: query, min: 1, max: 12 }
steps:
- plugin: { name: markets, handler: market-days, with: { from: $from, count: $count }, as: days }
respond:
json: { from: days, fields: [date, kind] }
cache: 1h Build it, then check:
engine-dmo plugin build --project .
engine-dmo check --fix --project . # writes the endpoint's id, then checks everything GET /api/market-days.json?count=3 answers the projection of what the handler returned, with only date and kind exposed.
plugins.yaml
One file per site, config/sites/<site>/plugins.yaml, with a map of plugins by key. A key is lowercase letters, digits and hyphens, at most 63 characters.
| Key | Default | Limits | Meaning |
|---|---|---|---|
command | (required) | A path inside the project, or absolute; never looked up on PATH, never interpolated (no $) | The binary the engine starts. |
args | none | Arguments passed to the binary. | |
build | none | A directory inside the project | The Go program engine-dmo plugin build compiles to command. Leave it out when you build the binary some other way. |
sha256 | none | 64 lowercase hex digits | Pins the binary: the engine refuses to start one whose digest differs. engine-dmo plugin build prints the digest. |
env | none | Names are uppercase letters, digits and underscores; not MERIDIAN_PLUGIN_* | Environment variables the plugin gets beyond the minimal base. A name containing auth, key, token, secret, password or cookie must be a ${VAR} interpolation, never a literal. |
endpoints | (required) | Handler names: lowercase letters, digits and hyphens, at most 63, no repeats | The handlers a plugin step may call. The plugin’s handshake must serve every one. |
timeout | 5s | At most 1m | How long one call may take. |
concurrency | 8 | 1 to 256 | Calls in flight at once. A call that finds every slot busy until its deadline fails. |
memory | 512MB | 32MB to 64GB | The process’s memory limit where the platform allows, and its GOMEMLIMIT. |
circuit_breaker.failures | 5 | 1 to 100 | Failed calls inside window that open the breaker. |
circuit_breaker.window | 1m | At most 1h | |
circuit_breaker.cooldown | 30s | At most 1h | How long the breaker stays open; meanwhile the plugin’s steps fail at once without calling it. |
The plugin step
- plugin: { name: markets, handler: market-days, with: { from: $from, count: $count }, as: days } | Key | Meaning |
|---|---|
name | A plugin declared in this site’s plugins.yaml. |
handler | One of the plugin’s endpoints. May be left out when the plugin is granted exactly one. |
with | A mapping of names to references ($from, $listing.title) or literals, sent as the call’s input. Without it, every input of the endpoint is sent. |
as | The name later steps and respond read the result by. Defaults to the handler’s name. |
The step takes only these four keys. engine-dmo check refuses a plugin that is not declared, a handler the plugin is not granted, and anything else, each with a suggestion where one is close. respond: json: projects the result as for a query or source step, exposing only the fields it lists.
Any failure of the call (the binary missing, the process down, the breaker open, every slot busy, a timeout, or the handler’s own error) fails the endpoint with HTTP 502 and the message “This information is not available just now. Try again in a moment.”, as a failing upstream source does. The handler’s error and any panic stack go to the engine’s log, never to the visitor.
Writing a plugin in Go
Require github.com/wrux/meridian-plugin in the plugin’s own go.mod. It uses the standard library only, and the plugin may use any Go version, since the contract is messages, not shared code.
// go.mod
module example.com/markets-plugin
go 1.26.0
require github.com/wrux/meridian-plugin v0.0.0-00010101000000-000000000000
replace github.com/wrux/meridian-plugin => ../meridian-plugin While the module is not published to a Go module proxy, point a replace directive at your copy of its source. A release does not carry that source (it is in the Meridian source repository, libs/plugin), so a Go plugin needs it from Cherwell Studio. A plugin in another language needs only the protocol, which this page describes.
| Name | What it is |
|---|---|
plugin.Serve(h Handlers) | Runs the plugin until the engine stops it, then exits the process. Your main calls it. |
plugin.Run(ctx, h) error | Serve without the exit, for a program that does more. |
plugin.Handler(h) http.Handler | The protocol as an http.Handler, for serving it another way or testing a plugin over the wire with net/http/httptest. |
Handlers.Name | The name in the handshake. Empty takes the key the engine started it under. |
Handlers.Endpoints | Handler name to EndpointFunc. |
EndpointFunc | func(ctx Context, r Request) (Response, error). An error fails the step. |
Context | A context.Context that ends when the engine stops waiting, with Site, Locale, Endpoint, RequestID, and Log, a *slog.Logger whose records the engine writes to its own log. |
Request.Input | The endpoint’s validated input as JSON values: a string, a float64, a bool, nil, []any or map[string]any. A relation input arrives as the document’s id. Helpers: Value(name), String(name), Float(name) (float64, bool), Bool(name). |
plugin.Data(v) | A Response carrying v, which must marshal to JSON. |
A panic in a handler is recovered and returned with its stack; the engine logs it and the plugin keeps running. Handlers are plain functions, so you test them with go test and nothing else running.
Building, pinning and deploying
engine-dmo plugin build [key...] [--site key] --project . For each plugin with a build: directory, this runs go build -trimpath -o <command> . in that directory (Go must be on PATH) and prints the binary’s sha256. When plugins.yaml pins a different digest, it says so and prints the one to set. engine-dmo check warns when a declared binary is not built (the binary bin/markets is not built, so its steps would fail with 502).
The binary runs on the machine the engine runs on, so build it for that operating system and architecture: in a container image, build it in the image build and copy it to the path command names. Pin sha256 in production so a changed binary is refused rather than run.
How the engine runs a plugin
- Started on first use, per site and plugin, and replaced when its declaration (command, arguments, pin, environment or memory) changes.
- Environment:
PATH,HOME,TMPDIR,USER,LANG,LC_ALL,SSL_CERT_FILEandSSL_CERT_DIRfrom the engine’s environment if set, theenv:you declared, andMERIDIAN_PLUGIN_SOCKET,MERIDIAN_PLUGIN_PROTOCOL,MERIDIAN_PLUGIN_NAMEandMERIDIAN_PLUGIN_LIFELINE. Nothing else of the engine’s environment, its secrets included, is passed. - Working directory: the project directory.
- Start: it must answer the handshake within 10 seconds, naming protocol 0 and serving every handler
endpoints:grants. - Health: checked every 10 seconds; a process that fails twice is killed and restarted.
- Restarts: a process that exits is restarted with a backoff from 100ms doubling to 30s; one that ran for a minute resets it.
- Lifeline: with
MERIDIAN_PLUGIN_LIFELINE=stdinthe engine holds the plugin’s standard input open; when it closes, the engine has gone and the plugin exits (plugin.Servedoes this for you). - Logs: the plugin writes JSON records to standard error, which the engine reads into its own log.
The protocol, version 0
For a plugin in another language. JSON over HTTP/1.1 on the Unix socket at the path in MERIDIAN_PLUGIN_SOCKET:
| Request | Answer |
|---|---|
GET /v0/handshake | {"protocol": 0, "name": "...", "build": "...", "go": "...", "endpoints": ["market-days"]}, at most 64 KiB. name and each endpoint are lowercase letters, digits and hyphens, none repeated. |
GET /v0/health | 200 while the plugin can take calls. |
POST /v0/endpoint/<handler> | Body {"site", "locale", "endpoint", "request_id", "input"}, at most 1 MiB, with the Meridian-Deadline header holding the deadline in Unix milliseconds. Answer 200 {"data": ...}, or any other status with {"error": {"message": "...", "panic": false, "stack": "..."}}, at most 4 MiB. A body over its bound is refused whole. |
Security
A plugin is not a sandbox. It runs as a normal process with the engine user’s privileges and can read what that user can read. The engine contains it (a timeout per call, bounded concurrency, restarts, a circuit breaker, a memory limit where the platform allows, no inherited secrets) but does not isolate it. Treat plugin code as trusted, like your templates: review it, build it yourself, and pin it.
engine-cms behaves the same.
First-party extensions
The extensions that ship beside the engine binary and load by name from engine.yaml (today the vite extension): what each adds, where its file lives in a release and in the container image, and how to turn it on.
Integrating
How other systems reach a Meridian site: the read-only content API for headless frontends and builds, outbound webhooks, upstream data sources and scheduled feed imports, the content MCP for AI agents, the member API for a custom portal, and moving content in from WordPress or Sanity.