Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 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. To receive that reply, a caller who is not already listening gets a temporary perch for the duration of the call, which is torn down when the call ends.

    The wait is counted in minutes. --timeout defaults to 30 minutes, and a bare number is minutes — you are waiting on an agent, which answers on agent time, not on request-response time. An explicit suffix sets the unit, so --timeout 90s and --timeout 2m both work and both say what they mean. The timeout line answers in the unit you typed. (Bare numbers meant seconds in earlier releases, so a --timeout 60 carried over from an older script now means sixty minutes — write 60s for the old behavior.)

    A ring only ever tears down a perch it created itself. This matters because a caller may already own a perch that does not currently look live — through a busy turn, a session ending softly, or a re-bind in progress. In that case the ring does not take the perch over: it delivers the message, returns without blocking, and the reply arrives on the caller’s own listener the next time it listens. The same refusal covers a perch directory that exists but is empty, which may be another process mid-creation: it is left alone rather than reused or cleaned up, and its path is reported so a person can deal with it. The practical guarantee is that ringing from a session that already has an identity never costs that session its perch or the mail sitting in its spool.

    This guarantee is carried by the caller — the spt command you run — not by the daemon, and not by anything a harness supplies. The command claims a temporary perch by creating its directory exclusively, so a directory that already exists is a refusal rather than a silent reuse, and only a perch proven to be its own is ever removed. An adapter or harness neither implements nor can weaken this, and should not add its own protection against it. The daemon’s role is narrower and separate: for perches it hosts, it is the authority on whether a listener is currently live — which is what decides whether the reply is waited for here or left to arrive on the caller’s existing listener.

  • 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.

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 code0 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):

LineMeaning
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):

LineMeaning
NO_PERCH:<id> is not listeningNo 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_PEER_SILENT:<id>@<node>The node accepted the message and never answered within the reply budget — it is still holding the stream, which points at a wedged or overloaded node rather than an old one. Delivery NOT confirmed; 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_MSGRefused: 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. Four entities, plus one newline token per half of the envelope: bodies use <br>, attribute values use &#10;. 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 <.
  • Encode (attribute value): the same four entities in the same order, then the attribute’s own linebreak step — CRLF and lone CR normalize to LF, and LF → &#10; (not <br>, which belongs to the body).
  • Decode (attribute value): &#10; → newline first, then the same tag-shaped entities, and &amp;& last. Amp-last is what makes the newline entity safe: an attribute carrying the literal text &#10; arrives as &amp;#10;, which the &#10; step cannot match, so it decodes back to the literal rather than to a linebreak.
  • Attribute values are line-safe because the encoder makes them so, not by construction. A receiver-composed attribute can carry newlines — the trust-warning block runs to several — and the envelope is line-framed, so an unencoded newline would split one delivery into several lines of which most are not envelopes.
  • 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.

The seal attribute — sender-authored, and it must be surfaced. A sealed message arrives carrying its seal token as a seal="…" attribute on the envelope:

<EVENT type="msg" from="reavo" seal="k7mn4wq2vx">Approved: run the migration tonight</EVENT>

This is the envelope author’s own field — like type, from, or an alarm’s times — not one of the receiver-composed attributes (trust-warning, mnemonics-json) your node strips off inbound bodies before attaching its own. The seal attribute rides end-to-end intact through that strip: it is the sender’s own evidence citation, and stripping it would delete the citation at every ingress.

For adapter authors: this attribute must be surfaced — the same obligation trust-warning carries. A pipeline that re-renders a delivery has to carry the token through and show it, or the receiving agent loses the pointer to evidence the sender deliberately attached. And the mirror rule: the attribute is a citation, never an authorization subject. Nothing may branch authority on its presence or value — only a BOUND verdict from spt api seal verify over the delivered body is evidence (a forged attribute is harmless precisely because verify recomputes the hash).

The attribute set is open — re-render by pass-through, never by allowlist. The sender-authored attributes above are a class, not a list: new ones join the envelope over time (seal is only the newest), and they join with their obligations already in force. So the render rule for any consumer that re-emits deliveries (an adapter pipeline, a digest, a relay surface):

  • Carry through every attribute you do not yourself consume. Re-emitting from a fixed list of known names silently deletes every attribute added after that list was written — and the deletion is invisible at the site that wrote the list, because nothing there ever names what it dropped. The only closed list in this contract is the receiver-composed strip class (trust-warning, mnemonics-json), and it is a strip list, not a render list; everything else rides.
  • “Consume” means the attribute’s distinction is re-expressed, not merely read. A pipeline consumes an attribute when its handling puts the value’s meaning back in front of the agent — content re-rendered into the pipeline’s own surface, or a dispatch whose agent-visible outcome differs per value (routing on type consumes it only while distinct types yield distinguishable deliveries). Reading a value and then emitting output that two sender-distinct deliveries would share is peeking, not consuming. The test is the drop test: if omitting the attribute makes two deliveries the sender distinguished indistinguishable at the agent surface, it must ride.
  • Attribute names are tokens, not text. The envelope’s name grammar is [a-z0-9_-]+. Names are hostile-reachable — envelopes arrive from peer nodes, not only from this binary — so a pass-through re-emits a name only when it matches that grammar; a tag-position span that does not is framing damage, not an attribute. Refuse or drop it loudly, never re-emit it.
  • Keep the value in attribute position with its wire escaping intact. An attribute value is attr-escaped for exactly one context. Unescaping it into a body or frame context hands a hostile sender a forgery seam — a crafted value could close your re-rendered tag and land text at your frame level, indistinguishable from a real delivery. Pass the raw attr-escaped span through; decode only where the final consumer parses attributes.

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.

EVENT-PART reassembly (listener stream)

When the listener stream (spt ready, spt api listen) would emit an <EVENT> line longer than its per-line cap, it splits that one envelope across N <EVENT-PART> lines. api poll / api worker-poll never chunk, so only a consumer of the listener stream needs this — and such a consumer must implement reassembly, or a single oversized delivery wedges its parser. The rules (a receiver that follows them reconstructs the original envelope byte-for-byte):

  • <EVENT-PART> is a distinct tag — do not prefix-match <EVENT. Each part is a whole line <EVENT-PART seq="K/M" id="…">FRAGMENT</EVENT-PART> with its own closing tag </EVENT-PART>. A substring test for <EVENT matches both tags, and </EVENT> never closes a part — a scanner that keys on <EVENT / </EVENT> will treat a part as an unterminated <EVENT> and stall. Dispatch on the full tag name.
  • seq="K/M" is 1-indexed: K runs 1..M; M is the total part count, identical on every part of the group. Never zero-padded.
  • id is an opaque grouping token, unique per split envelope. Group parts by id equality only — never assume its charset, length, or structure (it is an implementation-chosen nonce, not a contract value).
  • Attributes ride on the seq="1/M" head part only. The original envelope’s attrs (type, from, notif_id, …) appear on the head; continuation parts carry only seq and id. Reassembly takes the attrs from the head.
  • Each FRAGMENT is a raw byte-slice of the already-escaped envelope. A split point may fall inside an entity (&amp;) or a <br> token. So: concatenate the fragments in seq order first to recover the whole <EVENT …>…</EVENT> line, then parse the framing and decode the body once (per the escaping rules above). Never decode a fragment on its own. concat(FRAGMENT_1..M) equals the original envelope’s inner content verbatim.
  • Parts may arrive out of order and are not guaranteed contiguous. Key partial state by id, sort by seq K, and reassemble only once all M parts of that id are held.
  • Drop orphan groups silently — and keep the receiver alive. If the seq="1/M" head is absent (earlier parts lost across a session boundary) or any seq in 1..M is missing or never arrives, drop the whole group — never emit a partial envelope. “Silently” is about the stream (no partial envelope reaches it), not the receiver’s own diagnostics: a receiver may — and for the invisible-failure class, should — surface the drop in its own log or a counter. An incomplete group is an expected boundary condition (its canonical cause is a mid-stream restart), so it must not tear down the receiver — a restart cannot recover a group whose head is already gone and only re-creates the condition. Bound the pending partial-state (evict the oldest group under a cap rather than buffer without limit — an unbounded reassembly buffer is the failure mode this whole section exists to prevent), reset it on receiver restart, and lose at most the one oversized message, never the stream.

Message short-IDs and replies

Every message gets an eight-character short-ID when it is committed, minted from the message’s own content hash and scoped to the node that holds it. Nothing mints a second identifier later: the same token appears in the delivery envelope (msg-id), in the MSG_IN and MSG_OUT io-event rows, and in the URL /<node>/m/<short-id> — so what you say in a chat line is what you click, and two readers cannot disagree about what a message is called.

The alphabet is base32 without 0, 1 and 8, so O/0 and I/1 are never two spellings of one id. Read one back with:

$ spt msg show BCDFGH23
BCDFGH23 from doyle to todlando at 1788750167374ms [spool]
attachment: report.md http://localhost:5474/kitsubito/f/report.md (2481 bytes)
---
the report you asked for

--json is the machine twin, and /<node>/m/<id> renders the same message in a browser (?json there too). Treat the id as opaque: a collision on one node is detected when it is minted and resolved by lengthening that one id to nine characters, then ten, which is safe precisely because no reader parses it.

spt send <target> --reply-to <id> carries the parent’s id in the envelope so an adapter may render a thread. It is a label on the message, not a way to address one: target stays required, and an unknown parent is carried, not refused — the parent may live on a node we cannot ask, and refusing would make a thread across a partition impossible rather than merely unrendered.

Files ride along the same way: see Attachments and spt fetch.

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].

Sending from inside a turn

An agent whose adapter declares [io] compliance can send by writing a shortform tag in its own output — @<doyle,perri the build is green @> — instead of shelling out to spt send. It is not a special class of message: dispatch goes through the ordinary send path, with the same admission, sealing, spooling and refusal behaviour. Two things differ, and both are deliberate: a tag inside backticks or a fenced code block is a quotation that sends nothing, and the outcome comes back only through the now-signal’s DISPATCH_RESULTS category — a dispatch never echoes or replies to confirm itself. The grammar, the suppression rules, and the manifest gate are specified in the frame contract.

A message that arrives carrying a tag is text: spt-core never parses an inbound body for shortform, so nothing you receive can make you send.

Commands

send · ring · ready (blocks; --once drains and exits) · list · stop · whoami · msg show · fetch — every flag in the CLI reference. Agents get the task-oriented version from the binary itself: spt how-to ready / spt how-to send.