Defining Ports

Ports are the connection points of your component. They define what data your component can receive and send.

Port Structure

package module

type Port struct {
    // true = this port is a SOURCE of data (an output).
    // false (zero value) = input.
    Source bool

    // Which side of the node shows this port
    Position Position // module.Top / module.Right / module.Bottom / module.Left

    // Lower-case programmatic name
    Name string

    // Human-readable name
    Label string

    // Typed shape of the port. The runtime reflects its Go type into the
    // JSON schema shown in the editor, and decodes incoming messages into
    // this type before calling Handle.
    Configuration interface{}

    // For request/reply ports: the shape of the synchronous response that
    // flows back (e.g. http-server's Request port declares Response here).
    ResponseConfiguration interface{}

    // Escape hatch: when non-nil, published as the port's JSON schema
    // verbatim instead of reflecting Configuration. For forms whose shape
    // is only known at runtime.
    Schema json.RawMessage
}

Source semantics: Source: true means the port emits data — it is an output. Input ports leave Source unset. Edges run from a source port on one node to an input port on another.

Basic Port Definition

func (c *MyComponent) Ports() []module.Port {
    return []module.Port{
        // Input port
        {
            Name:          "input",
            Label:         "Input",
            Position:      module.Left,
            Configuration: InputMessage{},
        },
        // Output port
        {
            Name:          "output",
            Label:         "Output",
            Source:        true,
            Position:      module.Right,
            Configuration: new(OutputMessage),
        },
    }
}

Port Positions

                       module.Top
                          |
          +---------------+---------------+
          |                               |
   module.Left        Component        module.Right
          |                               |
          +---------------+---------------+
                          |
                     module.Bottom
ConstantTypical Use
module.LeftInput ports (data flowing in)
module.RightOutput ports (data flowing out)
module.TopControl/dashboard ports
module.BottomError outputs, default routes

Configuration Is Required to Receive Data

An input port's Configuration is not optional decoration — the runtime uses reflect on its type to decode every incoming message. A port with a nil Configuration silently drops messages (the runner logs "port has nil configuration" and returns without calling Handle).

// WRONG — messages to this port are dropped
{
    Name:     "input",
    Position: module.Left,
}

// RIGHT — the runtime decodes incoming JSON into InputMessage
{
    Name:          "input",
    Position:      module.Left,
    Configuration: InputMessage{},
}

For output ports, Configuration supplies the sample value/schema the editor uses for edge mapping and build-time validation. Populate it with representative defaults where you can — downstream edges are validated against this shape.

Schema Generation from Go Types

The runtime generates the port's JSON schema by reflecting the Configuration value's struct tags:

type Request struct {
    Method string `json:"method" title:"HTTP Method" enum:"GET,POST,PUT,DELETE" default:"GET"`
    URL    string `json:"url" title:"URL" format:"uri" required:"true"`
    Body   any    `json:"body,omitempty" title:"Request Body" configurable:"true"`
}

// In Ports()
Configuration: Request{}

Schema Tags

TagDescriptionExample
jsonJSON field namejson:"fieldName"
titleHuman-readable labeltitle:"Field Name"
descriptionHelp textdescription:"Enter the value"
requiredMark as requiredrequired:"true"
configurableField accepts arbitrary user-shaped data in the editorconfigurable:"true"
readonlyDisplay-only fieldreadonly:"true"
enumAllowed valuesenum:"a,b,c"
enumTitlesDisplay names for enum valuesenumTitles:"A,B,C"
formatUI/validation formatformat:"button", format:"textarea", format:"password", format:"uri"
defaultDefault valuedefault:"hello"
minimum / maximumNumber boundsminimum:"0"
minItems / uniqueItemsArray constraintsminItems:"1"
propertyOrderField order in rendered formspropertyOrder:"1"

The Schema Field: Runtime-Authored Shapes

Port.Schema (json.RawMessage) is the escape hatch for ports whose shape is only known at runtime — for example a component that receives a JSON Schema as data and renders it as a human form (the ask component). When Schema is non-nil it is published verbatim; otherwise the runtime reflects Configuration as usual.

{
    Name:     v1alpha1.ControlPort,
    Label:    "Control",
    Source:   true,
    Position: module.Top,
    // The data is a map so an untyped submission has something to decode
    // into; the schema is the authored form, published verbatim.
    Configuration: c.control(),   // map[string]interface{}
    Schema:        c.form(),      // json.RawMessage
}

Two rules:

  • Always pair Schema with a Configuration. A port carrying only Schema and a nil Configuration still drops incoming messages — the decode target comes from Configuration.
  • Don't rely on JSON key order in an authored schema; set propertyOrder on each field — that is what the editor sorts on.

schema.FromGo (in pkg/schema) bridges the two worlds — it builds schema bytes from a Go value, useful when you pick one of several Go shapes at runtime:

import "github.com/tiny-systems/module/pkg/schema"

{Name: v1alpha1.ControlPort, Source: true, Schema: schema.FromGo(c.getControl())}

A port whose shape is its Go type needs none of this: leave Schema nil.

Request/Reply Ports

A source port that expects a synchronous response back (http-server's Request port is the canonical case) declares the response shape via ResponseConfiguration:

{
    Name:                  "request",
    Label:                 "Request",
    Source:                true,
    Position:              module.Right,
    Configuration:         Request{},
    ResponseConfiguration: Response{},
}

Dynamic Ports

Ports can be generated from settings. This is the real router pattern — one input, one output port per configured route:

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:         "IN",
            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
}

Common Port Patterns

Request/Response with Error Port

func (c *HTTPClient) Ports() []module.Port {
    return []module.Port{
        {
            Name:          "request",
            Label:         "Request",
            Position:      module.Left,
            Configuration: HTTPRequest{},
        },
        {
            Name:          "response",
            Label:         "Response",
            Source:        true,
            Position:      module.Right,
            Configuration: new(HTTPResponse),
        },
        {
            Name:          "error",
            Label:         "Error",
            Source:        true,
            Position:      module.Bottom,
            Configuration: module.ErrorMessage{},
        },
    }
}

Use module.ErrorMessage for error ports — it is the canonical {context, error, retryable} shape the retry component and the platform expect.

Filter/Split

func (c *Filter) Ports() []module.Port {
    return []module.Port{
        {Name: "input", Label: "Input", Position: module.Left, Configuration: Item{}},
        {Name: "match", Label: "Match", Source: true, Position: module.Right, Configuration: new(Item)},
        {Name: "no_match", Label: "No Match", Source: true, Position: module.Bottom, Configuration: new(Item)},
    }
}

Aggregator

func (c *Aggregator) Ports() []module.Port {
    return []module.Port{
        {Name: "item", Label: "Item", Position: module.Left, Configuration: Item{}},
        {Name: "flush", Label: "Flush", Position: module.Top, Configuration: FlushSignal{}},
        {Name: "batch", Label: "Batch", Source: true, Position: module.Right, Configuration: new(Batch)},
    }
}

Next Steps