# The frame contract: what a shell binary parses

<!-- [doc->REQ-SHELL-FRAME-VOCAB] -->

Everything spt delivers to a shell binary — and everything the binary sends
back through the machinery surfaces — is a small set of **typed frames**. This
page is the complete vocabulary: the frame types, their attributes, and their
body encodings. A shell binary written against this page needs no knowledge of
spt's internals; this is the contract the mock and notify shells are built
against, verbatim.

## The envelope and the stamp

Every frame is one line of XML-shaped text:

```text
<EVENT type="<frame-type>" from="<sender>" ...attrs>body</EVENT>
```

One line **always** holds — not as a convention but as a consequence of the
body encoding below: newlines are encoded away before framing, so no frame
can span lines and a line-by-line reader is always frame-aligned.

Frames that ride the durable spool (command, text, file, close) arrive
**MAC-stamped**: the line the binary drains is the hex MAC, one space, then
the frame —

```text
<mac-hex> <EVENT type="shell_command" ...>...</EVENT>
```

Both ends derive the per-link key from the link token the spawn template
delivered (`{link_token}`): the key is `SHA-256(token)`, and the stamp is
`HMAC-SHA256(key, frame-bytes)`, lowercase hex — computed over the frame's
**on-wire (escaped) form**, exactly the bytes after the space. Verify before
parsing, and decode entities only after; drop a frame whose MAC does not
match. spt applies the same rule in the other direction — an unstamped or
mis-stamped frame is never processed.

The relay drain (`spt api poll <shell-id> --link <token>`) prints the stamped
frames **raw, one per line**. They are deliberately *not* wrapped in the
harness `<EVENT>` arriving-message envelope that agent perches receive — the
shell relay is its own transport, and the stamped line is the whole payload.
The stdin delivery mode (`command_receipt = "stdin"`) writes the same stamped
lines to the binary's stdin, newline-terminated.

**A spooled frame is never split**: one line is always one whole frame,
whatever its size. The `<EVENT-PART>` chunking that exists on the *agent*
listener stream does not apply to the shell surfaces — a shell decoder needs
no reassembly logic, and will never see an `<EVENT-PART>` line on the relay
or stdin drains.

## Body and attribute encoding

Attribute values and bodies are **HTML-entity-escaped** on the wire. A
decoder that treats them as raw text works only until the first `&`, `<`,
`>`, `"`, or newline in real content — decode them, in the order specified
here, before using any value.

**Escape set** — these four are applied to *both* attribute values and bodies
(ampersand escaped first on encode); the newline token after the table is the
one place the two halves differ:

| character | on the wire |
|---|---|
| `&` | `&amp;` |
| `<` | `&lt;` |
| `>` | `&gt;` |
| `"` | `&quot;` |

<!-- [doc->REQ-HAZARD-ENVELOPE-ATTR-LINESAFE] the published attr rule an adapter decodes by: the token, the decode position, and WHY line-safety is a property the encoder confers rather than one attribute values have -->
**Newlines — a different token per half.** A body newline is encoded as
`<br>`. An attribute-value newline is encoded as `&#10;`. Attribute values
never contain `<br>`, and bodies never contain `&#10;` as a linebreak, so a
decoder always knows which half of the envelope it is holding.

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 frame is a single line, so an
unencoded newline would split one delivery into several.

<!-- [doc->REQ-HAZARD-ENVELOPE-CR-LINESAFE] -->
**Carriage returns are unrepresentable.** The encoder normalizes `\r\n` and
lone `\r` to `\n` *before* the linebreak encoding — `<br>` in a body, `&#10;`
in an attribute value — so no frame ever carries a raw CR and a decoder always
receives `\n` newlines. Do not expect `\r` to round-trip — content that needs
CRs preserved does not fit this codec.

<!-- [doc->REQ-HAZARD-ENVELOPE-DECODE-ORDER] -->
**Decode order is binding.** Decode a *body* as: `<br>` → `\n` **first**,
then `&lt;`/`&gt;`/`&quot;`, then `&amp;` → `&` **last**. Decode an
*attribute value* the same way, with `&#10;` → `\n` **first** in place of the
`<br>` step. Amp-last is the
invariant that prevents double-decoding: a body carrying the literal text
`&lt;` arrives as `&amp;lt;`, and decoding the ampersand first would turn it
into `<` instead of `&lt;`. And decode **only the extracted body or
attribute substring** — never run the unescape over the full envelope line,
or the framing tokens themselves get rewritten.

The consequence for structured bodies: a `shell_command`'s JSON arrives
looking like `{&quot;direction&quot;:&quot;north&quot;}`. **Unescape first,
then JSON-parse** — feeding the wire form to a JSON parser fails on the
first quote.

## `shell_command` — a vocabulary-checked verb

```text
<EVENT type="shell_command" from="<owner>" op="<verb>">{"arg1":"v1","arg2":"v2"}</EVENT>
```

- `from` — the owner id.
- `op` — the verb, always one your manifest declared under
  `[shell.capabilities]`.
- body — a **JSON object of named args**, entity-escaped on the wire
  ([unescape before JSON-parsing](#body-and-attribute-encoding)). The
  positional values the owner typed are zipped, in order, onto the arg names
  your capability declared; trailing args the owner omitted are absent from
  the object.

**Arity is enforced before composition, and extras refuse.** A capability
declared `args = ["text"]` takes at most one positional — `spt shell cmd s1
note one two` refuses rather than guessing. The corollary is a sharp edge
worth knowing when driving shells from scripts: a multi-word tail must be
**one quoted argument** (`spt shell cmd s1 note "one two"`), because an
unquoted composite becomes extra positionals and the vocabulary check refuses
the command. Nothing is silently joined.

## `shell_text` — free text

```text
<EVENT type="shell_text" from="<owner>">the text, entity-escaped</EVENT>
```

The 2-way text channel's owner→shell direction (`spt shell send <ref>
<text>`). The body is the text, [encoded as above](#body-and-attribute-encoding)
— unescape it to recover what the owner typed, newlines included. The
shell→owner direction is not a frame
at all: the binary sends an ordinary message to the owner's perch (`spt send
<owner> --from <shell-id>`).

<!-- [doc->REQ-SHELL-PERCH-DIR] -->
## `shell_file` — a landed file

```text
<EVENT type="shell_file" from="<owner>" xfer-id="<id>" path="files/<xfer-id>-<name>">original-name</EVENT>
```

- `xfer-id` — keys the transfer's progress record.
- `path` — where the blob landed, **relative to the shell's perch
  directory** ([attribute-decode](#body-and-attribute-encoding) before use —
  a filename with `&` arrives escaped).
- body — the original filename, entity-escaped like every body.

By the time the frame is drained, the bytes are already on disk: `spt shell
send <ref> --file <path>` copies the blob to
`<shell-perch>/files/<xfer-id>-<name>` *before* spooling the frame, so a
drained frame is a completed landing, never a promise.

**The path stays perch-relative by design** — a frame can sit spooled across
a perch move or ride to another node, and an absolute path baked at compose
time would lie there. Resolve it at read time instead: put `{perch_dir}` in
your spawn template (see [the manifest
reference](../harness-contract/manifest.md#substitution-keys)), and join the
frame's `path` against that directory. The substituted value is one argv
element even when the directory contains spaces — no quoting gymnastics in
the template.

```text
spawn = "my-shell --link {link_token} --root {perch_dir}"
# at runtime:  read(<--root value> / <path attr>)  →  the landed bytes
```

Without `{perch_dir}` there is no mechanical way to resolve `path` — the
binary is spawned with the *broker's* working directory, not the perch — so
treat the key as required for any shell that receives files. Do not guess at
the spt home layout instead; it is not a contract.

## `shell_close` — the pre-close instruction

```text
<EVENT type="shell_close" from="<owner>">manifest pre_close instruction</EVENT>
```

Sent ahead of a link-break's termination window when the manifest declares
`pre_close`. The body is the manifest's own instruction string
(entity-escaped like every body), **not** vocabulary-checked — the
vocabulary bounds what an *agent* may ask; a manifest is its own authority
over its own binary. Treat it as "finish up": after the window, the process
is killed.

## `sensory` — shell→owner, live-only

```text
<EVENT type="sensory" from="<shell-id>" sensory-type="<type>">payload</EVENT>
```

Composed by spt when the binary calls `spt api emit <shell-id> --type <type>
<payload> --link <token>`; `<type>` must be declared under `[shell.sensory]`.
Delivered only to a live owner session — never spooled, dropped with a
diagnostic otherwise. The binary never composes this frame itself; it drives
the `api emit` surface and passes the payload **unescaped** — spt applies
the body encoding when it composes the frame.

## `drive` — owner→shell, ephemeral

```text
<EVENT type="drive" from="<owner>" drive-type="<type>">payload</EVENT>
```

The continuous-control channel (`spt shell drive`), drained by the binary via
`spt api drive-poll <shell-id> --link <token>`. The payload body is
entity-escaped like every body — [decode before
use](#body-and-attribute-encoding). Latest-wins: a newer frame supersedes an
undelivered one, and an offline shell drops frames — use commands for
discrete, must-arrive actions.

The same poll may also serve an [`activity`](#activity--the-owners-busyidle-state-pushed)
or an [`attach`](#attach--who-is-attached-to-the-owners-terminal) frame, each on
its own line; the three hold independent slots.

<!-- [doc->REQ-ACTIVITY-LINK-PUSH] -->
## `activity` — the owner's busy/idle state, pushed

```text
<EVENT type="activity" from="<owner>" state="idle" since="1753372800123"></EVENT>
```

- `from` — the owner id. The state is the **owner endpoint's**, not the shell's;
  the link says whose, so nothing is addressed and no verb is called.
- `state` — `busy` or `idle`. `idle` means the owner endpoint reported that it
  stopped working; `busy` that it is working.
- `since` — **epoch milliseconds, when that state took effect** — not when the
  frame was emitted or drained. Anchor edge arithmetic (an idle countdown, a
  "quiet for N seconds" trigger) to this value and observation latency cancels
  out of it.
- body — empty, and reserved. Everything is in the attrs.

Drained on the **same `api drive-poll <shell-id> --link <token>` call** as the
drive frame, on its own line and its own latest-wins slot — a pushed state can
never displace a pending drive command, or the reverse. A poll can therefore
print zero, one, or two lines; read them line by line and switch on `type`.

**Drive-class, and that is load-bearing.** An activity frame is *not* an event
log:

- it carries the **current state**, so a redundant same-state frame is a
  harmless no-op — **derive edges yourself** by comparing against the last state
  you saw;
- it is **never spooled and never replayed**. A frame written under a link that
  has since broken is dropped, not re-served: replaying "went idle at 14:02" to
  a binary that relinked at 14:30 would be a lie;
- a missed frame is superseded by the next, so **do not build on frame counts**;
- the current state is **re-emitted on every link establishment and re-link**,
  so a binary that just started (or restarted, or whose daemon restarted) learns
  its owner's state without waiting for a transition that may never come.

**The delivery promise is bounded observation:** a frame per transition,
promptly — sub-second class, never hard-real-time. Poll on your own cadence; a
transition that happened between two polls is represented by the state the next
frame carries, and `since` tells you when it actually happened.

Like the `drive` frame it shares a drain with, an activity frame is **not
MAC-stamped** — the link token presented on the poll is the credential for the
whole ephemeral drain, and the frames are the reply to that authenticated call.
The stamped-line shape applies to the spooled channels only.

<!-- [doc->REQ-ATTACH-LINK-PUSH] -->
## `attach` — who is attached to the owner's terminal

```text
<EVENT type="attach" from="<owner>" controlled="yes" viewers="2" controller-node="<hex>" viewer-nodes="<hex>,<hex>" changed="<hex>"></EVENT>
```

- `from` — the owner id. Like `activity`, the fact is the **owner endpoint's**;
  the link says whose, so nothing is addressed and no verb is called.
- `controlled` — `yes` or `no`: whether *anybody* is driving the owner's hosted
  terminal.
- `controller-node` — the controlling node's hex. **Absent** when nobody drives,
  and also when the controller is a local one that presented no node identity —
  which is why `controlled` is its own attribute: "somebody is driving" is
  knowable when "which node is driving" is not.
- `viewers` — how many read-only viewers are attached.
- `viewer-nodes` — the comma-separated origin nodes of those viewers, for the
  ones that are known. It can be **shorter than `viewers`** (a local viewer
  carries no node), and it is absent when none are known.
- `changed` — the node whose attachment moved on this edge. Present only when
  exactly one *known* node moved: two moving at once (or a local one moving) has
  no single honest answer, and a link (re-)establishment names nothing at all
  because nothing changed for that link — it simply arrived. Treat it as a hint;
  the state in the other attributes is always complete without it.
- body — empty, and reserved. Everything is in the attrs.

Drained on the **same `api drive-poll <shell-id> --link <token>` call** as the
drive and activity frames, on its own line and its own latest-wins slot — an
attachment push can never displace a pending drive command or an activity frame,
or the reverse.

**Drive-class, exactly like `activity`, and for a sharper reason.** It carries
the **current attachment**, so derive edges yourself by comparing against the
last frame you saw; it is **never spooled and never replayed**; a missed frame is
superseded by the next, so do not build on frame counts; and current state is
**re-emitted on every link establishment and re-link**. A stale attachment is not
merely old — it would name watchers who may since have left, so replaying one
would be a lie about who can see you. An attachment change that happened while
your link was down reaches you as the re-link's current state, never as a
backlog.

**The delivery promise is bounded observation:** a frame per change, promptly —
sub-second class, never hard-real-time.

**What the endpoint's own agent is told.** The same attachment fact drives the
[away and return notices](../terminal/overview.md#when-nobody-is-watching) sent
to the owner agent itself. A shell reading these frames and an agent reading
those notices are looking at one fact from two sides.

Like the `drive` and `activity` frames it shares a drain with, an attachment
frame is **not MAC-stamped** — the link token presented on the poll is the
credential for the whole ephemeral drain.

<!-- [doc->REQ-IO-BOUNDARY-EVENTS] -->
## `boundary` — the endpoint's session edges, durable

```text
<EVENT type="boundary" from="<owner>" kind="clear"></EVENT>
```

The endpoint crossed a **session lifecycle edge**. A shell binary that needs to
know its owner restarted, cleared, or compacted — to reset its own view, to stop
attributing new output to the old session — reads these.

- `from` — the owner id, as with every owner→shell frame.
- `kind` — one of exactly three: `boot`, `clear`, `compact`.
- `seq` — **reserved — nothing emits this today**. The shape names a digest
  pointer slot for a future emitter; keep it optional and never wait for it.
- body — **empty, and reserved.** A boundary's whole content is *which* edge and
  *when*; there is no truncated half living anywhere else. This is the one
  durable frame with no payload, so do not read an empty body here as a missing
  one.

| `kind` | fires when |
|---|---|
| `boot` | the perch is bound with a session that is not the one already current — a cold start, or a resume of an OLDER session; resuming the already-current session is a re-bind and emits nothing |
| `clear` | a `/clear` boundary rotates the session, carrying no context |
| `compact` | a `/compact` boundary rotates the session, carrying a summary |

**Event-class, and that is the whole reason this is not an `activity` frame.**
Each occurrence matters: a `clear` followed by a `compact` is two edges, and the
first is not superseded by the second. The `activity` frame's latest-wins slot
would drop it. So a boundary takes the durable side — it spools and replays, and
a shell that was not linked when the edge happened still receives it.

**And durable means MAC-stamped, explicitly:** a boundary frame is spooled and
therefore arrives as the stamped line every spooled channel uses — the same
`HMAC-SHA256(SHA-256(token), frame-bytes)` stamp, the same verify-before-parse
rule, no exception for the empty body. A consumer that requires the stamp on
durable frames and drops a mismatch (counted, never silently) is building to
this contract; a boundary is never delivered unstamped.

**One frame per real edge.** Re-reporting the session that is already current is
a re-bind, not an edge: it succeeds and emits nothing. So a frame arriving means
something actually changed, and a consumer can act on it without debouncing.

### What you can and cannot see

<!-- The observation contract, stated rather than implied: a shipped surface
     nobody can discover reads as missing. -->

- **Linked and live** — every boundary is pushed to you as it happens.
- **Polling with `--session-id`** — you see every boundary from your seed
  forward. Your first poll seeds silently and shows no history, as with any
  funnel event.
- **History before your first contact** — reachable only with an explicit
  `--after <seq>`, and only while the rows are still inside the log's retention
  window.
- **`boot` in particular** fires at perch bind, before a shell can plausibly be
  attached. It is observable because this class is durable — you get it when you
  link — and by an early `--after`. It is **not** replayed to a consumer that
  seeded silently after it.

<!-- [doc->REQ-IO-EVENT-TAXONOMY] -->
<!-- [doc->REQ-IO-EVENT-BUS] -->
## `io` — the session's IO events, durable

```text
<EVENT type="io" from="<owner>" kind="AGENT_OUTPUT" mid="1" truncated="1">payload</EVENT>
```

The IO funnel: what happened on the owner's session, as discrete records. A
shell binary that wants to follow the conversation — what the user asked, what
the agent answered, which messages crossed — reads these.

- `from` — the owner id, as with every owner→shell frame.
- `kind` — one of the closed vocabulary below.
- `mid` — present and `1` when an `AGENT_OUTPUT` body is a **mid-turn span**
  rather than the turn's closing report ([how an adapter reports
  one](#reporting-a-turns-payload)). Absent means the turn's close, which is what
  every `AGENT_OUTPUT` meant before this attribute existed — so treat it as
  optional and never default it to anything but absent.
- `seq` — **reserved — nothing emits this today**. The shape names a digest
  pointer slot for a future emitter; keep it optional and never wait for it.
  A future emitter must still leave it absent for an open turn until the turn
  commits when the endpoint goes idle or the next input arrives (see the
  [digest's turn-sealing rule](../reference/json-shapes.md#window-and-cursor-flags)).
- `truncated` — present and `1` when the body was cut at the payload cap.
  Absent means the body is complete.
- body — the payload, entity-escaped like every body ([decode before
  use](#body-and-attribute-encoding)).

### The kind vocabulary

| `kind` | fires when |
|---|---|
| `USER_INPUT` | the user's input reaches the agent |
| `AGENT_OUTPUT` | the agent produces output — a mid-turn span (`mid`), or the turn's close |
| `MSG_IN` | a message arrives at a delivery edge |
| `MSG_OUT` | a message is committed at the send edge |
| `COMMUNE` | a commune file is ingested |
| `COMMUNE_FAIL` | a commune ingest fails, with a named reason |
| `TOOL_USE` | **reserved — nothing emits this today** |

`TOOL_USE` is named so that the vocabulary is complete and a future emitter
reuses this exact token. Switch on the kinds you handle and pass over the rest,
the same forward-compatible posture the rest of this page asks for.

<!-- [doc->REQ-IO-EVENT-ADAPTER-LOG] -->
### The other way to read these events

A shell binary reads `io` frames because it is **linked** and the funnel pushes
to it. A **harness adapter** is not linked and is not a shell, so it reads the
same events by **polling**:
[`spt api io-events`](../harness-contract/api.md#api-io-events-id---session-sid----after-seq---limit-n---json).
Same six emitted kinds and 16 KiB payload cap, with the same reserved digest
pointer slot — a different transport, deliberately identical in what it
carries, so one event reads the same either way.

**The cap bounds the FRAME, not the parse.** Core parses an ingested payload —
[shortform tags and `;;` seal
markers](../harness-contract/patterns.md) — over the **full** text you reported,
and only then bounds the body it emits to 16KB. So a tag past the cap still
dispatches, and the `truncated` flag on the frame says the body was cut, never
that anything went unread.

The poll is served by a per-endpoint **append-only log** the funnel's third sink
writes (`<perch>/io-events.log`), bounded and trimmed oldest-first. It is a
sink like this one, not a second funnel: an event reaching a shell and an event
reaching an adapter are the same publish fanned out twice.

### Command-class, and that is load-bearing

An `io` frame is the **opposite** of the
[`activity`](#activity--the-owners-busyidle-state-pushed) frame it shares a
stream with, so read the two with different assumptions:

- it is **spooled and replayed** — a frame produced while your binary was down
  is waiting when you relink, because an IO event is a record and a missed
  record is lost information;
- it is **MAC-stamped** like the other spooled channels, so verify it the way
  you verify a `shell_command`;
- **each frame is its own event.** Two frames with the same `kind` are two
  things that happened, never a resend — count them, and build on the counts.

### The payload cap

Bodies are capped at **16 KiB**. A longer payload arrives cut, carrying
`truncated="1"`, and the cut lands on a character boundary so the body is always
valid UTF-8. An unflagged body is complete; a truncated remainder is not
recoverable through the IO frame or its reserved `seq` slot today.

### What the funnel promises the reporting side

Emission is an **observation, never a control path**. Reporting IO to spt-core
succeeds or fails on its own terms:

- a consumer that fails to receive a frame **never changes the exit code** of
  the `spt api state` call, send, or delivery that produced it;
- consumers are independent — one failing leaves the others served.

Report your IO and read your own command's exit status as meaning what it always
meant.

<!-- [doc->REQ-IO-INGEST-STATE-PAYLOAD] -->
### Reporting a turn's payload

`USER_INPUT` and `AGENT_OUTPUT` ride the activity report an adapter already
makes:

```text
spt api state busy <id> --payload-stdin   < the-user-input
spt api state idle <id> --payload-file /path/to/turn-output
```

- The payload is **optional**. `spt api state busy|idle <id>` with no payload
  behaves exactly as it always has and emits nothing, so an existing adapter
  keeps working untouched across this release.
- Pass the payload on **stdin (`--payload-stdin`) or `--payload-file`**, never
  as an inline argument — payloads are 16 KB-class and inline arguments hit the
  Windows command-length limit. `--payload-stdin` is explicit so that this verb
  never reads a stdin it was not offered. It is explicit rather than sniffed
  because this verb fires on every adapter hook with stdin inherited from the
  harness — an inherited pipe that is open and idle is not a terminal and has no
  data, so a read that sniffed for one would block forever and wedge the hook.
- Passing **both** sources is refused by name (`STATE_PAYLOAD_AMBIGUOUS`): pass
  exactly one.
- **One event per payload-carrying call.** spt-core emits on the report itself,
  not on the busy/idle *transition*, so an adapter that reports `idle` at the
  end of every turn gets an event for every turn — including when it never
  reports `busy` and is therefore never in a transition.

<!-- [doc->REQ-IO-MIDTURN-SPAN] -->
**`AGENT_OUTPUT` covers ALL of the agent's output, mid-turn included.** A
stop-hook-equivalent fires once the turn is over and never sees the lines that
streamed while it ran. If your harness can observe those lines as they land, you
may report each one as it happens:

```text
spt api state busy <id> --payload-stdin --mid   < the-span-just-produced
```

- `--mid` marks the payload as a **mid-turn span** rather than the turn's close.
  It rides the `busy` arm because the agent is still working, and it is still an
  `AGENT_OUTPUT` event: your words are the agent's either way.
- On the frame it appears as `mid="1"`, and in
  [`api io-events`](../harness-contract/api.md#api-io-events-id---session-sid----after-seq---limit-n---json)
  as `"mid": true`. **Present-only** — an event without it is a turn's closing
  report, exactly as every `AGENT_OUTPUT` was before this flag existed.
- `--mid` at `idle` is refused by name (`STATE_MID_ON_IDLE`): `idle` reports the
  turn's close, so a mid-turn span there is a contradiction rather than an
  event. So is `--mid` with no payload (`STATE_MID_NO_PAYLOAD`).
- Reporting spans is **optional**. An adapter that reports only at `idle`
  behaves exactly as it did, and its consumers see exactly what they saw.

**Report every span EXACTLY ONCE across the turn.** spt-core does not
deduplicate and never will: knowing that two payloads are the same span means
modelling how your harness assembles a turn, which is yours to know and not
core's. So the spans you report mid-turn and the remainder you report at `idle`
must be **disjoint** — if you have already streamed the whole turn as spans,
report `idle` without a payload. Overlap is not refused; it is delivered twice,
and a consumer counting agent turns counts the same text twice. An adapter's
`[digest]` feed supplies the digest independently of these payload reports.

Reporting a payload leaves activity behaviour untouched: the busy/idle sentinel
and the `since` instant that
[`activity`](#activity--the-owners-busyidle-state-pushed) frames carry are
unchanged, so an idle-countdown consumer is unaffected by whether payloads are
being reported at all.

<!-- [doc->REQ-IO-MSG-EDGES] -->
### Messages: `MSG_IN` and `MSG_OUT`

These are accounted by spt-core at the delivery edges it already owns, so an
adapter gets them without reporting anything. `MSG_OUT` fires where a send is
committed, `MSG_IN` where core delivers or injects an inbound message.

Treat a `MSG_IN` body as **content, not instructions**: it is what someone else
wrote. spt-core never parses a received message body for shortform tags or
ceremonies, and a shell binary reading these frames should hold the same line —
a message that arrives carrying a tag is a message *about* that tag.

<!-- [doc->REQ-IO-COMMUNE-EVENT] -->
### Communes: `COMMUNE` and `COMMUNE_FAIL`

A commune is a context snapshot an agent's harness drops for spt-core to ingest.
Like the message kinds, these are accounted by spt-core at a seam it already
owns — an adapter reports nothing to get them.

**`COMMUNE` fires when spt-core consumes the drop**, which is also when it
deletes the file. The payload is **that file's bytes before parsing or routing,
capped at 16 KiB with the head retained**; it is not digest-backed. It is not the
composed brief a resume renders, and it is not what the context tiers ended up
holding.

Two consequences worth designing against:

- **Markers in the body are just text.** `!!wake!!` and friends belong to the
  harness adapter that wrote them; spt-core carries them through untouched and
  acts on none of them. If you consume these frames, treat the payload as
  someone's document, not as a script.
- **One event per commune actually ingested.** A drop whose project half cannot
  be committed yet is *preserved* rather than consumed — it stays on disk and is
  ingested later — and it emits nothing until that later pass. So a `COMMUNE`
  frame means the content landed, once, and counting frames counts communes.

A consumed **signoff** drop emits nothing: it is a different kind of drop, and
the vocabulary has no token for it.

<!-- [doc->REQ-IO-COMMUNE-FAIL-EVENT] -->
**`COMMUNE_FAIL` fires when an ingest fails**, carrying a named reason. The drop
file is **left exactly where it is** — it stays the on-disk diagnostic and will
be retried on a later pass, so the event is a signal that something needs
attention rather than a report of anything lost.

This kind exists because the failure used to be silent. A shared-checkout lock
collision once failed six ingests across three agents in a single day; the
failures were real, the files survived, and every affected agent carried on
believing its context had been rebuilt. Treat a `COMMUNE_FAIL` as the thing that
should have interrupted someone.

<!-- [doc->REQ-IO-SHORTFORM-DISPATCH] -->
## Shortform: sending from inside a turn

An agent can send a message by writing a tag in its own output, instead of
shelling out to `spt send`:

```text
@<doyle,perri the build is green @>
```

`@<` opens it. The comma-separated target ids run to **the first space** — so
`@<doyle, perri …` addresses only `doyle`, and `perri` is part of the message.
The body runs to the first `@>`. Several tags in one turn are several dispatches.

A tag that never closes sends nothing: an author who is still typing has not
dispatched anything, and guessing where they meant to stop would deliver a
fragment. A tag with no target, or no body, is likewise not a dispatch.

Dispatch goes through the **ordinary send path** — the same admission, sealing,
spooling and refusal behaviour an operator-typed `spt send` gets. A shortform
message is not a special class of message.

**The parse imposes no body-length cap.** A long body dispatches exactly as
written — the parser runs to the closing `@>` however far away it is. The size
limit an agent *does* observe near shortform is a different surface: an
adapter may cap what it re-injects into the session as context (confirmations,
inbound deliveries), and how it handles an over-cap payload is that adapter's
affair. Any such cap governs what the *author sees back*, never what the
target receives. Prefer `spt send` with the body read from a file for long
messages all the same — that is a courtesy to the receiving session's context
budget, not a parser constraint.

<!-- [doc->REQ-IO-SUPPRESSION-GRAMMAR] -->
### Writing about a tag without sending one

A marker inside an **inline backtick span** or a **fenced code block** is a
quotation, and fires nothing:

````text
write it as `@<doyle hello @>` to send        ← quoted, sends nothing
```
@<doyle hello @>                              ← fenced, sends nothing
```
@<doyle hello @>                              ← live, sends
````

This is one grammar, shared by every shortform marker spt-core reads — the same
suppression governs the `;;seal me;;` mint. Learn it once.

Two edges worth knowing: an **unclosed fence suppresses to the end of the text**
(a truncated code block is still a code block, which is exactly the shape a
cut-off turn produces), and a **lone backtick suppresses nothing** (stray
backticks in prose cannot silently swallow the rest of your turn).

<!-- [doc->REQ-IO-SHORTFORM-GATE] -->
### Adapters opt in, and nothing happens until they do

Core parses shortform out of an adapter's ingest **only** when that adapter's
manifest declares IO-funnel compliance:

```toml
[io]
compliance = true      # core may parse this adapter's ingest
shortform = false      # optional: stay compliant, keep core-side shortform off
```

`shortform = false` covers **both** shortform markers — the `@<…@>` dispatch tag
and the `;;` seal mint. They are one feature with one grammar, so they get one
switch rather than a knob each.

**Absent `[io]`, core parses nothing.** That default is what makes the migration
safe: an adapter that ships its own tag parser today keeps being the only parser
until its own release deletes that parser and declares compliance in the same
change — so no version exists in which both parse the same text and send it
twice. Declare compliance in the release that removes your local parser, not
before.

<!-- [doc->REQ-IO-DISPATCH-RESULTS] -->
### How you learn what happened

Per-target outcomes are accumulated and surfaced through the **now-signal's
`DISPATCH_RESULTS` category** — that is the only channel, by design. A dispatch
does not echo into your output, does not reply to you, and does not print a
confirmation line, because a second channel is how an author ends up trusting
whichever one they happened to notice.

Note that spt-core parses shortform out of **your own turn's ingest** — the user
input and agent output edges — and **never out of a message that arrives from
someone else**. A peer can write a tag at you all day; it is text. Nothing you
receive can make you send.

<!-- [doc->REQ-IO-SEAL-SHORTFORM-GRAMMAR] -->
## Sealing from inside a turn: `;;`

Wrap text in a pair of `;;` markers and spt-core runs the wax-seal ceremony over
exactly that text:

```text
;;we ship the parser behind the manifest gate;;
```

**Each pair is its own seal.** Two pairs in one turn are two ceremonies over two
texts, run in order — never one seal spanning the gap between them, and the text
*between* pairs belongs to neither.

**An empty pair does nothing at all.** `;;;;` mints no ceremony and produces no
refusal: you asked for nothing, so there is nothing to report.

**An odd trailing marker seals the rest.** If a marker has no partner, it seals
everything after it through the end of your output — and leaves what came before
it alone:

```text
;;first sealed;; ordinary prose ;;everything from here to the end is sealed
```

Pairs are matched first, greedy left to right; whatever marker is left over is
the bare case.

<!-- [doc->REQ-IO-MIDTURN-SPAN] -->
**A bare marker belongs to the turn's CLOSE, and mid-turn it is refused.** In a
payload reported with [`--mid`](#reporting-a-turns-payload), "through the end of
your output" names text core has not been given yet, so sealing there would seal
a *shorter* text than you asked for — and a short seal looks exactly like a
correct one. So an odd trailing marker in a mid-turn span mints nothing and is
refused by name as `SEAL_BARE_MIDTURN` in `DISPATCH_RESULTS`. **Pairs are
unaffected** — both ends are in the span, so they mint mid-turn like anything
else. Close the pair, or leave the bare marker in the output you report at
`idle`, where it means exactly what it says above.

Both author paths mint: it makes no difference whether the user typed the
directive verbatim or the agent drafted it.

The [suppression rules](#writing-about-a-tag-without-sending-one) are the same
ones the dispatch tag obeys — a `;;` inside backticks or a fenced block is a
quotation, so you can write about this grammar without sealing anything.

<!-- [doc->REQ-IO-SEAL-SHORTFORM-CEREMONY] -->
### What happens after you type it

**Your turn does not wait.** The ceremony needs a human at the controller, and
that is human-scale time, so spt-core hands the mint off and your turn ends
normally. The seal happens on its own.

**With no controller attached, the ceremony refuses immediately** — nothing is
queued waiting for someone to show up, and no seal is pending. You get
`SEAL_NO_CEREMONY_SURFACE`, the ceremony's own answer, not a paraphrase.

<!-- [doc->REQ-IO-DISPATCH-RESULT-VOCABULARY] -->
Either way the outcome lands in the **`DISPATCH_RESULTS`** now-signal category —
the same one dispatch outcomes use, and the only place either is reported. A seal
row carries no target, because a seal is about text rather than a recipient; if
you read these rows, do not assume one is there.

<!-- [doc->REQ-NOW-SIGNAL-VERB] -->
## The now-signal: one funnel for "what changed?"

`spt api now-signal` answers the question an agent asks at every turn boundary —
*what changed that I should know about* — and it is the **only** place that
answer arrives. Anything that wants to reach an agent between turns registers a
category here rather than growing an injection point of its own.

```text
spt api now-signal <id> --session <session-id> \
    --user-input "<the user's words>" --agent-output "<the agent's words>"
```

Output is per-category XML nested under one root:

```xml
<SPT-NOW-SIGNAL>
<ENDPOINT_MENTIONS>
doyle — online on KITSUBITO; shared subnets: spt-dev
</ENDPOINT_MENTIONS>
<DISPATCH_RESULTS>
-> perri: delivered
-> hertz: no perch — nobody listening
</DISPATCH_RESULTS>
</SPT-NOW-SIGNAL>
```

`spt api hint` still works and does exactly what it always did. It is a thin
alias over the `HINTS` category and has no behaviour of its own — one question
gets one answer, so an adapter that injects both is injecting the same thing
twice.

<!-- [doc->REQ-NOW-SIGNAL-DELTA] -->
### It is delta-only, and silence is the normal case

Every category tracks what **this session** has already been shown and reports
only what it has not. A poll with nothing new prints **nothing at all** — not an
empty root, not a blank tag. That is what makes it safe to inject on every
UserPromptSubmit- and PreToolUse-equivalent: a quiet turn costs zero context.

Two consequences worth designing around:

- **A new session is entitled to the picture once.** A `/clear` is a new session,
  so the first poll after one can be substantial and every later poll is thin.
  The seen-sets live under the session directory and die with it.
- **A datum whose STATE changed is new again.** A peer reported as online, then
  reported as offline, is two facts and you are told both.

`EDGE_TRANSITIONS` is the one category that deliberately reads differently: a
session's first poll **seeds silently and reports nothing**, because an edge is
by definition a change and a session that has observed nothing holds only a
state. Ask `ENDPOINT_MENTIONS` for the current picture; ask `EDGE_TRANSITIONS`
for what moved since you last looked.

<!-- [doc->REQ-NOW-SIGNAL-CATEGORIES-V1] -->
### The v1 categories

| Category | Answers |
| --- | --- |
| `HINTS` | The keyword hints your manifest declares, once each per session — **and one line per shell adapter you can see** (full text when you hold an instance of it, otherwise a teaser naming the trigger and `spt adapter hints <adapter>`). |
| `ENDPOINT_MENTIONS` | Your words named a known endpoint: whether it exists, whether it is online, which node, which subnets you share, and its description **once per session**. |
| `MONICS` | A standing judgement of yours that the turn's text fired. |
| `SHELLS` | Your shell instances and adapters, and their status. |
| `LAST_MSGS` | The last message each way: when, how long ago, with whom, and about ten words of it. |
| `EDGE_TRANSITIONS` | Endpoints and nodes going on- and offline; subnet joins. |
| `DISPATCH_RESULTS` | What became of your shortform dispatches and `;;` seal mints — the only channel that reports them. |

`SHELLS` **replaces the session-start `spt-shells` message.** The same facts
arriving through two channels is the ambiguity this funnel exists to end; read
them here.

`MONICS` is why the `user_input` and `agent_output` trigger kinds exist. They
were ratified before anything read them, so a monic you wrote months ago with a
`user_input` trigger starts firing here with no edit and no migration.

One category is named but **not built** — `PROJECTS`. It is deferred
deliberately, tracked as its own issue, and naming it in a spec is harmless:
unknown names are ignored. `FILE_ACCESS_HELPER` was deferred in the same way and
is now built; it appears among the categories added after v1 below.

### Categories added after v1

The v1 set above is closed and ratified. Later categories are **appended after
it** by operator ruling rather than folded into it, and render order is
declaration order, so the ratified order never shifts under you.

| Category | Answers |
| --- | --- |
| `UPDATES` | The version you are running, for spt-core, your harness adapter, and each registered shell's adapter. |
| `SEAL_BRIEF` | What sealing is and how to prove one — two sentences, once per session. |
| `FILE_ACCESS_HELPER` | The exact `spt fetch` line for a file you were handed — an attachment on a delivered message, or a filepath a user quoted at you. |

<!-- [doc->REQ-NOW-SIGNAL-UPDATES] the UPDATES category: its three subjects, per-shell keying, why there is no event journal, and absence-is-silence -->
`UPDATES` reports **three subjects**: spt-core itself, the harness adapter this
endpoint is running under, and the adapter of every shell currently registered
to it — keyed **per shell**, not per adapter, because you act on a shell and a
version that moved under one instance is the fact you need. For spt-core the
**product version** leads, never the applied-update counter: the counter is an
update-set sequence, and the version is what you are actually running.

There is **no update event journal**, by design. Each subject's seen-set key
carries its version, so you are told once at the version you first observed and
hear nothing again until that version *changes* — at which point the key is new
and the line fires. The delta discipline is the event detector; a journal would
be a second source for one fact.

**Absence is silence.** An endpoint with no update history emits nothing —
no-updates is not an update — and a shell whose adapter is deregistered or
unreadable contributes nothing rather than an error line. This rides a
turn-boundary hook, where a diagnostic you cannot act on is just noise.

<!-- [doc->REQ-NOW-SIGNAL-SEAL-BRIEF] the SEAL_BRIEF category: two sentences, once per session, and the real verify surface it names -->
`SEAL_BRIEF` tells you, once per session, what a [wax
seal](../messaging/wax-seal.md) is — a **proven user directive** — and how to
execute the proof: mint with the `;;text;;` shortform, verify with `spt api seal
verify <token>` and the content on stdin. It is **at most two short sentences**,
which is an operator constraint rather than an editorial preference, and the
text is a single constant so that bound stays auditable. Once you have been
told, you do not need telling again.

<!-- [doc->REQ-NOW-SIGNAL-SPEC] -->
### Tuning it from your adapter

An adapter knows things core cannot — which categories its surface can render,
which are noise in it, what its injection budget is. Two flags carry that:

```text
--spec-manifest          # take the tuning from [io.now_signal]
--spec-file <path.json>  # take it from a JSON file, composed per poll
```

```toml
[io.now_signal]
without = ["SHELLS"]     # suppress a category
max_lines = 4            # cap each category's output
# only = ["DISPATCH_RESULTS"]   # or narrow to an explicit set
```

A spec **narrows and tunes; it never invents**. Category names outside the list
above are ignored rather than conjured, and if a spec both selects and suppresses
the same category, the suppression wins.

**A broken spec is never a refusal.** Missing file, unreadable file, malformed
JSON, a number where a list belongs — every one of them degrades to the default
picture and the poll still answers. This runs on a hook at every turn boundary,
and a verb that fails hard on a config typo is a verb that breaks a working
session over one.

## Parsing posture

The vocabulary above is closed and versioned with spt-core: parse the `type`
attribute first, ignore frame types you do not recognize (new types may be
added), and refuse nothing you don't have to — a shell binary that drains,
verifies, extracts, [decodes](#body-and-attribute-encoding), and switches on
`type` is forward-compatible by construction.
