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 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 → the active-profile pointer (set by spt adapter use) 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.
  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.
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):

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:

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:

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

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

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.

api boundary <clear|compact> <id> --to-session-id <new-sid> --session-id <prior-sid>

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-gatethe arming is unconditional; the fire waits for age.

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:

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 happens only when the manifest declares [io] 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.

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

spt api state busy <id> --payload-stdin --mid   < the-span-just-produced
  • The event carries mid="1" on its io frame and "mid": true from api io-events. 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_IDLEidle 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.

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.

armarmed byfires
edgean attention change — detach, attention shift, spt suspend — or echo-gate setimmediately, at the next pulse
workevery api state idle, i.e. every turn endonly 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

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.

--spec-manifest takes the tuning from the manifest’s [io.now_signal]; --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.

api io-events <id> {--session-id <sid> | --after <seq>} [--limit <n>] [--json] (authenticated)

Read the endpoint’s IO eventsUSER_INPUT, AGENT_OUTPUT, MSG_IN, MSG_OUT, COMMUNE, COMMUNE_FAIL — as a delta-cursored poll.

The same poll also serves the endpoint’s session boundariesboot, clear, compact — which ride this cursor rather than a second one. They are a frame class of their own 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 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 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’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.

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:

{ "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 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

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.

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.

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:

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.

OutcomeLineExit
BoundBOUND_SHELL:<shell_id> owner=<owner> status=online0
Unknown or retired tokenBIND_SHELL_REFUSED: no instance holds this link tokennon-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 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.

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.

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)

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.