--- meta-description: spt-core: a harness-independent core for an agent ecosystem — messaging, live-agent lifecycle, terminal hosting, P2P networking, and the runtime-manifest harness contract. meta-theme-color: #ffffff meta-viewport: width=device-width, initial-scale=1 title: Manifest reference - SPT developer docs --- ## 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 ![SVG Image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NDggNTEyIj48cGF0aCBkPSJNMCA5NkMwIDc4LjMgMTQuMyA2NCAzMiA2NEg0MTZjMTcuNyAwIDMyIDE0LjMgMzIgMzJzLTE0LjMgMzItMzIgMzJIMzJDMTQuMyAxMjggMCAxMTMuNyAwIDk2ek0wIDI1NmMwLTE3LjcgMTQuMy0zMiAzMi0zMkg0MTZjMTcuNyAwIDMyIDE0LjMgMzIgMzJzLTE0LjMgMzItMzIgMzJIMzJjLTE3LjcgMC0zMi0xNC4zLTMyLTMyek00NDggNDE2YzAgMTcuNy0xNC4zIDMyLTMyIDMySDMyYy0xNy43IDAtMzItMTQuMy0zMi0zMnMxNC4zLTMyIDMyLTMySDQxNmMxNy43IDAgMzIgMTQuMyAzMiAzMnoiIC8+PC9zdmc+) ![SVG Image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1NzYgNTEyIj48cGF0aCBkPSJNMzcxLjMgMzY3LjFjMjcuMy0zLjkgNTEuOS0xOS40IDY3LjItNDIuOUw2MDAuMiA3NC4xYzEyLjYtMTkuNSA5LjQtNDUuMy03LjYtNjEuMlM1NDkuNy00LjQgNTMxLjEgOS42TDI5NC40IDE4Ny4yYy0yNCAxOC0zOC4yIDQ2LjEtMzguNCA3Ni4xTDM3MS4zIDM2Ny4xem0tMTkuNiAyNS40bC0xMTYtMTA0LjRDMTc1LjkgMjkwLjMgMTI4IDMzOS42IDEyOCA0MDBjMCAzLjkgLjIgNy44IC42IDExLjZjMS44IDE3LjUtMTAuMiAzNi40LTI3LjggMzYuNEg5NmMtMTcuNyAwLTMyIDE0LjMtMzIgMzJzMTQuMyAzMiAzMiAzMkgyNDBjNjEuOSAwIDExMi01MC4xIDExMi0xMTJjMC0yLjUtLjEtNS0uMi03LjV6IiAvPjwvc3ZnPg==) - Auto - Light - Rust - Coal - Navy - Ayu ![SVG Image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48cGF0aCBkPSJNNDE2IDIwOGMwIDQ1LjktMTQuOSA4OC4zLTQwIDEyMi43TDUwMi42IDQ1Ny40YzEyLjUgMTIuNSAxMi41IDMyLjggMCA0NS4zcy0zMi44IDEyLjUtNDUuMyAwTDMzMC43IDM3NmMtMzQuNCAyNS4yLTc2LjggNDAtMTIyLjcgNDBDOTMuMSA0MTYgMCAzMjIuOSAwIDIwOFM5My4xIDAgMjA4IDBTNDE2IDkzLjEgNDE2IDIwOHpNMjA4IDM1MmM3OS41IDAgMTQ0LTY0LjUgMTQ0LTE0NHMtNjQuNS0xNDQtMTQ0LTE0NFM2NCAxMjguNSA2NCAyMDhzNjQuNSAxNDQgMTQ0IDE0NHoiIC8+PC9zdmc+) # SPT developer docs [![SVG Image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48cGF0aCBkPSJNMTI4IDBDOTIuNyAwIDY0IDI4LjcgNjQgNjR2OTZoNjRWNjRIMzU0LjdMMzg0IDkzLjNWMTYwaDY0VjkzLjNjMC0xNy02LjctMzMuMy0xOC43LTQ1LjNMNDAwIDE4LjdDMzg4IDYuNyAzNzEuNyAwIDM1NC43IDBIMTI4ek0zODQgMzUydjMyIDY0SDEyOFYzODQgMzY4IDM1MkgzODR6bTY0IDMyaDMyYzE3LjcgMCAzMi0xNC4zIDMyLTMyVjI1NmMwLTM1LjMtMjguNy02NC02NC02NEg2NGMtMzUuMyAwLTY0IDI4LjctNjQgNjR2OTZjMCAxNy43IDE0LjMgMzIgMzIgMzJINjR2NjRjMCAzNS4zIDI4LjcgNjQgNjQgNjRIMzg0YzM1LjMgMCA2NC0yOC43IDY0LTY0VjM4NHptLTE2LTg4Yy0xMy4zIDAtMjQtMTAuNy0yNC0yNHMxMC43LTI0IDI0LTI0czI0IDEwLjcgMjQgMjRzLTEwLjcgMjQtMjQgMjR6IiAvPjwvc3ZnPg==)](../print.html "Print this book") [![SVG Image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OTYgNTEyIj48cGF0aCBkPSJNMTY1LjkgMzk3LjRjMCAyLTIuMyAzLjYtNS4yIDMuNi0zLjMuMy01LjYtMS4zLTUuNi0zLjYgMC0yIDIuMy0zLjYgNS4yLTMuNiAzLS4zIDUuNiAxLjMgNS42IDMuNnptLTMxLjEtNC41Yy0uNyAyIDEuMyA0LjMgNC4zIDQuOSAyLjYgMSA1LjYgMCA2LjItMnMtMS4zLTQuMy00LjMtNS4yYy0yLjYtLjctNS41LjMtNi4yIDIuM3ptNDQuMi0xLjdjLTIuOS43LTQuOSAyLjYtNC42IDQuOS4zIDIgMi45IDMuMyA1LjkgMi42IDIuOS0uNyA0LjktMi42IDQuNi00LjYtLjMtMS45LTMtMy4yLTUuOS0yLjl6TTI0NC44IDhDMTA2LjEgOCAwIDExMy4zIDAgMjUyYzAgMTEwLjkgNjkuOCAyMDUuOCAxNjkuNSAyMzkuMiAxMi44IDIuMyAxNy4zLTUuNiAxNy4zLTEyLjEgMC02LjItLjMtNDAuNC0uMy02MS40IDAgMC03MCAxNS04NC43LTI5LjggMCAwLTExLjQtMjkuMS0yNy44LTM2LjYgMCAwLTIyLjktMTUuNyAxLjYtMTUuNCAwIDAgMjQuOSAyIDM4LjYgMjUuOCAyMS45IDM4LjYgNTguNiAyNy41IDcyLjkgMjAuOSAyLjMtMTYgOC44LTI3LjEgMTYtMzMuNy01NS45LTYuMi0xMTIuMy0xNC4zLTExMi4zLTExMC41IDAtMjcuNSA3LjYtNDEuMyAyMy42LTU4LjktMi42LTYuNS0xMS4xLTMzLjMgMi42LTY3LjkgMjAuOS02LjUgNjkgMjcgNjkgMjcgMjAtNS42IDQxLjUtOC41IDYyLjgtOC41czQyLjggMi45IDYyLjggOC41YzAgMCA0OC4xLTMzLjYgNjktMjcgMTMuNyAzNC43IDUuMiA2MS40IDIuNiA2Ny45IDE2IDE3LjcgMjUuOCAzMS41IDI1LjggNTguOSAwIDk2LjUtNTguOSAxMDQuMi0xMTQuOCAxMTAuNSA5LjIgNy45IDE3IDIyLjkgMTcgNDYuNCAwIDMzLjctLjMgNzUuNC0uMyA4My42IDAgNi41IDQuNiAxNC40IDE3LjMgMTIuMUM0MjguMiA0NTcuOCA0OTYgMzYyLjkgNDk2IDI1MiA0OTYgMTEzLjMgMzgzLjUgOCAyNDQuOCA4ek05Ny4yIDM1Mi45Yy0xLjMgMS0xIDMuMy43IDUuMiAxLjYgMS42IDMuOSAyLjMgNS4yIDEgMS4zLTEgMS0zLjMtLjctNS4yLTEuNi0xLjYtMy45LTIuMy01LjItMXptLTEwLjgtOC4xYy0uNyAxLjMuMyAyLjkgMi4zIDMuOSAxLjYgMSAzLjYuNyA0LjMtLjcuNy0xLjMtLjMtMi45LTIuMy0zLjktMi0uNi0zLjYtLjMtNC4zLjd6bTMyLjQgMzUuNmMtMS42IDEuMy0xIDQuMyAxLjMgNi4yIDIuMyAyLjMgNS4yIDIuNiA2LjUgMSAxLjMtMS4zLjctNC4zLTEuMy02LjItMi4yLTIuMy01LjItMi42LTYuNS0xem0tMTEuNC0xNC43Yy0xLjYgMS0xLjYgMy42IDAgNS45IDEuNiAyLjMgNC4zIDMuMyA1LjYgMi4zIDEuNi0xLjMgMS42LTMuOSAwLTYuMi0xLjQtMi4zLTQtMy4zLTUuNi0yeiIgLz48L3N2Zz4=)](https://github.com/SaberMage/spt-releases "Git repository") ![SVG Image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48cGF0aCBkPSJNMzA0IDQ4YzAtMjYuNS0yMS41LTQ4LTQ4LTQ4cy00OCAyMS41LTQ4IDQ4czIxLjUgNDggNDggNDhzNDgtMjEuNSA0OC00OHptMCA0MTZjMC0yNi41LTIxLjUtNDgtNDgtNDhzLTQ4IDIxLjUtNDggNDhzMjEuNSA0OCA0OCA0OHM0OC0yMS41IDQ4LTQ4ek00OCAzMDRjMjYuNSAwIDQ4LTIxLjUgNDgtNDhzLTIxLjUtNDgtNDgtNDhzLTQ4IDIxLjUtNDggNDhzMjEuNSA0OCA0OCA0OHptNDY0LTQ4YzAtMjYuNS0yMS41LTQ4LTQ4LTQ4cy00OCAyMS41LTQ4IDQ4czIxLjUgNDggNDggNDhzNDgtMjEuNSA0OC00OHpNMTQyLjkgNDM3YzE4LjctMTguNyAxOC43LTQ5LjEgMC02Ny45cy00OS4xLTE4LjctNjcuOSAwcy0xOC43IDQ5LjEgMCA2Ny45czQ5LjEgMTguNyA2Ny45IDB6bTAtMjk0LjJjMTguNy0xOC43IDE4LjctNDkuMSAwLTY3LjlTOTMuNyA1Ni4yIDc1IDc1cy0xOC43IDQ5LjEgMCA2Ny45czQ5LjEgMTguNyA2Ny45IDB6TTM2OS4xIDQzN2MxOC43IDE4LjcgNDkuMSAxOC43IDY3LjkgMHMxOC43LTQ5LjEgMC02Ny45cy00OS4xLTE4LjctNjcuOSAwcy0xOC43IDQ5LjEgMCA2Ny45eiIgLz48L3N2Zz4=) # [Manifest reference](#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`](https://sabermage.github.io/spt-releases/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](#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 `-commune.md` / `-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](#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 `-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, and lazy `[strings]`). 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 **home-subnet label** (`local` when unhomed), 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). | | `{source}` | The transcript/log path spt-core resolves for a `[digest]`/`[history]` extractor. | ## [`[adapter]` — header (required)](#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 ` 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.` (a self-update can rename the running exe); a declared name must not contain a dot | ## [`[hooks.]` — inbound hook table](#hooksevent--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](#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 -commune.md signoff_dir = ".my-harness" # watched for -signoff.md Commune and signoff are **file-drops, not commands** — an agent writes a markdown file; spt-core’s watcher does the rest. ### [`[session.]` — outbound templates](#sessionrole--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 run --resume `, 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"] # 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. **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 `/` (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]`](#digest--session-digest-extractor) 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.]` — env-var table](#envvar--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](#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](#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](#cross-field-rules-spt-adapter-add-enforces-these)). 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//.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"`](#envvar--env-var-table) 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 --sample `. `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 ` to pin a specific session id. ## [`[inject]` — input-injection methods](#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](#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 `` 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":""}` per inbound message (the `` 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":""}` · `{"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 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. **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 `\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. - 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.) ## [`[identity]` — session identity](#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](#session-digest--the-digest-record-contract) The live activity digest (`spt endpoint digest `) 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 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)](#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 ` — 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-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//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//{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 ` 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](#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)](#gh_release--ship-updates-from-your-github-releases-since-v080) 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 `.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"`)](#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](../shells/getting-started.html) 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 ` (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](../shells/overview.html): 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)](#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`. Under `strategy = "locate_normalize"` (the default) it **also** requires 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`”*). Under `strategy = "fetcher"` no `source` is needed (the extractor locates the transcript itself). 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.