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

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

Keyspt-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
FieldRequiredMeaning
nameyesAdapter id; the value an optional --adapter <name> override carries
kindno (default harness)harness hosts agents; shell provides a driven surface
versionyesThe adapter’s own version
min_spt_core_versionyesCompat gate, checked before install/update
hostable_typesnoEndpoint types this adapter can host
host_binariesno (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
FieldRequiredMeaning
firesyesOpaque api … command line the harness invokes for this event
readsnoInput fields (e.g. from the hook’s stdin payload) mapped into the command
can_injectno (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.

FieldRequiredMeaning
commandyesOpaque command line with {key} placeholders
cwdnoWorking directory (substitutable)
recursion_guard_envnoEnv var set on summarizer children so their hooks bail (no summarizer-of-summarizer loops)
detachno (default false)Spawn detached
env_removenoEnv vars stripped from the child’s inherited environment
keysnoThe 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}"
StrategyRequired fieldsMeaning
fetcherfetcherspt-core runs your binary; it emits normalized history
locate_normalizelocate_template, normalize_commandspt-core locates the raw transcript, then runs your normalizer over it
nativeThe 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
FieldRequiredMeaning
extractoryesOpaque command: native log → contract JSONL (one record/line). spt-core fills {source} with the resolved path and pipes the bytes on stdin.
sourcenoOwn-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_collapsenoAdapter-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 live spt rc controller’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 \r inside 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 \r inside 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 + \r inject 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"}}
  • roleinput | agent | tool (the source tag).
  • text — the input / agent span (omitted for tool).
  • tool{name, arg}, present iff role == "tool"; consecutive tool records collapse into one sprint (unless sprint_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 literalget-string prints it as-is.
  • File pointer — a value-position table with exactly one key, file: { file = "rel/path" }. get-string resolves 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 of profiles/); 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-string so 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
AvenueRequired fieldsMeaning
delegatedcommandspt-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_pullrepo, signing_keyspt-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_releaserepospt-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.name and adapter.version must be non-empty.
  • kind = "shell" requires a [shell] section, which is exclusive to shell adapters (a kind = "harness" adapter omits it).
  • [history] strategy = "fetcher" requires fetcher; locate_normalize requires both locate_template and normalize_command.
  • [digest] requires a non-empty extractor, and a resolvable source: either its own source or a [history] locate_template to fall back to. Absent both, registration rejects (“[digest] needs source (own-source) or a [history] locate_template) — the JSON schema alone accepts a bare extractor, so this only surfaces at spt adapter add.
  • [env.*] direction = "inject" requires a value.
  • [update] avenue = "delegated" requires command; file_pull requires repo and signing_key; gh_release requires repo (asset and signing_key optional).

A violation is a one-line error naming the field — fix and re-add.