bus — MixOS 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 (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}

Connection events

In --serve, on bus.connected receives {generation} in $event.args after the initial connection and after reconnection. The generation comes from the supervised client's successful-connection counter; it is not a wall-clock time or the script's reload generation. Use the event to refresh snapshots and reconcile after a broker restart, without a recovery timer.

The adapter waits on subscribe_state(). It emits at most once per observed connected generation. State changes can coalesce while a handler is busy: generations can skip, so treat the notice as a request to resynchronise rather than an audit log of every connection. The state observer is shared with the supervised connection across script reloads; a reload alone does not emit a new connection event. A dropped receive future preserves state readiness. The lazy non-serve Bus connection contract is unchanged.

Local native events (fs.changed, proc.exited, bus.connected) share the same handler dispatch and $event.args conversion as incoming Bus events. Structured event bodies are parsed even in a library build without the optional JSON builtin feature. This introduces no new transport or local-only command gate; the citizen's public verbs retain their existing local/mesh routing.

The model in one paragraph

A broker (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. Three result variables are set as a side effect:

VarMeaning
$resultthe reply value (a map, list, string, … — field-accessible)
$replythe WHOLE reply body, JSON-parsed, for every response — success or refusal, whatever its dialect — and nil when the body is empty or not JSON, or the send never got a reply (0.92.0). See below.
$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: 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

Verb names with keyword segments (mix 0.96.2)

A bare verb is a dotted name, and any segment may be a Mix keyword — Bus verbs belong to the services that define them, and select, if, print, end are ordinary verb words there:

send ced ced.select anchor=0
send edit edit.select
emit x x.if.for
on ced.select
  reply("ok")
end

The same rule holds everywhere a bare Bus name is written: the send/emit command, address body lines, a dotted target (send print.x verb), and the on handler header. A keyword may also lead the name, but only when dotted (send svc select.all, on print.page); a bare keyword verb (send svc select) stays a parse error — quote it: send svc "select". fn and function segments are read by their source spelling (x.fn is "x.fn"). Before 0.96.2 a keyword segment failed with unexpected token Dot and the verb had to be quoted (send ced "ced.select"); quoting still works.

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 — on the header route. When a header-routed send (one with a body= arg, or a SPEC-12 *.props.* call with namespace=) gets 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. (0.87.0) The ordinary JSON-body send never did this: there $result is the error header, else the body's error string, else the body text verbatim — so an error_code body with no error field arrives as raw JSON TEXT in $result. On either route, read the structure from $reply (below), which is the parsed body whatever its shape; $result is for the message.

$reply keeps every field of every reply. Most daemons refuse in the other dialect — {"error": "occluded", "under": {…}} — and $result reduces that to its message string on purpose, so existing scripts keep working. The detail (occluded.under, stale_target.current, timeout.waited_ms, invalid_args.allowed) is in $reply, the whole parsed body:

send comp comp.window.focus id=2
if $rc == 10 and $result == "occluded" then
  print("covered by window " .. to_string($reply.under.id))
end

$reply is set on every send (and every address-block line): the parsed body for a success (the same value as $result) or a refusal of either dialect, and nil when the body is empty or not JSON, or when no reply arrived ($rc < 0). One limit: a target reached over its local Unix port (a sibling user-service, not the broker) reports a refusal as a message only, so there $reply is nil for a refusal. (0.92.0)

$rc, $result and $reply are shared, not per-call. A send updates the existing variable of that name — and once a top-level send has created them, that is the script's global, so a send inside a function or an on handler overwrites it too. In a mix --serve citizen whose async handlers interleave, handler A can send, yield, and read back the $rc/$result/$reply of handler B's send. ($event is only partly different: a handler BODY's own $event is per-invocation, but a function called from that body reads a shared global $event and has the same interleaving hazard — pass $event into the function as an argument.) Per-invocation binding of all three is planned (filed in the Mix TODO list). Until it lands, read them immediately after the send, or capture the reply with the expression form — $r = send svc cmd — and copy $rc into a local before the next await.

A wrong verb name answers at once. A mix --serve citizen (or any script with on handlers) that receives a request for a command it has no handler for refuses it immediately with rc 10. On an ordinary send $result is the message ("unknown command 'x' (this citizen handles: …)"); on the header route (a body= send) the error_code rule above makes $result the whole object instead. Either way the structure is in $reply — $reply.error_code == "UNKNOWN_COMMAND", $reply.command the verb you sent, $reply.available the citizen's declared handlers. (Before 0.92.0 the request was dropped: the caller waited out its full timeout and got -2, which read as a mesh problem.) So a -2 from a citizen that answers its other verbs is no longer a typo symptom — look at the citizen's handler instead. Props paths such as lifecycle.generation are not verbs: read them with send svc svc.props.get path="lifecycle.generation". The code is a Mix citizen's: other daemons name the same refusal differently — comp answers unknown_verb, noded its own rc 10 message — so a script that must work against any service should test $rc >= 10 and read the code from $reply rather than match one spelling.

A Mix citizen's runtime refusals are all in the error band. Besides UNKNOWN_COMMAND, the serve runtime answers a request itself in two more cases, each with an error message and an error_code. rc 15 and 16 are the runtime's; pick another rc >= 10 for a handler's own reply() refusals, and treat error_code as the authoritative signal:

$rc$reply.error_codeWhen
10UNKNOWN_COMMANDno on handler for the verb
15HANDLER_FAULTthe handler raised, died or panicked before replying; the real error is in the citizen's log, never on the wire
16HANDLER_CANCELLEDthe citizen shut down or reloaded while the handler was still running

Before mix 0.94.0 the last two answered rc 1 and rc 2, which this section defines as delivered-with-warning success, so a $rc >= 10 check missed them. See handler fault isolation.

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 sets $reply to nil, so an earlier send's reply is never read as the publish's) and returns the rc, so if publish(..) != 0 reads naturally. Without a broker it degrades like send: $rc = -3, non-fatal.

Hyphenated targets need no quoting. send comp-nested …, emit desktop-vt1 … and address shell-scenes-p2 … end all read the hyphenated name whole. Hyphenated service names are the norm on the mesh, so the bare form has to work; quoting and $var remain exactly equivalent spellings.

The accepted shape is deliberately narrow, and is the shell classifier's tight-hyphen rule for command heads plus . for dotted names: an ASCII letter or _ first, at least one -, alphanumerics / _ / - / . within, an alphanumeric or _ last, and the whole thing delimited by whitespace, a newline, a ;, or end of input. A first segment that is a Mix keyword is fine too (next-hop, print-server, on-boot), because a bare keyword in target position was never anything but a parse error.

Anything else keeps the expression reading it always had — a call (send env("DEST") …), an index, a parenthesised expression, a $var, and a spaced send a - b …, where the space says an operator was meant. The one exception is a concat whose left operand is a bare hyphenated word (send a-b .. "c" …): that is now a parse error where it used to be a runtime one, because the word is taken as the target and the .. is left with nowhere to go. It could never have been a working concat — the left operand of .. there is a bareword, which is a string, so the subtraction ahead of it always failed first.

Quote a name the bare form cannot reach. Two shapes fall outside it, and quoting is the answer to both:

namewhywrite
a--b-- opens a comment, so the bare word stops there and the shape is refusedsend "a--b" …
fn-svcfn starts a lambdasend "fn-svc" …

All-digit segments are fine bare, including malformed-looking ones: svc-1, svc-10, bterm-bevy-3164175, desktop-vt01, and since 0.92.0 also svc-01, node-007 and a-1.2.3. Before 0.92.0 the lexer read a leading-zero or multi-dot segment as a bad NUMBER and refused the line (ambiguous leading-zero number '007', an error that never mentioned send); it now lexes such a segment as part of the word when it sits in the bare target right after send/emit/address, or in an on header (0.113.1). Everywhere else — $x = 007, a $var-007 target, a hyphenated command — the refusal is unchanged.

Nothing that worked before changed meaning: the shape requires a hyphen, so every target that already resolved still takes the expression path, and a bareword is a string, so a - b on two barewords was always the runtime type error "cannot use 'a' as number" rather than arithmetic. (Before this, the bare form died at runtime with exactly that message while mix --check and mix lint both passed the line — an earlier version of this paragraph told you to quote it and said the grammar would not change.)


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 name — noded, 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.

In --serve, a failed registry request raises a catchable mesh-unavailable error (Mix 0.109.0). Only a successful registry response can establish that a service is absent. emit retains its fire-and-forget, non-fatal language contract during a transport failure; it supplies no delivery acknowledgement.

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 end — no 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

The <command> is a dotted name whose segments may be Mix keywords (on ced.select, on print.page) — see keyword segments. A hyphenated name is read whole, with the same shape and the same two quoting exceptions as a hyphenated send target: on dopus-test.command.executed handles that topic (mix ≥ 0.113.1). Before 0.113.1 the header stopped at the hyphen, so the handler registered as dopus and -test.command.executed became its body: it parsed, linted clean and never fired. The quoted form, on "dopus-test.command.executed", works in every version.

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)

task_start(command, body) starts a registered handler chain in process, without sending a Bus message. command and body are strings; the body is decoded into $event.args just as for other handler invocations. The event has empty headers and no reply correlation. The chain must contain an async handler (TASK_HANDLER otherwise). It uses the existing Class C task registry, shutdown drain and runner LocalSet; embedders must provide that LocalSet. The builtin requires Process capability and is refused in expression mode.

Mixed chains retain declaration order: an async entry can wait on sleep, then a plain entry acquires the writer lock and reads current state to commit. Never assign a whole pre-wait state copy after a yield. A clock can start one local task at init and recheck its running flag on bus.connected, with no Bus self-emits. A task is a local continuation, not a separate service or transport, and does not provide exactly-once remote effects.

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 deadlock — async 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).

lifecycle.commit is a native hook, never a verb. After a hot-reload swap commits, the runtime queues one local lifecycle.commit event into the new generation (see Serve citizens — post-commit handover). A wire-delivered lifecycle.commit is refused with rc 10 before any author handler and is filtered from HELP: an external caller cannot run a loader's commit behaviour ahead of or behind the real commit.

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

For a one-shot local authority query, a caller may launch mix with MIXOS_NODED_EXPECTED_SERVER_PID set to a positive decimal PID obtained from its trusted process supervisor. This pins the server of the verified Unix connection to the kernel's connect-time SO_PEERCRED PID. A missing or mismatched PID refuses the connection before WebSocket setup or any Bus RPC. While the variable is present, Mix does not discover the Unix endpoint over TCP or fall back to anonymous TCP; it checks the pin again on every reconnect. An invalid value also refuses Bus traffic. Supply the variable only to the one-shot process that needs the check.

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"; $result names the URL it tried), emit is silently dropped, port_exists returns false — no raise. emit and port_exists don't re-probe (no per-call boot cost).
  • 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).
  • send retries. In either state a send dials again before it answers, re-reading MIXOS_NODED_URL and the node config, bounded by its own timeout=. Without timeout=, each lane's connect is bounded by 5 seconds, including the WebSocket handshake: up to 10 seconds if both the verified Unix and anonymous TCP lanes stall. After a connection succeeds, waiting for the reply is bounded by timeout= when supplied, else by the client's 60-second safety net. A script that sent before its broker listened, that changed MIXOS_NODED_URL, or that outlived a broker restart recovers on its next send (since 0.117.0; before, every send answered -3 until bus_reconnect()). A failed retry leaves the state as it was.
  • 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 for emit, port_exists and the serve forms 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, handler—event pump
on cmd async … endin, Class C handler—yields on send
reply(body) / reply(rc, body)answer a request—no
subscribe(t) / unsubscribe(t)topic interest—yes (raises if no broker)
port_exists(name)registry query—yes
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 commands — run / run_rc / ssh_run for non-Bus I/O
  • builtins index — port_exists, subscribe, reply, bus_reconnect, sleep_ms
  • The cos repo — noded, the Bus broker
  • The mix repo — AGENTS.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)

This page as Markdown · This page describes the current main branch of MixOS.