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
| Tag | Description | Example |
|---|---|---|
json | Field name in JSON | json:"fieldName" |
title | Display label | title:"Field Name" |
description | Help text | description:"Enter value" |
default | Default value | default:"100" |
required | Mark required | required:"true" |
configurable | Field accepts arbitrary user-shaped data | configurable:"true" |
Validation Tags
| Tag | Description | Example |
|---|---|---|
minimum | Min number value | minimum:"0" |
maximum | Max number value | maximum:"100" |
minItems | Min array items | minItems:"1" |
uniqueItems | Unique array items | uniqueItems:"true" |
enum | Allowed values | enum:"GET,POST,PUT" |
format | Format hint | format:"uri" |
UI Tags
| Tag | Description | Example |
|---|---|---|
propertyOrder | Field order | propertyOrder:"1" |
enumTitles | Enum display names | enumTitles:"Get,Post,Put" |
readonly | Display-only field | readonly:"true" |
format:"textarea" | Multi-line input | format:"textarea" |
format:"password" | Masked input | format:"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
- Control Ports - UI buttons and actions
- System Ports - All system ports
- Schema Definition - Advanced schemas