URL: https://sabermage.github.io/spt-releases/harness-contract/manifest.html
Content-Type: text/html
Method: native

---

---
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 `<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](#substitution-keys)

The full `{key}` vocabulary spt-core fills into command templates. A role’s `keys` list must be a subset of this catalog, and every `{placeholder}` in a `command` (or `cwd`/`source`/`fires`/…) must resolve to a value spt-core
supplies for that spawn — an unknown or unprovided key fails with a one-line
error. Not every key exists in every context: spt-core fills only those
relevant to the spawn (e.g. `{psyche_*}` only for a live agent’s Psyche role, `{source}` only for a `[digest]`/`[history]` extractor).

| Key | spt-core fills it with |
| --- | --- |
| `{id}` | The endpoint id being hosted. For a **Psyche** role this is the **parent endpoint id** (the LiveAgent being hosted), not the nested `<parent>-psyche` perch id. |
| `{adapter_name}` | The adapter’s declared `name` (the value every `api` call carries). |
| `{adapter_dir}` | The adapter’s install dir (the registry record’s `source_dir`) — adapter-static, available wherever substitution runs (every `[session.*]`, `[digest]`, the translation binary, 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 <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](#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 <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](#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 <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"]

    # 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 `<install_dir>/<program>` (on Windows also trying the `.exe` suffix) when
that file exists, else falls back to `PATH`. The install dir is where your
adapter was registered (the `--release`/`--github` durable home, or the
copy-mode source dir). This applies to the `[session.psyche_resume]` per-event
turn, the [`[digest]`](#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.<VAR>]` — 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/<munge(cwd)>/<session_id>.jsonl` or a date-globbed rollout tree. spt-core feeds the extractor only the
harness-**neutral** inputs it owns — `{session_id}`, the perch-bound `{cwd}`, and any
captured [`[env] direction = "read"`](#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 <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](#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 `<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 — 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 <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 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 <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-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](#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 `<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"`)](#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 <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](../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.