# 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, 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.

**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] -->

### `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.

## Activity and presence

### `api state <busy|idle> <id> [--no-gate]`

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`.

### `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.

### `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.

## 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.

**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`.
