Workflow SDKs

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:

Typespec.yaml typeDeliveryMulti-language?
StrategystrategyStandalone Go moduleGo, or gRPC/remote
EnricherenricherPluginGo, gRPC, or WASM
FilterfilterPluginGo, gRPC, or WASM
NotifiernotifierPluginGo, gRPC, or WASM
TransformertransformerPluginGo, gRPC, or WASM
CEL functioncel_functionIn-treeGo only
MiddlewaremiddlewareIn-treeGo only
ProviderproviderIn-treeGo 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.

6–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):

spec.yaml
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.