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 frommix 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:
| Var | Meaning |
|---|---|
$result | the reply value (a map, list, string, … — field-accessible) |
$reply | the 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. |
$rc | numeric 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 likeprops.audit.watch) with anamespace=arg — which reads args from Bus headers, so a kv-onlysend webd webd.props.get namespace=x key=yworks without an explicitbody="". (SPEC-07 flat-path reads —noded.props.get path=…— carry nonamespace=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_code | When |
|---|---|---|
| 10 | UNKNOWN_COMMAND | no on handler for the verb |
| 15 | HANDLER_FAULT | the handler raised, died or panicked before replying; the real error is in the citizen's log, never on the wire |
| 16 | HANDLER_CANCELLED | the 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:
emitis a statement, not an expression —$x = emit …is a parse error. (sendcan be an expression;emitcannot.)- Because
emitwrites neither$rcnor$result, reading$rcright after anemit(with no priorsend) 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'scommand:header (what subscribers'onmatches).body— the payload string (json_encodea 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 topiccomp.corner.enteredwith inner commandcorner.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:
| name | why | write |
|---|---|---|
a--b | -- opens a comment, so the bare word stops there and the shape is refused | send "a--b" … |
fn-svc | fn starts a lambda | send "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 returnfalseeven whilesend noded noded.pingsucceeds —port_existsreflects 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:
| Field | Type | Access |
|---|---|---|
$event.command | string | the inbound verb |
$event.headers | map | scalar headers, e.g. $event.headers["topic"] |
$event.body | string | the raw message body |
$event.args | parsed body | nil | the 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) cansend/emitand evensubscribe, 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).sendreturnsnilwith$rc = -3(RC_UNAVAILABLE — "Bus unavailable before delivery";$resultnames the URL it tried),emitis silently dropped,port_existsreturnsfalse— no raise.emitandport_existsdon't re-probe (no per-call boot cost). - Had a broker, lost the connection (
Lost).sendreturnsnilwith$rc = -1(RC_TRANSPORT) andemitis silently dropped — both non-fatal, so a mesh script survives a broker blip instead of crashing.port_existsraisesmesh unavailable: … (call bus_reconnect() to retry the probe). sendretries. In either state asenddials again before it answers, re-readingMIXOS_NODED_URLand the node config, bounded by its owntimeout=. Withouttimeout=, 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 bytimeout=when supplied, else by the client's 60-second safety net. A script that sent before its broker listened, that changedMIXOS_NODED_URL, or that outlived a broker restart recovers on its nextsend(since 0.117.0; before, every send answered-3untilbus_reconnect()). A failed retry leaves the state as it was.subscribe/unsubscribe/replyraise in both states — these can't be faked; a script must know they didn't happen.bus_reconnect()resets the probe toUnprobedso the next Bus form re-dials — the recovery primitive foremit,port_existsand 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
| Form | Direction | Sets $rc/$result? | Waits? |
|---|---|---|---|
send t cmd k=v | out, RPC | yes | yes (reply) |
$r = send t cmd | out, RPC (expr) | yes | yes |
emit t cmd k=v | out, fire-and-forget | no | no |
address t … end | out, block of sends | yes (last line) | yes per line |
on cmd … end | in, handler | — | event pump |
on cmd async … end | in, 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.
emitis a statement only (no$x = emit …) and sets neither$rcnor$result.- Inside
address, do not writesend(each line is already a send). - Synchronous request cycles deadlock — keep the graph acyclic.
send/emitare non-fatal in every failure state:sendwrites a negative$rc(-3no broker,-1lost/transport,-2timeout) and returnsnil,emitis silently dropped.port_existsraises once a broker was seen and lost;subscribe/unsubscribe/replyraise whenever the broker is unreachable.
See also
- strings —
..concat for building dynamic targets/verbs;${…}vs literal$name - collections — field access into
$result/$eventmaps - functions — handler bodies, lambdas, the pass-in/return/reassign state idiom
- running commands —
run/run_rc/ssh_runfor 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/replyare handler formsmix whatdoes not index)