Messaging
The substrate everything else rides on: durable, addressed, reply-routable messages between endpoints — live when the target listens, spooled when it doesn’t, across machines once nodes are paired.
You’ve probably already run the quickstart; this page is the model.
Semantics
- Live-first, spool-fallback.
spt send <id>connects directly to a listening target (SENT); if the perch exists but nothing is listening, the message lands in the target’s durable spool (QUEUED) and drains on its nextready. A target with no perch is an error (NO_PERCH) — identity is never invented on someone else’s behalf. - Reply routing. Every message carries its sender id structurally;
the arriving
<EVENT from="…">envelope surfaces it, andspt send <sender>answers without knowing anything else. - The blocking ask.
spt ring <id>sends and waits for the reply (with a timeout) — the synchronous question between agents. - Per-message send control (three orthogonal axes). Each
spt sendcarries one value per axis; every axis defaults to unrestricted:- Delivery window (when) —
--active-onlyspools for the agent’s own poll without waking a live listener (it reaches the agent at its next natural boundary instead of interrupting now; also held for resting dormant/suspended instances and released exactly once on wake). This renames the older--deferred, which still parses as a hidden alias.--idle-onlyholds until the target is idle, then delivers (the wake). Default delivers in whichever window fires first. - Channel (through what) —
--prefer-nativeroutes through the target’s translation binary when one is running, else falls back to the standard delivery;--force-nativeuses the binary only (no fallback, no reroute — if no binary is live it reports non-delivery rather than spooling to another method). Default is unrestricted. The translation binary is the adapter’s idle-delivery filter; an adapter declares it with a[message-idle-translation-binary].command(a program token plus args, new in v0.16.0 — the barepathform is deprecated) and spt-core lifecycle-manages it. - Persistence (how long) —
--ephemeraldrops the message if it can’t be delivered in its accepted window instead of spooling; it is the one path allowed to drop silently (everything else spools and reports non-delivery). In this release ephemeral evaporation applies to translation-binary delivery and TTL expiry; the harness-relay carrier-absence case is not yet wired.
- Delivery window (when) —
- Opaque metadata.
--json-payload '<json>'attaches a JSON metadata block alongside the body. spt-core carries it verbatim across every rail and never interprets it — the receiving adapter parses it. It can’t forge spt-core’s own envelope attributes (it rides inside a singlejsonvalue), and any sender may attach it. - Typed payloads. Message bodies carry typed operations and file blobs, not just text — file transfers are addressable and progress-queryable mid-flight.
Send outcomes — the closed set
Every spt send reports exactly one outcome line. The line goes to
stderr (stdout is reserved for message payloads); classify success by the
exit code — 0 for every delivered/spooled outcome, non-zero for every
failure. The token before the first : is stable; this set is complete as of
v0.26.0.
Success (exit 0):
| Line | Meaning |
|---|---|
SENT:<id> | Delivered live to a listening target on this node. |
SENT(WAN):<id>@<node> | Delivered cross-node, receiver-confirmed — printed only when the remote daemon acknowledged. A suffix annotates the confirmed disposition: (spooled) — the remote daemon accepted it into the target’s durable spool; (duplicate) — the receiver had already processed this message (a safe dedup’d replay). No suffix = delivered live. |
QUEUED:<id> | The perch exists but nothing is listening — spooled durably, drains on the target’s next ready. Success, not an error. |
QUEUED(idle-only):<id> | An --idle-only send holding for the target’s idle window. |
DEFERRED:<id> | An --active-only send spooled for the target’s own next poll (never interrupts a live listener). |
Failure (non-zero exit, stderr):
| Line | Meaning |
|---|---|
NO_PERCH:<id> is not listening | No perch for that id — identity is never invented on someone else’s behalf. An --active-only send reports this with (active-only stays local-only): the hook channel does not take the cross-node leg. |
WAN_NO_PERCH:<id> — no perch on <node> | The route resolved to a node, but no perch lives there (a stale route — the endpoint may have moved or stopped). |
WAN_REFUSED:<id>@<node> | The receiver denied the message (access gate). |
WAN_UNCONFIRMED:<id>@<node> | No receiver acknowledgment — the peer may be offline or on an old version. The message may or may not have landed; only SENT(WAN) means confirmed. |
WAN_FAIL:<id> — <error> | Cross-node transport failure. |
AMBIGUOUS:<id> — <why> | Several nodes host that id — qualify (<id>@<node>). |
EMPTY_MSG | Refused: empty body. |
The <EVENT> wire contract
Every arriving message on an agent surface (spt ready, api listen,
api poll, api worker-poll) is one <EVENT …>body</EVENT> envelope —
never a bare body:
<EVENT type="msg" from="lea">hello</EVENT>
<EVENT type="alarm" target-time="…" current-time="…">check the build</EVENT>
A body that is already a fully-formed typed envelope (echo_commune,
notify, user-msg, …) passes through verbatim — one envelope, never
re-wrapped, and the body’s own from wins. On the listener stream an
oversized line splits into <EVENT-PART seq="K/M" id="…"> chunks the
receiver reassembles; api poll / api worker-poll always emit one whole
envelope per message, never chunked.
Escaping — the closed entity set. Exactly four entities, plus the newline
token; there is no '/' (single quotes ride literal):
- Encode (body):
&→&first, then<→<,>→>,"→"; then CRLF and lone CR normalize to LF, and LF →<br>. - Decode (body): split/replace
<br>→ newline first, then<→<,>→>,"→", and&→&last. Amp-last is the invariant that keeps an embedded&lt;from double-decoding into<. - Attribute values: the same four entities in the same order, with no
<br>step (attribute values are line-safe by construction). - Decode only the extracted body substring after parsing the envelope framing — never run the entity decode over the full line, or the framing tokens themselves unescape.
MAC-stamped frames are a different surface. The shell relay drain
(api poll <shell-id> --link <token>) emits raw stamped frames of the form
<mac> <frame> — a 64-hex-char HMAC-SHA256 over the frame bytes, one space,
then the frame — and is deliberately not <EVENT>-wrapped (the shell
child verifies the MAC and parses its own vocabulary). Agent-perch surfaces
never emit stamped frames; a parser of agent traffic only ever sees
<EVENT> / <EVENT-PART> lines.
Addressing
Bare ids (sergey) resolve locally first, then across the subnet; when the
same id is live on several nodes, resolution refuses and asks you to
qualify (sergey@desktop — node labels and key prefixes both work) rather
than guessing. The full form is [subnet:]id[@node].
Commands
send · ring · ready (blocks; --once drains and exits) · list ·
stop · whoami — every flag in the CLI reference.
Agents get the task-oriented version from the binary itself: spt how-to ready / spt how-to send.