External plugins run as separate OS processes that communicate with the workflow engine over gRPC, powered by HashiCorp go-plugin. They can provide module types, step types, and trigger types that are loaded and unloaded at runtime without recompiling the engine.
The engine treats external plugins identically to built-in plugins. Both implement the EnginePlugin interface; the engine does not know whether a plugin is compiled-in or running in a separate process. An ExternalPluginAdapter wraps the gRPC client so the engine sees a standard EnginePlugin.
Key properties of external plugins:
- Process isolation: Each plugin runs in its own OS process. A crash in the plugin does not crash the engine.
- Language agnostic: Any language that can implement the gRPC protocol can serve as a plugin (Go SDK provided, protobuf definitions available for other languages).
- Hot load/unload: Plugins can be loaded, unloaded, and reloaded at runtime via the HTTP API.
- Bidirectional communication: Plugins can call back to the engine to trigger workflows, look up services, and send log entries.
go get github.com/GoCodeAlone/workflow/plugin/external/sdkEvery external plugin must implement the sdk.PluginProvider interface, which returns metadata about the plugin:
type PluginProvider interface {
Manifest() sdk.Manifest
}To provide step types, also implement sdk.StepProvider:
type StepProvider interface {
StepTypes() []string
CreateStep(typeName, name string, config map[string]any) (sdk.Step, error)
}To provide module types, also implement sdk.ModuleProvider:
type ModuleProvider interface {
ModuleTypes() []string
CreateModule(typeName, name string, config map[string]any) (sdk.Module, error)
}func main() {
sdk.Serve(&MyPlugin{})
}The Serve function handles:
- Setting the magic cookie environment variable for the go-plugin handshake
- Starting the gRPC server
- Registering all provided services
- Blocking until the host process terminates the connection
Every external plugin must include a plugin.json file in its directory. This file provides metadata used during discovery, dependency resolution, and display in the admin UI.
{
"name": "my-plugin",
"version": "1.0.0",
"author": "Your Name",
"description": "A brief description of what this plugin does"
}| Field | Required | Description |
|---|---|---|
name |
Yes | Lowercase alphanumeric with hyphens. Must be unique across all plugins. |
version |
Yes | Semantic version string (e.g., 1.0.0, 0.2.1). |
author |
Yes | Author name or organization. |
description |
Yes | Human-readable description of the plugin's purpose. |
license |
No | SPDX license identifier (e.g., MIT, Apache-2.0). |
tags |
No | Array of search/categorization tags. |
repository |
No | URL to the source code repository. |
dependencies |
No | Array of {"name": "other-plugin", "constraint": ">=1.0.0"} objects. |
modernizeRules |
No | Array of migration rules for wfctl modernize (see below). |
The name field must match the directory name under data/plugins/. Names are validated against the pattern ^[a-z][a-z0-9-]*[a-z0-9]$ (minimum 2 characters) or a single lowercase letter.
When you rename module types, step types, or config keys in a new version of your plugin, declare migration rules in plugin.json so that users can detect and auto-fix stale configs with wfctl modernize --plugin-dir data/plugins.
{
"name": "my-plugin",
"version": "2.0.0",
"author": "Your Name",
"description": "My plugin v2",
"moduleTypes": ["my.connector"],
"modernizeRules": [
{
"id": "my-plugin-rename-type",
"description": "Rename my.old_connector to my.connector (v2 migration)",
"severity": "error",
"oldModuleType": "my.old_connector",
"newModuleType": "my.connector"
},
{
"id": "my-plugin-rename-key",
"description": "Rename apiEndpoint to endpoint in my.connector config",
"moduleType": "my.connector",
"oldKey": "apiEndpoint",
"newKey": "endpoint"
}
]
}Each rule object supports these fields:
| Field | Required | Description |
|---|---|---|
id |
Yes | Unique kebab-case rule identifier |
description |
Yes | Human-readable description of the migration |
severity |
No | "error" or "warning" (default: "warning") |
message |
No | Custom finding message (auto-generated if omitted) |
oldModuleType + newModuleType |
— | Module type rename rule |
oldStepType + newStepType |
— | Step type rename rule |
moduleType + oldKey + newKey |
— | Config key rename in a specific module type |
stepType + oldKey + newKey |
— | Config key rename in a specific step type |
Users run modernize with your plugin directory to apply these rules:
# Detect issues (dry-run)
wfctl modernize --plugin-dir data/plugins config.yaml
# Apply fixes
wfctl modernize --apply --plugin-dir data/plugins config.yaml
# See all available rules (built-in + plugin)
wfctl modernize --plugin-dir data/plugins --list-rulesExternal plugins live under the data/plugins/ directory relative to the engine's working directory. Each plugin gets its own subdirectory. The binary inside must have the same name as the directory.
data/plugins/
my-plugin/
plugin.json # manifest (required)
my-plugin # compiled binary (same name as directory)
another-plugin/
plugin.json
another-plugin
The engine scans this directory on startup to discover available plugins. You can also trigger discovery at runtime through the API.
Pipeline steps are the most common type of external plugin. A step receives a PipelineContext (trigger data, previous step outputs, current merged state, metadata), performs some operation, and returns output data.
Here is a complete example of a step plugin that converts string values to uppercase:
package main
import (
"context"
"strings"
"github.com/GoCodeAlone/workflow/plugin/external/sdk"
)
// UppercasePlugin provides a single step type: "step.uppercase".
type UppercasePlugin struct{}
// Manifest returns the plugin metadata.
func (p *UppercasePlugin) Manifest() sdk.Manifest {
return sdk.Manifest{
Name: "uppercase",
Version: "1.0.0",
Author: "Example Author",
Description: "Converts string values to uppercase",
}
}
// StepTypes returns the step type names this plugin provides.
func (p *UppercasePlugin) StepTypes() []string {
return []string{"step.uppercase"}
}
// CreateStep creates a new step instance of the given type.
func (p *UppercasePlugin) CreateStep(typeName, name string, config map[string]any) (sdk.Step, error) {
field, _ := config["field"].(string)
if field == "" {
field = "value" // default field to transform
}
return &UppercaseStep{name: name, field: field}, nil
}
// UppercaseStep implements sdk.Step.
type UppercaseStep struct {
name string
field string
}
// Name returns the step's instance name.
func (s *UppercaseStep) Name() string {
return s.name
}
// Execute reads a string field from the pipeline context's current data,
// converts it to uppercase, and returns the result.
func (s *UppercaseStep) Execute(ctx context.Context, pc *sdk.PipelineContext) (*sdk.StepResult, error) {
input, _ := pc.Current[s.field].(string)
output := strings.ToUpper(input)
return &sdk.StepResult{
Output: map[string]any{
s.field: output,
},
}, nil
}
func main() {
sdk.Serve(&UppercasePlugin{})
}Once the plugin is loaded, the step type is available in any workflow configuration:
workflows:
- name: process-text
type: pipeline
trigger:
type: http
config:
method: POST
path: /process
pipeline:
steps:
- name: uppercase-name
type: step.uppercase
config:
field: name
- name: uppercase-email
type: step.uppercase
config:
field: emailModule plugins provide full modular.Module implementations that participate in the engine's lifecycle (Init, Start, Stop). They can provide services, depend on other modules, and run background tasks.
package main
import (
"context"
"log"
"github.com/GoCodeAlone/workflow/plugin/external/sdk"
)
// MetricsPlugin provides a custom metrics collector module.
type MetricsPlugin struct{}
func (p *MetricsPlugin) Manifest() sdk.Manifest {
return sdk.Manifest{
Name: "custom-metrics",
Version: "1.0.0",
Author: "Example Author",
Description: "Custom metrics collection module",
}
}
func (p *MetricsPlugin) ModuleTypes() []string {
return []string{"custom.metrics_collector"}
}
func (p *MetricsPlugin) CreateModule(typeName, name string, config map[string]any) (sdk.Module, error) {
endpoint, _ := config["endpoint"].(string)
interval, _ := config["interval"].(string)
return &MetricsModule{
name: name,
endpoint: endpoint,
interval: interval,
}, nil
}
// MetricsModule implements sdk.Module.
type MetricsModule struct {
name string
endpoint string
interval string
}
func (m *MetricsModule) Name() string { return m.name }
func (m *MetricsModule) Dependencies() []string { return nil }
func (m *MetricsModule) Init() error {
log.Printf("MetricsModule %s: initialized with endpoint=%s interval=%s",
m.name, m.endpoint, m.interval)
return nil
}
func (m *MetricsModule) Start(ctx context.Context) error {
log.Printf("MetricsModule %s: started", m.name)
// Start collecting metrics in a goroutine, respect ctx.Done()
return nil
}
func (m *MetricsModule) Stop(ctx context.Context) error {
log.Printf("MetricsModule %s: stopped", m.name)
return nil
}
func (m *MetricsModule) InvokeService(method string, args map[string]any) (map[string]any, error) {
switch method {
case "get_metrics":
return map[string]any{
"requests_total": 42,
"errors_total": 3,
}, nil
default:
return nil, nil
}
}
func main() {
sdk.Serve(&MetricsPlugin{})
}modules:
- name: app-metrics
type: custom.metrics_collector
config:
endpoint: http://metrics-server:9090
interval: 30sExternal plugins can provide UI schema definitions so the workflow editor knows how to render configuration forms for the plugin's module types. Implement the sdk.SchemaProvider interface:
func (p *MetricsPlugin) ModuleSchemas() []sdk.ModuleSchema {
return []sdk.ModuleSchema{
{
Type: "custom.metrics_collector",
Label: "Metrics Collector",
Category: "observability",
Description: "Collects and reports custom metrics",
Inputs: []sdk.ServiceIODef{
{Name: "config", Type: "object", Description: "Collector configuration"},
},
Outputs: []sdk.ServiceIODef{
{Name: "metrics", Type: "object", Description: "Collected metrics data"},
},
ConfigFields: []sdk.ConfigFieldDef{
{Name: "endpoint", Type: "string", Description: "Metrics server endpoint", Required: true},
{Name: "interval", Type: "string", Description: "Collection interval (e.g., 30s, 1m)", DefaultValue: "30s"},
},
},
}
}These schemas are automatically merged into the engine's schema registry and served to the UI at /api/v1/module-schemas.
External plugins can call back to the engine through the EngineCallbackService gRPC interface. The SDK wraps this as a Callback object passed during plugin initialization:
err := callback.TriggerWorkflow("http", "POST /webhook", map[string]any{
"source": "my-plugin",
"event": "data_ready",
})found := callback.GetService("user-store")
if found {
// The host has a "user-store" service available
}callback.Log("info", "Processing complete", map[string]any{
"items_processed": 150,
"duration_ms": 342,
})Log entries from plugins are routed to the engine's logger with a [plugin] prefix and the log level tag.
Test your step logic independently without gRPC by constructing the step directly:
package main
import (
"context"
"testing"
"github.com/GoCodeAlone/workflow/plugin/external/sdk"
)
func TestUppercaseStep(t *testing.T) {
step := &UppercaseStep{name: "test-step", field: "value"}
result, err := step.Execute(context.Background(), &sdk.PipelineContext{
TriggerData: map[string]any{"source": "test"},
StepOutputs: map[string]map[string]any{},
Current: map[string]any{"value": "hello world"},
Metadata: map[string]any{},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Output["value"] != "HELLO WORLD" {
t.Errorf("expected HELLO WORLD, got %v", result.Output["value"])
}
}HashiCorp go-plugin supports a test mode where the plugin runs in-process, avoiding subprocess management in tests:
package main
import (
"testing"
goplugin "github.com/GoCodeAlone/go-plugin"
"github.com/GoCodeAlone/workflow/plugin/external"
)
func TestPluginIntegration(t *testing.T) {
// Create a test plugin client that runs in-process
client := goplugin.NewClient(&goplugin.ClientConfig{
HandshakeConfig: external.Handshake,
Plugins: map[string]goplugin.Plugin{
"plugin": &external.GRPCPlugin{},
},
Cmd: nil, // nil Cmd = test mode
AllowedProtocols: []goplugin.Protocol{goplugin.ProtocolGRPC},
})
defer client.Kill()
// Use the client to test your plugin's gRPC interface...
}For end-to-end testing, build the plugin binary and use the engine's discovery mechanism:
# Build the plugin
cd my-plugin/
go build -o my-plugin .
# Create the expected directory structure
mkdir -p data/plugins/my-plugin
cp my-plugin data/plugins/my-plugin/
cp plugin.json data/plugins/my-plugin/
# Start the engine — it discovers the plugin on startup
cd ../../
go run ./cmd/server -config my-config.yamlThen use the API to load the plugin and verify it works:
# Load the plugin
curl -X POST http://localhost:8081/api/v1/plugins/external/my-plugin/load
# List loaded plugins to verify
curl http://localhost:8081/api/v1/plugins/external/loaded
# Test a workflow that uses the plugin's step type
curl -X POST http://localhost:8081/process -d '{"name": "test"}'cd my-plugin/
go build -o my-plugin .Cross-compile for the target platform if needed:
GOOS=linux GOARCH=amd64 go build -o my-plugin .Place the built binary and manifest in the correct directory structure:
mkdir -p data/plugins/my-plugin
cp my-plugin data/plugins/my-plugin/
cp plugin.json data/plugins/my-plugin/For installing published plugins as a wfctl user, prefer the plugin lifecycle
commands documented in
wfctl Installation And Plugin Lifecycle: use
wfctl plugin add, wfctl plugin lock, and wfctl plugin ci for project
plugins, or wfctl plugin install -g and wfctl plugin update -g --all for
operator-global plugins.
The engine discovers external plugins through two mechanisms:
-
Startup discovery: On boot, the engine scans
data/plugins/for directories containing both aplugin.jsonmanifest and a matching binary. -
Runtime API: Use the HTTP API to load, unload, and reload plugins without restarting the engine.
Load a discovered plugin:
curl -X POST http://localhost:8081/api/v1/plugins/external/my-plugin/loadUnload a running plugin (graceful shutdown of the subprocess):
curl -X POST http://localhost:8081/api/v1/plugins/external/my-plugin/unloadReload a plugin using the safe try-activate contract (candidate subprocess is validated before the active process is killed):
curl -X POST http://localhost:8081/api/v1/plugins/external/my-plugin/reloadLegacy vs. safe reload semantics
Legacy (unsafe, pre-v1.0): kill old → start new. If the new start fails, the slot is empty and the plugin surface is dark.
Safe (current): start candidate → validate handshake/contract → kill old only after candidate is healthy. If the candidate fails to start or validate, the old process stays registered and serving. The
/reloadendpoint uses the safe contract exclusively.
All external plugin management endpoints are under the /api/v1/plugins/external prefix.
GET /api/v1/plugins/external
Returns all plugins discovered in the data/plugins/ directory, whether loaded or not.
Response:
[
{
"name": "my-plugin",
"version": "1.0.0",
"author": "Example Author",
"description": "Converts string values to uppercase",
"loaded": false
}
]GET /api/v1/plugins/external/loaded
Returns only the plugins that are currently loaded and active.
Response:
[
{
"name": "my-plugin",
"version": "1.0.0",
"author": "Example Author",
"description": "Converts string values to uppercase",
"loaded": true,
"moduleTypes": [],
"stepTypes": ["step.uppercase"],
"triggerTypes": []
}
]POST /api/v1/plugins/external/{name}/load
Starts the plugin subprocess, performs the handshake, and registers its types with the engine. Returns an error if the plugin is already loaded or if the binary is not found.
Response (success):
{
"status": "loaded",
"name": "my-plugin",
"moduleTypes": [],
"stepTypes": ["step.uppercase"],
"triggerTypes": []
}POST /api/v1/plugins/external/{name}/unload
Gracefully stops the plugin subprocess and removes its types from the engine. Any modules or steps created by the plugin become unavailable. Returns an error if the plugin is not currently loaded.
Response (success):
{
"status": "unloaded",
"name": "my-plugin"
}POST /api/v1/plugins/external/{name}/reload
Starts the replacement plugin subprocess, performs the handshake and contract validation, then swaps it into the active slot. If the candidate fails to load, the previously active plugin process stays registered and running.
This API is a local activation primitive only: it consumes the plugin binary and manifest already staged on disk. Package trust, artifact download, signature verification, and fleet rollout policy belong to the caller or update manager.
Response (success):
{
"status": "reloaded",
"name": "my-plugin",
"moduleTypes": [],
"stepTypes": ["step.uppercase"],
"triggerTypes": []
}When a workflow step is backed by an external plugin, the execution flow is:
1. Pipeline engine calls step.Execute(ctx, pipelineContext)
2. RemoteStep resolves template expressions in step config against the current
pipeline context (e.g. {{ index .steps "fetch-user" "row" "id" }} becomes
the actual value from a previous step's output).
3. RemoteStep converts PipelineContext to protobuf:
- TriggerData -> google.protobuf.Struct
- StepOutputs -> map<string, google.protobuf.Struct>
- Current -> google.protobuf.Struct
- Metadata -> google.protobuf.Struct
- Config -> google.protobuf.Struct (resolved step config)
4. gRPC call: ExecuteStep(ExecuteStepRequest) -> ExecuteStepResponse
5. Plugin deserializes, runs logic using resolved config, returns output
6. RemoteStep converts response back to StepResult{Output, Stop}
7. Pipeline engine merges output into Current state
All complex data structures are serialized through google.protobuf.Struct, which represents JSON-compatible maps. This means plugin data must be expressible as JSON (strings, numbers, booleans, arrays, nested objects). Binary data should be base64-encoded.
The magic cookie values must match exactly. Ensure your plugin uses the SDK's Serve() function, which sets the correct handshake configuration:
- Cookie key:
WORKFLOW_PLUGIN - Cookie value:
workflow-external-plugin-v1 - Protocol version:
1
Check the plugin's GetStepTypes() / GetModuleTypes() / GetTriggerTypes() responses. The type names returned here must match exactly what you use in your YAML configurations.
Check the engine logs for plugin "<name>" stderr: entries. The default external-plugin logger also adds an [external-plugins] prefix, but callers may supply a custom logger. Plugin stderr is streamed so progress and diagnostics remain visible in CI logs. If the plugin process exits unexpectedly, the RemoteStep or RemoteModule proxy will return a gRPC connection error.
All data passed between host and plugin goes through protobuf.Struct (JSON-compatible). Ensure your step output contains only JSON-serializable types. Go-specific types (channels, functions, complex numbers) cannot be transmitted.
Verify the directory layout matches the expected convention:
- Directory name matches the
namefield inplugin.json - Binary inside has the same name as the directory
- Binary has execute permissions (
chmod +x data/plugins/my-plugin/my-plugin)
To participate in the wfctl modernize ecosystem, external plugin authors must:
-
Add
modernizeRulestoplugin.jsonwhenever a new release renames module/step types or config keys (see Declaring Modernize Rules above). -
Ship the updated
plugin.jsonalongside the plugin binary in each release. Users who runwfctl modernize --plugin-dir data/plugins config.yamlwill automatically pick up the rules from the installed version. -
Include rule history: Rules should remain in
plugin.jsonas long as users may still be running configs with the old names. Only remove a rule when you are confident that no users have configs referencing the old type/key.
The GoCodeAlone/workflow-registry repository stores registry manifests (manifest.json) for discovery purposes. To surface modernize rules in the registry:
-
Extend
RegistryCapabilitiesin the registry manifest format to include amodernizeRulessummary (e.g., list of rule IDs) so that the registry can inform users "this plugin version includes N migration rules." -
Update
wfctl publishto optionally include themodernizeRulesfromplugin.jsonin the registry manifest so that it is available for discovery viawfctl registry search. -
Registry manifest convention: Until the registry manifest format is extended, external plugins should embed their
modernizeRulesinplugin.json(the installed copy). The registry entry is informational; the rules are read from the local installed file at modernize time.
Note: The
wfctl modernize --plugin-dirflag reads installedplugin.jsonfiles from disk. It does not fetch rules from the registry. This design keeps the command offline-capable and deterministic.