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/wirepackage — same effect (dispatch to a specificnode:portfrom 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 port | Subject | Delivery |
|---|---|---|
Business port (input, request, …) | tinymodule.<module>.msg | Queue group — one pod of the module handles it |
System port (starts with _: _control, _settings, _reconcile) | tinymodule.<module>.sysmsg | Fan-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 thex-errorheader); 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
- Controller Reconciliation - Reconciliation patterns
- Message Flow - How messages travel
- Cross-Module Communication - Transport details