Create Your Own Module

The fastest way to start building your own TinySystems module is to use the example-module template repository. It comes pre-configured with a working component, GitHub Actions CI/CD, and a release script.

Prerequisites

  • Go 1.25+
  • A GitHub account (or any Git hosting with Actions support)
  • A container registry the workflow can push to (the template uses GHCR)

Step 1: Create a Repository from the Template

Go to github.com/tiny-systems/example-module and click Use this template to create a new repository in your GitHub organization or personal account.

my-org/my-awesome-module

Clone your new repository locally:

git clone https://github.com/my-org/my-awesome-module
cd my-awesome-module

Update go.mod to match your repository path:

go mod edit -module github.com/my-org/my-awesome-module

No account, developer key, or secret setup is needed — the workflow pushes to GHCR with the repository's built-in GITHUB_TOKEN.

Step 2: Write Your Component

The template includes an echo component at components/echo/echo.go. Use it as a starting point or replace it with your own.

Here's the echo component for reference:

package echo

import (
    "context"
    "fmt"

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

const (
    ComponentName        = "echo"
    InPort        string = "in"
    OutPort       string = "out"
)

type Context any

type InMessage struct {
    Context Context `json:"context" configurable:"true" required:"true" title:"Context" description:"Arbitrary message to be echoed"`
}

// OutMessage keeps the passthrough Context under a `context` key so a
// downstream edge reads $.context.<field> — the mid-chain convention.
type OutMessage struct {
    Context Context `json:"context" configurable:"true" title:"Context" description:"Passthrough — echoed unchanged"`
}

type Component struct{}

func (t *Component) Instance() module.Component {
    return &Component{}
}

func (t *Component) GetInfo() module.ComponentInfo {
    return module.ComponentInfo{
        Name:        ComponentName,
        Description: "Echo",
        Info:        "Sends the same message as it receives",
        Tags:        []string{"Echo", "Demo"},
    }
}

func (t *Component) Handle(ctx context.Context, handler module.Handler, port string, msg interface{}) module.Result {
    if in, ok := msg.(InMessage); ok {
        return handler(ctx, OutPort, OutMessage{Context: in.Context})
    }
    return module.Fail(fmt.Errorf("invalid message"))
}

func (t *Component) Ports() []module.Port {
    return []module.Port{
        {
            Name:          InPort,
            Label:         "In",
            Configuration: InMessage{},
            Position:      module.Left,
        },
        {
            Name:          OutPort,
            Label:         "Out",
            Source:        true,
            Configuration: new(OutMessage),
            Position:      module.Right,
        },
    }
}

var _ module.Component = (*Component)(nil)

func init() {
    registry.Register(&Component{})
}

Note the shape of Handle: it returns module.Result, and the handler(...) return is passed straight through — that chaining is what lets synchronous responses flow back to blocking callers upstream.

To add more components, create a new package under components/ and register it with a blank import in cmd/main.go:

import (
    _ "github.com/my-org/my-awesome-module/components/echo"
    _ "github.com/my-org/my-awesome-module/components/mycomponent"
)

Step 3: Publish with a Git Tag

The template includes a GitHub Actions workflow (.github/workflows/release.yml) that automatically builds and publishes your module when you push a semver tag:

git tag v0.0.1
git push origin v0.0.1

A convenience script is also included:

./release.sh        # interactive - asks for patch/minor/major
./release.sh patch  # direct bump

What Happens on Tag Push

  1. GitHub Actions checks out your code
  2. Builds a container image from your Dockerfile, for amd64 and arm64
  3. Pushes it to GHCR, tagged with the version without the leading v (v0.0.1ghcr.io/my-org/my-awesome-module:0.0.1) — that bare tag is what the module index references

There is no publish step and no developer key. The image is the whole artifact.

The workflow file (.github/workflows/release.yml):

name: Publish module image to GHCR

on:
  push:
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+'

permissions:
  contents: read
  packages: write

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Image tag is the version without the leading 'v' (v0.5.24 -> 0.5.24),
      # matching what the module index references.
      - name: Derive version
        id: v
        run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"

      - uses: docker/setup-qemu-action@v3
      - uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build & push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ steps.v.outputs.version }}
            ghcr.io/${{ github.repository }}:latest
          build-args: |
            VERSION=${{ steps.v.outputs.version }}

Note that GHCR packages default to private on first push. Make yours public once, or no cluster can pull it.

Step 4: List It in a Module Repo

Modules are discovered from repo indexes, not pushed anywhere. In a module repo (a directory of manifests hosted as static files — see tiny-systems/modules for the public one), add a directory for your module:

my-module/module.yaml:

name: my-module
source: github.com/my-org/my-module
description: What it does
category: core
versions:
  - version: 1.0.0
    image: ghcr.io/my-org/my-module:1.0.0
    chart: tinysystems/tinysystems-operator
    chartVersion: 0.2.10

Generate components.yaml beside it from the built image, so consumers can browse your components before installing:

docker run --rm --entrypoint /manager ghcr.io/my-org/my-module:1.0.0 \
  tools components-info --json > my-module/components.yaml

If your module calls the Kubernetes API, it must declare what it needs. Generate the values.yaml overlay from the image rather than writing it by hand — the install grants exactly what is declared, so a missing verb becomes a 403 at runtime:

docker run --rm --entrypoint /manager ghcr.io/my-org/my-module:1.0.0 \
  tools rbac-values > my-module/values.yaml

tools rbac-check, run in your module's source directory, reports any Kubernetes call your code makes that the declaration does not cover.

Then regenerate the index and publish the repo (GitHub Pages works):

tiny repo index . -o index.yaml

Step 5: Install Your Module

Consumers learn about your module by adding your repo's index:

tiny repo add myrepo https://my-org.github.io/modules/index.yaml
tiny repo update
tiny install my-module

On the platform, a workspace registers module repos the same way — once your index is in a workspace's repo list, your module appears in its catalog and can be installed into any connected cluster. Your components then show up in the visual editor's component palette, ready to use in flows.

Updating the SDK

To update to the latest SDK version:

./release.sh update
# or manually:
go get github.com/tiny-systems/module@latest
go mod tidy

Project Structure

my-awesome-module/
├── cmd/
│   └── main.go                          # Entry point, cli.RegisterCommands + blank imports
├── components/
│   └── echo/
│       └── echo.go                      # Component implementation
├── .github/
│   └── workflows/
│       └── release.yml                  # CI/CD pipeline
├── Dockerfile                           # golang:1.25 -> distroless static:nonroot
├── release.sh                           # Release helper script
├── go.mod
└── README.md

Next Steps