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. |
{adapter_name} | The adapter’s declared name (the value every api call carries). |
{session_id} | The harness session id (minted at spawn; reported back via api seed). |
{session_name} | The session’s display name, when one is supplied. |
{parent_pid} | The harness parent process pid — the SessionStart api seed anchor. |
{agent_type} | The hosted agent type. |
{agents_json} | The resolved agents roster for the session. |
{psyche_dir} | A live agent’s Psyche perch directory (its working dir). |
{psyche_prompt} | The composed Psyche turn prompt for this spawn. |
{psyche_context} | The Psyche’s carried context block. |
{link_token} | A shell-link capability token (shell adapters). |
{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
| 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 |
[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 / psyche_resume
(the endpoint’s persistent-context companion) · 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 run --resume <session>, or the picker’s Resume from
history) and your manifest declares the role. 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.
[session.resume]
command = "my-harness resume --session {session_id} --id {id}"
keys = ["session_id", "id"]
[session.psyche_init]
command = "my-harness run --agent psyche --prompt {psyche_prompt} --model cheap"
cwd = "{psyche_dir}"
env_remove = ["MY_HARNESS_SESSION_ID"]
recursion_guard_env = "SPT_ECHO_COMMUNE"
detach = true
keys = ["psyche_prompt", "psyche_dir"]
For the Psyche roles (psyche_init / psyche_resume) spt-core fills exactly
{id} (the nested <parent>-psyche id), {session_id}, {psyche_dir}, and
{psyche_prompt} — not {session_name} (that key is a [session.self]
fill). Declaring a key your role’s spawn isn’t given fails at spawn, so a
psyche_init template must template only those four.
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_init] runner, the
[digest] extractor, and
spt adapter digest-proof. 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 |
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). spt-core fills {source} with the resolved path and pipes the bytes on stdin. |
source | no | Own-source log path; absent, reuse [history].locate_template. One of the two must resolve, else spt adapter add rejects (see Cross-field rules). |
window_turns / arg_truncation / sprint_collapse | no | Adapter-declared presentation defaults; any consumer may override. spt-core fallback: 3 / 25 / collapse-on. |
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"]
[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.
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. 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. If no{"commit":true}arrives within the commit deadline (5 s), spt-core faults the sequence and falls back to a raw inject — buffered operator input is still flushed, but the delivery is degraded.- 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.- The raw
payload + \rinject is the degenerate case: a binary that emits{"text":payload}{"key":"enter"}{"commit":true}with no choreography.
[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
version_check = true # check min_spt_core_version before/after
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.
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]
spawn = "my-shell --link {link_token}" # broker-launched; opaque template
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
wake_command = "my-waker --link {link_token}" # offline wake-watcher; exit code 86 = wake
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, and 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”) — 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.