Skip to content

Custom Module Development Guide

Custom Module Development Guide

Overview

Custom modules extend flow8 with new capabilities. They are Go structs that implement a simple interface, placed in a specific directory, and auto-discovered via reflection at startup β€” no registration needed.

Step 1: Create the File

Create a file in pkg/plugins/layers/v2/:

pkg/plugins/layers/v2/module_textprocessor.go

Convention: module_<lowercase_name>.go. Use underscores.

Step 2: Define the Struct

package layers
import (
"strings"
"fmt"
)
// Embed *PluginModule β€” required for all modules
type ModuleTextProcessor struct {
*PluginModule
}

Step 3: Add the Factory Method

This is the most critical step. The naming convention is exactly NewModule<CamelCaseName> on receiver *PluginModule:

// Auto-discovered via Go reflection at startup.
// The method MUST be on *PluginModule (not *ModuleTextProcessor).
// The method MUST start with "NewModule".
func (p *PluginModule) NewModuleTextProcessor() *ModuleTextProcessor {
return &ModuleTextProcessor{PluginModule: p}
}

Step 4: Implement the Interface

All five methods are required:

// Type returns the unique module ID used as appId in flows.
// Use kebab-case. Must be globally unique across all modules.
func (m *ModuleTextProcessor) Type() string {
return "text-processor"
}
// RequiredComponents declares which runtime capabilities this module needs.
// Valid: "storage", "ai", "db", "request", "console"
func (m *ModuleTextProcessor) RequiredComponents() []string {
return []string{} // no external dependencies
}
// ArgsExample defines the expected input schema.
// Used for UI form generation and documentation.
func (m *ModuleTextProcessor) ArgsExample() map[string]any {
return map[string]any{
"input": "Hello, World!",
"operation": "uppercase",
"prefix": "",
}
}
// OutExample defines what this module returns.
// Used for expression autocomplete in the flow editor.
func (m *ModuleTextProcessor) OutExample() map[string]any {
return map[string]any{
"result": "HELLO, WORLD!",
"length": 13,
"wordCount": 2,
}
}

Step 5: Implement Run()

func (m *ModuleTextProcessor) Run(params Params) Response {
// --- Read arguments with safe type assertion ---
input, ok := params.Args["input"].(string)
if !ok || input == "" {
return params.Response().Fail("input is required and must be a string")
}
operation, _ := params.Args["operation"].(string)
prefix, _ := params.Args["prefix"].(string)
// --- Log progress (visible in play layer output in the UI) ---
params.Log(fmt.Sprintf("Processing %d chars with operation: %s", len(input), operation))
// --- Apply operation ---
var result string
switch operation {
case "uppercase":
result = strings.ToUpper(input)
case "lowercase":
result = strings.ToLower(input)
case "trim":
result = strings.TrimSpace(input)
case "reverse":
runes := []rune(input)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
result = string(runes)
default:
result = input
}
if prefix != "" {
result = prefix + result
}
// --- Return DONE with output data ---
return params.Response().
Done(map[string]any{
"result": result,
"length": len(result),
"wordCount": len(strings.Fields(result)),
}).
// Optionally update the KV store
WithKV([]KVUpdate{
{Scope: "flow", Key: "lastProcessedLength", Value: fmt.Sprintf("%d", len(result))},
})
}

Step 6: Accessing Components

If your module needs external capabilities, request them:

func (m *ModuleHTTPCaller) RequiredComponents() []string {
return []string{"request"}
}
func (m *ModuleHTTPCaller) Run(params Params) Response {
reqComp, err := params.GetComponent("request")
if err != nil {
return params.Response().Fail("request component not available: " + err.Error())
}
// Use reqComp to make HTTP calls
_ = reqComp
// ...
}

Step 7: Return States

ResponseUse When
params.Response().Done(data)Module succeeded; pass output to next steps
params.Response().Fail("reason")Module failed; triggers retry/error handling
params.Response().Skip()Module deliberately did nothing (e.g., nothing to process)

Step 8: Verify Discovery

Restart the server with APPS_FORCEUPDATE=true, then verify:

Terminal window
# Via API
curl -b cookies.txt "http://localhost:4454/api/apps?search=text-processor"
# Via MongoDB
mongosh ud --eval "db.apps.findOne({alias: 'text-processor'})"

Common Mistakes

MistakeSymptomFix
Factory on wrong receiver (*ModuleX instead of *PluginModule)Module never discoveredChange receiver type
Factory method name doesn’t start with NewModuleModule never discoveredRename the method
Wrong package (package main)Compile errorUse package layers
nil returned from ArgsExample()UI form brokenReturn empty map[string]any{}
No type assertion checkRuntime panic on bad inputAlways use comma-ok: v, ok := args["k"].(string)