# 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](https://github.com/markc/mixos)). 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](#no-broker--graceful-degradation)) —
> 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.

```mix
send noded noded.ping            -- RPC to the LOCAL broker
print("rc=" .. $rc)
print("" .. $result)
```
```text
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](#serve-mode--mix-as-a-daemon-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](collections.md), 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](#reading-rc-ok-vs-application-error-vs-transport-failure). |
| `$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. |

```mix
send noded noded.info
print("rc=" .. $rc)
print("" .. $result)
```
```text
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](collections.md)):

```mix
send noded noded.ping
if $result.pong then
  print("pong is true")
end
print("core ext: " .. ("" .. $result.extensions.core))
```
```text
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:

```mix
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):

```mix
$r = send noded noded.ping
print("captured: " .. ("" .. $r))
```
```text
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.)

```mix
send noded noded.ping limit=2 note="hello"
print("rc=" .. $rc)
```
```text
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:

```mix
send nonesuch some.cmd
print("rc=" .. $rc)
print("result=" .. ("" .. $result))
```
```text
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](#per-send-timeout) and
[no broker](#no-broker--graceful-degradation).

**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:

```mix
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, `die`d 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](serve.md#handler-fault-isolation-the-per-request-boundary).

## `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:

```mix
$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.

```mix
emit noded noded.ping note=hi
-- no $rc is written by emit; do not read it after an emit
print("emitted")
```
```text
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:

```mix
$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](shell-mode.md)'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.

```mix
address noded
  noded.ping
  noded.info
end
print("rc=" .. ("" .. $rc))
```
```text
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.

```mix
send statecache INFO
print("" .. $result)
```
```text
{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.

```mix
-- 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:

```mix
$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))
```
```text
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.

```mix
-- 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.

```mix
print("statecache: " .. ("" .. port_exists("statecache")))
print("ghost:      " .. ("" .. port_exists("ghostservice")))
```
```text
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](serve.md).

```mix
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](#verb-names-with-keyword-segments-mix-0962).
A hyphenated name is read whole, with the same shape and the same two
quoting exceptions as a [hyphenated `send` target](#send-and-the-verified-session-lane):
`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` |

```mix
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:

```mix
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)`:

```mix
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.

```mix
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](bus.md) 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).

```mix
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](serve.md) — 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:

```mix
send statecache HELP
print("" .. $result)
```
```text
[{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:

```mix
-- 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 `send`s 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](#async-handlers-class-c)).

```mix
-- 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

| 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.
- `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](strings.md) — `..` concat for building dynamic targets/verbs; `${…}` vs literal `$name`
- [collections](collections.md) — field access into `$result` / `$event` maps
- [functions](functions.md) — handler bodies, lambdas, the pass-in/return/reassign state idiom
- [running commands](system.md) — `run` / `run_rc` / `ssh_run` for non-Bus I/O
- [builtins index](builtins.md) — `port_exists`, `subscribe`, `reply`, `bus_reconnect`, `sleep_ms`
- The [cos repo](https://github.com/markc/mixos) — `noded`, the Bus broker
- The [mix repo](https://github.com/markc/mixos) — [AGENTS.md](https://github.com/markc/mixos/blob/main/AGENTS.md) is the agent orientation sheet; this manual is the language reference
- ARexx background — [Wikipedia: ARexx](https://en.wikipedia.org/wiki/ARexx)
- `mix help` · `mix what send` · `mix what emit` · `mix what address` (`on`/`reply` are handler forms `mix what` does not index)
