Engine Extensions
The eight extension-point types — strategies, plugins, CEL functions, middleware, providers — how to ship them, and the spec.yaml that declares their identity and compatibility.
The SDKs let you author workflows in your language. Extensions let you plug new behavior into the routing and workflow engine itself. There are eight extension-point types, and they differ sharply in how far outside the Go process they can live: some are Go-only, some speak gRPC in any language, and some can run as sandboxed WebAssembly.
Every extension declares its identity and compatibility in a spec.yaml
(described below). The type field is one of eight
values, validated by modules/validation/validation.go:
| Type | spec.yaml type | Delivery | Multi-language? |
|---|---|---|---|
| Strategy | strategy | Standalone Go module | Go, or gRPC/remote |
| Enricher | enricher | Plugin | Go, gRPC, or WASM |
| Filter | filter | Plugin | Go, gRPC, or WASM |
| Notifier | notifier | Plugin | Go, gRPC, or WASM |
| Transformer | transformer | Plugin | Go, gRPC, or WASM |
| CEL function | cel_function | In-tree | Go only |
| Middleware | middleware | In-tree | Go only |
| Provider | provider | In-tree | Go only |
1. Strategies
A strategy is a routing algorithm — given a set of candidates, pick one (or rank
or allocate them). You ship one as a standalone Go module: implement
strategy.Strategy, expose a factory, and register it with a name.
reg.Register("my_algorithm", myalgo.NewFactory())The factory's Create builds a configured instance; Info() reports the
strategy's identity, including an InterfaceVersion — currently 1.0.0
(pkg/strategy/version.go) — that Ductor checks before loading so it never runs
a strategy whose interface it can't speak. Users install your module the ordinary
Go way:
go get github.com/yourusername/[email protected]The full path — interface, capabilities, tunable params, contracts, and certification — is covered in Writing a custom strategy.
2–5. Plugins: enricher, filter, notifier, transformer
Four plugin types intercept the routing pipeline around the decision:
- Enricher — add data to a routable before the decision.
- Filter — remove ineligible candidates.
- Notifier — react to routing events.
- Transformer — convert payloads between formats.
Each implements the base plugin.Plugin interface (Info, Start, Stop,
HealthCheck) plus its type-specific method — a Filter, for example, adds
Filter(ctx, candidates, routable) (examples/plugins/filter/filter.go).
Plugins have three delivery mechanisms:
Compiled into the host, the fastest path.
The plugin runs as a separate process in any language, declared via the grpc
block in spec.yaml.
Compiled with TinyGo and run in a sandboxed wazero runtime for memory isolation and deterministic execution.
A WASM plugin exports a fixed protocol (examples/plugins/wasm/README.md):
memory management (alloc, dealloc), the base lifecycle
(plugin_info, plugin_start, plugin_stop, plugin_health_check), and the
per-type entry point (filter_candidates for filters, enrich for enrichers).
The host provides a ductor module with host_log for logging back into
Ductor's stream. Build it with TinyGo:
tinygo build -o plugin.wasm -target=wasi -no-debug ./main.go6–8. Go-only extension points
Three types cannot cross a process boundary and must be written in Go, compiled in-tree:
- CEL functions — custom functions callable from the CEL expressions that guard edges, scatter keys, and dataflow steps.
- Middleware — intercepts the routing pipeline at defined hook points
(
pkg/middleware,AllHookPoints). - Providers — connector integrations (see building a provider).
These three cannot be gRPC or WASM
CEL functions, middleware, and providers run inside the engine's evaluation and request paths where a cross-process hop would be incorrect or prohibitively expensive. They are Go-only by design — there is no remote delivery for them.
Action templates
There is also a lower-ceremony extension seam that needs no Go at all: action
templates. These are YAML-authored composite actions that stitch existing
connector actions into a new one. A template declares expects (a typed input
schema), a linear list of steps, and a returns expression, with
${{ inputs.x }} and ${{ steps.<ref>.result.* }} interpolation between them
(domain/actiontemplate/template.go). At load time each template is compiled into
a real connector ActionSpec and registered under a namespaced key, so callers
invoke it exactly like any built-in action.
Use an action template when your extension is "call these three connector actions in sequence and shape the result" — no plugin, no gRPC, no build step.
The spec.yaml manifest
Every extension (except in-tree providers wired directly) declares a spec.yaml
that carries its identity and compatibility. It is validated structurally, and
its config block is validated as a JSON Schema
(modules/validation/validation.go):
apiVersion: ductor.io/v1
kind: ExtensionSpec
metadata:
name: language-filter
version: 1.0.0
type: filter # one of the eight types
interfaceVersion: 1.0.0
author: Acme
license: Apache-2.0
repository: https://github.com/acme/ductor-language-filter
spec:
description: Filters candidates by supported language.
config: { } # JSON Schema for the plugin's config
capabilities: [cacheable]
hooks: [ ]
grpc: # present only for gRPC-delivered plugins
service: acme.LanguageFilter
methods: [FilterCandidates]apiVersion must be ductor.io/v1 and kind must be ExtensionSpec; the
config block, when present, is compiled and used to validate an operator's
supplied configuration before the extension is activated.
Sandbox kinds
Non-native extensions use the in_process sandbox kind. The extension
shares the host's memory and process and must be treated as trusted code.
Extensions are not an isolation boundary
Vet third-party extensions before deployment and grant them only the configuration and credentials they require.