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

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, --last and --after included. 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. --follow is 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:

  1. Compare the pair (version, highest agent-produced seq) — computed at a constant window depth on every pull. max seq is taken over what the window returned, so comparing a pull made under --last 1 against one made at the default depth manufactures a false “unchanged”. Unlike version, seq is derived from the on-disk transcript ledger and survives a daemon restart, so a fresh run that coincidentally matches your last version will not also match your last max seq — unless the content really is identical, in which case there is nothing to skip.
  2. 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 no seq until the turn closes) and spt-injected entries (Boundary/Context never carry seq at 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.
  3. 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

FlagEffect
--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. --last is a human / one-shot flag.

A non-default --last is 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 1 pull collapses every concurrent --follow client’s view to one turn (they receive it as a from == 0 full replace) and bumps version for everyone — then the next default-depth request flips it back. version therefore 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_turns in 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 --follow stream, 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 sets window_turns on 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’s adapter as <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 declares window_turns and 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_turns is 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 declares window_turns and 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:

FieldTypeNotes
inputstring | nullThe input that opened the turn; null for a preamble turn.
entriesarray of entryAgent/tool/boundary/context entries in stream order.
input_seqnumberOmitted when absent.
partialboolOmitted 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):

VariantProvenanceFields
Agentagent-produced — the agent’s own output texttext (string); seq, ts optional
ToolSprintagent-produced — tools the agent invoked; consecutive uses collapse into one sprinttools (array of {name, arg}); seq, ts optional
Boundaryspt-injected — a context reset (/clear, /compact, boot) spliced between spanned sessions; never emitted by the agent or the adapterkindclear | compact | boot; ts optional
Contextspt-injected — context spt fed to the agent (psyche download, owl message) or produced for it (echo commune); adapters never emit thesekindpsyche_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_seq is identified by it, and seq identifies 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 no input_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 where Boundary entries live.
  • Injected entries (Boundary, Context) never carry seq — 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 (ts is 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 */ ] }
FieldTypeMeaning
versionintegerThe digest version after this change — the same counter the snapshot carries.
fromintegerThe window index where turns begins.
turnsarray of TurnThe 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 --follow against 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.

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