Meridian handbook
Extending

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

Project plugins speak plugin protocol version 0, which is experimental and may change in any engine release. The engine refuses a plugin built against a protocol version it does not speak, at 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.

KeyDefaultLimitsMeaning
command(required)A path inside the project, or absolute; never looked up on PATH, never interpolated (no $)The binary the engine starts.
argsnoneArguments passed to the binary.
buildnoneA directory inside the projectThe Go program engine-dmo plugin build compiles to command. Leave it out when you build the binary some other way.
sha256none64 lowercase hex digitsPins the binary: the engine refuses to start one whose digest differs. engine-dmo plugin build prints the digest.
envnoneNames 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 repeatsThe handlers a plugin step may call. The plugin’s handshake must serve every one.
timeout5sAt most 1mHow long one call may take.
concurrency81 to 256Calls in flight at once. A call that finds every slot busy until its deadline fails.
memory512MB32MB to 64GBThe process’s memory limit where the platform allows, and its GOMEMLIMIT.
circuit_breaker.failures51 to 100Failed calls inside window that open the breaker.
circuit_breaker.window1mAt most 1h
circuit_breaker.cooldown30sAt most 1hHow 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 }
KeyMeaning
nameA plugin declared in this site’s plugins.yaml.
handlerOne of the plugin’s endpoints. May be left out when the plugin is granted exactly one.
withA 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.
asThe 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.

NameWhat 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) errorServe without the exit, for a program that does more.
plugin.Handler(h) http.HandlerThe protocol as an http.Handler, for serving it another way or testing a plugin over the wire with net/http/httptest.
Handlers.NameThe name in the handshake. Empty takes the key the engine started it under.
Handlers.EndpointsHandler name to EndpointFunc.
EndpointFuncfunc(ctx Context, r Request) (Response, error). An error fails the step.
ContextA 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.InputThe 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_FILE and SSL_CERT_DIR from the engine’s environment if set, the env: you declared, and MERIDIAN_PLUGIN_SOCKET, MERIDIAN_PLUGIN_PROTOCOL, MERIDIAN_PLUGIN_NAME and MERIDIAN_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=stdin the engine holds the plugin’s standard input open; when it closes, the engine has gone and the plugin exits (plugin.Serve does 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:

RequestAnswer
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/health200 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.