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
    }
}
FieldDescription
NameUnique identifier within the module (kebab-case / snake_case)
DescriptionShort human-readable label for the UI
InfoLonger usage documentation: port contracts, wiring guidance
TagsCategories 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))
}
ParameterDescription
ctxContext with cancellation, tracing, leader info
outputmodule.Handler — call it to emit messages on output ports
portName of the port that received the message
msgThe message, already decoded into the port's Configuration type
Returnsmodule.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