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
| Tag | Purpose |
|---|---|
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
- Struct tags control UI rendering and validation — keep each tag on one line
- Handle() receives business-port messages and returns
module.Result— return the handler's Result, never discard it - OnSettings (the
SettingsHandlercapability) receives configuration; no port switch needed in Handle - output() emits data to connected nodes and returns a
Result— never call it withgo - Source: true marks OUTPUT ports; inputs leave it false
- Instance() is a factory — return a new instance with defaults each time
- init() registers the component with
registry.Register
Next Steps
- Project Structure - Organize larger modules
- Ports and Messages - Deep dive into ports
- Component Patterns - Common patterns