System Ports

System ports are special ports the SDK uses to deliver framework-level messages to components. They follow an underscore-prefix naming convention.

System ports never reach Handle. The runner dispatches each one to a typed capability interface; a component that doesn't implement the interface simply receives nothing (a no-op). There is no legacy fallback — a switch port { case v1alpha1.SettingsPort: ... } inside Handle is dead code.

System Port Overview

PortConstantCapability interfacePurpose
_settingsv1alpha1.SettingsPortmodule.SettingsHandlerUser configuration
_controlv1alpha1.ControlPortmodule.ControlHandlerDashboard widget interactions
_reconcilev1alpha1.ReconcilePortmodule.ReconcileHandlerTinyNode reconcile events; metadata patches
_clientv1alpha1.ClientPortmodule.ClientAwareKubernetes client wrapper injection
_identityv1alpha1.IdentityPortmodule.IdentityAwareNode identity (name, namespace, flow, project)
package v1alpha1

const (
    ReconcilePort = "_reconcile"
    ControlPort   = "_control"
    SettingsPort  = "_settings"
    ClientPort    = "_client"
    IdentityPort  = "_identity"
)

Dispatch Order

On a fresh runner, the framework dispatches capabilities in a fixed order:

+-----------------------------------------------------------------------------+
|                         CAPABILITY DISPATCH ORDER                            |
+-----------------------------------------------------------------------------+

1. OnIdentity   — node knows who it is            (IdentityAware)
2. OnClient     — K8s client wired up             (ClientAware)
3. OnNATS       — JetStream handle wired up       (NATSAware)
4. OnState      — state backend wired up          (Stateful)
5. OnReconcile  — restore from metadata/spec      (ReconcileHandler)
6. OnSettings   — apply user settings             (SettingsHandler)

Reconcile runs before settings, so user settings always win over state restored from metadata. On subsequent reconciles, OnReconcile fires again; OnSettings only re-fires when the stored configuration changes.

_settings Port

Delivers user configuration, already deserialized into the Go type declared as the _settings port's Configuration.

type SettingsHandler interface {
    OnSettings(ctx context.Context, settings any) error
}
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
}

Port definition (the current value is the Configuration):

{
    Name:          v1alpha1.SettingsPort,
    Label:         "Settings",
    Configuration: c.settings,
}

Stored settings live in the TinyNode's spec.ports (entry with port: _settings). See Settings and Configuration.

_control Port

Delivers dashboard widget interactions (button clicks, form submissions).

type ControlHandler interface {
    OnControl(ctx context.Context, control any) error
}
func (t *Component) OnControl(ctx context.Context, msg any) error {
    if !utils.IsLeader(ctx) {
        return nil // only the leader acts on control clicks
    }
    switch ctrl := msg.(type) {
    case ControlRunning:
        if ctrl.Stop {
            return t.stop()
        }
    case ControlStopped:
        go t.run(context.Background())
    }
    return nil
}

To refresh the widget after a state change, emit the new control value (via module.Base):

t.Emit(context.Background(), v1alpha1.ControlPort, t.getControl())

See Control Ports.

_reconcile Port

Receiving reconciles

type ReconcileHandler interface {
    OnReconcile(ctx context.Context, node v1alpha1.TinyNode) error
}

Called on every TinyNode reconcile. Components use it to restore state from node.Status.Metadata or react to spec changes:

func (t *Component) OnReconcile(ctx context.Context, node v1alpha1.TinyNode) error {
    if node.Status.Metadata == nil {
        return nil
    }
    if _, running := node.Status.Metadata["ticker-running"]; !running {
        return nil
    }
    if !utils.IsLeader(ctx) {
        return nil
    }
    go t.run(ctx) // restore background work after pod restart
    return nil
}

Writing node metadata

Emitting on _reconcile patches the TinyNode. The payload is a node-updater callback of type func(node *v1alpha1.TinyNode) error; updaters are accumulated and applied in a debounced patch to protect the K8s API:

t.Emit(context.Background(), v1alpha1.ReconcilePort, func(n *v1alpha1.TinyNode) error {
    if n.Status.Metadata == nil {
        n.Status.Metadata = make(map[string]string)
    }
    n.Status.Metadata["ticker-running"] = "true"
    return nil
})

For key-value state, prefer the higher-level State backend (module.Stateful / module.Base.State()), which is built on the same mechanism.

_client Port

Injects a Kubernetes client wrapper — not HTTP requests. Components that need direct access to cluster resources implement ClientAware:

type ClientAware interface {
    OnClient(client K8sClient)
}

type K8sClient interface {
    GetK8sClient() client.WithWatch // controller-runtime client
    GetNamespace() string
}
type Component struct {
    client module.K8sClient
}

func (c *Component) OnClient(k8sClient module.K8sClient) {
    c.client = k8sClient
}

// later:
func (c *Component) patchConfigMap(ctx context.Context) error {
    cl := c.client.GetK8sClient()
    ns := c.client.GetNamespace()
    // use cl to Get/Update/Watch resources in ns
    ...
}

Embedding module.Base provides this for free — call c.Client().

_identity Port

Delivers the node's identity once during setup, before OnReconcile:

type IdentityAware interface {
    OnIdentity(id v1alpha1.NodeIdentity)
}

type NodeIdentity struct {
    NodeName    string `json:"nodeName"`
    Namespace   string `json:"namespace"`
    FlowName    string `json:"flowName"`
    ProjectName string `json:"projectName"`
}

Components that need to namespace local resources (PVC paths, key prefixes) stash the identity for later use. With module.Base embedded, call c.Identity().

Other Capability Interfaces

Not port-backed, but part of the same dispatch:

InterfaceMethodPurpose
module.StatefulOnState(s State)Durable key-value state backend
module.NATSAwareOnNATS(js jetstream.JetStream)JetStream handle (nil if TINY_NATS_URL unset — handle that case)
module.EmitterAwareOnEmitter(emit Handler)Long-lived emit handler for background goroutines
module.DestroyerOnDestroy(metadata map[string]string)Cleanup when the node is destroyed

module.Base

Embed module.Base to satisfy Stateful, IdentityAware, ClientAware, and EmitterAware in one line, with accessors:

type Component struct {
    module.Base
}

// c.State()      — injected State backend
// c.Identity()   — v1alpha1.NodeIdentity
// c.Client()     — module.K8sClient
// c.Emit(ctx, port, data) — emit from any goroutine

If you override one of Base's On* methods, call the embedded method too so the accessors keep working.

Complete Example

A component using several capabilities:

package mycomponent

import (
    "context"
    "fmt"

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

type Settings struct {
    Interval int `json:"interval" title:"Interval (ms)" default:"1000"`
}

type Input struct {
    Data string `json:"data" title:"Data" required:"true"`
}

type Output struct {
    Result string `json:"result" title:"Result"`
}

type Component struct {
    module.Base // Stateful + IdentityAware + ClientAware + EmitterAware

    settings Settings
}

func (c *Component) Instance() module.Component {
    return &Component{settings: Settings{Interval: 1000}}
}

func (c *Component) GetInfo() module.ComponentInfo {
    return module.ComponentInfo{
        Name:        "my-component",
        Description: "Demonstrates system ports",
        Info:        "Receives Input, emits Output. Restores state on reconcile.",
        Tags:        []string{"example"},
    }
}

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
}

func (c *Component) OnReconcile(ctx context.Context, node v1alpha1.TinyNode) error {
    // React to spec changes / restore from node.Status.Metadata.
    // Runs BEFORE OnSettings on a fresh runner.
    _ = utils.IsLeader(ctx) // gate leader-only restoration
    return nil
}

func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    if port == "input" {
        in, ok := msg.(Input)
        if !ok {
            return module.Fail(fmt.Errorf("invalid message"))
        }
        return output(ctx, "output", Output{Result: "Processed: " + in.Data})
    }
    return module.Fail(fmt.Errorf("unknown port %q", port))
}

func (c *Component) Ports() []module.Port {
    return []module.Port{
        {Name: v1alpha1.SettingsPort, Label: "Settings", Configuration: c.settings},
        {Name: "input", Label: "Input", Position: module.Left, Configuration: Input{}},
        {Name: "output", Label: "Output", Source: true, Position: module.Right, Configuration: new(Output)},
    }
}

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

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

Best Practices

1. Assert Your Capabilities at Compile Time

var (
    _ module.Component        = (*Component)(nil)
    _ module.SettingsHandler  = (*Component)(nil)
    _ module.ControlHandler   = (*Component)(nil)
    _ module.ReconcileHandler = (*Component)(nil)
)

2. Leader Check for Side Effects

func (c *Component) OnControl(ctx context.Context, msg any) error {
    if !utils.IsLeader(ctx) {
        return nil
    }
    // process control
}

3. Keep OnSettings and OnReconcile Idempotent

Both can fire repeatedly; write them so a re-delivery with identical data is harmless.

4. Handle nil Injections

OnNATS receives nil when the runtime starts without TINY_NATS_URL; Base.Client() is nil before OnClient fires. Guard accordingly.

Next Steps