# 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`](http://localhost:5474/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. 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, lazy `[strings]`, and — since v0.44.0 — the `[shell].spawn` and `[shell].wake_command` templates). 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 **anchor-subnet label** (`local` when unanchored), 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). |
| `{perch_dir}` | The shell instance's perch directory (shell `spawn` template only — a `wake_command` fills its own smaller catalog, which since v0.44.0 includes `{adapter_dir}` but never this key). What lets the binary resolve a `shell_file` frame's **perch-relative** `path` attribute — the spawned child inherits the *broker's* working directory, so without this key no mechanical resolution exists. Opt-in: a template that never names it fills exactly as before. Filled as **one argv element** even when the directory contains spaces. See [the frame contract](../shells/frames.md#shell_file--a-landed-file). |
| `{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` (**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).

<!-- [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 resume <id>`, `spt go <id>` on an offline endpoint, 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
# 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.

**Reserved exit codes — how a `psyche_resume` turn tells spt-core *why* it
failed.** spt-core classifies a failed turn on the process **exit code alone —
never on output text** (your harness may reword, restructure, or JSON-wrap its
errors freely; only the code is contract). Any other nonzero exit is a generic
failure: spt-core keeps the Psyche's session custody, counts a strike, and
retries.

<!-- [doc->REQ-PSYCHE-ACCOUNT-REFUSAL-EXIT] -->
<!-- [doc->REQ-PSYCHE-SID-CUSTODY] -->

| Exit code | Meaning | What spt-core does |
|---|---|---|
| `95` | **Psyche session gone** — the harness's own session store no longer resolves the `{session_id}` it was asked to resume. Exit `95` **only** when the resumed session itself is missing/expired; a *fresh* turn (non-empty `{psyche_context_file}`) must never exit `95`. | Reseeds: clears custody and re-mints the Psyche session from the carried context. The **only** exit that reseeds. |
| `96` | **Account/credential refusal** — the inner tool refused for account-level reasons (spend/usage cap, expired or revoked credential, org quota): the session is healthy, the code is healthy, a retry would succeed on a healthy account, and only a **human** can restore it. | Retries on its own slow pacing and surfaces the outage under a distinct label. Never reseeds (custody is fine — a reseed would destroy a healthy transcript), and never spends the crash/defect strike budget. |

Adapters own the mapping from their inner tool's observable failure to these
codes (match your tool's output **in the adapter**, where you can track its
wording — that is exactly why spt-core never does). Emit the diagnostic text on
**stderr** and keep it in the exit line if you wrap an inner process: a bounded
tail of both streams in the failure message is what turns an outage from a
mystery `exit code: 1` into a one-glance diagnosis.

**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,
`spt adapter digest-proof`, and — since v0.44.0 — the `[shell].spawn` and
`[shell].wake_command` templates: a released shell adapter's binary launches by
bare name from its install dir, with no node-local manifest edits. 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 |
| `invocation_budget_secs` | no (default `90`) | How long a **bounded** invocation of this role may run before spt-core kills it. Declare it on any role that is an LLM turn — the adapter is the only party that knows what its own model costs. Clamped to `300`; a role that declares nothing gets `90`. See [Invocation budgets](#invocation-budgets) |

### Invocation budgets

<!-- [doc->REQ-PSYCHE-INVOCATION-BUDGET-PER-ROLE] -->

Some roles spt-core runs are **bounded**: it spawns them, waits, and kills them
if they overrun. A role that is an LLM turn should declare how long its own
model needs, because spt-core cannot know it:

```toml
[session.echo_commune]
command = "claude -p --model haiku …"
invocation_budget_secs = 120
```

**A role that declares nothing gets 90 seconds**, and a declared value is
**clamped to 300** — an adapter may ask for more time than the default, never
for effectively unbounded. The resolution keys on the field, not on which role
is asking, so a future bounded role picks this up with no core change.

Pick the number from what your model actually costs, with headroom for a loaded
machine. The default exists because a flat 30-second bound used to kill
legitimate summarizer turns: the same input on the same node measured 23s and
35s depending on load, so whether a healthy agent was killed came down to what
else the machine was doing. If your turns measure near the bound, raise it —
being killed at the bound is not treated as a defect (see below), but it does
mean the work is lost.

**A bound kill is not counted as a fault.** spt-core tracks bound kills on a
separate, far more forgiving budget than real failures, precisely because a kill
says more about the machine's load than about your adapter. A slow-but-healthy
harness will not be marked failed.

`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). 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. |

<!-- [doc->REQ-DIGEST-FETCHER-STRATEGY] -->
`[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:

```toml
[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

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 — 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

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
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.

### `[update.post]` — the composite post-step (since v0.16.0)

<!-- [doc->REQ-ADAPTER-UPDATE-POST] -->

An optional, avenue-agnostic second step spt-core runs **after** the primary
avenue resolves — one lever pulls your `.spt` **and** runs your in-harness
reconcile (e.g. a plugin updater):

```toml
[update.post]
command = "{adapter_dir}/reconcile --sync-plugin"  # required; {key} substitution + program-token
                                                   # resolution against the install dir
self_verifies = true                               # attestation, mirrors the delegated avenue
```

**When it runs.** On every `spt adapter update` of this adapter, **and on
`spt adapter add`** (all three sources — install is the first update; since
v0.19.0): an eager-extract acquisition (`--release` / `gh_release`) runs it
right after registration; a `delegated` acquisition runs it only once the
acquisition command succeeded. The one exception is a `file_pull` add with no
payload yet (`ADAPTER_INSTALL_PENDING`) — nothing is installed, so no
post-step until the payload lands via the update engine. It runs
**unconditionally** on updates — even a no-op version check — so make the
step idempotent and let its own check decide what to do.

**Execution model — foreground, bounded, no background leg.** The step runs
as a child of the `spt adapter add`/`update` process, cwd = the adapter's
install dir, with a **120-second timeout** (a hung step is killed and counts
as failed). spt-core never backgrounds it and never detaches it: when the CLI
returns, the post-step has finished (or failed). If your step spawns and
detaches its own child, spt-core cannot see that child or its errors — keep
real work in the foreground and finish within the bound.

**stdin seam.** One JSON line (additive keys — ignore unknown):

```json
{"adapter_applied": true, "adapter_name": "spt", "profile_name": null,
 "version": "0.21.0", "previous_version": "0.20.0", "adapter_dir": "…"}
```

**stdout arbitrates the post-update notice** (exit code is orthogonal):
non-empty custom text **supersedes** the static `[update].message`
(markdown-rendered); the reserved sentinel `!!update-message!!` fires the
static message; empty prints nothing.

**Failure contract — how it surfaces.** A nonzero exit, spawn failure, or
timeout prints `ADAPTER_UPDATE_POST_FAIL:<adapter>: …` **with your step's
stderr detail, on the CLI's stderr**, and the CLI **exits nonzero**. The
committed pull/registration is never rolled back (failure-isolated), and —
deliberately — the static `[update].message` still fires when the adapter
applied: a post-step failure never swallows the adapter's own notice.

**Verify-then-notify (recommended).** Because the static message prints even
when the post-step failed, a static message that promises success ("finishing
in the background…") can read as a happy install over a failed one to an
operator watching only stdout. Instead: keep the static `[update].message`
modest (or omit it), have the post-step **verify its own work** and print a
custom success notice on stdout only when verified, exit nonzero when not —
and have whatever invokes `spt adapter add`/`update` check the **exit code**
and surface **stderr**. That combination makes a fresh-install failure loud
end to end.

## 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]
# Broker-launched; opaque template. Substitution keys: {id}, {adapter_name},
# {link_token}, {adapter_dir} (since v0.44.0), and {perch_dir} (the instance's
# perch dir — required in practice for a shell that receives files; see the
# frame contract). The program token resolves against the adapter's install
# dir before PATH (since v0.44.0) — a shipped binary launches by bare name.
spawn = "my-shell --link {link_token} --root {perch_dir}"
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; across a node restart a boot sweep restores it (owner online + launch predates boot; an instance with no launch stamp is left down)
wake_command = "my-waker --link {link_token}"  # wake-watcher, run while offline AND eligible — an offline instance sitting on a same-boot corpse arms no watcher and waits for relink. Exit code 86 = wake. Program token + {adapter_dir} resolve like spawn (since v0.44.0); {perch_dir} never fills here
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`. 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.
