# 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

**SPT is not a harness.** Command templates are opaque strings — spt-core
never parses out a model, tool list, or flag; the adapter writes the full
command line and spt-core runs it with `{key}` substitution placeholders
filled. Anything spt-core owns is *not* in the manifest:

- **Sentinels** (idle markers, the echo gate) — managed via `spt api state` /
  `spt api echo-gate`; adapters only call them.
- **Spool, registry, perch, and daemon-state schemas.**
- **The event-block vocabulary** — the tags spt-core surfaces to agents are a
  fixed, documented constant. Adapters pass spt-core's output through
  unchanged.
- **File-drop filenames** — statically `<endpoint_id>-commune.md` /
  `<endpoint_id>-signoff.md`; only the watched *directory* is declared.
- **Config knobs** (pulse period, summarizer windows, …) — global spt-core
  settings with per-endpoint overrides, never per-adapter.

## Substitution keys

The full `{key}` vocabulary spt-core fills into command templates. A role's
`keys` list must be a subset of this catalog, and every `{placeholder}` in a
`command` (or `cwd`/`source`/`fires`/…) must resolve to a value spt-core
supplies for that spawn — an unknown or unprovided key fails with a one-line
error. Not every key exists in every context: spt-core fills only those
relevant to the spawn (e.g. `{psyche_*}` only for a live agent's Psyche role,
`{source}` only for a `[digest]`/`[history]` extractor).

| Key | spt-core fills it with |
|---|---|
| `{id}` | The endpoint id being hosted. |
| `{adapter_name}` | The adapter's declared `name` (the value every `api` call carries). |
| `{session_id}` | The harness session id (minted at spawn; reported back via `api seed`). |
| `{session_name}` | The session's display name, when one is supplied. |
| `{parent_pid}` | The harness parent process pid — the SessionStart `api seed` anchor. |
| `{agent_type}` | The hosted agent type. |
| `{agents_json}` | The resolved agents roster for the session. |
| `{psyche_dir}` | A live agent's Psyche perch directory (its working dir). |
| `{psyche_prompt}` | The composed Psyche turn prompt for this spawn. |
| `{psyche_context}` | The Psyche's carried context block. |
| `{link_token}` | A shell-link capability token (shell adapters). |
| `{source}` | The transcript/log path spt-core resolves for a `[digest]`/`[history]` extractor. |

## `[adapter]` — header (required)

The only mandatory section, and it must be readable *before* any install or
update — `min_spt_core_version` is the compatibility gate.

```toml
[adapter]
name = "my-harness"                # the adapter_name; an optional --adapter override
kind = "harness"                   # "harness" (default) | "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"     # lowest spt-core this adapter tolerates
hostable_types = ["LiveAgent", "ReadyAgent", "Worker"]
host_binaries = ["my-harness"]     # harness exe(s) you host → bind-time resolution, no --adapter
```

| Field | Required | Meaning |
|---|---|---|
| `name` | yes | Adapter id; the value an optional `--adapter <name>` override carries |
| `kind` | no (default `harness`) | `harness` hosts agents; `shell` provides a driven surface |
| `version` | yes | The adapter's own version |
| `min_spt_core_version` | yes | Compat gate, checked before install/update |
| `hostable_types` | no | Endpoint types this adapter can host |
| `host_binaries` | no (harness) | Harness exe basenames you host — the bind-time match-key so `seed`/`listen` resolve with no `--adapter` (since v0.9.0). Matched on **lowercase + stem-before-first-dot**, so `claude` matches `claude`/`claude.exe`/`claude.cmd`/`claude.exe.old.<ts>` (a self-update can rename the running exe); a declared name must not contain a dot |

## `[hooks.<event>]` — inbound hook table

One entry per harness event, declaring the `spt api` command it fires, the
input fields it maps in, and whether the hook can surface text into the
agent's context.

```toml
[hooks.SessionStart]
fires = "api seed --pid {parent_pid} --session-id {session_id}"   # adapter-agnostic since v0.9.0
reads = ["session_id", "parent_pid"]
can_inject = true

[hooks.Stop]
fires = "api state idle"
can_inject = false     # no inject channel -> sentinel/relay fallback
```

| Field | Required | Meaning |
|---|---|---|
| `fires` | yes | Opaque `api …` command line the harness invokes for this event |
| `reads` | no | Input fields (e.g. from the hook's stdin payload) mapped into the command |
| `can_inject` | no (default `false`) | Whether this hook can inject context back to the agent. When `false`, spt-core falls back to its sentinel + relay/poll path instead of expecting injection |

`can_inject` is the single most load-bearing harness-varying fact — declare
it honestly per hook.

## `[session]` — watched dirs + role templates

Two watched-directory keys sit directly on `[session]`; the file *names* are
fixed by spt-core, only the directory varies:

```toml
[session]
commune_dir = ".my-harness"    # watched for <endpoint_id>-commune.md
signoff_dir = ".my-harness"    # watched for <endpoint_id>-signoff.md
```

Commune and signoff are **file-drops, not commands** — an agent writes a
markdown file; spt-core's watcher does the rest.

### `[session.<role>]` — outbound templates

One opaque command template per role. Model, tools, flags, permissions — all
live inside `command`, never as separate fields.

Roles: `self` (the agent's own session) · `resume` (the agent's own-session
**native resume**, the `self` sibling) · `psyche_init` / `psyche_resume`
(the endpoint's persistent-context companion) · `echo_commune` (the bounded
history summarizer for sessions that end without a signoff) · `signoff`
(final context save) · `notif` (endpoint-native notification render).

<!-- [doc->REQ-SESSION-RESUME-TEMPLATE] -->
**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.

```toml
[session.resume]
command = "my-harness resume --session {session_id} --id {id}"
keys = ["session_id", "id"]
```

```toml
[session.psyche_init]
command = "my-harness run --agent psyche --prompt {psyche_prompt} --model cheap"
cwd = "{psyche_dir}"
env_remove = ["MY_HARNESS_SESSION_ID"]
recursion_guard_env = "SPT_ECHO_COMMUNE"
detach = true
keys = ["psyche_prompt", "psyche_dir"]
```

For the Psyche roles (`psyche_init` / `psyche_resume`) spt-core fills exactly
`{id}` (the nested `<parent>-psyche` id), `{session_id}`, `{psyche_dir}`, and
`{psyche_prompt}` — **not** `{session_name}` (that key is a `[session.self]`
fill). Declaring a key your role's spawn isn't given fails at spawn, so a
`psyche_init` template must template only those four.

**Shipped binaries resolve from the install dir (since v0.8.0).** A command
template's bare program token (its first token, e.g. `my-harness-digest`)
resolves against the adapter's **install dir** before `PATH`, so a `.spt` that
ships its own binaries is self-contained — no PATH placement needed. spt-core
runs `<install_dir>/<program>` (on Windows also trying the `.exe` suffix) when
that file exists, else falls back to `PATH`. The install dir is where your
adapter was registered (the `--release`/`--github` durable home, or the
copy-mode source dir). This applies to the `[session.psyche_init]` runner, the
[`[digest]`](#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}`.

```toml
[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.

```toml
[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:

```toml
[history]
strategy = "fetcher"      # "fetcher" | "locate_normalize" | "native"
fetcher = "my-harness-history --session {session_id}"
```

| Strategy | Required fields | Meaning |
|---|---|---|
| `fetcher` | `fetcher` | spt-core runs your binary; it emits normalized history |
| `locate_normalize` | `locate_template`, `normalize_command` | spt-core locates the raw transcript, then runs your normalizer over it |
| `native` | — | The adapter pushes via `spt api history-log`; spt-core stores it |

spt-core has **no built-in transcript parser for any harness** — the adapter
always owns that knowledge.

## `[digest]` — session-digest extractor

The session digest's own seam (ADR-0019) — separate from `[history]`, which stays
opaque and single-session for the echo-commune. `[digest]` declares an
**imperative extractor** that maps your harness's native log to the digest-record
contract:

```toml
[digest]
extractor = "my-harness-digest --session {session_id} --in {source}"
source = "~/.my-harness/{session_id}.jsonl"   # optional; defaults to [history].locate_template
window_turns = 5         # optional presentation defaults you declare…
arg_truncation = 40      # …any consumer may override at pull/subscribe
sprint_collapse = true
```

| Field | Required | Meaning |
|---|---|---|
| `extractor` | yes | Opaque command: native log → contract JSONL (one record/line). spt-core fills `{source}` with the resolved path and pipes the bytes on stdin. |
| `source` | no | Own-source log path; absent, reuse `[history].locate_template`. One of the two **must** resolve, else `spt adapter add` rejects (see [Cross-field rules](#cross-field-rules-spt-adapter-add-enforces-these)). |
| `window_turns` / `arg_truncation` / `sprint_collapse` | no | Adapter-declared presentation **defaults**; any consumer may override. spt-core fallback: `3` / `25` / collapse-on. |

Why a command, not a declarative map: real harness logs are nested (one line →
many entries, mixed block lists, types to filter); a flat map can't express them.
A **log-less** adapter declares no `[digest]` and pushes via `spt api
digest-entry` instead. Validate before shipping with `spt adapter digest-proof
<adapter> --sample <real-log>`. `digest-proof` fills the same `{id}` and
`{session_id}` the runtime `endpoint digest` does, so a `{session_id}`-templated
extractor (e.g. `--session {session_id} --in {source}`) proofs exactly as it
runs live; pass `--session <id>` to pin a specific session id.

## `[inject]` — input-injection methods

How text can be put in front of the agent, per activity state. Any
combination of `pty`, `hook`, `relay`, `http`:

```toml
[inject]
activity = ["hook"]            # non-disruptive while the agent is working
idle = ["pty", "hook"]
```

## `[message-idle-translation-binary]` — spt-hosted idle delivery

<!-- [doc->REQ-MSG-IDLE-TRANSLATION-BINARY] -->

**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):

```toml
[message-idle-translation-binary]
path = "cc-spt-idle-translate"     # the binary spt-core spawns + drives
```

- **stdin** (spt-core → binary, one JSON object per line): `{"type":"init","endpoint_id":…,"node":…}` first · `{"type":"event","envelope":"<EVENT…>"}` per inbound message (the `<EVENT>` envelope) · `{"type":"input"}` — a **content-free** ping each time the operator types, so the binary can track user-idle (the PTY input content is **never** duplicated to the binary).
- **stdout** (binary → spt-core, one per line): `{"key":"ctrl+s"}` · `{"delay_ms":50}` · `{"text":"<payload>"}` · `{"key":"enter"}` · `{"commit":true}`, … (extensible vocabulary).
- **`{"commit":true}` is the mandatory sequence terminator.** While your emitted sequence is in flight, spt-core buffers a live `spt rc` controller's keystrokes (the *inject floor*) and applies your commands to the PTY atomically; `{"commit":true}` — emitted as the **last** record — releases that floor and flushes the buffered controller input *after* your sequence. The submit keystroke is **not** the terminator: `{"key":"enter"}` (or a trailing `\r` inside a text payload) submits the input, but a choreography may keep typing *after* it (e.g. a stash/restore that presses a key after submitting), so commit is a distinct, explicit signal you always send last. If no `{"commit":true}` arrives within the **commit deadline (5 s)**, spt-core faults the sequence and falls back to a raw inject — buffered operator input is still flushed, but the delivery is degraded.
- Unknown fields are **not** rejected here — a newer adapter declaring a future key against an older spt-core parses fine (the key is ignored), so the contract degrades gracefully.
- `{"text":…}` is applied to the PTY **verbatim** — bytes are typed exactly, with **no** control-character stripping. A trailing `\r` *inside* a text payload (`{"text":"…\r"}`) therefore **submits**, identical to a following `{"key":"enter"}` (`enter`→`\r`). Submit either way; just don't do both. Corollary: neutralize any CR/LF *inside* the message body before the trailing submit, or an embedded newline fires the input early.
- The raw `payload + \r` inject is the **degenerate** case: a binary that emits `{"text":payload}{"key":"enter"}{"commit":true}` with no choreography.

## `[identity]` — session identity

How the harness's session id is obtained:

```toml
[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:

```json
{"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)

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.

```toml
[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

<!-- [doc->REQ-ADAPTER-UPDATE-MESSAGE] -->

How spt-core updates (and first installs — install is the first update) this
adapter:

```toml
[update]
avenue = "delegated"                      # "delegated" | "file_pull" | "gh_release"
command = "my-harness plugin update spt"  # delegated: the updater to run
self_verifies = true                      # delegated: attests the updater verifies its content
version_check = true                      # check min_spt_core_version before/after
uninstall = "my-harness plugin uninstall spt"   # optional inverse, run by `spt adapter remove`
message = "Run `/reload-plugins` in any ongoing sessions."   # optional; shown on apply
```

| Avenue | Required fields | Meaning |
|---|---|---|
| `delegated` | `command` | spt-core delegates to the harness's own updater. Set `self_verifies = true` to attest that updater verifies what it installs — an unattested delegated update is skipped as unverifiable |
| `file_pull` | `repo`, `signing_key` | spt-core pulls files from `repo` (optionally filtered by `path_regex`) and verifies them against the adapter author's Ed25519 `signing_key` (64 hex chars) before applying |
| `gh_release` | `repo` | spt-core ships your updates from your own GitHub releases (since v0.8.0). `asset` (default `adapter.spt`) and `signing_key` are optional |

**`message`** (optional, any avenue) — a plain human notice `spt adapter update` prints to
stdout, markdown-rendered, **only when a new version is actually applied** (never on a
no-op). Printed after the update completes; multi-line supported. No `{key}`
substitution. Use it to tell the operator what to do after updating — e.g.
`"Run \`/reload-plugins\` in any ongoing sessions."` for spt-claude-code.

With `file_pull`, **you** sign your releases with your own key; spt-core's
release keys never extend to adapter content.

### `gh_release` — ship updates from your GitHub releases (since v0.8.0)

The simplest avenue to publish for: distribute exactly as you do for
`spt adapter add --release`, and your registered adapter stays current.

```toml
[update]
avenue = "gh_release"
repo = "your-org/your-adapter"   # required: whose releases ship updates
asset = "adapter.spt"            # optional: the release asset to fetch (default adapter.spt)
signing_key = "deadbeef…"        # optional Ed25519 (64 hex): enables fail-closed verify
```

`spt adapter update [name]` (no name sweeps every registered `gh_release`
adapter; a name updates just that one) compares your repo's latest release
version against the installed one and, when newer, fetches the release `.spt`
archive — the same archive `spt adapter add --release` installs — then
re-extracts and re-registers it. `repo` is the only required field.

**Trust is opt-in signing, fail-closed.** Declare no `signing_key` and the
fetched `.spt` is trusted on HTTPS + GitHub, exactly like first acquisition.
Declare a `signing_key` and the fetched `.spt` is verified against a **detached
signature** you publish as a sibling release asset named `<asset>.sig` — a
lowercase-hex Ed25519 signature over the raw archive bytes. Verification runs
after the archive is fetched and before it is extracted, against the key in the
**installed** manifest (so a new release must verify against the key already on
the node). A bad or missing signature refuses the update and the fetched bytes
are discarded, never extracted. You sign your own releases with your own key;
spt-core's release keys never extend to adapter content.

## Shell adapters (`kind = "shell"`)

A shell adapter provides a **driven surface** (notifier, robot, sensor)
instead of hosting agents: same file, different body — the `[shell]` section
is required for (and exclusive to) `kind = "shell"`. See
[Shells: getting started](../shells/getting-started.md) for a worked,
shipping example; the field reference:

```toml
[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.md):
commands are **durable** (spooled, replayed); **drive** is **ephemeral**
(latest-wins, dropped if offline); **sensory** is **live-only**; the **tunnel**
carries **opaque bytes** the taxonomy never reinterprets (not enveloped, not
framed, not spooled — the link lifecycle closes it). The tunnel is reliable-
ordered ⇒ congestion is lag never loss ⇒ **on-LAN only**.

Per-capability `require_approval` reuses the same grant store as the per-spawn
gate; `class_key` narrows a grant to `(owner × verb × class × node)`. Shell
ownership is **owner-type-agnostic** — a Gateway (or any non-shell endpoint)
owns and drives a shell identically to an agent; exclusivity keys on the owner's
endpoint id, never its type.

## Cross-field rules (`spt adapter add` enforces these)

The schema validates structure; registration additionally enforces:

- `adapter.name` and `adapter.version` must be non-empty.
- `kind = "shell"` **requires** a `[shell]` section, which is **exclusive to**
  shell adapters (a `kind = "harness"` adapter omits it).
- `[history] strategy = "fetcher"` requires `fetcher`;
  `locate_normalize` requires both `locate_template` and `normalize_command`.
- `[digest]` requires a non-empty `extractor`, **and** a resolvable source:
  either its own `source` or a `[history] locate_template` to fall back to.
  Absent both, registration rejects (*"[digest] needs `source` (own-source) or
  a [history] `locate_template`"*) — the JSON schema alone accepts a bare
  `extractor`, so this only surfaces at `spt adapter add`.
- `[env.*] direction = "inject"` requires a `value`.
- `[update] avenue = "delegated"` requires `command`; `file_pull` requires
  `repo` **and** `signing_key`; `gh_release` requires `repo` (`asset` and
  `signing_key` optional).

A violation is a one-line error naming the field — fix and re-add.
