URL: https://sabermage.github.io/spt-releases/messaging/overview.md
Content-Type: text/markdown
Method: text

---

# 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](../quickstart/messaging.md); 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 next `ready`. 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, and
  `spt 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 send`
  carries one value per axis; every axis defaults to unrestricted:
  - **Delivery window** (*when*) — `--active-only` spools 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-only` holds until the target is idle, then delivers (the wake).
    Default delivers in whichever window fires first.
  - **Channel** (*through what*) — `--prefer-native` routes through the
    target's translation binary when one is running, else falls back to the
    standard delivery; `--force-native` uses 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 bare `path` form is deprecated) and spt-core
    lifecycle-manages it.
  - **Persistence** (*how long*) — `--ephemeral` drops 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.*
- **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 single `json`
  value), 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.

<!-- [doc->REQ-DOC-DELIVERY-VOCAB] the send-outcome vocabulary itself — the closed set of SENT/SENT(WAN)/QUEUED/QUEUED(idle-only)/DEFERRED/NO_PERCH + WAN failure tags with their exact conditions; the JSON-consumer view + digest/poll shapes live in reference/json-shapes.md -->

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

```text
<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 `&#39;`/`&apos;` (single quotes ride literal):

- **Encode (body):** `&` → `&amp;` **first**, then `<` → `&lt;`, `>` → `&gt;`,
  `"` → `&quot;`; then CRLF and lone CR normalize to LF, and LF → `<br>`.
- **Decode (body):** split/replace `<br>` → newline **first**, then
  `&lt;` → `<`, `&gt;` → `>`, `&quot;` → `"`, and `&amp;` → `&` **last**.
  Amp-last is the invariant that keeps an embedded `&amp;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](../cli/reference.md).
Agents get the task-oriented version from the binary itself: `spt how-to
ready` / `spt how-to send`.