JSON output shapes
Every read/status command takes a global --json flag and prints one
pretty JSON value to stdout (status lines stay on stderr — see the
api output discipline). This page is the
machine-consumer’s reference: the send-outcome vocabulary you classify by, the
session-digest schema, the shell relay’s MAC-stamped frames, and the catalog
of --json shapes.
A JSON value on stdout, a status tag on stderr, the truth in the exit code. A program reads whichever it needs and never has to scrape a human line.
Send outcomes
The closed set of spt send outcome lines — SENT, SENT(WAN), QUEUED,
QUEUED(idle-only), DEFERRED, NO_PERCH, and the WAN failure tags — is
documented with its exact conditions in
Messaging → Send outcomes.
The one rule a caller must encode: classify by exit code (0 = every
delivered/spooled outcome, non-zero = every failure), and treat QUEUED as
success — the message is durably spooled and drains when the target next
comes online; never retry on it.
Session digest — endpoint digest --json
spt endpoint digest <id> --json prints the endpoint’s activity digest as a
projection of its session logs (not a PTY scrape). The top-level object:
Reaching an endpoint on another machine. The snapshot pull accepts a qualified address —
spt endpoint digest <id@node> --json,--lastand--afterincluded. The endpoint’s own node projects and answers, so the content is identical to what someone standing on that machine would see, and the same access rules apply as for every other qualified address.--followis local-only: a delta subscription is not available across nodes, so poll the snapshot with--after <seq>instead. A qualified address that resolves to the machine you are already on is answered locally, not over the network.
{
"turns": [ /* Turn, oldest → newest */ ],
"version": 42
}
version (top-level integer, always present) is the digest’s
snapshot version — a monotonic counter that bumps each time the projected
digest changes (an unchanged projection does not bump it). Use it to detect
“anything new since my last pull” by comparing against the last version you
saw. It is not an entry seq and is not valid --after input — the
two are different number spaces (--after takes the entry-level seq
described below; a version passed as --after would predate the window and
trigger a full-window refresh every time).
version is process-lifetime: monotonic within one daemon run, held in
memory and reset when the daemon restarts. A version lower than one you
already saw therefore means the daemon restarted — treat it as changed,
re-pull the full window, and reset your comparator. Never compare with >
alone: a restart makes the counter go backwards, so a > test reports
“unchanged” forever and stalls.
Version gating is an optimization, never a correctness boundary. Because the counter restarts, a fresh daemon run can land back on a value you have already seen, and an equality test would then report “unchanged” across two genuinely different digests — a silent skip, which is worse than a stall because nothing surfaces. Build the correctness argument on content, not on the counter:
- Compare the pair
(version, highest agent-produced seq)— computed at a constant window depth on every pull.max seqis taken over what the window returned, so comparing a pull made under--last 1against one made at the default depth manufactures a false “unchanged”. Unlikeversion,seqis derived from the on-disk transcript ledger and survives a daemon restart, so a fresh run that coincidentally matches your lastversionwill not also match your lastmax seq— unless the content really is identical, in which case there is nothing to skip. - Know what the pair can and cannot see. It detects only changes that
move a committed agent-produced
seq. Two whole classes of real change move no such number, so the pair is blind to both: entries on an open turn (they carry nosequntil the turn closes) and spt-injected entries (Boundary/Contextnever carryseqat all — see the asymmetry note below, and note this holds on a closed turn as readily as an open one). A restart-collision that coincides with either matches on both halves while the content differs. - Therefore also poll unconditionally on a slow cadence. A pair is not
an identity, and it is specifically blind to injected-only and open-turn
changes — the unconditional pull is what covers those seams, bounding any
residual collision to one interval instead of forever. A consumer that
treats the pair as total and drops this pull re-opens the silent skip:
injected entries signalling that something happened while every
agent-produced number sits still is exactly the shape of a real fleet
incident. This poll is a correctness belt only — it cannot establish
liveness. It reads the same surface as everything else here, so it
cannot distinguish a wedged daemon from an idle one from a torn-down
endpoint. Liveness comes from endpoint state (
endpoint list/api endpoint-info) — see When the stream ends, below, for the full case.
The --json object is self-contained — under --json, stderr carries
no status trailer, so the output parses identically whether you read
stdout alone or merge 2>&1. (The human, non---json path keeps its
DIGEST:<id> version=N stderr trailer instead — the version is never part of
the human stdout rendering.)
Window and cursor flags
| Flag | Effect |
|---|---|
--last <N> | Return the last N turns instead of the default window depth (--last 1 is the latest turn — the turn-end view). Snapshot only — ignored under --follow. |
| (default depth) | Absent --last, the window is the last 3 user turns — but an adapter’s [digest] config can change that default and there is no surface to query the effective value, so do not hard-code it. |
Programmatic consumers: use the default depth.
--lastis a human / one-shot flag.A non-default
--lastis not a per-call presentation knob — it is a write into shared daemon state for that endpoint. The daemon holds one projected digest per endpoint, and every request publishes its own projection into it, so a--last 1pull collapses every concurrent--followclient’s view to one turn (they receive it as afrom == 0full replace) and bumpsversionfor everyone — then the next default-depth request flips it back.versiontherefore oscillates with no content change at all.This is a property of the current implementation and may change; do not build a design that depends on the contamination either. Pinning programmatic consumers to the default depth also satisfies the constant-depth requirement of the version discipline above by construction — the two rules are one rule.
If you need a different depth, change the default rather than the call. An adapter declares its endpoints’ window as
window_turnsin its manifest[digest], and that becomes the effective default every request resolves against — so an adapter author who needs a deeper window sets it there and then pulls without--last, getting the depth they need while writing nothing foreign into shared state. This is also the only way to deepen a--followstream, which always uses the effective default.Check the profile overlay, not just the base manifest. The effective default can come from two places:
[digest]on the base manifest, or[profiles.<name>.digest]on a profile overlay. Endpoints running under a profile resolve against the overlay, and the overlay wins (profiles are sparse leaf-replace). An author who setswindow_turnson the base while their endpoints run under a profile that also declares it will see no change at all — and the natural next move for someone who still needs depth is to reach for--last, straight back into the operation this rule exists to prevent.Determine the layer per endpoint, not per adapter.
spt api endpoint-info <id>reports the endpoint’sadapteras<adapter>[:profile]— the suffix is the profile it resolves against, and its absence means base. The answer differs between endpoints of the same adapter: one endpoint may run under base while its siblings run under a profile. So if a profile declareswindow_turnsand base does too, raising base fixes only the base-layer endpoints and silently misses the rest — a partial no-op, which is worse than the total one above, because partial success reads as “it worked” on whichever endpoint you happened to test.Checking existing endpoints is a point-in-time audit;
window_turnsis durable. Resolution happens per request against whatever layer that endpoint runs under, and endpoints created later resolve the same way — so auditing today’s endpoints protects only today’s endpoints. Bring up one new endpoint under a profile that declareswindow_turnsand it silently takes that profile’s depth. Rather than auditing once, make every layer your endpoints can resolve against declare the depth you need — or verify that none of them declares it, so base governs uniformly. Otherwise the endpoint that breaks you is the next one someone creates.A consumer that is not the endpoint’s adapter cannot do any of this. There is no per-consumer depth today, so for them the default-depth rule is a hard constraint rather than a preference — no flag makes a non-default depth safe. The honest options are to ask the endpoint’s adapter author to raise
window_turns, or to work within the default. Do not try to accumulate turns client-side across pulls as a substitute: that is only sound while you poll faster than turns age out of the window, and nothing tells you when you have fallen behind. |--after <seq>| Return only entries newer than that entry seq (see the asymmetry note below). A cursor that predates the retained window returns the full window plus"after_predates_window": true. Snapshot only — ignored under--follow. | |--follow| Stream deltas instead of one snapshot — a different output shape, documented below. A follow subscription always uses the default window depth and starts from a full base, regardless of--last/--after. |
A --after <seq> cursor that predated the retained window adds one top-level
field, "after_predates_window": true, so a consumer knows it missed rows.
Turn — one user-opened turn:
| Field | Type | Notes |
|---|---|---|
input | string | null | The input that opened the turn; null for a preamble turn. |
entries | array of entry | Agent/tool/boundary/context entries in stream order. |
input_seq | number | Omitted when absent. |
partial | bool | Omitted when false; true on the trailing turn while it is still being worked. |
Entry — an externally-tagged variant (the tag key names the kind). This is the complete set — there are exactly four kinds, and each is either agent-produced (derived from the harness transcript: the agent did this) or spt-injected (spt’s own bookkeeping merged into the timeline: the agent did not produce it):
| Variant | Provenance | Fields |
|---|---|---|
Agent | agent-produced — the agent’s own output text | text (string); seq, ts optional |
ToolSprint | agent-produced — tools the agent invoked; consecutive uses collapse into one sprint | tools (array of {name, arg}); seq, ts optional |
Boundary | spt-injected — a context reset (/clear, /compact, boot) spliced between spanned sessions; never emitted by the agent or the adapter | kind ∈ clear | compact | boot; ts optional |
Context | spt-injected — context spt fed to the agent (psyche download, owl message) or produced for it (echo commune); adapters never emit these | kind ∈ psyche_download | echo_commune | owl_message; body (string); ts optional |
seq and the --after cursor are asymmetric across kinds: only the
agent-produced kinds (Agent, ToolSprint) are transcript records and carry
a seq (optional — blanked on an open/partial turn). The spt-injected kinds
(Boundary, Context) are not transcript records and never carry seq —
they order by ts only. Consequences for a poller: cursor --after on the
highest seq among agent-produced entries (that is the number space
--after filters on), use the top-level version to cheaply detect that a
new pull is worth making, and never key “newest thing seen” on the presence
of injected entries — a Boundary/Context can appear in a window without
moving any seq.
Open turns re-deliver, by design. The trailing turn is marked
"partial": true while it is still being written, and its entries carry no
seq until they are committed. --after can only filter what carries a
seq, so a poller re-sees the partial turn’s content on every pull until
the turn completes and its entries take seqs — at which point your cursor
advances past them. Expect it and de-duplicate on your side (or ignore
entries in a partial turn until it closes); the cursor is not stuck, it is
correctly refusing to skip content that has no committed position yet.
Meanwhile version keeps bumping, because the projection really is changing.
A turn closes when the endpoint goes idle — not when the next input
arrives. The moment the endpoint reports it stopped working, the trailing
turn is finished: partial drops away and its entries take their seqs, with
no further prompting needed. This matters if you scan for something in the
latest turn: you no longer have to wait for (or manufacture) another user
input before that turn has a stable cursor. A later input still closes the
previous turn as it always did — that path simply is not the only one any
more.
Anchor your cursor on input_seq. A turn’s input_seq is fixed once the
turn exists and never moves — that is the number to remember. A turn still
being worked has no input_seq key at all (absent, not null), so “ignore
turns with no input_seq, anchor on the ones that have it” is safe by
construction: an unfinished turn cannot be mistaken for an anchored one, and
the reseal blink below cannot disturb a cursor that has already moved past the
turn. The seqs on
entries inside a turn may still advance forward while that turn is
gaining records: a reply that reaches the log late folds into the trailing
tool sprint and carries it to a higher seq. A forward move re-delivers, never
gaps — you may see a sprint a second time with more in it, but nothing you
have already read is skipped or renumbered downward. That holds on this
machine and across the network alike.
One blink to know about. Closing on idle is a live reading, not a latch: if
the endpoint goes busy again before the next input record reaches the log,
the trailing turn briefly reads as partial once more, then recloses with
the same seq as soon as the record lands. Nothing you already read moves,
and --after cursors are unaffected — the numbers are computed from log
position, so a turn that recloses reproduces exactly the values it had. The
blink is only visible to a poller that keys on “the latest turn currently has
a seq”; if that is you, treat a seq you have already seen as still valid
rather than as withdrawn. This cannot happen while the owner stays idle — it
takes a new prompt to open the window at all.
De-duplicating repeated content. Within a turn, entries are append-only
in position — never reordered or removed — and the trailing entry is
replace-in-place: a run of tool calls collapses into one ToolSprint
that grows, taking the latest record’s seq/ts each time. A content hash
is specifically the wrong key, because a growing sprint hashes differently on
every tool call and reads as a brand-new entry — counting one sprint many
times over.
Which key you use depends on how you are reading:
- Following (
--follow):(turn index, entry index)is the stable key. Indices are window-relative, but that is safe here because a window slide forces a full replace (from == 0), which tells you to rebuild. - Polling snapshots: do not key on turn index. Nothing announces a
window slide between two pulls — turns age out and every index silently
renumbers, so index-keyed state mis-keys with no error. Key on the durable
ids instead: a turn that has an
input_seqis identified by it, andseqidentifies an agent-produced entry.
Two things have no durable id, in any reading mode. Say them out loud rather than discover them:
- A leading preamble turn (
input: null) carries noinput_seq— there is no input record to take one from. It is identifiable only as the leading turn of the window. This is not a corner case: a preamble turn is whereBoundaryentries live. - Injected entries (
Boundary,Context) never carryseq— and unlike open-turn entries, they never will: they are not transcript records, so there is nothing for them to commit to. A snapshot poller can only identify them by content within their enclosing turn (kind+ts+body), which is a weaker key than everything else on this page gets: it cannot distinguish two genuinely identical injections, and it breaks if any field is absent (tsis optional).
That weakness lands on exactly the class the version discipline flags as most
dangerous — injected-only change, invisible to the pair. If you must not
double-count a Boundary as new activity, prefer --follow, where position
within the delta is meaningful, over snapshot polling.
ts is an RFC3339-UTC ordering key.
--follow --json — the delta stream
--follow --json does not stream the snapshot object above. It prints one
compact JSON object per line, one per change (only when the digest
actually changed — there are no heartbeats). Only the follow stream is
line-delimited: the snapshot path prints a single pretty-printed object
spanning many lines, so a line-oriented (NDJSON) reader built for --follow
will not parse a snapshot.
{ "version": 12, "from": 3, "turns": [ /* Turn, from index `from` onward */ ] }
| Field | Type | Meaning |
|---|---|---|
version | integer | The digest version after this change — the same counter the snapshot carries. |
from | integer | The window index where turns begins. |
turns | array of Turn | The changed turns, starting at from. |
Applying an update: truncate your view to from, then append turns.
from == 0 is a full replace — either the first (base) update, or a
window slide that invalidated the old indices. Turn and its entries are the
same shapes documented above, so provenance and the seq rules are unchanged.
A follow stream carries no after_predates_window field: that signal belongs
to the --after snapshot path.
Subscribing and resubscribing. Every subscription opens with a full
base update (from == 0) carrying the current window, then streams
changes. So a consumer that lost its stream simply resubscribes and rebuilds
from that base — there is no cursor to carry across a reconnect, and no
partial-state resume to get wrong.
When the stream ends. The stream ends observably: if the daemon stops
or restarts, the connection drops and --follow terminates. Idle and dead
are therefore not indistinguishable in the ordinary case — silence means
idle, and a dead daemon ends the stream rather than leaving you on it. Two
consequences to encode:
-
Treat “follow exited” as “resubscribe” — with backoff. The command currently exits 0 on a dropped connection, exactly as it does on a clean end, so the exit code is not a verdict on why it ended: do not branch on it. Because a down daemon drops the subscription immediately, a literal respawn loop becomes a hot loop precisely when the daemon is least able to absorb it — back off between attempts. A subscription that ends before its base update never established; count that as a failed attempt (and back off), not as a normal end.
-
A wedged-but-alive daemon still looks idle — it holds the connection open and publishes nothing. That narrow case is the one where a consumer with a hard freshness requirement needs its own liveness timer; it is not the general rule. That timer cannot be a digest pull. The slow unconditional snapshot poll above is a correctness belt and nothing more: it reads this same surface, so it can no more distinguish wedged from idle from torn-down than the stream can. Liveness comes from endpoint state (
endpoint list/api endpoint-info), never from digest activity. -
A dead or torn-down endpoint is not an error either — and it does not look empty. The digest is projected from on-disk records, so tearing an endpoint down does not empty it. A torn-down endpoint that has history keeps serving its real last-known content, indefinitely; only one with no records serves an empty digest. The selecting condition is records-on-disk, not liveness.
So a watchdog polling a torn-down endpoint does not see silence — it sees plausible, real, permanently-frozen content, and
--followagainst it subscribes successfully, delivers that content as its base, and then streams nothing forever. Neither the content nor the silence tells you anything about whether the endpoint is alive. Establish endpoint liveness out-of-band (endpoint list/api endpoint-info) and never infer it from this surface.
This is the digest read shape. It is distinct from the digest record an adapter pushes via
spt api digest-entry/ a[digest]extractor — that ingest contract (role/text/tool/ts) is documented in the manifest digest-record reference.
The shell relay — MAC-stamped frames
Two poll surfaces authenticate differently:
spt api poll <id>— the agent hook-channel drain. Reads the caller’s own perch spool; the manifest[inject]set must include the hook method. Each row prints as one whole<EVENT …>envelope on stdout.spt api poll <shell-id> --link <token>— the shell relay drain. The link token is the credential: it resolves the(owner, shell)pair and is refused (exit 1,AUTH_REFUSED) if no instance holds it. Rows are emitted raw, one per line — deliberately not<EVENT>-wrapped (the shell child parses its own vocabulary).
Shell-relay frames are MAC-stamped. The on-wire form is:
<mac> <frame>
— a 64-hex-char HMAC-SHA256 over the frame bytes, one ASCII space, then the
raw frame. The key is SHA-256(link_token); a frame with no valid MAC is
dropped, never processed. Agent-perch surfaces (spt ready, api listen,
api poll <id>) never emit stamped frames — a consumer of agent traffic
only ever sees <EVENT> / <EVENT-PART> lines.
--json catalog
Commands that emit --json, and the top-level shape each prints. Fields marked
optional are omitted when empty.
node status (also daemon status) obtains its service PID from the live
service reply, alongside its compiled version. JSON pid remains a string,
or null when the service is unreachable or too old to report its PID; it is
never populated from daemon.pid. The human view reports a differing file
record as recorded pid <N> is stale (daemon.pid). With no live PID to compare,
it says daemon.pid records <N>, not verified; an unreachable service is
reported as daemon: not running, never as running from the file alone.
| Command | Top-level shape |
|---|---|
endpoint list | { self, subnets[], local[] } — self: {id, status, ready, alive, unbound, description, psyche_host_error, translation_fault?, host_error?} (note the ? convention holds literally here: psyche_host_error carries no ? because the key is ALWAYS emitted, null when there is no fault, while its two sibling fault fields are omitted when absent); subnets[]: {name, endpoints[]} where each endpoint is {id, node, node_label, status, resources, endpoint_type?, project?}; local[]: {id, state, address, ready, alive, unbound, project?, activity?}. (Since v0.33.0 the local project field reads the daemon-maintained project index — answers are immediate and may lag a just-changed project by moments; absent while the index has never been built.) activity is busy or idle — the endpoint’s current state for surveying many endpoints at once. Local rows only: a remote row is gossiped and carries no activity sentinel, so the key is omitted there rather than guessed; it is also omitted for an unbound perch, which has no endpoint to be busy. |
whoami | { id, state?, ready?, alive?, unbound?, description? } — identity-only (since v0.33.0; previously the endpoint list shape): the calling session’s own endpoint, or {"id": null} + exit 1 when the session owns none. Never derives projects — the bounded-time identity verb for hooks. |
endpoint digest | { turns[], version, after_predates_window? }; --follow streams { version, from, turns[] } per change — see above |
endpoint description show | { id, description } |
endpoint role | { id, role } |
api endpoint-info [<id>] | { id, endpoint_type, adapter, local_node:{label,key}, attached_node:{label,key}|null, controlled, project, cwd, subnets[] } (always JSON) (since v0.33.0 project is index-fed — bounded time, safe on hook paths) |
daemon status | { running, pid, net_up, pump_heartbeat_ms, managed_by, managed_active, subnets[], local_endpoints[], broker_image?, broker_stale?, stall_evict_count?, stall_evict_last_ms?, project_index? } — project_index (since v0.33.0) is the index writer’s health block: {generated_ms, source_generation, pending_refresh, last_run_ms, last_duration_ms, last_error?, endpoints, projects, cwds, cwd_cache_hits, cwd_cache_misses, stale_reads, repairs, last_cycle:{branch_enumerations,tree_scans,derivations}, cumulative:{…}}; absent when no writer has ever run on the home |
subnet status [--nodes] | { daemon_running, subnets[] } — each {name, node_count, endpoint_count, nodes[], declared_mode?, captured_mode?, pending_declared_mode?, pending_seen_ms?}. The mode facts ride every row of the bare view, not only a named subnet’s: declared_mode is the posture the subnet declares as this node knows it, captured_mode what this node actually enforces as its fallback, and the pending_* pair a declared change seen but not adopted. Each is omitted when absent, and absent is a real state to read for: a row carrying declared_mode: "closed" with no captured_mode is enforcing open here — see Viewing access rules and posture. |
subnet show-code | { subnet, code, otpauth_uri? } |
notif list | { notifs[] } — each {notif_id, subnet, kind, state, from_id, head} |
access list | { entries[] } — each {endpoint, nodes[], locked} |
grant list | { grants[] } — each {capability, agent, node, qualifier} |
adapter list | { adapters[] } — each {name, kind, mode, version, source_dir, active} |
adapter version <option> | { adapter, version } |
shell list | { owner, shells[], instantiable[] } — each shell {id, alias, adapter, status} |
All shapes are additive-forever: new keys may appear, existing keys keep their meaning. Parse tolerantly (ignore unknown fields) and a newer daemon never breaks an older consumer.