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 every api call carries
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"]
FieldRequiredMeaning
nameyesAdapter id; --adapter <name> on every machinery call
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

[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 {adapter_name}"
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) · 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).

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

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"]

[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"
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`
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

With file_pull, you sign your 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
notify = { args = ["title", "body"] }
clear  = {}

[shell.sensory]                # the shell->agent sensory vocabulary
types = ["event"]

The capability and sensory vocabularies live in the manifest — spt-core resolves them by adapter name, validates agent commands 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) and pushes sensory payloads with spt api … emit.

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; kind = "harness" must not have one.
  • [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.

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