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.goConvention: module_<lowercase_name>.go. Use underscores.
Step 2: Define the Struct
package layers
import ( "strings" "fmt")
// Embed *PluginModule β required for all modulestype 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
| Response | Use 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:
# Via APIcurl -b cookies.txt "http://localhost:4454/api/apps?search=text-processor"
# Via MongoDBmongosh ud --eval "db.apps.findOne({alias: 'text-processor'})"Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Factory on wrong receiver (*ModuleX instead of *PluginModule) | Module never discovered | Change receiver type |
Factory method name doesnβt start with NewModule | Module never discovered | Rename the method |
Wrong package (package main) | Compile error | Use package layers |
nil returned from ArgsExample() | UI form broken | Return empty map[string]any{} |
| No type assertion check | Runtime panic on bad input | Always use comma-ok: v, ok := args["k"].(string) |