Manifest reference
The runtime manifest is the declarative half of the harness contract: one TOML file per adapter, declaring only what varies per harness or shell. This page is the complete field reference.
Machine-readable companion: manifest.schema.json
— generated from the exact code that parses manifests, so it never drifts.
Validate your manifest against it, then spt adapter add enforces the
cross-field rules listed at the bottom.
The principle
SPT is not a harness. Command templates are opaque strings — spt-core
never parses out a model, tool list, or flag; the adapter writes the full
command line and spt-core runs it with {key} substitution placeholders
filled. Anything spt-core owns is not in the manifest:
- Sentinels (idle markers, the echo gate) — managed via
spt api state/spt api echo-gate; adapters only call them. - Spool, registry, perch, and daemon-state schemas.
- The event-block vocabulary — the tags spt-core surfaces to agents are a fixed, documented constant. Adapters pass spt-core’s output through unchanged.
- File-drop filenames — statically
<endpoint_id>-commune.md/<endpoint_id>-signoff.md; only the watched directory is declared. - Config knobs (pulse period, summarizer windows, …) — global spt-core settings with per-endpoint overrides, never per-adapter.
Substitution keys
The full {key} vocabulary spt-core fills into command templates. A role’s
keys list must be a subset of this catalog, and every {placeholder} in a
command (or cwd/source/fires/…) must resolve to a value spt-core
supplies for that spawn — an unknown or unprovided key fails with a one-line
error. Not every key exists in every context: spt-core fills only those
relevant to the spawn (e.g. {psyche_*} only for a live agent’s Psyche role,
{source} only for a [digest]/[history] extractor).
| Key | spt-core fills it with |
|---|---|
{id} | The endpoint id being hosted. For a Psyche role this is the parent endpoint id (the LiveAgent being hosted), not the nested <parent>-psyche perch id. |
{adapter_name} | The adapter’s declared name (the value every api call carries). |
{adapter_dir} | The adapter’s install dir (the registry record’s source_dir) — adapter-static, available wherever substitution runs (every [session.*], [digest], the translation binary, lazy [strings], and — since v0.44.0 — the [shell].spawn and [shell].wake_command templates). Survives updates; lets a command point at the adapter’s own packed binary (resolve-not-execute, ADR-0029). |
{session_id} | The harness session id (minted at spawn; reported back via api seed). For a Psyche role this is the Psyche’s own custody session id — its own conversational thread, which a parent boundary (/clear, /compact) does not rotate — never the parent’s. |
{parent_session_id} | The parent session id, exposed under its own explicit key so a Psyche role template that needs the parent’s id never aliases {session_id} (which on a Psyche spawn is the Psyche’s own custody id). |
{session_name} | The session’s display name, when one is supplied. |
{node} | This node’s advertised label — the value the roster and picker render (its OS hostname, read into the label store at daemon startup), never the pubkey. Node-static: available wherever the session keys populate and in lazy [strings] resolution. Single-token fill only (a space-carrying label stays adapter-shim territory); when no label is known the key is left unfilled so a referencing template fails loudly, never an empty token. Note: the daemon-side lifecycle resolves it once at startup while a CLI-originated spawn reads a fresh hostname, so the two differ only across a mid-life hostname change. |
{subnet} | This endpoint’s anchor-subnet label (local when unanchored), filled whenever it is known so a nested Psyche turn need not resolve a --subnet it cannot know. A single {subnet} concept — there is deliberately no {home} key. When no subnet is known the key is left unfilled so a referencing template fails loudly (the {node} precedent). |
{parent_pid} | The harness parent process pid — the SessionStart api seed anchor. |
{agent_type} | The hosted agent type. |
{psyche_context_file} | The path to the file spt-core writes the Psyche’s carried context into before each turn (never the context body on the argv — a large mind would exceed the command-line length cap). Fresh vs continue is the file’s content: a fresh/reseeded turn writes it non-empty, a continue turn writes it 0-byte. Replaces the former {psyche_context} body key. |
{link_token} | A shell-link capability token (shell adapters). |
{perch_dir} | The shell instance’s perch directory (shell spawn template only — a wake_command fills its own smaller catalog, which since v0.44.0 includes {adapter_dir} but never this key). What lets the binary resolve a shell_file frame’s perch-relative path attribute — the spawned child inherits the broker’s working directory, so without this key no mechanical resolution exists. Opt-in: a template that never names it fills exactly as before. Filled as one argv element even when the directory contains spaces. See the frame contract. |
{source} | The transcript/log path spt-core resolves for a [digest]/[history] extractor. |
[adapter] — header (required)
The only mandatory section, and it must be readable before any install or
update — min_spt_core_version is the compatibility gate.
[adapter]
name = "my-harness" # the adapter_name; an optional --adapter override
kind = "harness" # "harness" (default) | "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0" # lowest spt-core this adapter tolerates
hostable_types = ["LiveAgent", "ReadyAgent", "Worker"]
host_binaries = ["my-harness"] # harness exe(s) you host → bind-time resolution, no --adapter
web_short_path = "reports" # optional alias; the adapter facet exists without it
| Field | Required | Meaning |
|---|---|---|
name | yes | Adapter id; the value an optional --adapter <name> override carries |
kind | no (default harness) | harness hosts agents; shell provides a driven surface |
version | yes | The adapter’s own version |
min_spt_core_version | yes | Compat gate, checked before install/update |
hostable_types | no | Endpoint types this adapter can host |
host_binaries | no (harness) | Harness exe basenames you host — the bind-time match-key so seed/listen resolve with no --adapter (since v0.9.0). Matched on lowercase + stem-before-first-dot, so claude matches claude/claude.exe/claude.cmd/claude.exe.old.<ts> (a self-update can rename the running exe); a declared name must not contain a dot |
web_short_path | no | Requested short alias for the base adapter’s core-owned served root; collisions receive a persisted suffix |
Unknown keys. A key spt-core does not know is ignored, never refused, so an
adapter written against a newer core still registers. spt adapter add names
each one on stderr as manifest: unknown key [<table>].<key> (ignored) and
proceeds. A misspelled web_short_path therefore registers without an alias
and says so. Tables the contract declares free-form ([profiles.*],
[strings]) are the adapter’s own vocabulary and are not swept.
Served output. Core creates $SPT_HOME/adapters/<adapter>/web/ before
committing activation, even if the daemon is stopped. Write only intended
served output there; core never exposes the adapter’s install tree, manifest,
records, or strings on its behalf. The daemon reconciles one dir entry per
active base adapter at startup and after adapter changes.
http://localhost:5474/<node>/a/<adapter>/ always names that entry;
web_short_path adds /<node>/<assigned-name>/ as an alias. A profile never
adds a separate row or root; the base declaration controls the shared resource.
Aliases must be nonblank single segments, not . or .., and contain no
path separators, colon, or control characters. The reserved facets f, docs,
a, m, bin, and install are refused case-insensitively before registration
writes and when resolving manifests. Other collisions allocate reports~1,
reports~2, …; spt serve list reports the assigned alias.
An update or repeated registration preserves the live assignment, including
whether it has an alias. A changed/added/removed declaration takes effect on
deactivation followed by reactivation. With the same declaration, reactivation
reclaims the root’s prior name and kind; other sources cannot take retired
names. Removing or deactivating an adapter removes exposure, not output bytes;
updates and reactivation also retain them. Redirected roots
and symlinks escaping the served subtree are refused.
[hooks.<event>] — inbound hook table
One entry per harness event, declaring the spt api command it fires, the
input fields it maps in, and whether the hook can surface text into the
agent’s context.
[hooks.SessionStart]
fires = "api seed --pid {parent_pid} --session-id {session_id}" # adapter-agnostic since v0.9.0
reads = ["session_id", "parent_pid"]
can_inject = true
[hooks.Stop]
fires = "api state idle"
can_inject = false # no inject channel -> sentinel/relay fallback
| Field | Required | Meaning |
|---|---|---|
fires | yes | Opaque api … command line the harness invokes for this event |
reads | no | Input fields (e.g. from the hook’s stdin payload) mapped into the command |
can_inject | no (default false) | Whether this hook can inject context back to the agent. When false, spt-core falls back to its sentinel + relay/poll path instead of expecting injection |
can_inject is the single most load-bearing harness-varying fact — declare
it honestly per hook.
[session] — watched dirs + role templates
Two watched-directory keys sit directly on [session]; the file names are
fixed by spt-core, only the directory varies:
[session]
commune_dir = ".my-harness" # watched for <endpoint_id>-commune.md
signoff_dir = ".my-harness" # watched for <endpoint_id>-signoff.md
Commune and signoff are file-drops, not commands — an agent writes a markdown file; spt-core’s watcher does the rest.
[session.<role>] — outbound templates
One opaque command template per role. Model, tools, flags, permissions — all
live inside command, never as separate fields.
Roles: self (the agent’s own session) · resume (the agent’s own-session
native resume, the self sibling) · psyche_init (go-live gate only —
its presence promotes the endpoint to a LiveAgent; spt-core never spawns it) ·
psyche_resume (the sole driven Psyche role — one bounded per-event turn) ·
echo_commune (the bounded history summarizer for sessions that end without a
signoff) · signoff (final context save) · notif (endpoint-native
notification render).
Resuming an existing harness session (since v0.13.0). [session.self] is the fresh
bringup; [session.resume] is the native-resume sibling. spt-core selects
[session.resume] over [session.self] only when a bringup carries a prior
session (spt endpoint resume <id>, spt go <id> on an offline endpoint, or the picker’s Resume from
history) and your manifest declares the role. Resume is latest-only — no
verb takes a session argument, and one is refused rather than ignored: core
resolves which session to resume from the endpoint’s ledger. Declare it with your
harness’s native-resume verb — if your harness resumes a transcript by id, use
that form (Claude Code: claude -r {session_id} …), not the fresh
create-session form. Skip the role and a resume silently re-runs [session.self]
(a fresh session → a blank transcript). spt-core fills the SAME key catalog as
self ({id}, {session_id} = the resumed id, {session_name},
{adapter_name}) and lands the PTY in the session’s recorded project cwd (a
harness resolves a transcript by session_id + cwd) — the per-session
ledger row’s cwd, else the endpoint’s bind cwd, else the current dir.
A resume can legitimately start FRESH — and says so. {session_id} is
always a harness-reported id from the endpoint’s ledger, never spt-core’s own
spawn-time provisional (your harness has never seen that one and cannot resolve
it). If the endpoint has no harness-reported session on record — only a spawn
provisional — spt-core does not hand the provisional to your template: it prints
RESUME_NO_HARNESS_SESSION:<id> and starts a fresh session through
[session.self]. Your [session.resume] command simply does not run that time,
which is correct and is not a template failure; the notice on stderr is how you
tell the two apart.
[session.resume]
command = "my-harness resume --session {session_id} --id {id}"
keys = ["session_id", "id"]
# Go-live gate ONLY — spt-core never spawns this; its presence makes the endpoint live.
[session.psyche_init]
command = "my-harness run --agent psyche --model cheap"
# The role spt-core actually drives — one bounded turn per Psyche event.
[session.psyche_resume]
command = "my-harness run --agent psyche --resume {session_id} --model cheap"
env_remove = ["MY_HARNESS_SESSION_ID"]
recursion_guard_env = "SPT_ECHO_COMMUNE"
keys = ["session_id", "parent_session_id", "psyche_context_file", "subnet"]
A Psyche runs as a bounded per-event turn, not a resident process (there is
no psyche pid to poll — liveness is that turns succeed). Declaring
[session.psyche_init] is the go-live signal only — spt-core never spawns
it; the per-event turn drives [session.psyche_resume] exclusively. For
that turn spt-core fills {session_id} (the Psyche’s own custody id — see
the key table), {parent_session_id}, {psyche_context_file}, and {subnet}
(when known); the adapter-static/node keys {id} (the parent endpoint id),
{adapter_dir}, {adapter_name}, and {node} are also available. It does
not fill {session_name} (a [session.self] key). Declaring a key your
role’s spawn isn’t given fails at spawn, so template only the keys spt-core
fills for the role.
Not declaring [session.psyche_resume] is allowed, and it is loud (since
releases#239). The role is load-bearing when declared — it is the only
role the per-event turn drives — so an endpoint without it simply runs no psyche
turns. That is a supported configuration, not a fault, and spt-core treats it as
one:
- the turn is skipped, decided before anything is spawned;
- a single
PSYCHE_ROLE_ABSENT:<id>line is logged once per endpoint, not once per fire; - no strikes accrue. The consecutive-failure budget is for a declared role that fails; a role that was never declared cannot fail and cannot self-heal, so spending that budget on it would exhaust a guard built for a different mechanism;
- the endpoint’s status carries
psyche_role_absent(visible onspt endpoint list --json), and it is a status field, not thepsyche_host_errorfailure latch — nothing is being retried, so there is no attempt count and the stamp records when the absence was first noticed; - it clears itself. Declare the role and the next fire runs a turn and drops the status, with no operator gesture.
[session.echo_commune] behaves the same way with one deliberate difference:
its absence is quiet, because the published contract presents it as an
optional template, while a missing psyche_resume silently disabling every
psyche turn is exactly the kind of degradation that has to announce itself.
Reserved exit codes — how a psyche_resume turn tells spt-core why it
failed. spt-core classifies a failed turn on the process exit code alone —
never on output text (your harness may reword, restructure, or JSON-wrap its
errors freely; only the code is contract). Any other nonzero exit is a generic
failure: spt-core keeps the Psyche’s session custody, counts a strike, and
retries.
| Exit code | Meaning | What spt-core does |
|---|---|---|
95 | Psyche session gone — the harness’s own session store no longer resolves the {session_id} it was asked to resume. Exit 95 only when the resumed session itself is missing/expired; a fresh turn (non-empty {psyche_context_file}) must never exit 95. | Reseeds: clears custody and re-mints the Psyche session from the carried context. The only exit that reseeds. |
96 | Account/credential refusal — the inner tool refused for account-level reasons (spend/usage cap, expired or revoked credential, org quota): the session is healthy, the code is healthy, a retry would succeed on a healthy account, and only a human can restore it. | Retries on its own slow pacing and surfaces the outage under a distinct label. Never reseeds (custody is fine — a reseed would destroy a healthy transcript), and never spends the crash/defect strike budget. |
Adapters own the mapping from their inner tool’s observable failure to these
codes (match your tool’s output in the adapter, where you can track its
wording — that is exactly why spt-core never does). Emit the diagnostic text on
stderr and keep it in the exit line if you wrap an inner process: a bounded
tail of both streams in the failure message is what turns an outage from a
mystery exit code: 1 into a one-glance diagnosis.
Shipped binaries resolve from the install dir (since v0.8.0). A command
template’s bare program token (its first token, e.g. my-harness-digest)
resolves against the adapter’s install dir before PATH, so a .spt that
ships its own binaries is self-contained — no PATH placement needed. spt-core
runs <install_dir>/<program> (on Windows also trying the .exe suffix) when
that file exists, else falls back to PATH. The install dir is where your
adapter was registered (the --release/--github durable home, or the
copy-mode source dir). This applies to the [session.psyche_resume] per-event
turn, the [digest] extractor,
spt adapter digest-proof, and — since v0.44.0 — the [shell].spawn and
[shell].wake_command templates: a released shell adapter’s binary launches by
bare name from its install dir, with no node-local manifest edits. Ship a
binary in your .spt and reference it by bare name; you need not place it on
PATH.
| Field | Required | Meaning |
|---|---|---|
command | yes | Opaque command line with {key} placeholders |
cwd | no | Working directory (substitutable) |
recursion_guard_env | no | Env var set on summarizer children so their hooks bail (no summarizer-of-summarizer loops) |
detach | no (default false) | Spawn detached |
env_remove | no | Env vars stripped from the child’s inherited environment |
keys | no | The substitution keys spt-core fills for this role |
invocation_budget_secs | no (default 90) | How long a bounded invocation of this role may run before spt-core kills it. Declare it on any role that is an LLM turn — the adapter is the only party that knows what its own model costs. Clamped to 300; a role that declares nothing gets 90. See Invocation budgets |
Invocation budgets
Some roles spt-core runs are bounded: it spawns them, waits, and kills them if they overrun. A role that is an LLM turn should declare how long its own model needs, because spt-core cannot know it:
[session.echo_commune]
command = "claude -p --model haiku …"
invocation_budget_secs = 120
A role that declares nothing gets 90 seconds, and a declared value is clamped to 300 — an adapter may ask for more time than the default, never for effectively unbounded. The resolution keys on the field, not on which role is asking, so a future bounded role picks this up with no core change.
Pick the number from what your model actually costs, with headroom for a loaded machine. The default exists because a flat 30-second bound used to kill legitimate summarizer turns: the same input on the same node measured 23s and 35s depending on load, so whether a healthy agent was killed came down to what else the machine was doing. If your turns measure near the bound, raise it — being killed at the bound is not treated as a defect (see below), but it does mean the work is lost.
A bound kill is not counted as a fault. spt-core tracks bound kills on a separate, far more forgiving budget than real failures, precisely because a kill says more about the machine’s load than about your adapter. A slow-but-healthy harness will not be marked failed.
notif is the endpoint-native notification render — an OS toast, a status
LED, anything the adapter can run. Spawned detached when a notification
surfaces at this endpoint. Keys spt-core fills: {notif_id}, {notif_from},
{notif_subnet}, {notif_body}.
[session.notif]
command = "powershell -Command New-BurntToastNotification -Text '{notif_from}','{notif_body}'"
keys = ["notif_id", "notif_from", "notif_subnet", "notif_body"]
[env.<VAR>] — env-var table
Vars to inject into (or read from) sessions, and how. The injection channel is asymmetric by hosting mode: spt-hosted sessions inherit env from the broker that spawned them (no channel needed); harness-hosted sessions need the harness’s declared channel.
[env.MY_HARNESS_SESSION_ID]
direction = "inject" # "inject" | "read"
value = "{session_id}" # required for inject
channel = "MY_ENV_FILE" # harness-hosted only
[history] — transcript access
How spt-core reads a session’s conversation history (it powers the echo-commune summarizer). Three strategies; pick exactly one:
[history]
strategy = "fetcher" # "fetcher" | "locate_normalize" | "native"
fetcher = "my-harness-history --session {session_id}"
| Strategy | Required fields | Meaning |
|---|---|---|
fetcher | fetcher | spt-core runs your binary; it emits normalized history |
locate_normalize | locate_template, normalize_command | spt-core locates the raw transcript, then runs your normalizer over it |
native | — | The adapter pushes via spt api history-log; spt-core stores it |
spt-core has no built-in transcript parser for any harness — the adapter always owns that knowledge.
[digest] — session-digest extractor
The session digest’s own seam (ADR-0019) — separate from [history], which stays
opaque and single-session for the echo-commune. [digest] declares an
imperative extractor that maps your harness’s native log to the digest-record
contract:
[digest]
extractor = "my-harness-digest --session {session_id} --in {source}"
source = "~/.my-harness/{session_id}.jsonl" # optional; defaults to [history].locate_template
window_turns = 5 # optional presentation defaults you declare…
arg_truncation = 40 # …any consumer may override at pull/subscribe
sprint_collapse = true
| Field | Required | Meaning |
|---|---|---|
extractor | yes | Opaque command: native log → contract JSONL (one record/line). Under locate_normalize spt-core fills {source} with the resolved path and pipes the bytes on stdin; under fetcher it just runs the command and reads stdout. |
strategy | no | Which side locates the transcript, mirroring [history] — locate_normalize (default) or fetcher. See the strategy table below. |
source | no (locate_normalize only) | Own-source log path; absent, reuse [history].locate_template. Under locate_normalize one of the two must resolve, else spt adapter add rejects (see Cross-field rules). Ignored under fetcher. |
window_turns / arg_truncation / sprint_collapse | no | Adapter-declared presentation defaults; any consumer may override. spt-core fallback: 3 / 25 / collapse-on. |
[digest] supports the same two locate strategies as [history] — pick with strategy:
| Strategy | Who locates the transcript | source |
|---|---|---|
locate_normalize (default) | spt-core resolves the single source file, reads it, pipes the bytes to the extractor on stdin. | Required (own source or inherited [history].locate_template). |
fetcher | The adapter’s extractor locates + reads + emits; spt-core runs it bounded and consumes stdout — no source, no pre-read. | Not used. |
Use fetcher when the transcript lives in a partitioned layout spt-core cannot
name with one template — e.g. Claude Code’s projects/<munge(cwd)>/<session_id>.jsonl
or a date-globbed rollout tree. spt-core feeds the extractor only the
harness-neutral inputs it owns — {session_id}, the perch-bound {cwd}, and any
captured [env] direction = "read" vars (e.g.
{CLAUDE_CONFIG_DIR}) — never a harness-specific project slug; the extractor globs
the unique {session_id} under the root itself:
[digest]
strategy = "fetcher"
extractor = "my-harness-digest --session {session_id} --config-dir {CLAUDE_CONFIG_DIR} --cwd {cwd}"
# no `source` — the extractor locates the file
Why a command, not a declarative map: real harness logs are nested (one line →
many entries, mixed block lists, types to filter); a flat map can’t express them.
A log-less adapter declares no [digest] and pushes via spt api digest-entry instead. Validate before shipping with spt adapter digest-proof <adapter> --sample <real-log>. digest-proof fills the same {id} and
{session_id} the runtime endpoint digest does, so a {session_id}-templated
extractor (e.g. --session {session_id} --in {source}) proofs exactly as it
runs live; pass --session <id> to pin a specific session id.
[inject] — input-injection methods
How text can be put in front of the agent, per activity state. Any
combination of pty, hook, relay, http:
[inject]
activity = ["hook"] # non-disruptive while the agent is working
idle = ["pty", "hook"]
[io] — IO-funnel compliance
Opt-in, and absent means off. Declaring [io] is how an adapter tells
spt-core that its own local tag parsers are gone as of this release, so core may
parse its ingest:
[io]
compliance = true # core may parse this adapter's ingest
shortform = false # optional: stay compliant, keep core-side shortform off
compliance(bool, defaultfalse) — the declaration itself. Absent orfalse, core parses nothing of this adapter’s ingest.shortform(bool, optional; absent ⇒ enabled) — the exotic-harness opt-out. It is one switch over both shortform markers, the@<…@>dispatch tag and the;;seal mint, because they are one feature with one suppression grammar. Setting itfalsedisables that reader and leaves the compliance declaration standing.
Declare compliance in the release that deletes your own parser, not before. That ordering is the whole point of the default: an adapter that ships its own tag parser today stays the only parser until one change removes it and declares here, so no version exists in which both parse the same text and send it twice.
The grammar these fields gate — what a tag looks like, how backticks and fenced blocks suppress it, what happens with no controller attached, and where the outcomes surface — is the frame contract’s shortform section.
[io.now_signal] — standing now-signal tuning
What spt api now-signal --spec-manifest reads. An adapter knows which
categories its surface can render, which are noise in it, and what its injection
budget is; this is where it says so:
[io.now_signal]
without = ["SHELLS"] # suppress a category
max_lines = 4 # cap each category's output per poll
# only = ["DISPATCH_RESULTS"] # or narrow to an explicit set
only(array of category names; empty ⇒ no narrowing) — when non-empty, the only categories rendered.without(array of category names) — categories to suppress. If a category appears in both, suppression wins.max_lines(integer, optional) — a per-poll cap on the lines a single category may print.
It narrows and tunes; it never invents. A name outside the category
vocabulary is ignored rather than conjured — and ignored rather than refused,
so a manifest may carry a deferred category’s name ahead of core building it and
still load. Absent [io.now_signal], a --spec-manifest poll renders the
default picture; so does a malformed one, because this verb runs on a hook at
every turn boundary and a config typo must not break a working session. The
category list itself is in the frame
contract.
[message-idle-translation-binary] — spt-hosted idle delivery
Opt-in, spt-hosted only (since v0.13.0). An adapter’s idle-delivery translation binary: a
pure stdin → stdout JSON-lines filter spt-core lifecycle-manages (spawned when
the spt-hosted endpoint comes up, terminated when it goes down). spt-core feeds it
the inbound <EVENT> message feed and reads back keystroke-commands, which it
applies to the broker-held PTY atomically — a live spt rc controller’s input
is buffered during the emitted sequence and flushed after, so idle injection
coexists with an attached operator (spt-core owns every PTY write). Idle delivery
only — busy / mid-turn delivery stays your [inject] hook path.
Every byte spt-core writes on this leg, so your editor is never surprised. Three
things reach the PTY on an idle delivery and nothing else: (1) a readiness probe
before the delivery’s first byte on an echoing PTY — the DSR cursor-position query
ESC [ 6 n, repeated every ~40 ms until the session produces output or a bounded
deadline elapses (a non-echoing ConPTY, where the probe is never observable, latches
it off); (2) the keystroke-commands your binary emitted, verbatim; (3) a
re-drive — the same payload typed again, after another probe — if the typed
head does not echo back. spt-core never sends a clear-line or clear-input of its
own; if a draft in your editor vanishes on delivery, the erasure is either your
emitted sequence or your editor’s reading of the probe (a TUI that treats an
unrecognised CSI, or a bare ESC, as clear-input). If you declare no translation
binary, spt-core never touches the PTY on message arrival and idle messages spool.
“Idle” is spt-core’s view of the agent: a human composing a draft while the agent
is idle is on this leg.
Declared as a table carrying a path scalar (a table can’t be silently
absorbed by a preceding section and stays extensible):
[message-idle-translation-binary]
path = "cc-spt-idle-translate" # the binary spt-core spawns + drives
- stdin (spt-core → binary, one JSON object per line):
{"type":"init","endpoint_id":…,"node":…}first ·{"type":"event","envelope":"<EVENT…>"}per inbound message (the<EVENT>envelope) ·{"type":"input"}— a content-free ping each time the operator types, so the binary can track user-idle (the PTY input content is never duplicated to the binary). - stdout (binary → spt-core, one per line):
{"key":"ctrl+s"}·{"delay_ms":50}·{"text":"<payload>"}·{"key":"enter"}·{"commit":true}, … (extensible vocabulary). {"commit":true}is the mandatory sequence terminator — and you MUST send it for EVERY{"type":"event"}. While your emitted sequence is in flight, spt-core buffers a livespt rccontroller’s keystrokes (the inject floor) and applies your commands to the PTY atomically;{"commit":true}— emitted as the last record — releases that floor and flushes the buffered controller input after your sequence. The submit keystroke is not the terminator:{"key":"enter"}(or a trailing\rinside a text payload) submits the input, but a choreography may keep typing after it (e.g. a stash/restore that presses a key after submitting), so commit is a distinct, explicit signal you always send last. An empty response is a protocol violation: even when you have nothing to inject (an event with nothing armed, or an event without an envelope), you MUST still answer with at least a bare{"commit":true}— a response of zero records is treated as a missed commit.- Missed commit → the sequence is tolerated, not fatal. If no
{"commit":true}arrives within the commit deadline (5 s), spt-core still flushes the buffered operator input (never stranded) and re-spools that one message once so it is not lost — but it does NOT terminate a healthy binary. A single miss is tolerated; the binary is preserved and the next event delivers through it as normal. Only after 3 consecutive missed commits (a genuinely wedged binary), or a real binary death, does spt-core fault the binary — and even then it bounded-eager-respawns it (a healthy commit resets the budget) rather than leaving it permanently dead, surfacing the fault on the endpoint’s status while it is degraded. (This supersedes the pre-v0.14.3 “falls back to a raw inject” behavior — raw inject was removed; a missed commit never types your payload raw.) - Unknown fields are not rejected here — a newer adapter declaring a future key against an older spt-core parses fine (the key is ignored), so the contract degrades gracefully.
{"text":…}is applied to the PTY verbatim — bytes are typed exactly, with no control-character stripping. A trailing\rinside a text payload ({"text":"…\r"}) therefore submits, identical to a following{"key":"enter"}(enter→\r). Submit either way; just don’t do both. Corollary: neutralize any CR/LF inside the message body before the trailing submit, or an embedded newline fires the input early.- A minimal binary just emits
{"text":payload}{"key":"enter"}{"commit":true}with no choreography. (spt-hosted idle delivery is translation-binary-only since v0.14.3; there is no raw-inject fallback — a binary that fails to spawn or misses its commits spools the message, it is never typed raw.)
[service] — a daemon-supervised resident service
Opt-in (since v0.44.0). Declares a resident service: a binary the daemon supervises on the adapter’s behalf, core-owned from birth. It is spawned job-neutrally by the daemon, so it is never a shell’s child (a tree-kill of the shell cannot reach it) and never inside a launching terminal’s Job Object (closing the terminal cannot sweep it). It has no perch, no identity, and no address — a service needing a two-way agent-facing surface has one at its adapter’s endpoint/shell layer. It runs independent of any agent’s liveness.
[service]
command = "{adapter_dir}/gw-hub serve" # opaque; program token + args
start = "boot" # "boot" | "bind" — REQUIRED
stop_grace_ms = 30000 # optional; defaults to 30s
-
command— an opaque command string (program token plus args), like every other command seam. Its program token resolves against the adapter install dir beforePATH, and args support adapter-static{adapter_dir}/{adapter_name}substitution only. Must be non-empty: a declared service means spt-core owns and supervises a process. -
start— required, no default."boot"is desired-state-running, not an event: the supervisor reconciles the service toward running at daemon boot, at adapter registration against a live daemon (installing or registering an adapter never requires restarting spt to bring its service up), at update-hold release, and at first shell bind as a defensive ensure."bind"starts lazily at the adapter’s first shell bind. Both are supervised identically once running. The key is required rather than defaulted because the choice decides whether a least-trusted third-party binary rises with the daemon itself — an adapter author says so explicitly. -
Registration reports what it did, and never fails over the service. After a successful
spt adapter addorspt adapter update, the CLI asks the daemon to reconcile that adapter and prints one line per option — started, already running, held, deferred to first bind, or not started with the reason. If the daemon is not running, the registration still succeeds and the CLI says so: the service is declared and starts at the next daemon boot. A registration is never refused because a service could not be started, and the notice is not optional — without it astart = "boot"service would silently not exist until something else restarted the daemon. -
stop_grace_ms— how long a cooperative exit has before the supervisor force-kills. Defaults to30000. Must be> 0: a zero grace would leave no window to exit in. -
Cardinality is one supervised instance per registered adapter-option (
<adapter>[:profile]). The supervisor threads the option name and a per-option runtime dir into the service’s spawn environment, so an adapter can scope its own guards and config per option. An adapter may keep its own file lock as a private double-start guard; spt-core neither reads nor depends on it. -
The spawn contract, by name. A supervised service is started with these environment variables, and they are the whole interface:
variable value SPT_SERVICE_OPTIONthe adapter-option this instance serves, e.g. huborhub:stagingSPT_SERVICE_DIRthis instance’s private runtime directory, created before the spawn SPT_BINabsolute path to the sptexecutable to call (see below)SPT_HOMEthe state root that sptmust be run againstThe option is the raw string an operator types, not a path encoding. The runtime dir is per-option, so two options of one adapter never share a directory — scope any private lock or config file inside it.
-
Calling the
sptCLI from a supervised service. Invoke$SPT_BIN, not a barespt. A service is spawned by the daemon, not by a login shell, so itsPATHis whatever the daemon inherited — a daemon started by the platform service manager or a scheduler has no reason to carry the install dir, and a baresptwould work on the author’s box and be silently missing in the field.SPT_BINalso names the running daemon’s own image, so the CLI the service calls is never a different build than the supervisor that owns it.SPT_HOMEis likewise pinned to the home the daemon resolved rather than left for the child to re-derive from a per-account platform default. Both are absolute.SPT_BINis the one variable here that can be absent — if core cannot read its own image path it says nothing rather than handing out a guess, so treat an unsetSPT_BINas “the CLI is not available” and report it. -
A supervised service has no session identity. Core removes
SPT_ENDPOINT_ID,OWL_SESSION_IDandSPT_AGENT_IDfrom the spawn: a service is a node-scoped process with no session and no perch, so itsspt sendpresents as the anonymouscli@<node>origin. Without the scrub a daemon that happened to be started from inside an agent’s shell would hand that agent’s identity to every service on the node, and their messages would claim to be from that agent — with the replies routed back to it. A service that needs a perch identity registers one; it never inherits one. -
The runtime dir’s core-owned filenames, also by name. Three names inside
SPT_SERVICE_DIRare contract; everything else in there is the adapter’s own.file written by meaning stop-requestedspt-core the quiesce request — its existence is the whole message (see below) status-advisorythe service (optional) one advisory line surfaced by spt adapter service statusstartup.capturespt-core the service’s own stdout+stderr from its startup window — the evidence a STARTUP_FAULTcarriesstatus-advisoryis the one file in this contract an adapter writes and core reads. It is display-only and never consulted for a decision — no reconcile, hold, kill or adoption reads it — because core deciding on it would put a least-trusted binary’s self-report in the control path, and a service that stopped updating it would silently become whatever it last claimed. The read is bounded (the first line, capped) and an unreadable file is simply no advisory: nothing here ever requires reading a file the service holds an exclusive OS lock on. Write it however suits the service — a whole-file rewrite each cycle is the intended shape.startup.captureholds both streams on one handle (an adapter reporting its fault on stdout must not produce a diagnostic-freeSTARTUP_FAULT). Core truncates it at every spawn and again once a run outlives the startup threshold, so it is evidence and never a log to accumulate in — a service that wants a durable log writes its own file beside it. -
Quiesce is cooperative exit plus a deadline. The supervisor creates the file
stop-requestedinsideSPT_SERVICE_DIR. Its existence is the entire message: there is no content to parse, no reply to send, and no channel to open — a service that already has a loop just stats that one path each cycle and exits when it is safe to. The kernel-observed exit is the acknowledgement, so “not ready yet” is expressed by not-yet-exiting and no busy record exists to go stale in either direction. Paststop_grace_msthe supervisor force-kills. Delay is possible; veto is not. -
Update holds the service. An adapter update is an ordered supervisor operation: quiesce → hold (stopped, and never relaunched while held) → swap bits → start the new bits → release. Crash-relaunch applies only when not held, because a supervisor that eagerly relaunched mid-swap would re-pin the old executable and turn a diagnosable failure into an unwinnable race.
-
Liveness is derived, never recorded. The supervisor is the parent and holds the child handle, so exit is kernel-observed and no “running” record exists to go stale.
-
Consecutive fast exits are a configuration fault, not a crash. A service that exits repeatedly within the startup threshold trips a loud
STARTUP_FAULTdiagnostic carrying its captured startup output, rather than being ground silently through relaunch backoff. A run that outlives the startup threshold resets that counter. -
The service can invoke the spt CLI (
spt sendand friends) from its supervised environment, under the usual identitylesscli@<node>from-label. It takes no inbound spt traffic. -
Operator surface:
spt adapter service listandspt adapter service status <adapter[:profile]>(see the CLI reference). These live underspt adapter— the resident service belongs to the adapter, whereasspt daemon’s “service” wording refers to the OS service manager hosting the daemon itself. Both are read-only and both are answered by the daemon or not at all: the supervisor holds the child handles, so service state exists only in the daemon’s memory. With no daemon running the CLI says exactly that — it never derives “running” from the pid file in the runtime dir, which exists so the next daemon can kill an orphan and is not a liveness record. Each row reports the declared start trigger, whether the service is running, whether an update is holding it, any relaunch suppression with the captured startup output behind it, and the service’s own advisory line when it wrote one.
[identity] — session identity
How the harness’s session id is obtained:
[identity]
session_id_source = "post_spawn" # "post_spawn" | "uuid_inject"
parent_ancestor_name = "my-harness"
post_spawn: discovered after spawn (process tree / wrapper hand-off), with
parent_ancestor_name as the process-tree anchor. uuid_inject: spt-core
injects a UUID the harness echoes back.
Session digest — the digest-record contract
The live activity digest (spt endpoint digest <id>) is a projection of the
endpoint’s session logs, not a parse of the PTY byte stream. Your [digest]
extractor (or a spt api digest-entry push) emits the digest-record contract —
JSON objects spt-core projects:
{"role": "input", "text": "add a file", "ts": "2026-06-13T21:00:00Z"}
{"role": "agent", "text": "on it"}
{"role": "tool", "tool": {"name": "Write", "arg": "src/a.rs"}}
role∈input|agent|tool(the source tag).text— the input / agent span (omitted fortool).tool—{name, arg}, present iffrole == "tool"; consecutive tool records collapse into one sprint (unlesssprint_collapse = false).ts— optional RFC3339-UTC ordering key (used to interleave with spt’s own injected-context entries).
Unknown fields are ignored; a line that isn’t a valid record is dropped with a
counted reason (never silently). spt adapter digest-proof shows you exactly
what dropped and why. Presentation (window depth, arg truncation, sprint collapse)
is spt-core’s, defaulted by your [digest] and consumer-overridable; extraction is
yours.
[strings] — adapter string values (+ profiles)
An adapter-authored key/value tree any process on the node reads by dot-path with
spt adapter get-string <adapter[:profile]> <key.path> — e.g. a harness hook
fetching per-profile additionalContext, so one hook script serves every profile
and only the data differs. Strings are data only — spt-core never executes a
string (command templates live in the typed sections, never here). Node-local; not
cross-node synced.
[strings]
greeting = "hello" # inline literal
skills.whoami = { file = "whoami.md" } # file pointer → resolved to the file's contents
Two value forms:
- Inline literal —
get-stringprints it as-is. - File pointer — a value-position table with exactly one key,
file:{ file = "rel/path" }.get-stringresolves it to the file’s contents (large bodies — skill instructions, hint text — stay out of the manifest). The exactly-one-key rule disambiguates: any other table shape stays an opaque nested strings tree, and{ file = … }is reserved as the pointer form (it can’t double as inline data).
File-pointer rules (since v0.7.0):
- Files live in the adapter’s per-adapter aux dir
adapters/<adapter>/strings/(sibling ofprofiles/); the path is relative to that dir and must stay inside it —..traversal and absolute paths are refused at registration (ADAPTER_ADD_FAIL: invalid [strings] file pointer: pointer … must be a relative path inside the strings/ dir (no absolute paths, no..traversal)— manifest-first, so the whole add registers nothing). - Validated at registration (fail-fast on an escaping/missing pointer), read
lazily at
get-stringso live file edits reflect without re-register. A missing/unreadable file at read time skip-diagnoses — a diagnostic plus “not set”, never a silent drop or hard error (mirrors[digest]). - On
spt adapter add, the adapter dir is copied into the registry (adapters/<adapter>/{manifest.toml, record.toml, strings/…}).
Profiles + update-safety: strings resolve through the same leaf-replace
profile overlay as the rest of the manifest — a shipped or local profile may override
base strings, and get-string <adapter:profile> returns the merged view. A local
profile’s own file pointers resolve against the user-owned local-profile dir, not
the adapter-shipped strings/ (which adapter updates overwrite) — so a local override
survives updates (or a local profile may just inline a literal). set-string edits a
local profile’s [strings] only, never adapter-shipped files.
[update] — adapter self-update
How spt-core updates (and first installs — install is the first update) this adapter:
[update]
avenue = "delegated" # "delegated" | "file_pull" | "gh_release"
command = "my-harness plugin update spt" # delegated: the updater to run
self_verifies = true # delegated: attests the updater verifies its content
uninstall = "my-harness plugin uninstall spt" # optional inverse, run by `spt adapter remove`
message = "Run `/reload-plugins` in any ongoing sessions." # optional; shown on apply
| Avenue | Required fields | Meaning |
|---|---|---|
delegated | command | spt-core delegates to the harness’s own updater. Set self_verifies = true to attest that updater verifies what it installs — an unattested delegated update is skipped as unverifiable |
file_pull | repo, signing_key | spt-core pulls files from repo (optionally filtered by path_regex) and verifies them against the adapter author’s Ed25519 signing_key (64 hex chars) before applying |
gh_release | repo | spt-core ships your updates from your own GitHub releases (since v0.8.0). asset (default adapter.spt) and signing_key are optional |
message (optional, any avenue) — a plain human notice spt adapter update prints to
stdout, markdown-rendered, only when a new version is actually applied (never on a
no-op). Printed after the update completes; multi-line supported. No {key}
substitution. Use it to tell the operator what to do after updating — e.g.
"Run \/reload-plugins` in any ongoing sessions.“` for spt-claude-code.
With file_pull, you sign your releases with your own key; spt-core’s
release keys never extend to adapter content.
gh_release — ship updates from your GitHub releases (since v0.8.0)
The simplest avenue to publish for: distribute exactly as you do for
spt adapter add --release, and your registered adapter stays current.
[update]
avenue = "gh_release"
repo = "your-org/your-adapter" # required: whose releases ship updates
asset = "adapter.spt" # optional: the release asset to fetch (default adapter.spt)
signing_key = "deadbeef…" # optional Ed25519 (64 hex): enables fail-closed verify
spt adapter update [name] (no name sweeps every registered gh_release
adapter; a name updates just that one) compares your repo’s latest release
version against the installed one and, when newer, fetches the release .spt
archive — the same archive spt adapter add --release installs — then
re-extracts and re-registers it. repo is the only required field.
Trust is opt-in signing, fail-closed. Declare no signing_key and the
fetched .spt is trusted on HTTPS + GitHub, exactly like first acquisition.
Declare a signing_key and the fetched .spt is verified against a detached
signature you publish as a sibling release asset named <asset>.sig — a
lowercase-hex Ed25519 signature over the raw archive bytes. Verification runs
after the archive is fetched and before it is extracted, against the key in the
installed manifest (so a new release must verify against the key already on
the node). A bad or missing signature refuses the update and the fetched bytes
are discarded, never extracted. You sign your own releases with your own key;
spt-core’s release keys never extend to adapter content.
[update.post] — the composite post-step (since v0.16.0)
An optional, avenue-agnostic second step spt-core runs after the primary
avenue resolves — one lever pulls your .spt and runs your in-harness
reconcile (e.g. a plugin updater):
[update.post]
command = "{adapter_dir}/reconcile --sync-plugin" # required; {key} substitution + program-token
# resolution against the install dir
self_verifies = true # attestation, mirrors the delegated avenue
When it runs. On every spt adapter update of this adapter, and on
spt adapter add (all three sources — install is the first update; since
v0.19.0): an eager-extract acquisition (--release / gh_release) runs it
right after registration; a delegated acquisition runs it only once the
acquisition command succeeded. The one exception is a file_pull add with no
payload yet (ADAPTER_INSTALL_PENDING) — nothing is installed, so no
post-step until the payload lands via the update engine. It runs
unconditionally on updates — even a no-op version check — so make the
step idempotent and let its own check decide what to do.
Execution model — foreground, bounded, no background leg. The step runs
as a child of the spt adapter add/update process, cwd = the adapter’s
install dir, with a 120-second timeout (a hung step is killed and counts
as failed). spt-core never backgrounds it and never detaches it: when the CLI
returns, the post-step has finished (or failed). If your step spawns and
detaches its own child, spt-core cannot see that child or its errors — keep
real work in the foreground and finish within the bound.
stdin seam. One JSON line (additive keys — ignore unknown):
{"adapter_applied": true, "adapter_name": "spt", "profile_name": null,
"version": "0.21.0", "previous_version": "0.20.0", "adapter_dir": "…"}
stdout arbitrates the post-update notice (exit code is orthogonal):
non-empty custom text supersedes the static [update].message
(markdown-rendered); the reserved sentinel !!update-message!! fires the
static message; empty prints nothing.
Failure contract — how it surfaces. A nonzero exit, spawn failure, or
timeout prints ADAPTER_UPDATE_POST_FAIL:<adapter>: … with your step’s
stderr detail, on the CLI’s stderr, and the CLI exits nonzero. The
committed pull/registration is never rolled back (failure-isolated), and —
deliberately — the static [update].message still fires when the adapter
applied: a post-step failure never swallows the adapter’s own notice.
Verify-then-notify (recommended). Because the static message prints even
when the post-step failed, a static message that promises success (“finishing
in the background…”) can read as a happy install over a failed one to an
operator watching only stdout. Instead: keep the static [update].message
modest (or omit it), have the post-step verify its own work and print a
custom success notice on stdout only when verified, exit nonzero when not —
and have whatever invokes spt adapter add/update check the exit code
and surface stderr. That combination makes a fresh-install failure loud
end to end.
Shell adapters (kind = "shell")
A shell adapter provides a driven surface (notifier, robot, sensor)
instead of hosting agents: same file, different body — the [shell] section
is required for (and exclusive to) kind = "shell". See
Shells: getting started for a worked,
shipping example; the field reference:
[shell]
# Broker-launched; opaque template. Substitution keys: {id}, {adapter_name},
# {link_token}, {adapter_dir} (since v0.44.0), and {perch_dir} (the instance's
# perch dir — required in practice for a shell that receives files; see the
# frame contract). The program token resolves against the adapter's install
# dir before PATH (since v0.44.0) — a shipped binary launches by bare name.
spawn = "my-shell --link {link_token} --root {perch_dir}"
ephemeral = false # true -> no offline perch, no history retention
broadcast = "subnet" # "subnet" | "same-node" | "none" (discovery scope)
command_receipt = "stdin" # "http" | "stdin" | "relay" (how commands arrive)
pre_close = "park-and-save" # optional instruction sent on link-break
close_timeout_ms = 3000 # graceful-termination window
persistent = true # auto-online whenever the owner endpoint is online; across a node restart a boot sweep restores it, and an owner that comes online after that sweep restores it on that transition (owner online + launch predates boot; an instance with no launch stamp is left down)
wake_command = "my-waker --link {link_token}" # wake-watcher, run while offline AND eligible — an offline instance sitting on a same-boot corpse arms no watcher and waits for relink. Exit code 86 = wake. Program token + {adapter_dir} resolve like spawn (since v0.44.0); {perch_dir} never fills here
can_shutdown = false # may the shell fire `api owner-shutdown`?
require_approval = "none" # "none" | "remembered" | "always" (per-spawn gate)
max_instances_per_owner = 4 # optional cap (online + offline both count)
over_cap = "reject" # "reject" | "approve" at the cap
[shell.capabilities] # the agent->shell command vocabulary (durable)
notify = { args = ["title", "body"] }
clear = {}
# A capability may carry its OWN approval gate (independent of the per-spawn
# gate), with an optional class_key scoping the grant finer than the verb:
[shell.capabilities.attach]
args = ["busid"]
require_approval = "remembered" # "none" | "remembered" | "always" (per-act gate)
class_key = "hid" # a remembered hid grant never authorizes another class
[shell.sensory] # the shell->agent sensory vocabulary (live-only)
types = ["event"]
[shell.drive] # the owner->shell continuous control channel
types = ["stick"] # latest-wins, ephemeral, never spooled (real-time input)
[shell.tunnel] # an opaque reliable-ordered byte stream pair (on-LAN)
enable = true
protocol = "usbip-urb" # opaque label; the taxonomy never interprets the bytes
The capability, sensory, and drive vocabularies live in the manifest — spt-core
resolves them by adapter name, validates against them, and rejects anything
outside the declared vocabulary. The shell binary binds with
spt api … bind-shell --link <token> (the link token is the credential),
pushes sensory payloads with spt api … emit, and takes drive frames with
spt api … drive-poll.
Channel contracts differ — see Shells: four channels: commands are durable (spooled, replayed); drive is ephemeral (latest-wins, dropped if offline); sensory is live-only; the tunnel carries opaque bytes the taxonomy never reinterprets (not enveloped, not framed, not spooled — the link lifecycle closes it). The tunnel is reliable- ordered ⇒ congestion is lag never loss ⇒ on-LAN only.
Per-capability require_approval reuses the same grant store as the per-spawn
gate; class_key narrows a grant to (owner × verb × class × node). Shell
ownership is owner-type-agnostic — a Gateway (or any non-shell endpoint)
owns and drives a shell identically to an agent; exclusivity keys on the owner’s
endpoint id, never its type.
Cross-field rules (spt adapter add enforces these)
The schema validates structure; registration additionally enforces:
adapter.nameandadapter.versionmust be non-empty.kind = "shell"requires a[shell]section, which is exclusive to shell adapters (akind = "harness"adapter omits it).[history] strategy = "fetcher"requiresfetcher;locate_normalizerequires bothlocate_templateandnormalize_command.[digest]requires a non-emptyextractor. Understrategy = "locate_normalize"(the default) it also requires a resolvable source: either its ownsourceor a[history] locate_templateto fall back to — absent both, registration rejects (“[digest] needssource(own-source) or a [history]locate_template”). Understrategy = "fetcher"nosourceis needed (the extractor locates the transcript itself). The JSON schema alone accepts a bareextractor, so this only surfaces atspt adapter add.[env.*] direction = "inject"requires avalue.[update] avenue = "delegated"requirescommand;file_pullrequiresrepoandsigning_key;gh_releaserequiresrepo(assetandsigning_keyoptional).
A violation is a one-line error naming the field — fix and re-add.