# 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)
frame on its own line; the two 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-IO-EVENT-TAXONOMY] -->
<!-- [doc->REQ-IO-EVENT-BUS] -->
## `io` — the session's IO events, durable

```text
<EVENT type="io" from="<owner>" kind="AGENT_OUTPUT" seq="418" 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.
- `seq` — **present only when there is a digest entry to point at**: the entry
  holding the untruncated content. Follow it with `spt endpoint digest` when you
  need the whole thing. An absent `seq` means no pointer exists, so treat the
  attribute as optional rather than defaulting it.
- `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 finishes a turn |
| `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, same 16KB payload cap, same digest pointer — a different
transport, deliberately identical in what it carries, so one event reads the
same either way.

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 KB**. A longer payload arrives cut, carrying
`truncated="1"`, and the cut lands on a character boundary so the body is always
valid UTF-8. The digest stays the content surface: when a frame is truncated,
follow its `seq` for the whole payload rather than reassembling from frames.

### 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. spt-core does not
  deduplicate, so **send one payload-carrying report per turn**; that
  once-per-turn discipline is the adapter's to keep.

**`AGENT_OUTPUT` reports the END of a turn, not the whole of it.** A
stop-hook-equivalent fires once the turn is over and never sees the lines that
streamed while it ran, so the payload you report at `idle` is the turn's closing
output. Mid-turn output belongs to your adapter's `[digest]` feed, and the
digest is where a consumer goes for it — an `io` frame points there with `seq`
rather than restating it.

**Report a turn's output once.** If your adapter already streams a turn through
its digest, report `idle` without a payload rather than sending the same text
again — spt-core does not deduplicate, so a second copy is a second event, and a
consumer counting agent turns counts it twice.

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 content, verbatim**: the bytes
the harness wrote, before any parsing or routing. 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.

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. |
| `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.

Two categories are named but **not built** — `PROJECTS` and
`FILE_ACCESS_HELPER`. They are deferred deliberately, tracked as their own
issues, and naming them in a spec is harmless: unknown names are ignored.

<!-- [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.
