Signals (NATS wire)

The TinySignal CRD has been removed. External triggers no longer create Custom Resources. Signals are now published directly over NATS using the SDK's pkg/wire package — same effect (dispatch to a specific node:port from outside the flow graph) without Kubernetes API writes or controller round-trips. This page documents the replacement mechanism and is kept at this URL so existing links keep working.

Why the CRD Was Removed

The old flow — create a TinySignal CR, wait for the module's controller to notice it, dispatch, delete the CR — added an etcd write, a watch round-trip, and a delete for every trigger. With the NATS transport already carrying every cross-module message, external triggers can ride the same wire: one publish, delivered straight to the target module's subscriber.

Publishing a Signal

External callers (the platform's send-signal handler, the MCP server's send_signal, the desktop debugger) use wire.Publish:

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

// targetNode is the node's full name WITHOUT the port; port is separate.
reply, err := wire.Publish(ctx, nc, "myflow.mymodule.node-abc123", "input",
    []byte(`{"message": "Hello World", "userId": "user-123"}`),
    wire.Options{
        From:         wire.FromSignal, // marks the payload as an external signal
        WaitForReply: true,            // block for the synchronous response
        Timeout:      30 * time.Second,
    })

Key parts of the API:

// Options controls a single Publish call.
type Options struct {
    EdgeID       string        // correlates to a TinyNodeEdge; empty for external triggers
    From         string        // upstream node:port; use wire.FromSignal for external triggers
    WaitForReply bool          // block until the receiver replies; false = fire-and-forget
    Timeout      time.Duration // caps WaitForReply; zero defaults to 30s
}

func Publish(ctx context.Context, nc *nats.Conn, targetNode, port string,
    data []byte, opts Options) ([]byte, error)

Callers obtain the *nats.Conn however they can — directly in-cluster for hosted deployments, or port-forwarded for BYOC clusters.

From: wire.FromSignal Matters

The runner unmarshals the raw payload into the port's struct only for signal-originated messages. Any other From value (including empty) makes it fall back to edge-config evaluation or the port's default configuration — silently discarding your payload. Always set From: wire.FromSignal for external triggers.

Subject Routing

Publish picks the NATS subject from the target port name:

Target portSubjectDelivery
Business port (input, request, …)tinymodule.<module>.msgQueue group — one pod of the module handles it
System port (starts with _: _control, _settings, _reconcile)tinymodule.<module>.sysmsgFan-out — every pod receives it; each component's utils.IsLeader(ctx) check gates the action

Wire metadata travels in NATS headers (x-to, x-from, x-edge-id, W3C traceparent, …) — the same headers the inter-module wire uses, so the SDK receiver dispatches signals and edge hops through one code path.

Signal Execution Flow

+-----------------------------------------------------------------------------+
|                           SIGNAL EXECUTION FLOW                              |
+-----------------------------------------------------------------------------+

1. PUBLISH
   External trigger (dashboard Send, MCP send_signal, debugger) calls
   wire.Publish -> NATS subject tinymodule.<module>.msg (or .sysmsg)

2. DELIVERY
   The module's subscriber receives the message
   (queue group for business ports, per-pod fan-out for system ports)

3. DISPATCH
   transport.handleIncoming -> scheduler.Handle -> Runner
   From == "signal" => raw payload unmarshaled into the port struct

4. EXECUTION
   Component.Handle() runs; downstream emits continue the flow

5. REPLY (optional)
   With WaitForReply, the handler's synchronous result travels back
   on a core-NATS inbox; errors surface via the x-error header

Replies and Errors

  • Fire-and-forget (WaitForReply: false): returns (nil, nil) as soon as the broker accepts the publish.
  • With WaitForReply: true: the receiver's reply payload is returned. A handler failure comes back as a Go error (from the x-error header); non-retryable failures carry their error code (x-error-code).
  • An empty-but-successful reply returns (nil, nil).

Sending Signals from a Component

Components never need wire.Publish for in-flow work — emitting to an output port via the injected module.Handler already routes across modules. wire is the boundary primitive for processes outside the flow graph.

Best Practices

1. Always Mark External Triggers

opts := wire.Options{From: wire.FromSignal}

2. Set a Deadline When Waiting

opts := wire.Options{From: wire.FromSignal, WaitForReply: true, Timeout: 10 * time.Second}

3. Return Quickly from Handlers

Signals into long-running work should acknowledge fast and continue asynchronously. Components that emit from background goroutines receive a long-lived emitter via module.EmitterAware:

func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    go func() {
        result := longOperation()
        c.emit(context.Background(), "result", result) // emitter from OnEmitter
    }()
    return module.Ok(nil) // acknowledge quickly
}

Next Steps