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

The frame contract: what a shell binary parses

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:

<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 —

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

characteron the wire
&&amp;
<&lt;
>&gt;
"&quot;

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.

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.

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

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

<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 — 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>).

shell_file — a landed file

<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 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), 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.

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

<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

<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

<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. 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 or an attach frame, each on its own line; the three hold independent slots.

activity — the owner’s busy/idle state, pushed

<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.
  • statebusy or idle. idle means the owner endpoint reported that it stopped working; busy that it is working.
  • sinceepoch 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.

attach — who is attached to the owner’s terminal

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

boundary — the endpoint’s session edges, durable

<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.
  • seqreserved — 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.
kindfires when
bootthe 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
cleara /clear boundary rotates the session, carrying no context
compacta /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

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

io — the session’s IO events, durable

<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). 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.
  • seqreserved — 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).
  • 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).

The kind vocabulary

kindfires when
USER_INPUTthe user’s input reaches the agent
AGENT_OUTPUTthe agent produces output — a mid-turn span (mid), or the turn’s close
MSG_INa message arrives at a delivery edge
MSG_OUTa message is committed at the send edge
COMMUNEa commune file is ingested
COMMUNE_FAILa commune ingest fails, with a named reason
TOOL_USEreserved — 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.

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

Reporting a turn’s payload

USER_INPUT and AGENT_OUTPUT ride the activity report an adapter already makes:

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.

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:

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 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 frames carry are unchanged, so an idle-countdown consumer is unaffected by whether payloads are being reported at all.

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.

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.

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.

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:

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

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:

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

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:

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

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.

Sealing from inside a turn: ;;

Wrap text in a pair of ;; markers and spt-core runs the wax-seal ceremony over exactly that 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:

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

A bare marker belongs to the turn’s CLOSE, and mid-turn it is refused. In a payload reported with --mid, “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 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.

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.

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.

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.

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:

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

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.

The v1 categories

CategoryAnswers
HINTSThe 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_MENTIONSYour words named a known endpoint: whether it exists, whether it is online, which node, which subnets you share, and its description once per session.
MONICSA standing judgement of yours that the turn’s text fired.
SHELLSYour shell instances and adapters, and their status.
LAST_MSGSThe last message each way: when, how long ago, with whom, and about ten words of it.
EDGE_TRANSITIONSEndpoints and nodes going on- and offline; subnet joins.
DISPATCH_RESULTSWhat 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 builtPROJECTS. 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.

CategoryAnswers
UPDATESThe version you are running, for spt-core, your harness adapter, and each registered shell’s adapter.
SEAL_BRIEFWhat sealing is and how to prove one — two sentences, once per session.
FILE_ACCESS_HELPERThe exact spt fetch line for a file you were handed — an attachment on a delivered message, or a filepath a user quoted at you.

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.

SEAL_BRIEF tells you, once per session, what a wax seal 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.

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:

--spec-manifest          # take the tuning from [io.now_signal]
--spec-file <path.json>  # take it from a JSON file, composed per poll
[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, and switches on type is forward-compatible by construction.