# The `spt api` surface

The imperative half of the harness contract: the inbound entry points a
harness's hooks (and a shell's binary) fire to keep spt-core's on-disk state
in sync. This page is the complete command reference plus the two startup
flows that tie it together.

Three rules apply to `api` calls:

1. **`--adapter <name[:profile]>` is an optional override** (since v0.9.0). For a
   harness-hosted session you normally **omit it**: `listen` resolves the owning
   adapter/profile at bind, from the seed's parent pid → the harness exe basename →
   the adapter(s) that declare it in [`[adapter] host_binaries`](manifest.md) → the
   active-profile pointer (set by [`spt adapter use`](../cli/reference.md)) or, with
   no pointer, the freshest-registered hosting adapter. Pass `--adapter` only to
   **pin** a specific adapter/profile (adapter dev, or explicit disambiguation).
   The profile qualifier `<adapter>:<profile>` is **runtime selection** — retained
   onto the perch record, and the daemon resolves the profile **overlay** when it
   later spawns the session's lifecycle roles. So a `live` profile whose
   `[session.psyche_init]` is in the resolved manifest is a **LiveAgent** (spt-core
   drives the Psyche as a bounded per-event turn); a profile without it is a **ReadyAgent**.
   Ready-vs-live is a profile choice, not a separate "go-live" verb.
2. **Prove association.** Commands that touch an existing perch take
   `--session-id <id>` (matching the perch's record) or a capability
   `--token`; shell commands authenticate with `--link <token>` (the link
   token minted at launch *is* the credential — no token, no access).
   This includes the read-side drain: an unauthenticated `api poll` is
   **refused** (exit 1, nothing printed) — see [`api poll`](#api-poll-id---include-deferred---link-token).
3. **Status rides stderr; stdout is payload; the exit code is authoritative.**
   Action-command status lines (`BOUND:<id> token=…`, `READY:<id>`, `SENT:`,
   `QUEUED:`, `WORKER_STARTED:…`, failure tags) print to **stderr** — always,
   piped or not (only the *color* is tty-gated; a redirect gets the bare tag
   unchanged). **stdout** is reserved for machine payloads: `--json` output,
   polled message frames, and documented payload emissions. A program
   shelling out must therefore capture **stderr** to read a status tag
   (`2>&1`, or capture the streams separately) — discarding stderr
   (`2>$null` / `2>/dev/null`) discards the status line by design — and
   should treat the **exit code** as the success contract: `0` = the action
   took effect, non-zero = it did not.

<!-- [doc->REQ-START-5] -->
```text
spt api [--adapter <name[:profile]>] [--manifest <path>] <command> …
```

`--manifest` points at the adapter's manifest for the commands that need it
(e.g. `capability`).

## The two startup flows

**Harness-hosted** — the harness owns the process; spt-core is invoked from
inside it (hooks):

```text
SessionStart hook ──► api seed --pid {parent_pid} --session-id {session_id}
session's listener ──► api listen <id>      (consumes the seed, holds the perch)
```

`seed` records an ephemeral hand-off keyed by parent pid; `listen` consumes
it, registers the perch, drains backlog, and blocks relaying events into the
session.

**spt-hosted** — spt-core spawns the session itself from the manifest's
`[session.self]` template, in its own terminal layer:

```text
spt-core spawns the template ──► session comes up
session (or its wrapper) ──► api bind <id> --set-session-id <discovered-id>
```

No seed file is involved; `bind` attaches the live session to its perch
post-spawn.

**Going ONLINE (the presence badge).** The `endpoint list` ONLINE badge means
one thing: a live process is **holding the relay** — an `api listen <id>`
that consumed a seed and is blocked relaying events. Binding alone does not
light it. A headless adapter binary (a gateway or any `[session.self]` host
that wants ONLINE presence and a live event stream) uses the same two-step
the harness-hosted flow does, against its **own** process:

```text
api seed --pid <own-pid> --session-id <sid>   (hand-off keyed to itself)
api listen <id>                               (consume, hold the perch, stay ONLINE)
```

Drop the listener and the endpoint decays to Dormant/Offline as its
last-seen ages out.

## Session lifecycle

### `api seed --pid <pid> --session-id <id>`

Harness-hosted startup, step 1: record an ephemeral seed keyed by the parent
process id. Fired by the harness's session-start hook. Prints `SEEDED:<pid>`.

<!-- [doc->REQ-LISTEN-SESSION-ID-FALLBACK] -->
**Seed lifetime.** The seed lives **in the daemon's memory only** — no file —
and survives until exactly one of: a successful `listen` bind consumes it, a
newer `seed` for the same pid overwrites it, or the daemon process restarts
(which drops the whole map). Nothing re-fires it until the harness's **next**
SessionStart. So an adapter must not rely on the seed for a session that goes
live late (hours after SessionStart) or after a daemon restart — that is what
`listen --session-id` (below) is for.

### `api listen <id> [--once] [--parent-pid <pid>] [--subnet <name>] [--session-id <sid>]`

Harness-hosted startup, step 2: consume the seed, register/hold the perch,
drain spooled backlog (the same spool [`api poll` drains — see there](#api-poll-id---include-deferred---link-token)),
then block relaying messages. `--once` runs a single drain+receive cycle
(testing). `--subnet` names the anchor subnet when this
creates a brand-new endpoint on a multi-subnet node (the anchor is assigned
deterministically at creation).

<!-- [doc->REQ-LISTEN-SEED-CONSUME-AFTER-BIND] -->
**Recoverable refusals do not consume the seed.** The seed is consumed by a
**successful bind** — or by a refusal that proves the seed itself dead (see
spend-vs-restore below). A recoverable refusal that never bound —
`ANCHOR_REFUSED` on a multi-subnet node without `--subnet`,
`ADAPTER_UNRESOLVED`, a live-perch conflict — leaves the seed consumable, so
the corrected retry on the same pid binds instead of dead-ending on
`NO_SEED`. (Effect before irreversible consume: the destructive step follows
the successful effect, never a recoverable refusal.)

**`--session-id <sid>` — binding when the seed is gone.** A session that goes
live late, or after a daemon restart, finds no live seed; with `--session-id`
the listener binds directly from the given harness session id (loud
`SID_BIND:<id>` marker). The fallback fires **only** on `NO_SEED` — every
other refusal keeps its own diagnostic — and carries the same identity/auth
gates as a seeded bind (live-conflict refusal, dead-anchor refusal on the
parent pid). Without a live seed **and** without `--session-id`, `listen`
refuses with `NO_SEED`.

> **Provenance caveat — same gates, weaker provenance.** A seed is a
> consume-once capability minted by the harness for exactly one anchor pid;
> `--session-id` is a **bearer string** — any local caller who knows a live
> session id can present it and revive that perch. Treat session ids as
> secrets: never log or publish them. (The local surface already trusts local
> callers — `--parent-pid` is an override — so this is a contract
> qualification for adapter authors, not a sandbox.)

**Refusals and the seed, spend vs restore.** A refusal that proves the seed
itself dead — a stale (dead-pid) anchor, an empty session id — **spends** it:
that seed can never retry as itself, and restoring it would re-arm a
dead-keyed seed for a recycled pid to steal. Recoverable refusals (the
`ANCHOR_REFUSED` retry case above, a live-perch conflict) restore it.

### `api bind <id> [--set-session-id <sid>]`

spt-hosted startup: bind a freshly spawned session to its perch, recording the
session id discovered post-spawn. Identity precedes sessions — rebinding never
mints a new endpoint.

**Auth is intrinsic — `bind` takes no association proof.** It is an
*establishing* call (the exception to Rule 2), not a touch-an-existing-perch
call: spt-core spawned this session into its own broker-held terminal layer, so
that parentage *is* the credential. The only guard is ownership — an existing
*live* perch under a different session id is refused (you can only bind your
own). The broker injects **no** capability token into the spawned environment,
so there is nothing to echo back and no `[env.*]` entry to author for one; the
endpoint id arrives via the `{id}` fill in `[session.self]`, and that is the
only identity spt-core plants. `--set-session-id` *records* the discovered id
into the perch — it is not a proof.

`bind` prints `BOUND:<id> token=<token>`. The token is a freshly minted local
credential the session *may* retain for later authenticated calls, but it is
optional: every subsequent mutating call can instead prove association the
Rule 2 way, passing `--session-id <that same id>` for spt-core to match against
the record this bind wrote.

<!-- [doc->REQ-BOUNDARY-MANIFEST-COMPLIANCE] -->
Successful bind and `api boundary` resolve the endpoint's recorded adapter/profile
before resurfacing context. An adapter declaring `[io] compliance = true` receives
shell awareness through the now-signal `SHELLS` category, not the deprecated
session-start `spt-shells` message. Noncompliant adapters and missing or unresolved
manifests retain the legacy context. Resolution is best-effort and does not make
a successful bind or session rotation fail.

**A perch is claimed to the session only after this call SUCCEEDS.** `bind` can
refuse — a live-ownership conflict, or `ANCHOR_REFUSED` when the endpoint has no
record yet and the node holds more than one subnet, since an endpoint's anchor
subnet is its default scope and spt-core will not guess one. An adapter must
therefore treat a session brief that asserts perch ownership as conditional on
the bind's exit, never on having *attempted* it: **on a refused bind, emit the
no-perch shape and carry the refusal into it** so the agent learns it is
unreachable and why. Telling a session it "already owns a live perch" after a
bind that failed produces an agent that reports itself reachable, does not
listen, and cannot be sent to — while every message addressed to it spools
against an endpoint that is in no roster (releases#204). The refusal text is the
diagnosis; putting it only in an adapter-side log leaves the one party who could
act on it — the session itself — the only party who never sees it.

<!-- [doc->REQ-ER-BRINGUP-ANCHORS-PERCH] -->

<!-- [doc->REQ-ECHO-BOUNDARY-INPUT-BEFORE-ROTATION] the surface half: a boundary leaves an echo owed, keyed to the session that ended, and never fails the boundary -->
### `api boundary <clear|compact> <id> --to-session-id <new-sid> --session-id <prior-sid>`

<!-- [doc->REQ-BOUNDARY-ROTATION-CREDENTIAL] -->

The session was reset (context cleared or compacted) and continues under a new
session id: rebind the perch, preserving the endpoint's identity, spool, and
history across the boundary.

**The rotation catch-22 — read this before wiring the hook.** Rule 2 applies to
`boundary` like every mutating verb, but here the "matching" `--session-id` is
the sid on the perch record — i.e. the session being **departed**, not the one
you are rotating to. `--to-session-id` is the *payload*, never the *proof*. By
the time your rotation hook fires, the old session's context (its env, its
per-session files) is typically gone and the hook payload carries only the new
sid — so a hook that only knows "the current sid" cannot authenticate this one
verb. Two hard requirements for adapter authors:

1. **Persist the current session id at every SessionStart** in adapter-owned
   state keyed by the *endpoint id* (reference pattern:
   `{adapter_dir}/state/session/<endpoint_id>.sid`, single line, rewritten each
   SessionStart). At clear/compact, read it back as the **prior** sid and pass
   it as `--session-id`. Do **not** persist it in a per-session env file — that
   file dies with the session, which is the catch-22 itself.
2. **Never skip or swallow this call.** A rotation that is silently skipped
   (unresolvable endpoint id) or silently refused (`AUTH_REFUSED` on stderr,
   exit 1) leaves the perch pinned to the dead sid — after which **every**
   id-scoped call from the live session refuses, including a boundary retry,
   and the endpoint strands with zero message delivery until a full relaunch.
   Surface the id-resolution failure and the refusal reason loudly in your hook
   output. Resolve the endpoint id from your stable identity (`$SPT_ENDPOINT_ID`),
   not by looking up the new sid (it is not registered yet — the same catch-22).

A perch stranded by a *crashed* session (recorded pid dead) self-heals on the
next call via the dead-owner re-pin; a **live** session's rotation always
requires the prior-sid (or `--token`) proof. The long-term design (ADR-0032)
adds an OS-verified ancestry proof keyed on the endpoint's stable `parent_pid`
anchor, which will make the persisted-sid pattern optional; until then it is
required.

### `api psyche-download <id> [--session-id <sid>]`

Pull the agent's **resume context** to stdout, to inject as the session's
additional context at every session start — after a `/clear`, a `/compact`, or
a fresh resume. Emits the durable two-tier mind — the agent's role, its
cross-project live context, and the current project's context — **plus** any
commune/signoff drop that has been written but **not yet synthesized** into
that durable context (as `<pending-commune>` / `<pending-signoff>` slices), so
a just-written delta is never invisible on resume. The project is resolved
from the perch's recorded cwd. Read-only — it never writes the mind store.
Prints `NO-CONTEXT:<id>` on stderr (exit 0) when nothing is stored yet, the
adapter's fresh-init signal.

> The *read-back-in* half of the commune/signoff file-drops (the *write*
> side): the agent drops its delta, spt-core synthesizes it into the durable
> mind, and `psyche-download` is how the next session reads that mind back in.
> Call it wherever your harness starts a session, and inject its stdout — that
> is how a resumed session keeps its accumulated context.

**Call it from whatever your start path actually is — the verb is not
hook-shaped.** A hook-driven harness fires it from `SessionStart` alongside
`seed`/`listen`. A harness with no hook surface — an spt-hosted one whose own
extension owns bind and delivery — has no `SessionStart` to hang it on and must
call it **itself, immediately after `api bind`**, injecting stdout as the
session's opening context. A harness-hosted **go-live** needs the same explicit
call: promoting an already-running session to a live agent does not replay
`SessionStart`, so nothing pulls the mind unless the go-live path does.
Skipping it is silent — every other surface works, messages deliver, the mind
is written faithfully by the Psyche, and the agent simply resumes knowing
nothing. Nothing in spt-core can detect the omission for you: the pull is the
adapter's, always.

**Authentication — prefer `--token`.** Like every perch-scoped verb this needs
association proof: `--token <perch token>` or `--session-id <sid matching the
perch's info.json>`; without one it exits non-zero with `AUTH_REFUSED:<id>`.
**Use the bind token.** The two forms are not equivalent here: `--session-id` is
*also* a lifecycle lever — against a perch whose recorded owner is gone, a
mismatched sid triggers the dead-owner rescue and **re-pins the perch** to the
sid you presented (loudly, as `SESSION_REPIN:<id>` on stderr, and refused
outright if that sid already owns another perch or is a Psyche custody sid).
A read-only context pull has no business writing lifecycle state, so the token
keeps it inert. If you do pass a sid, pass only the **real, already-bound** one —
a speculative or placeholder value can move a recoverable perch onto a session
that does not exist.

**Keep stderr out of the injected context.** This verb writes the mind to
*stdout* and its signals to *stderr*. A runner that folds the two together will
inject `NO-CONTEXT:<id>`, a `SESSION_REPIN` line, or an auth refusal into the
model's context **as if it were the agent's mind**. Read stdout alone for the
injection; surface stderr through your logs — never swallow it, and never inject
it. `NO-CONTEXT:<id>` with exit 0 means inject nothing: it is the fresh-init
signal, not an error, and a failed pull should log and let the session start
rather than block it.

### `api session-end <id> [--erase]`

Soft teardown: the session is over; the perch's spool and history are
preserved (that's what makes the next `poll`/`listen` drain work). `--erase`
hard-wipes instead — the exception, not the rule.

### `api shutdown <id>`

Graceful live-agent signoff: runs the final echo-commune **before** teardown
(the context delta is never lost to ordering), then soft-stops. This is what
the `spt endpoint shutdown` lifecycle path calls.

**A boundary leaves an echo owed.** The session that just ended is the one with
an unsummarized delta in it, so the boundary records *that* session id and arms
the echo gate; the next pulse summarizes the departing session rather than the
empty one the harness just opened. The key is captured under the same lock as
the rotation, because by the time anything downstream runs, the pin has already
moved.

It is best-effort in both halves and **never fails the boundary**. Your harness
has already rotated by the time you tell us; turning an echo problem into a
refusal would only desync our record from the reality it exists to describe.

## Activity and presence

### `api state <busy|idle> <id> [--no-gate] [--payload-stdin | --payload-file <path>] [--mid]`

Report the session's activity state. Activity/idleness comes from these
explicit reports — **never** from terminal quiescence, which lies. Reporting
`idle` also arms the echo gate (below) unless `--no-gate` — **the arming is
unconditional; the *fire* waits for age.**

<!-- [doc->REQ-IO-INGEST-STATE-PAYLOAD] the api-surface half: the two payload sources, the ambiguity refusal, the optional-payload back-compat arm, and per-call (not per-transition) emission -->
**The turn's text rides this call, optionally.** `busy` carries the
`USER_INPUT` payload and `idle` carries the `AGENT_OUTPUT` end-of-turn payload
into [the IO funnel](../shells/frames.md#io--the-sessions-io-events-durable):

```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 and its absence is the back-compat arm.** With no
  payload the verb behaves exactly as it always has and emits nothing, so an
  already-shipped adapter keeps working untouched.
- **Stdin or a file, never an inline argument** — payloads are 16 KB-class and
  an inline arg hits the Windows command-length limit. `--payload-stdin` is
  explicit rather than sniffed because this verb fires on every hook with stdin
  inherited from the harness, and a read that sniffed for one would block
  forever and wedge the hook.
- **Both sources at once is a named refusal**, `STATE_PAYLOAD_AMBIGUOUS`.
- **One event per payload-carrying call**, not per busy/idle *transition*, and
  core does not deduplicate — report every payload span **exactly once across
  the turn**; that discipline is the adapter's, because knowing that two
  payloads are the same span means modelling how your harness assembles a turn.
  The idle-edge stamp `activity` frames carry is untouched by any of this.
- Parsing this payload for [shortform](../shells/frames.md#shortform-sending-from-inside-a-turn)
  happens only when the manifest declares [`[io] compliance`](manifest.md#io--io-funnel-compliance).
  Parsing runs over the **full** payload you reported; the 16 KB cap bounds only
  the frame body that is emitted, so a marker past the cap is still read.

<!-- [doc->REQ-API-USER-INPUT-PROVENANCE-DOC] -->
A non-mid `busy` payload is a **provenance claim that the seated user typed
the text**, just like `now-signal --user-input`. Never put peer-delivered text
there. For path-bearing reports, core may register the quoted files on the live
authenticated remote controller's node for the receiving endpoint. See
[the file helper](../serving/attachments.md#the-file_access_helper-signal).
Core's exclusion of delivery bytes it physically wrote is a backstop, not
permission for adapters to forward peer text as user input.

<!-- [doc->REQ-IO-MIDTURN-SPAN] the api-surface half: the flag, the arm it rides, the two named refusals, and the present-only field it produces -->
**`--mid` reports a MID-TURN SPAN of agent output.** The agent is still working,
so the span rides the `busy` arm — and it is still an `AGENT_OUTPUT` event:

```text
spt api state busy <id> --payload-stdin --mid   < the-span-just-produced
```

- The event carries `mid="1"` on its [`io` frame](../shells/frames.md#io--the-sessions-io-events-durable)
  and `"mid": true` from [`api io-events`](#api-io-events-id---session-sid----after-seq---limit-n---json).
  **Present-only**: an event without it is the turn's close, which is what every
  `AGENT_OUTPUT` meant before this flag existed.
- **`--mid` at `idle` is a named refusal**, `STATE_MID_ON_IDLE` — `idle` reports
  the turn's close, so a mid-turn span there is a contradiction. **`--mid` with
  no payload** is `STATE_MID_NO_PAYLOAD`.
- Spans are parsed for shortform like any other ingest. One narrowing: a **bare
  trailing `;;`** in a span mints nothing and is refused as `SEAL_BARE_MIDTURN`,
  because it seals through end of output and a span has not reached it. Pairs
  mint normally. See [the seal grammar](../shells/frames.md#what-happens-after-you-type-it).

<!-- [doc->REQ-ECHO-IDLE-AGE-GATE] the surface half: two arms, which one `state idle` arms, which one this verb arms, and the age discipline that separates their FIRES -->
### `api echo-gate <set|clear> <id>`

Manage the echo-gate sentinel directly. The gate marks "a summarization may
be needed when this session ends without a graceful signoff" — `state idle`
sets it as a side effect; a graceful signoff clears it.

**The gate has two arms, and they fire on different rules.**

| arm | armed by | fires |
| --- | --- | --- |
| **edge** | an attention change — detach, attention shift, `spt suspend` — or `echo-gate set` | **immediately**, at the next pulse |
| **work** | every `api state idle`, i.e. every turn end | only once that report is **15 minutes old** |

The edge arm is ungated because its whole value is timeliness: it fires when
attention is leaving, and an echo that arrives after the agent is gone is an
echo that did not happen. The work arm is age-gated because a turn end is not
by itself news — an agent reports dozens an hour, and summarizing each one
would spend a model call to say almost nothing. Fifteen minutes of accumulated
work is the unit that has something in it.

They are **two sentinel files**, not one file with a flag, so an idle report
can never overwrite a pending attention-change fire and quietly turn an urgent
echo into a delayed one.

`set` arms the **edge** arm — an explicit set means *now*, and routing it to
the age-gated arm would silently mean *within fifteen minutes*. `clear` clears
**both**: you asked for no pending echo, so no arm is left standing.

The window is a constant, not a knob. If you want it configurable, that is its
own request rather than a flag added here.

### `api presence <id>`

Report user/agent presence at this endpoint (feeds most-recently-active
resolution across the subnet).

### `api driven-by <id>`

Print which node (if any) is currently remote-driving this endpoint, so a
session can tell whether input is local or remote.

## Situational awareness

<!-- [doc->REQ-NOW-SIGNAL-VERB] the api-surface entry: the one turn-boundary funnel, its flags, the thin `hint` alias, and the emptiness that makes per-turn injection affordable -->
### `api now-signal <id> --session <sid> [--user-input <text>] [--agent-output <text>] [--spec-manifest | --spec-file <path>]`

The one answer to *what changed that I should know about* — the verb an adapter
injects at every turn boundary. It prints per-category XML under a single
`<SPT-NOW-SIGNAL>` root, and **nothing at all** when nothing is new: not an
empty root, not a blank tag, so a quiet turn costs zero context. Pass the turn's
text so the categories that read it (endpoint mentions, monics) can fire.

**It is delta-only, per session.** Each category tracks what this `--session`
has already been shown. A new session — a `/clear` is one — is entitled to the
picture once, and every later poll is thin. Inject it on every
UserPromptSubmit- and PreToolUse-equivalent; that cadence is what it is built
for.

<!-- [doc->REQ-API-USER-INPUT-PROVENANCE-DOC] -->
**`--user-input` is a provenance claim, not a generic text input.** It and the
non-mid `api state busy` payload claim words typed by the seated user. Core
binds a path-bearing report to the session's live authenticated remote-controller
seat and may register quoted absolute or `~`-rooted paths on **that controller's
node**, addressed to the receiving endpoint. The
[file helper](../serving/attachments.md#the-file_access_helper-signal)
normally arrives with the prompt after a short bounded wait; a late answer
remains available at a later poll.

**Adapters must never submit peer-delivered text through either input.**
Core excludes matching delivery bytes it physically wrote, but that is a
backstop for its own delivery path, not permission to forward peer text or
proof that arbitrary text was human-authored.

`--spec-manifest` takes the tuning from the manifest's
[`[io.now_signal]`](manifest.md#ionow_signal--standing-now-signal-tuning);
`--spec-file` takes the same shape as JSON composed per poll, and wins over
`--spec-manifest` when both are passed. A missing, unreadable or malformed spec
degrades to the default picture — **never a refusal**. The category vocabulary
and the delta rules are in [the frame
contract](../shells/frames.md#the-now-signal-one-funnel-for-what-changed).

<!-- [doc->REQ-IO-EVENT-POLL-VERB] the api-surface entry: the adapter's IO-event poll, its two cursor modes, the silent seed, and the declared cap -->
### `api io-events <id> {--session-id <sid> | --after <seq>} [--limit <n>] [--json]` *(authenticated)*

Read the endpoint's **IO events** — `USER_INPUT`, `AGENT_OUTPUT`, `MSG_IN`,
`MSG_OUT`, `COMMUNE`, `COMMUNE_FAIL` — as a delta-cursored poll.

<!-- [doc->REQ-IO-BOUNDARY-EVENTS] -->
The same poll also serves the endpoint's **session boundaries** — `boot`,
`clear`, `compact` — which ride this cursor rather than a second one. They are a
[frame class of their own](../shells/frames.md#boundary--the-endpoints-session-edges-durable)
on the push side; here they arrive as ordinary events with an **empty payload**,
so switch on `kind` and do not read the empty body as a missing one. A kind in
neither vocabulary is ignored rather than refused, so a newer core's tokens
cannot break an older adapter's hook. This is how an
adapter builds behaviour on top of what a session did (wake-marker-class
constructs over commune and IO events are the motivating case) without polling a
digest and diffing it.

**Give it a cursor; one of the two is required.**

- `--session-id <sid>` keeps a per-session cursor, the way
  [`now-signal`](#api-now-signal-id---session-sid---user-input-text---agent-output-text---spec-manifest----spec-file-path)
  keeps per-session seen-sets. This is the shape for a turn-boundary hook, which
  has a session id and no memory of its own. **It is the same flag that
  authenticates the call** — the harness session is one identity, so it is one
  flag.
- `--after <seq>` answers with events newer than a seq you carry yourself, the
  way [`endpoint digest --after`](../cli/reference.md) does. It writes no session
  cursor and wins over the session cursor when both are passed. This is the mode
  for a `--token` caller, which has no session identity. A cursor above the log's
  head returns no events and the actual head as `cursor`, so the caller can
  resume from that lower value instead of remaining blind.

A poll carrying **neither** cursor is refused by name (`IO_EVENTS_NO_CURSOR`,
exit 2) rather than answered with an empty poll — a cursorless poll could only
replay the log, and an empty answer would read as *nothing happened*.

**A new session's first poll returns nothing and seeds its cursor silently.**
That is deliberate: history is [`endpoint digest`](../cli/reference.md)'s job,
and replaying a backlog into a turn-boundary hook is the cost the delta
discipline exists to avoid. Poll again after the next turn and you get that
turn's events.

<!-- [doc->REQ-IO-EVENT-ADAPTER-LOG] -->
On the first append to a log damaged by the former sequence-reset bug, core
repairs its retained rows under the log lock: file order and payloads are kept,
and sequences are reassigned above the old global maximum. A session carrying
an old cursor therefore sees retained history **once**, bounded by the log's
1250-row retention ceiling. This is not a new emission: adapters acting on old
`COMMUNE` content must still reject frames older than their current session.

**`--limit` says when it capped.** The answer carries `more`, and the rows it
deferred are the next poll's first rows — a bounded poll never silently reads as
a complete one. While `more` is true, `cursor` is the last event handed over;
otherwise it is the log snapshot's true head, including ignored kinds.

**`--json` is the adapter shape and is emitted even when empty:**

```json
{ "cursor": 412, "seeded": false, "more": false,
  "events": [ { "seq": 412, "at_ms": 1787960000000, "kind": "AGENT_OUTPUT",
                "payload": "…", "truncated": true } ] }
```

`payload` is capped at the same 16 KiB the shell IO frame uses and is complete
unless `truncated` is set; a truncated remainder is not recoverable through
this verb today. `digest_seq` is **reserved — nothing emits this today**: the
shape names a digest pointer slot for a future emitter, so keep it optional
and never wait for it. `seq` is **this log's own cursor** and `digest_seq` is
**the digest's**; they are unrelated numbers, which is why they have different
names.

**Authentication is required**, as it is for [`api poll`](#api-poll-id---include-deferred---link-token)
and for the same reason: this returns the session's **verbatim** user input and
agent output. Prove association with `--session-id <sid>` — the sid you already
pass to `api state`, which doubles as the cursor key — or a capability `--token`
plus `--after`. (`now-signal` is ungated because it renders derived summaries and
never a raw payload; the gate follows the content, not the verb family.)

A kind this binary does not know is **ignored, not refused**, so a newer core on
the other side of an event cannot break your hook. `TOOL_USE` is named in the
taxonomy but **nothing emits it**, so no poll will ever carry one.

### `api hint --session <sid>` *(message on stdin)*

The keyword-hint verb, unchanged — at most one matched hint line, once per
session. It is now a **thin alias** over the `HINTS` category and shares its
seen-set, so an adapter injecting both `hint` and `now-signal` is injecting the
same thing twice — inject one.

Select the manifest through a group-level option **before** `hint`:
`spt api --manifest <path> hint --session <sid>`, or
`spt api --adapter <name[:profile]> hint --session <sid>` for a registered
adapter. If neither route resolves a manifest, the command refuses.

## Messages

### `api poll <id> [--include-deferred] [--link <token>]`

Drain delivered messages over the hook channel (the pull-based path for
harnesses whose hooks can't inject). Deferred-flagged rows are excluded
unless `--include-deferred`. With `--link` this is the shell-flavored drain:
the link token authenticates, and the rows are the shell's stamped
command/text/file frames.

`poll` and `listen` drain the same per-perch spool — a message either one
delivers is marked taken and is never re-served by the other, so a harness
running both sees each message exactly once, on whichever leg reaches it first.

**Authentication is required** (rule 2 above): the drain must prove
association with `--session-id <sid>` (the perch's recorded session) or a
capability `--token` (`--link <token>` for the shell flavor). An
unauthenticated `poll` is refused with **exit 1 and no output** — messages
are addressed to the endpoint's occupant, not to whoever asks.

### `api history-log <id>`

Append normalized history (body on stdin) to the endpoint's native history
store — the push half of `[history] strategy = "native"`.

## Workers

Nested, short-lived agents under a parent endpoint. A worker is process-local
machinery — it authenticates with its parent's session id and carries no
capability token of its own.

<!-- [doc->REQ-WORKER-MINTED-NAME] -->
### `api worker-start <parent> [--agent-id <id>] [--agent-type <type>]`

Create a nested worker perch under `parent`. The worker id is **minted by
spt-core**, not supplied by the caller: `{parent}-w{N}` with a persistent,
per-parent counter. The caller does **not** pass an id (a stray positional id is
rejected).

Output channels follow the api status-line discipline:

- **stdout** carries the bare minted id and nothing else (the machine-readable
  result — empty on any refusal). Read this to learn the worker's id.
- **stderr** carries the human line `WORKER_STARTED:{parent}-w{N} under {parent}`.

`--agent-id` / `--agent-type` are optional: the caller's own agent identifiers,
recorded on the worker as **correlation metadata only** — never the perch
identity.

Authenticates against the **parent** (the parent's session id or token); the
worker record stores the parent's current session id as its registration sid.

<!-- [doc->REQ-WORKER-SID-SYMMETRIC-AUTH] -->
### `api worker-stop <id> --session-id <sid>` · `api worker-poll <id> --session-id <sid>`

Soft-stop (drop the ready marker; info + spool preserved) or drain a worker.
Both authenticate **symmetrically by session id** — no token. A presented sid is
accepted when it matches **either** the worker's stored registration sid **or**
the parent's *current* session id, so a context clear/compact that rotates the
parent's sid between start and stop does not lock the worker out. The natural
call `worker-stop <id> --session-id <parent sid>` is therefore correct as-is.

## Shells

The driven-surface flavor of the contract. The **link token** minted at
launch is the only credential a shell binary ever holds or needs:

### `api bind-shell --link <token>`

The shell binary's first call: resolve the instance **by link token alone**
(the spawn template carries only `{link_token}`; the owner is derived from
the link) and flip it online.

**What it prints.** Everything is a line on **stderr**; stdout stays empty.
There is no JSON form — `--json` is a query-shape flag, and this is an action
command, so passing it changes nothing here.

| Outcome | Line | Exit |
|---|---|---|
| Bound | `BOUND_SHELL:<shell_id> owner=<owner> status=online` | `0` |
| Unknown or retired token | `BIND_SHELL_REFUSED: no instance holds this link token` | non-zero |

A bound instance whose manifest enables `[shell.tunnel]` adds one more line
after the bind line — `SHELL_TUNNEL_OPEN:<shell_id>` when the tunnel came up,
or `SHELL_TUNNEL_WARN:<shell_id>: <reason>` when it did not (the bind itself
still succeeded; a shell that declares no tunnel prints neither). Parse the
`BOUND_SHELL:` line for the outcome, not the tunnel line.

The bind also pushes the owner's current
[`activity` frame](../shells/frames.md#activity--the-owners-busyidle-state-pushed)
to the freshly linked binary, so your first `api drive-poll` can already tell
you whether the owner is busy or idle. Its `since` is the owner's **last real
transition**, not the moment you linked — re-emitting the current state on a
link is not itself a state change.

### `api emit <id> <payload> --type <type> --link <token>`

Push a sensory payload (one of the manifest's declared `[shell.sensory]`
types) to the owner's **live** session. REST-only by definition: never
spooled — if the owner isn't live, it's dropped with a diagnostic. Sensors
report the present, not the past.

### `api owner-shutdown <id> --link <token>`

A shell suspends its linked owner directly (e.g. a power-button surface),
bypassing agent messaging. Gated by the manifest's `can_shutdown`
pre-consent flag — fail-closed; an undeclared shell gets a refusal. The
firing shell cascades offline with its siblings, by design.

## Introspection

### `api capability`

Print the adapter's declared `hostable_types` (requires `--manifest`). The
cheap way to smoke-test that spt-core reads your manifest the way you meant
it.

### `spt whoami` — the identity verb *(identity-only since v0.33.0)*

<!-- [doc->REQ-WHOAMI-IDENTITY-ONLY] -->
The bounded-time "which endpoint am I?" answer for hooks and adapter glue:
resolves the calling session to its endpoint (`$OWL_SESSION_ID` /
`$SPT_AGENT_ID` / process ancestry) and prints that ONE endpoint's SELF line —
id, liveness, description. **The no-derivation bound is the contract**: whoami
never enumerates the roster, never derives projects, never runs git, never
touches the network — safe to call from deadline-bounded hook paths (the class
that previously timed out and black-holed message delivery). Unresolved is a
clean answer, not an error stall: `NO_PERCH` on stderr (`--json`:
`{"id": null}`), exit 1. `spt whoami --json` emits the committed identity
shape `{id, state, ready, alive, unbound, description}` — additive evolution
only. The full roster view lives on `spt endpoint list`; `api endpoint-info`
is NOT an identity carrier (it derives projects).

## Conventions

- **Output is line-oriented and stable**: `SEEDED:<pid>`, `READY:<id>`,
  `SENT:<id>`, `QUEUED:<id>`, error lines as `CODE:detail`. Parse lines, not
  prose.
- **Exit codes**: `0` success; non-zero = refused or failed, with the reason
  on stderr.
- **Commune/signoff are file-drops, not api commands.** An agent writes
  `<endpoint_id>-commune.md` / `<endpoint_id>-signoff.md` into the manifest's
  watched directory; spt-core's watcher ingests it. There is deliberately no
  `api commune`.
