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:
--adapter <name[:profile]>is an optional override (since v0.9.0). For a harness-hosted session you normally omit it:listenresolves 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 byspt adapter use) or, with no pointer, the freshest-registered hosting adapter. Pass--adapteronly 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 aliveprofile 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.- 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 unauthenticatedapi pollis refused (exit 1, nothing printed) — seeapi poll. - 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:--jsonoutput, 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-idis 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-pidis 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.
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.
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:
- 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. - Never skip or swallow this call. A rotation that is silently skipped
(unresolvable endpoint id) or silently refused (
AUTH_REFUSEDon 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-downloadis 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.
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-stdinis 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
activityframes 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.
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.
Core’s exclusion of delivery bytes it physically wrote is a backstop, not
permission for adapters to forward peer text as user input.
--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 itsioframe and"mid": truefromapi io-events. Present-only: an event without it is the turn’s close, which is what everyAGENT_OUTPUTmeant before this flag existed. --midatidleis a named refusal,STATE_MID_ON_IDLE—idlereports the turn’s close, so a mid-turn span there is a contradiction.--midwith no payload isSTATE_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 asSEAL_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.
| 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
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.
--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
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];
--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 events — USER_INPUT, AGENT_OUTPUT, MSG_IN,
MSG_OUT, COMMUNE, COMMUNE_FAIL — as a delta-cursored poll.
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
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 waynow-signalkeeps 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 wayendpoint digest --afterdoes. It writes no session cursor and wins over the session cursor when both are passed. This is the mode for a--tokencaller, which has no session identity. A cursor above the log’s head returns no events and the actual head ascursor, 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
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.
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:
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
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)
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 asCODE:detail. Parse lines, not prose. - Exit codes:
0success; 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.mdinto the manifest’s watched directory; spt-core’s watcher ingests it. There is deliberately noapi commune.