bus — CosMix Agent Bus messaging (send / emit / address / on)

Bus messaging is what makes Mix more than "a better shell." send, emit, address, and on … end are language keywords, not library calls — Mix talks to a local message broker (and, over the mesh, to remote brokers) with no SDK, no client object, no boilerplate. This is the ARexx lineage made native: every service is a named, addressable port; a Mix one-liner can drive any of them.

Most examples on this page need a live broker (cosmix-noded, from the cos repo). Where a broker is running the output shown is real, captured from mix 0.21.2. Where no broker is present the Bus forms degrade gracefully (see No broker) — that path is verified separately. Treat the networked examples as illustrative of the shape; the language facts (keywords, $result/$rc, RPC vs fire-and-forget, target syntax) are exact.

send noded noded.ping            -- RPC to the LOCAL broker
print("rc=" .. $rc)
print("" .. $result)
rc=0
result={extensions: {core: 1.0, topic: 1.0}, pong: true}

The model in one paragraph

A broker (cosmix-noded) runs on the node. Services register a name (noded, statecache, webd, …). You send a service a command (a dotted verb like noded.ping) with optional key=value args; the reply lands in $result and the status code in $rc. That's an RPC. emit is the same shape but fire-and-forget — no reply, no wait. address is a block of implicit sends to one target. on … end is the other direction: it subscribes a handler that fires when a matching message arrives — the basis of a Mix citizen that is an addressable service.

The win over bash/python: there is no equivalent without a client library. In Mix it is grammar.


send — RPC (request/reply)

send <target> <command> [key=value …] dispatches a command and waits for the reply. Two result variables are set as a side effect:

VarMeaning
$resultthe reply value (a map, list, string, … — field-accessible)
$rcnumeric status in signed bands: 0 delivered+accepted · 1..9 delivered with a warning (still success) · >= 10 peer application error (the exact peer rc is kept) · -1 transport failure · -2 per-send timeout= exceeded · -3 Bus unavailable (no broker). All negatives are non-fatal.
send noded noded.info
print("rc=" .. $rc)
print("" .. $result)
rc=0
{node: node1, noded: {binary: cosmix-noded, name: noded, pid: 1342, version: 0.6.10, ...}, schema_version: 1, service_count: 8, uptime_s: 9445, wg_ip: 192.0.2.5}

$result is structured data — reach into it with .field access or ["key"] (see collections):

send noded noded.ping
if $result.pong then
  print("pong is true")
end
print("core ext: " .. ("" .. $result.extensions.core))
pong is true
core ext: 1.0

send as an expression

send also returns the reply, so you can capture it directly (it still sets $result/$rc too):

$r = send noded noded.ping
print("captured: " .. ("" .. $r))
captured: {extensions: {core: 1.0, topic: 1.0}, pong: true}

Args: scalars, body=, and numbers

For send, bare key=value args are serialized as a JSON body (RPC framing) by default — noded/indexd/maild verbs read their args from it — with two exceptions that select header routing (scalar args → Bus headers, body= → the body channel):

  • a body= key is present (the caller is speaking the headers+body shape), or
  • the command is a SPEC-12 namespace-mode property call — <svc>.props.<op> (incl. multi-segment ops like props.audit.watch) with a namespace= arg — which reads args from Bus headers, so a kv-only send webd webd.props.get namespace=x key=y works without an explicit body="". (SPEC-07 flat-path reads — noded.props.get path=… — carry no namespace= and stay JSON-body, as their servers expect. Both triggers apply on the broker path only; the local Unix-port path has no header mode.)

(emit header-routes all map args — RPC-style JSON body is a send/call shape.) Whole-number args serialize as JSON integers, not floats — so limit=2 arrives as 2, and a peer field typed usize/i64 accepts it; a fractional value (2.5) stays a float.

A body=-bearing send awaits its reply exactly like a positional or scalar-header send — the handler's reply(...) lands in $rc/$result. (Only emit is fire-and-forget; the arg shape never changes whether a reply is collected.)

send noded noded.ping limit=2 note="hello"
print("rc=" .. $rc)
rc=0

Reading $rc: ok vs application error vs transport failure

A $rc of >= 10 from a reachable broker is an application error (e.g. unknown service, rejected command) — the peer's exact rc is preserved (a peer rc=42 stays 42, never flattened to 10) and it does not mean the mesh is down. A $rc in 1..9 is a delivered-with-warning success:

send nonesuch some.cmd
print("rc=" .. $rc)
print("result=" .. ("" .. $result))
rc=10
result=Service 'nonesuch' not found

A negative $rc is a local, non-fatal signal — the send never reached a peer, so $result carries the reason string and the script continues: -1 transport failure (a broker was there but the send failed — lost or broken connection), -2 a per-send timeout= budget exceeded, -3 Bus unavailable (no broker was ever present, a bare host). See timeout and no broker.

A structured refusal keeps its body. When a peer answers rc >= 10 with a JSON object naming an error_code, $result is that object — field-accessible, so $result.error_code and whatever else it carries (reason, retry_requires) are readable. Branching on those is the whole point of a refusal; flattened to prose it leaves a script parsing English to decide whether to retry. This is narrow on purpose: a peer that answers an error as plain text, or as JSON of some other shape, still produces exactly the string it always did. Only a body naming error_code takes the structured path. (0.87.0)

send and the verified session lane

A locally registered service can be addressed two ways, and only one of them carries an identity. A TCP connection is known by a name the caller asserts about itself, which is not an authority — which is why a session-enrolled service's protected verbs answer FORBIDDEN over it. A Unix connection to the local broker carries peer credentials the KERNEL supplies, so the broker learns who is calling without having to be told.

send now prefers that verified lane, with no change to how you write it:

$list = send noded "noded.list"          -- find the instance
$r = send $name "term.session" body=$b   -- a protected verb, admitted

This grants no new authority. Any same-uid process can already open this connection — the pane shell and every test harness do — and a local DefaultOpen service admits the resulting session-less ambient principal for a matching uid/node/broker epoch. What was missing was a way for a SCRIPT to use it. (0.87.0)

The fallback is not an error path. Where no verified lane is available — a headless host with no local broker socket, a broker running under another account, a genuinely remote target — send falls back to exactly the path it used before and behaves exactly as it did. The attempt costs nothing when the socket is absent: a path that does not exist fails at the stat, not at a timeout. The lane is chosen once per connection, so a script can never end up with some sends authenticated and others not.

Discovery. noded.list projects by uid: a caller that owns a record gets its full ServiceInfo including the session details, while everyone else gets the bare name. A driver over the verified lane is same-uid by construction, so one send noded "noded.list" is enough to find a service's per-instance name and address it.


emit — fire-and-forget

emit <target> <command> [key=value …] dispatches and returns immediately. No reply is awaited and $rc/$result are not set by emit — it is a statement that yields nil, used when you don't care about (or can't get) an answer, e.g. publishing to a topic or notifying a service.

emit noded noded.ping note=hi
-- no $rc is written by emit; do not read it after an emit
print("emitted")
emitted

Gotchas:

  • emit is a statement, not an expression — $x = emit … is a parse error. (send can be an expression; emit cannot.)
  • Because emit writes neither $rc nor $result, reading $rc right after an emit (with no prior send) raises undefined variable $rc — pre-init it or just don't.

publish(topic, body[, opts]) — one-call topic publish (0.63.0)

Publishing to a topic used to require two pieces of lore: the body handed to noded topic.publish must itself be a SPEC-02 wire frame (---\ncommand: …\n---\npayload), and the send needed body= spelled before name=/retain= would header-route. publish() does both:

$rc2 = publish("comp.corner.entered", json_encode({corner: "tl"}))
if $rc2 != 0 then eprint("publish failed: " .. $result) end
  • topic — the noded topic name; by default it is also the inner frame's command: header (what subscribers' on matches).
  • body — the payload string (json_encode a map first — the wire format is the caller's choice, never hidden). nil/absent → empty.
  • opts{retain: bool} (default false); {command: "corner.entered"} overrides the inner frame header (the fleet publishes topic comp.corner.entered with inner command corner.entered); {headers: {event_seq: 1042}} adds frame header lines. Topic, command and headers are newline-checked — frame injection raises instead of corrupting the wire.

Sets $rc/$result exactly like send and returns the rc, so if publish(..) != 0 reads naturally. Without a broker it degrades like send: $rc = -3, non-fatal.

Hyphenated targets — quote them. send comp-nested … parses the bare hyphen as subtraction; write send "comp-nested" …. Hyphenated service names are the norm on the mesh, so this bites early — quoting is the supported spelling (a bare-hyphen grammar change would collide with arithmetic and is not planned).


address — a block of implicit sends to one target

address <target> … end opens a block where each line is an implicit send to that target. Drop the send keyword inside the block — writing it is a parse error (the runtime catches the typo deliberately). Each line sets $rc/$result in turn, so after the block they hold the last send's outcome.

address noded
  noded.ping
  noded.info
end
print("rc=" .. ("" .. $rc))
rc=0

address is the ergonomic ARexx-style "talk to one port for a while" form. It is purely a shorthand for repeated send <same-target> … lines.


Targets: static dotted .bus vs dynamic built

The command position of send/emit/address accepts an expression, and so does the target. Two shapes matter:

1. A bare local service namenoded, statecache, webd. The broker routes it on the local node.

send statecache INFO
print("" .. $result)
{description: Mix supervised Bus citizen (SPEC 18 Phase 1 runtime), name: statecache, version: 0.21.2}

2. A static dotted .bus address<service>.<node>.bus addresses a remote node's broker directly, mesh-routed, no ssh and no string building. This literal-address path fires only for a bare identifier immediately followed by a dot (e.g. noded.node1.bus); a single bareword, a $var, a (paren expr), a bareword call like env("X"), an index, and a .. concat all stay ordinary expressions.

-- illustrative (needs the named node to exist on the mesh):
send noded.node1.bus noded.info
print("" .. $result)

Self-addressing works — send noded.<thisnode>.bus resolves to the local broker.

3. A dynamic target from a loop/var — build the address (or the verb) with .. concat. This is the path when the node name isn't a literal:

$node = "node1"
$target = "noded." .. $node .. ".bus"
-- send $target noded.info   -- illustrative; routes to that node's broker

-- the verb is an expression too:
$verb = "noded" .. ".ping"
send noded $verb
print("rc=" .. ("" .. $rc) .. " result=" .. ("" .. $result))
rc=0 result={extensions: {core: 1.0, topic: 1.0}, pong: true}

Edge: if an inline (expr) in command position collides with the parser, pre-build the verb into a $var (as above) and pass the var.


Per-send timeout=

send … timeout=<seconds> puts a wall-clock budget on the RPC (cooperative cancellation; the pending-reply slot is freed on expiry). On timeout you get $rc = -2 (RC_TIMEOUT) — its own numeric band, distinct from -1 transport — with a timeout: … reason in $result. Non-fatal: the script continues.

-- against a 2s-slow downstream:
send slowsvc do.work timeout=0.5
-- $rc     == -2      (RC_TIMEOUT, numeric)
-- $result == "timeout: send to slowsvc exceeded 0.5s"

Rules (all raise a RuntimeError if violated): timeout= must be a finite, positive number; absence means "no timeout" (there is no sentinel value); nil is treated as absent (so timeout=$t is fine when $t may be unbound); duplicate timeout= is last-wins. The timeout= slot is consumed locally and does not appear in the args delivered to the peer.


port_exists(name) — is a service registered?

A builtin (not a keyword) that asks the broker whether a named service is in its registry. Useful as a guard before an RPC.

print("statecache: " .. ("" .. port_exists("statecache")))
print("ghost:      " .. ("" .. port_exists("ghostservice")))
statecache: true
ghost:      false

Note: the broker need not list itself in its service registry, so port_exists("noded") can return false even while send noded noded.ping succeeds — port_exists reflects the registered-services list, not broker reachability.


on … end — receiving messages (handlers)

on <command> [desc "doc-string"] [async] … end (desc and async compose in either order) registers a handler that fires when a matching Bus message arrives. The handler body uses newline- or ;-separated statements closed by endno do keyword. The <command> matches the inner command of the inbound message (the verb the publisher sent), not the topic/target name — check the publisher to know what to match. The optional desc "…" doc-string (mix ≥ 0.87.1) is static metadata: a serve citizen surfaces it as the verb's description in its HELP reply — see Serve citizens.

on order.created desc "Acknowledge a new order"
  print("got an order: " .. $event.body)
  reply("ack")
end

Legacy done still closes on (with a deprecation warning); prefer end.

The $event map

Inside a handler, the inbound message is available as $event, a map with four fields:

FieldTypeAccess
$event.commandstringthe inbound verb
$event.headersmapscalar headers, e.g. $event.headers["topic"]
$event.bodystringthe raw message body
$event.argsparsed body | nilthe body pre-parsed as JSON, or nil
on topic.delivery
  $topic = $event.headers["topic"]
  print("delivery on " .. $topic .. ": " .. $event.body)
end

$event has dynamic extent (0.63.0): a fn the handler body calls — at any depth — sees the same $event, so helpers like fn describe() return $event.headers["topic"] end work. (Before 0.63.0 the handler→fn hop lost it — function frames only fall through to globals — and the resulting NAME_UNDEFINED was swallowed by fault isolation: the blind-but-healthy citizen.) A fn's own $event parameter or local still shadows it; $event stays read-only inside the dispatch; after the dispatch a top-level $event global is restored (or reads nil if there was none).

$event.args is a convenience view of the body: when the sender used the JSON-body path — a positional send svc cmd "a" "b" (which arrives as {"_0":"a","_1":"b"}) or a map arg — the body is already-parsed JSON, so $event.args["_0"] / $event.args.field save you a json_parse($event.body). It is symmetric with the already-parsed $event.headers map; $event.body stays the raw string regardless.

$event.args is nil when the body is empty or is not JSON — a raw body="hi" text payload reads back as nil, so a handler tells "structured args" from "raw text" and reaches for $event.body in the latter case:

on order.submit
  if $event.args != nil then
    reply("ok, item " .. ("" .. $event.args["_0"]))   -- send order.submit "widget"
  else
    reply(10, "expected structured args, got: " .. $event.body)
  end
end

reply(...) — answer a request

From inside an on handler servicing a request (one expecting a reply), call reply(body) or reply(rc, body):

on statecache.get
  reply($current_value)         -- rc defaults to 0
end

on do.validate
  if not $event.body then
    reply(10, "missing input")  -- non-zero rc = application error to the caller
  else
    reply(0, "ok")
  end
end

reply rules: rc must be an integer 0..=255; it may only be called from inside an on handler; it errors loudly if the current event is not a request (a topic delivery has no caller to answer). A dropped reply would block the requester forever, so every reply failure path is a hard error by design.

async handlers (Class C)

A trailing async on the handler header marks it Class C — the dispatch yields at every send/reply/sleep_ms so concurrent invocations interleave instead of head-of-line-blocking each other behind a slow downstream call. Plain (non-async) handlers are Class S: run-to-completion, one at a time. Use async only when a handler makes a slow/remote downstream send and the citizen serves concurrent callers.

on aggregate.report async
  send slow.upstream fetch.data          -- yields here; peers can interleave
  reply($result)
end

async is a contextual modifier, not a reserved word — variables/keys named async elsewhere are unaffected. Synchronous request cycles (A→B→A) are prohibited and deadlockasync does not legalise them; break a cycle with fire-and-forget emit + a topic reply instead.

subscribe / unsubscribe — topic pub/sub

subscribe("topic.name") registers interest in a topic so topic deliveries reach a matching on handler; unsubscribe("topic.name") drops it. Both are builtins that require a live broker — unlike send/emit, they raise rather than no-op when no broker is reachable (a script must not believe it subscribed when it didn't).

subscribe("metrics.cpu")
on metrics.cpu
  print("cpu sample: " .. $event.body)
end
-- … then enter serve mode / the event pump to actually receive them

Serve mode — Mix as a daemon (citizen)

mix --serve <script.mix> [--name <service>] runs a script as a first-class, supervised Bus citizen: the top-level body executes once as init (open resources, subscribe, register on handlers), then the runtime registers the service name and enters the event pump, dispatching inbound messages to your on handlers. This is the AmigaOS "application with an ARexx port" model — a long-lived, addressable Mix process that is a service. The full lifecycle contract (registration, reconnect/backoff, handler fault isolation, graceful SIGTERM/QUIT shutdown, health properties) is SPEC 18.

Handler faults are isolated but OBSERVABLE (0.63.0). A raise or panic in a handler body never kills the citizen (SPEC 18 §3.4) — but it no longer disappears either: one mix: handler fault: <command>[N]: … line reaches stderr, lifecycle.health flips to degraded, and lifecycle.handler_faults / lifecycle.last_fault count and name it in the props surface. Before 0.63.0 a faulting handler looked exactly like a healthy one (noded reported delivered=1 while its side effects silently never happened).

A live citizen answers the standard verbs like any Rust daemon:

send statecache HELP
print("" .. $result)
[{args: [], description: List all commands this service accepts, name: HELP}, {args: [], description: Service identity and capabilities, name: INFO}, {args: [], description: Graceful shutdown: deregister, then exit 0 (SPEC 18 §3.5), name: QUIT}, {args: [path?], description: Property snapshot at an optional path, name: statecache.props.get}, ...]

A minimal state-holder citizen:

-- statecache.mix — run with: mix --serve statecache.mix
$value = "(none)"

subscribe("config.current")

on config.current               -- a topic delivery updates our state
  $value = $event.body
end

on statecache.get               -- a request reads it back
  reply($value)
end

A transient mix <script> (not --serve) can send/emit and even subscribe, but it has no service name, no reconnect, and the process ends when the script does — fine for one-shot orchestration drivers, not for a resident service.


Orchestration — citizen ↔ citizen

A Mix citizen can send/address other citizens, so multi-service orchestration is native: pipeline (A→B→C), scatter-gather (fan a request to N workers, merge replies), supervisor/worker, and review-loop (a proposer and a critic iterating). A driver that is its own sole caller (a cron/oneshot/CLI invocation, no registered name) issuing sequential sends is correct under the cooperative loop. The hard rule: keep the synchronous-request graph acyclic; break any cycle with emit + topic replies (see the deadlock note).

-- scatter-gather sketch (illustrative)
$replies = []
for each $w in ["worker.a", "worker.b", "worker.c"]
  send $w do.task input=$payload
  push($replies, $result)
end
-- merge $replies …

No broker — graceful degradation

The handler uses a lazy probe with a small state machine, so Bus forms behave predictably on a host that may or may not have a broker:

  • Never had a broker (NeverPresent). send returns nil with $rc = -3 (RC_UNAVAILABLE — "Bus unavailable before delivery"), emit is silently dropped, port_exists returns falseno raise, and subsequent Bus forms don't re-probe (no per-call boot cost). The same binary becomes mesh-viable the moment a broker appears.
  • Had a broker, lost the connection (Lost). send returns nil with $rc = -1 (RC_TRANSPORT) and emit is silently dropped — both non-fatal, so a mesh script survives a broker blip instead of crashing. port_exists raises mesh unavailable: … (call bus_reconnect() to retry the probe).
  • subscribe/unsubscribe/reply raise in both states — these can't be faked; a script must know they didn't happen.
  • bus_reconnect() resets the probe to Unprobed so the next Bus form re-dials — the recovery primitive after installing a broker or after a broker bounce.

This is deliberate: send/emit stay non-fatal in every state (a bare host reads $rc = -3, a lost broker -1), so a script isn't forced to guard Bus it may never use — and because the bare-host path no longer fakes rc=0, a $rc == 0 now strictly means delivered. The forms that can't be faked (port_exists once a broker was lost, subscribe/unsubscribe/reply in either state) still raise, so a genuine outage is heard.


Quick reference

FormDirectionSets $rc/$result?Waits?
send t cmd k=vout, RPCyesyes (reply)
$r = send t cmdout, RPC (expr)yesyes
emit t cmd k=vout, fire-and-forgetnono
address t … endout, block of sendsyes (last line)yes per line
on cmd … endin, handlerevent pump
on cmd async … endin, Class C handleryields on send
reply(body) / reply(rc, body)answer a requestno
subscribe(t) / unsubscribe(t)topic interestyes (raises if no broker)
port_exists(name)registry queryyes
bus_reconnect()reset the probe

Sharp edges:

  • Command position matches the publisher's inner verb, not the topic/target.
  • emit is a statement only (no $x = emit …) and sets neither $rc nor $result.
  • Inside address, do not write send (each line is already a send).
  • Synchronous request cycles deadlock — keep the graph acyclic.
  • send/emit are non-fatal in every failure state: send writes a negative $rc (-3 no broker, -1 lost/transport, -2 timeout) and returns nil, emit is silently dropped. port_exists raises once a broker was seen and lost; subscribe/unsubscribe/reply raise whenever the broker is unreachable.

See also

  • strings.. concat for building dynamic targets/verbs; ${…} vs literal $name
  • collections — field access into $result / $event maps
  • functions — handler bodies, lambdas, the pass-in/return/reassign state idiom
  • running commandsrun / run_rc / ssh_run for non-Bus I/O
  • builtins indexport_exists, subscribe, reply, bus_reconnect, sleep_ms
  • The cos repocosmix-noded, the Bus broker
  • The mix repoAGENTS.md is the agent orientation sheet; this manual is the language reference
  • ARexx background — Wikipedia: ARexx
  • mix help · mix what send · mix what emit · mix what address (on/reply are handler forms mix what does not index)