Settings and Configuration

Settings allow users to configure component behavior through the visual editor. The SDK delivers them via the _settings system port to a typed capability method — OnSettings — not through Handle.

Settings Flow Overview

+-----------------------------------------------------------------------------+
|                         SETTINGS FLOW                                        |
+-----------------------------------------------------------------------------+

    +---------------+        +--------------------+        +----------------+
    |  Visual UI    |        |      TinyNode      |        |   Component    |
    |               |------->|                    |------->|                |
    | User edits    |  Save  |  spec.ports entry  | Decode |  OnSettings()  |
    | settings form |        |  port: _settings   |  into  |                |
    +---------------+        |  configuration: {} |  type  +----------------+
                             +--------------------+

Stored settings live in the TinyNode's spec.ports (an entry with port: _settings and a configuration JSON blob) — not in spec.edges. On reconcile, the runner deserializes that blob into the Go type you declared as the _settings port's Configuration and calls OnSettings with it.

Defining Settings

Settings Struct

type Settings struct {
    Timeout    int    `json:"timeout" title:"Timeout (ms)" default:"5000" minimum:"100"`
    RetryCount int    `json:"retryCount" title:"Retry Count" default:"3" minimum:"0" maximum:"10"`
    Endpoint   string `json:"endpoint" title:"API Endpoint" format:"uri" required:"true"`
    Debug      bool   `json:"debug" title:"Debug Mode"`
}

Component with Settings

Implement module.SettingsHandler and declare the _settings port with the current settings value as its Configuration:

type Component struct {
    settings Settings
}

func (c *Component) Ports() []module.Port {
    return []module.Port{
        // Settings port (system port). The current value is the Configuration,
        // so the editor form shows what is actually applied.
        {
            Name:          v1alpha1.SettingsPort, // "_settings"
            Label:         "Settings",
            Configuration: c.settings,
        },
        // Business ports...
        {
            Name:          "request",
            Label:         "Request",
            Position:      module.Left,
            Configuration: Request{},
        },
    }
}

// OnSettings is called by the runner with the already-deserialized Settings.
// Returning an error surfaces it on the node.
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
}

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

There is no case v1alpha1.SettingsPort: inside Handle — system ports never reach Handle. A component without SettingsHandler simply receives no settings.

Settings Schema Tags

Basic Tags

TagDescriptionExample
jsonField name in JSONjson:"fieldName"
titleDisplay labeltitle:"Field Name"
descriptionHelp textdescription:"Enter value"
defaultDefault valuedefault:"100"
requiredMark requiredrequired:"true"
configurableField accepts arbitrary user-shaped dataconfigurable:"true"

Validation Tags

TagDescriptionExample
minimumMin number valueminimum:"0"
maximumMax number valuemaximum:"100"
minItemsMin array itemsminItems:"1"
uniqueItemsUnique array itemsuniqueItems:"true"
enumAllowed valuesenum:"GET,POST,PUT"
formatFormat hintformat:"uri"

UI Tags

TagDescriptionExample
propertyOrderField orderpropertyOrder:"1"
enumTitlesEnum display namesenumTitles:"Get,Post,Put"
readonlyDisplay-only fieldreadonly:"true"
format:"textarea"Multi-line inputformat:"textarea"
format:"password"Masked inputformat:"password"

Settings Examples

Enum Field

type Settings struct {
    Method string `json:"method" title:"HTTP Method" enum:"GET,POST,PUT,DELETE" default:"GET"`
}

Nested Settings

type Settings struct {
    Server  ServerSettings  `json:"server" title:"Server Configuration"`
    Logging LoggingSettings `json:"logging" title:"Logging Options"`
}

type ServerSettings struct {
    Host string `json:"host" title:"Host" default:"localhost"`
    Port int    `json:"port" title:"Port" default:"8080" minimum:"1" maximum:"65535"`
}

type LoggingSettings struct {
    Level  string `json:"level" title:"Log Level" enum:"debug,info,warn,error" default:"info"`
    Format string `json:"format" title:"Format" enum:"json,text" default:"json"`
}

Array Settings

type Settings struct {
    Endpoints []EndpointConfig `json:"endpoints" title:"Endpoints" minItems:"1"`
}

type EndpointConfig struct {
    Name    string `json:"name" title:"Name" required:"true"`
    URL     string `json:"url" title:"URL" format:"uri" required:"true"`
    Timeout int    `json:"timeout" title:"Timeout (ms)" default:"5000"`
}

Credentials and Secrets

Settings values can reference Kubernetes Secrets with a whole-string placeholder:

[[secret:<secret-name>/<key>]]

For example, "apiKey": "[[secret:anthropic-keys/api_key]]". The placeholder must be the entire string value — "Bearer [[secret:a/b]]" is not resolved. The runtime resolves placeholders before delivering settings to OnSettings, and re-delivers settings that carry placeholders after a TTL even when the raw bytes are unchanged, so Secret rotation is picked up without editing the node.

A module must declare which Secret names it may read, via module requirements:

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

func init() {
    registry.SetRequirements(module.Requirements{
        Secrets: module.SecretRequirements{
            Names: []string{"anthropic-keys"},
        },
    })
}

The platform install flow pins the module's RBAC Role to exactly those Secret names. Use format:"password" on the field so the UI masks direct input.

Applying Settings

Validation

OnSettings returns an error — use it:

func (c *Component) OnSettings(_ context.Context, msg any) error {
    in, ok := msg.(Settings)
    if !ok {
        return fmt.Errorf("invalid settings")
    }
    if in.Timeout < 100 {
        return fmt.Errorf("timeout must be at least 100ms")
    }
    if in.Endpoint == "" {
        return fmt.Errorf("endpoint is required")
    }
    c.settings = in
    return nil
}

Settings-Triggered Reinitialization

type Server struct {
    settings Settings
    listener net.Listener
    mu       sync.Mutex
}

func (s *Server) OnSettings(_ context.Context, msg any) error {
    in, ok := msg.(Settings)
    if !ok {
        return fmt.Errorf("invalid settings")
    }

    s.mu.Lock()
    defer s.mu.Unlock()

    // Port changed? Restart listener
    if s.settings.Port != in.Port && s.listener != nil {
        s.listener.Close()
        s.listener = nil
    }
    s.settings = in
    return nil
}

Delivery Order and Deduplication

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

1. OnIdentity   — node knows who it is
2. OnClient     — K8s client wired up
3. OnNATS       — JetStream handle (if configured)
4. OnState      — state backend wired up
5. OnReconcile  — restore from metadata, react to spec
6. OnSettings   — apply user-provided settings (wins over reconcile)

OnReconcile runs before OnSettings, so state restored from node metadata is always overridden by explicit user settings. On subsequent reconciles, OnReconcile fires again; OnSettings only re-fires when the stored configuration actually changes (with the secret-TTL exception above). Write OnSettings to be idempotent.

Dynamic Settings

Settings Affect Ports

Settings can drive which ports exist (see the router). Ports() is re-read after settings change:

type Settings struct {
    Routes []string `json:"routes" required:"true" title:"Routes" minItems:"1" uniqueItems:"true"`
}

func (r *Component) Ports() []module.Port {
    ports := []module.Port{
        {
            Name:          v1alpha1.SettingsPort,
            Label:         "Settings",
            Configuration: r.settings,
        },
        {
            Name:          "input",
            Label:         "Input",
            Position:      module.Left,
            Configuration: InMessage{},
        },
    }
    for _, route := range r.settings.Routes {
        ports = append(ports, module.Port{
            Name:          "out_" + strings.ToLower(route),
            Label:         route,
            Source:        true,
            Position:      module.Right,
            Configuration: new(OutMessage),
        })
    }
    return ports
}

Best Practices

1. Sensible Defaults

type Settings struct {
    // Good: reasonable defaults, and Instance() mirrors them
    Timeout int `json:"timeout" default:"5000"`
    Retries int `json:"retries" default:"3"`
}

func (c *Component) Instance() module.Component {
    return &Component{settings: Settings{Timeout: 5000, Retries: 3}}
}

2. Clear Descriptions

type Settings struct {
    // Good: clear what to enter
    APIKey string `json:"apiKey" title:"API Key" format:"password" description:"Your API key, or a [[secret:name/key]] placeholder"`
}

3. Appropriate Validation

type Settings struct {
    Port     int    `json:"port" minimum:"1" maximum:"65535" default:"8080"`
    LogLevel string `json:"logLevel" enum:"debug,info,warn,error" default:"info"`
}

4. Thread Safety

OnSettings can race with in-flight Handle calls; guard shared state:

type Component struct {
    settings Settings
    mu       sync.RWMutex
}

func (c *Component) OnSettings(_ context.Context, msg any) error {
    in, ok := msg.(Settings)
    if !ok {
        return fmt.Errorf("invalid settings")
    }
    c.mu.Lock()
    c.settings = in
    c.mu.Unlock()
    return nil
}

func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    c.mu.RLock()
    settings := c.settings
    c.mu.RUnlock()
    return c.processWithSettings(ctx, output, msg, settings)
}

Next Steps