Component Interface
Every TinySystems component must implement the module.Component interface. This interface defines how your component integrates with the SDK and the visual editor.
The Interface
package module
type Component interface {
// GetInfo returns component metadata
GetInfo() ComponentInfo
// Handle processes a message arriving on `port`
Handle(ctx context.Context, output Handler, port string, message any) Result
// Ports returns the component's port definitions
Ports() []Port
// Instance creates a new instance with default settings
Instance() Component
}
Interface Methods
GetInfo()
Returns metadata about your component:
func (c *MyComponent) GetInfo() module.ComponentInfo {
return module.ComponentInfo{
Name: "my-component", // Unique identifier
Description: "Does something useful", // Short label shown in UI
Info: "Longer usage notes for agents and humans: what the ports expect, how to wire it.",
Tags: []string{"utility"}, // For categorization
}
}
| Field | Description |
|---|---|
Name | Unique identifier within the module (kebab-case / snake_case) |
Description | Short human-readable label for the UI |
Info | Longer usage documentation: port contracts, wiring guidance |
Tags | Categories for filtering in the component palette |
Handle()
The business logic of your component. Called when a message arrives on one of your input ports:
func (c *MyComponent) Handle(
ctx context.Context,
output module.Handler,
port string,
msg any,
) module.Result {
if port == "input" {
input, ok := msg.(InputMessage)
if !ok {
return module.Fail(fmt.Errorf("invalid message on port %q", port))
}
return output(ctx, "output", process(input))
}
return module.Fail(fmt.Errorf("unknown port %q", port))
}
| Parameter | Description |
|---|---|
ctx | Context with cancellation, tracing, leader info |
output | module.Handler — call it to emit messages on output ports |
port | Name of the port that received the message |
msg | The message, already decoded into the port's Configuration type |
| Returns | module.Result — build with module.Ok(v) / module.Fail(err) |
Handle returns module.Result, and so does every output(...) call. Chain them up the call stack (return output(ctx, ...)) so blocking-I/O callers such as http-server receive the synchronous response that flows back from downstream nodes.
Note that system ports (_settings, _control, _reconcile, _client, _identity) never reach Handle. They are dispatched through capability interfaces (SettingsHandler, ControlHandler, ReconcileHandler, ...) — see System Ports.
Ports()
Defines the component's input and output ports. Source: true marks a port as a source of data — an output; inputs leave it false (the zero value):
func (c *MyComponent) Ports() []module.Port {
return []module.Port{
{
Name: "input",
Label: "Input",
Position: module.Left, // Source: false => input
Configuration: InputMessage{}, // decoded type for incoming data
},
{
Name: "output",
Label: "Output",
Source: true, // output
Position: module.Right,
Configuration: new(OutputMessage),
},
}
}
A port's Configuration value does double duty: the runtime reflects its Go type into the JSON schema shown in the editor, and decodes incoming messages into that type. See Defining Ports.
Instance()
Creates a new instance of the component with default settings:
func (c *MyComponent) Instance() module.Component {
return &MyComponent{
settings: Settings{Timeout: 5000},
}
}
This is used when creating new nodes, and by convention at registration time.
Complete Example
package uppercaser
import (
"context"
"fmt"
"strings"
"github.com/tiny-systems/module/module"
"github.com/tiny-systems/module/registry"
)
const (
InPort = "input"
OutPort = "output"
)
type Input struct {
Text string `json:"text" title:"Text" description:"Text to uppercase"`
}
type Output struct {
Text string `json:"text" title:"Result" description:"Uppercased text"`
}
type Component struct{}
func (u *Component) Instance() module.Component {
return &Component{}
}
func (u *Component) GetInfo() module.ComponentInfo {
return module.ComponentInfo{
Name: "uppercaser",
Description: "Converts text to uppercase",
Info: "Receives {text} on Input and emits {text} uppercased on Output.",
Tags: []string{"text", "transform"},
}
}
func (u *Component) Handle(
ctx context.Context,
output module.Handler,
port string,
msg any,
) module.Result {
if port == InPort {
input, ok := msg.(Input)
if !ok {
return module.Fail(fmt.Errorf("invalid message on port %q", port))
}
return output(ctx, OutPort, Output{
Text: strings.ToUpper(input.Text),
})
}
return module.Fail(fmt.Errorf("unknown port %q", port))
}
func (u *Component) Ports() []module.Port {
return []module.Port{
{
Name: InPort,
Label: "Input",
Position: module.Left,
Configuration: Input{},
},
{
Name: OutPort,
Label: "Output",
Source: true,
Position: module.Right,
Configuration: new(Output),
},
}
}
var _ module.Component = (*Component)(nil)
func init() {
registry.Register((&Component{}).Instance())
}
Registration
Components register themselves in an init() function via the registry package:
import "github.com/tiny-systems/module/registry"
func init() {
registry.Register((&Component{}).Instance())
}
Module-level requirements (RBAC, storage, Secrets the module may read) are declared once per module with registry.SetRequirements(module.Requirements{...}).
Component Lifecycle
+-----------------------------------------------------------------------------+
| COMPONENT LIFECYCLE |
+-----------------------------------------------------------------------------+
1. REGISTRATION
| init() calls registry.Register((&Component{}).Instance())
v
2. DISCOVERY
| SDK calls GetInfo() and Ports()
| Component appears in the editor palette
v
3. INSTANTIATION (when a node is created)
| SDK calls Instance(); a TinyNode CR tracks the node in Kubernetes
v
4. CAPABILITY INJECTION + RECONCILE (fresh runner, in this order)
| OnIdentity -> OnClient -> OnNATS -> OnState -> OnReconcile -> OnSettings
| (each step only if the component implements the interface)
v
5. MESSAGE HANDLING (ongoing)
| Handle() called for each message on business ports
| output() / Base.Emit() send to connected nodes
v
6. DESTRUCTION
TinyNode deleted; contexts cancelled;
OnDestroy(metadata) called if the component implements module.Destroyer
Best Practices
1. Stateless When Possible
// Good: stateless component — everything comes from the message
type Component struct{}
// Acceptable: settings-based state, updated via SettingsHandler
type Component struct {
settings Settings
}
func (c *Component) OnSettings(_ context.Context, msg any) error {
in, ok := msg.(Settings)
if !ok {
return fmt.Errorf("invalid settings")
}
c.settings = in
return nil
}
2. Use Meaningful Names
// Good
module.ComponentInfo{
Name: "http_request",
Description: "Makes HTTP requests to external APIs",
}
// Bad
module.ComponentInfo{
Name: "req",
Description: "Does HTTP stuff",
}
3. Fail Loudly on Unknown Ports
Because system ports never reach Handle, any unexpected port name is a wiring bug — surface it:
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
switch port {
case InPort:
return c.handleInput(ctx, output, msg)
default:
return module.Fail(fmt.Errorf("unknown port %q", port))
}
}
Source-only components (ticker, signal) have no business inputs at all and use a guard stub:
func (t *Component) Handle(_ context.Context, _ module.Handler, port string, _ any) module.Result {
return module.Fail(fmt.Errorf("no business-port input: got %q", port))
}
4. Respect Context Cancellation
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
for _, item := range items {
select {
case <-ctx.Done():
return module.Fail(ctx.Err()) // graceful shutdown
default:
process(item)
}
}
return module.Ok(nil)
}
Next Steps
- Defining Ports - Port configuration details
- Handling Messages - Message processing patterns
- System Ports - Built-in port types