Ports and Messages

Ports define the interface of a component - what data it accepts and produces. Understanding ports is crucial for building interoperable components.

Port Types

                        +-------------------------------------+
                        |            COMPONENT                 |
                        |                                      |
     INPUT PORTS        |                                      |     OUTPUT PORTS
     (Source: false)    |                                      |     (Source: true)
                        |                                      |
  +---------+           |         +--------------+             |           +---------+
  |  input  |---------->|         |   Handle()   |-------------|---------->| output  |
  +---------+           |         +--------------+             |           +---------+
                        |                                      |
  +---------+           |         +--------------+             |
  |_settings|---------->|         | OnSettings() |             |
  +---------+           |         +--------------+             |
                        |                                      |
  +---------+           |         +--------------+             |
  |_control |<--------->|         | OnControl()  |             |
  +---------+           |         +--------------+             |
                        +-------------------------------------+

Business ports (input, output, ...) are delivered to Handle(). System ports (names starting with _) are dispatched to typed capability interfaces — they never reach Handle().

Input Ports

Receive data from other nodes or the system:

{
    Name:          "input",
    Label:         "Input",
    Position:      module.Left,
    Source:        false,           // Input port
    Configuration: InputMessage{},  // Typed schema definition
}

Output Ports

Send data to connected nodes:

{
    Name:          "output",
    Label:         "Output",
    Position:      module.Right,
    Source:        true,            // Output port (source of data)
    Configuration: OutputMessage{}, // Typed schema definition
}

Bidirectional Ports

Control ports can be both input and output (for UI interaction):

{
    Name:          v1alpha1.ControlPort,  // "_control"
    Label:         "Control",
    Source:        true,                  // Marked as source for UI updates
    Configuration: Control{},
}

Incoming control messages are dispatched to ControlHandler.OnControl; the component pushes widget updates by emitting to v1alpha1.ControlPort.

Port Definition

Port Struct

type Port struct {
    Source                bool            // true = output (source of data), false = input
    Position              Position        // Visual position (module.Left, Right, Top, Bottom)
    Name                  string          // Unique lower-case identifier within component
    Label                 string          // Display name in UI
    Configuration         interface{}     // Struct defining the message schema (request)
    ResponseConfiguration interface{}     // Struct defining the response schema (blocking calls)
    Schema                json.RawMessage // Optional: verbatim JSON Schema for runtime-authored forms
}

Typed ports set Configuration to a struct value (e.g. InputMessage{}) and leave Schema nil — the runtime reflects the JSON Schema from Configuration automatically. Schema exists for the rare case where no compile-time Go type exists at all (a component that receives a JSON Schema as data and renders it to a human, like ask); when non-nil it is published verbatim instead of the reflected schema.

Position Constants

const (
    Top    Position = iota  // 0 - Special ports (rarely used)
    Right                   // 1 - Output ports
    Bottom                  // 2 - Fallback/default ports
    Left                    // 3 - Input ports
)

Visual Layout:

              +-----------+
         Top  |           |
              |           |
    Left ---->| Component |----> Right
              |           |
              |           |
              +-----------+
                 Bottom

Message Types

Messages are strongly typed using Go structs:

Input Message

type RequestInput struct {
    Method  string            `json:"method" required:"true"`
    URL     string            `json:"url" required:"true"`
    Headers map[string]string `json:"headers"`
    Body    any               `json:"body" configurable:"true"`
}

Output Message

type ResponseOutput struct {
    StatusCode int               `json:"statusCode"`
    Headers    map[string]string `json:"headers"`
    Body       any               `json:"body" shared:"true"`
}

Key Struct Tags

TagPurposeExample
json:"name"JSON field namejson:"userId"
required:"true"Field must be providedValidation
configurable:"true"User can set via expressionsEdge configuration
shared:"true"Available for reference by other componentsType-safe references
readonly:"true"Display only, no editingStatus display

System Ports

The SDK defines special system ports. They are never delivered to Handle() — the framework dispatches each one to a typed capability interface. A component opts in by implementing the interface; there is no fallback port switch.

Settings Port (_settings)

Receives user configuration. Declare the port with a typed Configuration; implement SettingsHandler to receive it:

const SettingsPort = "_settings"

// In Ports():
{
    Name:          v1alpha1.SettingsPort,
    Label:         "Settings",
    Source:        false,
    Configuration: Settings{},
}

// Capability interface — replaces the old port switch in Handle():
func (c *MyComponent) OnSettings(ctx context.Context, settings any) error {
    s, ok := settings.(Settings)
    if !ok {
        return fmt.Errorf("expected Settings, got %T", settings)
    }
    c.settings = s
    return nil
}

OnSettings fires after OnReconcile on a fresh runner, so settings always win over reconcile-restored state. It re-fires only when the configured value changes.

Control Port (_control)

UI interaction (buttons, status). Implement ControlHandler:

const ControlPort = "_control"

// Control struct with buttons
type Control struct {
    Status string `json:"status" readonly:"true"`
    Start  bool   `json:"start" format:"button"`
    Stop   bool   `json:"stop" format:"button"`
}

func (c *MyComponent) OnControl(ctx context.Context, control any) error {
    ctrl := control.(Control)
    if ctrl.Start {
        // ...
    }
    return nil
}

Reconcile Port (_reconcile)

Fires on every TinyNode reconcile. Implement ReconcileHandler to read node state; emit a patch callback to update it:

const ReconcilePort = "_reconcile"

// Receive the TinyNode on each reconcile
func (c *MyComponent) OnReconcile(ctx context.Context, node v1alpha1.TinyNode) error {
    value := node.Status.Metadata["key"]
    // restore state, react to spec changes
    return nil
}

// Send state update (callback form) — the callback MUST return error
output(ctx, v1alpha1.ReconcilePort, func(node *v1alpha1.TinyNode) error {
    node.Status.Metadata["key"] = "value"
    return nil
})

Identity Port (_identity)

Delivers the node's own identity (name, namespace, flow, project). Implement IdentityAware — or embed module.Base and call c.Identity():

const IdentityPort = "_identity"

func (c *MyComponent) OnIdentity(id v1alpha1.NodeIdentity) {
    c.prefix = id.Namespace + "/" + id.NodeName
}

Client Port (_client)

Access to Kubernetes resources. Implement ClientAware — or embed module.Base and call c.Client():

const ClientPort = "_client"

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

Embedding module.Base satisfies IdentityAware, ClientAware, Stateful, and EmitterAware in one line — most components never implement those four by hand.

Message Flow Through Ports

Node A                           Node B
+---------------------+          +---------------------+
|                     |          |                     |
| Handle() processes  |          |                     |
| input message       |          |                     |
|         |           |          |                     |
|         v           |          |                     |
| output(ctx,         |          |                     |
|   "out",           -+----------+> Handle() receives  |
|   data)             |   Edge   |    "input" port     |
|                     |          |                     |
+---------------------+          +---------------------+
                           |
                           v
                    Expression evaluation
                    transforms data

Multiple Output Ports

Components can have multiple output ports:

func (c *Router) Ports() []module.Port {
    ports := []module.Port{
        {Name: "input", Position: module.Left, Source: false, Configuration: Input{}},
    }

    // Dynamic output ports based on settings
    for _, route := range c.settings.Routes {
        ports = append(ports, module.Port{
            Name:          fmt.Sprintf("out_%s", route),
            Label:         route,
            Position:      module.Right,
            Source:        true,
            Configuration: Output{},
        })
    }

    // Default port
    if c.settings.EnableDefault {
        ports = append(ports, module.Port{
            Name:          "default",
            Position:      module.Bottom,
            Source:        true,
            Configuration: Output{},
        })
    }

    return ports
}

Port Configuration Schema

The Configuration field defines the JSON Schema for the port:

type Input struct {
    // Basic types
    Name    string  `json:"name" required:"true"`
    Count   int     `json:"count" minimum:"0" maximum:"100"`
    Enabled bool    `json:"enabled" default:"true"`

    // Complex types
    Items   []Item  `json:"items" minItems:"1"`
    Options Options `json:"options"`

    // Configurable fields (can use expressions)
    Data    any     `json:"data" configurable:"true"`
}

The SDK automatically generates JSON Schema from these structs — leave Port.Schema nil for typed ports. See Schema from Go.

Handling Messages

Type Assertion

Always assert message types in Handle():

func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    switch port {
    case "input":
        input, ok := msg.(Input)
        if !ok {
            return module.Fail(fmt.Errorf("expected Input, got %T", msg))
        }
        // Process input...
        return output(ctx, "output", process(input))
    }
    return module.Fail(fmt.Errorf("unknown port: %s", port))
}

Sending Output

Use the output handler to send data. output returns module.Result — check it or chain it, never drop it:

// Simple output — chain the Result up the call stack
return output(ctx, "output", OutputMessage{
    Result: "success",
    Data:   processedData,
})

// Output with error handling
if res := output(ctx, "output", data); res.Err() != nil {
    return res  // Propagate the failure
}
return module.Ok(nil)

Best Practices

1. Use Descriptive Port Names

// Good
{Name: "request", ...}
{Name: "response", ...}
{Name: "out_success", ...}
{Name: "out_error", ...}

// Bad
{Name: "in1", ...}
{Name: "out", ...}

2. Document with Struct Tags

type Input struct {
    UserID string `json:"userId" required:"true" title:"User ID" description:"The unique identifier of the user"`
}

3. Keep Schemas Focused

// Good: Focused schema
type RequestInput struct {
    URL    string `json:"url"`
    Method string `json:"method"`
}

// Bad: Kitchen sink
type Everything struct {
    URL, Method, Headers, Body, Auth, Retry, Timeout, Cache, Log...
}

4. Use Shared Types

Mark output fields as shared:"true" for type-safe references:

type Output struct {
    UserID   string `json:"userId" shared:"true"`   // Can be referenced
    UserName string `json:"userName" shared:"true"` // Can be referenced
}

Next Steps