Hello World Component

Let's build a complete component from scratch. This tutorial creates a "Greeter" component that receives a name and outputs a greeting.

What We'll Build

+-----------------------------------------+
|              Greeter                     |
|                                          |
|  +---------+              +----------+  |
|  |  input  |              |  output  |  |
|  |  (name) |--------------|(greeting)|  |
|  +---------+              +----------+  |
|                                          |
|  Settings: prefix ("Hello", "Hi", etc)   |
+-----------------------------------------+

Step 1: Create Project Structure

The fastest start is the official template: open github.com/tiny-systems/example-module, click Use this template, clone your copy and rename the Go module path. Or by hand:

mkdir greeter-module && cd greeter-module
go mod init github.com/myorg/greeter-module

# Create directories
mkdir -p cmd components/greeter

Step 2: Define Message Types

Create components/greeter/types.go:

package greeter

// Input message - what the component receives
type Input struct {
    Name string `json:"name" required:"true" title:"Name" description:"Name to greet"`
}

// Output message - what the component produces
type Output struct {
    Greeting string `json:"greeting" title:"Greeting"`
    Original string `json:"original" title:"Original Name"`
}

// Settings - component configuration
type Settings struct {
    Prefix string `json:"prefix" required:"true" title:"Greeting Prefix" default:"Hello"`
}

Understanding Struct Tags

TagPurpose
json:"name"JSON field name
required:"true"Field must be provided
title:"Name"Label shown in UI
description:"..."Help text in UI
default:"Hello"Default value
configurable:"true"Field can be mapped from upstream data on an edge

Struct tags must stay on one line — a raw string literal split across lines is not valid Go.

Step 3: Implement the Component

Create components/greeter/component.go:

package greeter

import (
    "context"
    "fmt"

    "github.com/tiny-systems/module/api/v1alpha1"
    "github.com/tiny-systems/module/module"
    "github.com/tiny-systems/module/registry"
)

const (
    ComponentName = "greeter"

    // Port names
    InputPort  = "input"
    OutputPort = "output"
)

// Component implements module.Component
type Component struct {
    settings Settings
}

// GetInfo returns component metadata
func (c *Component) GetInfo() module.ComponentInfo {
    return module.ComponentInfo{
        Name:        ComponentName,
        Description: "Greeter",
        Info:        "Receives a name and outputs a personalized greeting",
        Tags:        []string{"example", "greeting"},
    }
}

// OnSettings receives settings from the SettingsPort. Implementing
// module.SettingsHandler means Handle never sees the settings port.
func (c *Component) OnSettings(_ context.Context, msg any) error {
    settings, ok := msg.(Settings)
    if !ok {
        return fmt.Errorf("invalid settings type: %T", msg)
    }
    c.settings = settings
    return nil
}

// Ports defines the component's input and output ports
func (c *Component) Ports() []module.Port {
    return []module.Port{
        // Settings port (system port)
        {
            Name:          v1alpha1.SettingsPort,
            Label:         "Settings",
            Configuration: c.settings,
        },
        // Input port
        {
            Name:          InputPort,
            Label:         "Input",
            Position:      module.Left,
            Configuration: Input{},
        },
        // Output port (Source: true marks it as an output)
        {
            Name:          OutputPort,
            Label:         "Output",
            Position:      module.Right,
            Configuration: new(Output),
            Source:        true,
        },
    }
}

// Handle processes incoming messages and returns a module.Result
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    if port != InputPort {
        return module.Fail(fmt.Errorf("unknown port: %s", port))
    }

    input, ok := msg.(Input)
    if !ok {
        return module.Fail(fmt.Errorf("invalid input type: %T", msg))
    }

    // Create greeting
    greeting := fmt.Sprintf("%s, %s!", c.settings.Prefix, input.Name)

    // Emit to the output port and return its Result so responses
    // can flow back through the call chain
    return output(ctx, OutputPort, Output{
        Greeting: greeting,
        Original: input.Name,
    })
}

// Instance creates a new component instance (factory method)
func (c *Component) Instance() module.Component {
    return &Component{
        settings: Settings{
            Prefix: "Hello", // Default value
        },
    }
}

// Compile-time interface checks
var (
    _ module.Component       = (*Component)(nil)
    _ module.SettingsHandler = (*Component)(nil)
)

// Register component on package init
func init() {
    registry.Register((&Component{}).Instance())
}

Step 4: Create the Main Entry Point

Create cmd/main.go (this mirrors the template's entrypoint — a cobra root command with the SDK's commands registered on it):

package main

import (
    "context"
    "fmt"
    "os"
    "os/signal"
    "syscall"

    "github.com/rs/zerolog"
    "github.com/spf13/cobra"
    "github.com/spf13/viper"
    "github.com/tiny-systems/module/cli"

    // Import components to register them via init()
    _ "github.com/myorg/greeter-module/components/greeter"
)

var rootCmd = &cobra.Command{
    Use:   "server",
    Short: "greeter module",
    Run: func(cmd *cobra.Command, args []string) {
        cmd.Help()
    },
}

func main() {
    zerolog.SetGlobalLevel(zerolog.InfoLevel)
    viper.AutomaticEnv()
    if viper.GetBool("debug") {
        zerolog.SetGlobalLevel(zerolog.DebugLevel)
    }

    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    cli.RegisterCommands(rootCmd)
    if err := rootCmd.ExecuteContext(ctx); err != nil {
        fmt.Printf("command execute error: %v\n", err)
    }
}

Step 5: Install Dependencies

go mod tidy

Step 6: Build and Test

Build the Module

go build -o greeter-module ./cmd

View Registered Components

./greeter-module tools components-info

Run Locally

./greeter-module run \
    --name=greeter-module \
    --version=1.0.0 \
    --namespace=tinysystems

Step 7: Test with Kubernetes

When the module runs against a cluster, a node is an instance of your component described by a TinyNode resource. spec.module and spec.component are separate fields; settings live in spec.ports as the _settings port's configuration; edges are declared on the node that OWNS the source port:

# test-node.yaml
apiVersion: operator.tinysystems.io/v1alpha1
kind: TinyNode
metadata:
  name: test-greeter
  namespace: tinysystems
spec:
  module: greeter-module
  component: greeter
  ports:
    - port: _settings
      configuration: eyJwcmVmaXgiOiJIZWxsbyJ9   # base64 of {"prefix":"Hello"}
  edges: []

Port configuration is JSON stored as bytes (base64 in raw YAML). In practice you rarely write TinyNodes by hand — the visual editor and the MCP tools (build_flow, configure_edge) write them for you.

# Apply the node
kubectl apply -f test-node.yaml

To trigger the input port, use the platform: send a message with the MCP send_signal tool (or click Send on a signal node wired into input in the editor). There is no TinySignal resource to apply.

Check logs:

# View module logs
kubectl logs -l app=greeter-module -f

Complete Project Structure

greeter-module/
+-- cmd/
|   +-- main.go              # Entry point
+-- components/
|   +-- greeter/
|       +-- component.go     # Component implementation
|       +-- types.go         # Message types
+-- go.mod
+-- go.sum
+-- Dockerfile               # For containerization
+-- README.md

Adding a Dockerfile

The standard pattern (same as the example-module template): build on golang:1.25, ship a distroless static image whose entrypoint is /manager run:

# Dockerfile
FROM --platform=$BUILDPLATFORM golang:1.25 AS builder
ARG TARGETOS
ARG TARGETARCH
ARG VERSION
WORKDIR /manager
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \
    go build -ldflags="-X github.com/tiny-systems/module/cli.versionID=${VERSION}" \
    -o /bin/manager ./cmd

FROM gcr.io/distroless/static:nonroot
COPY --from=builder /bin/manager /manager
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 65532:65532
CMD ["/manager", "run"]

Build and push:

docker build -t myregistry/greeter-module:1.0.0 .
docker push myregistry/greeter-module:1.0.0

Key Takeaways

  1. Struct tags control UI rendering and validation — keep each tag on one line
  2. Handle() receives business-port messages and returns module.Result — return the handler's Result, never discard it
  3. OnSettings (the SettingsHandler capability) receives configuration; no port switch needed in Handle
  4. output() emits data to connected nodes and returns a Result — never call it with go
  5. Source: true marks OUTPUT ports; inputs leave it false
  6. Instance() is a factory — return a new instance with defaults each time
  7. init() registers the component with registry.Register

Next Steps