# 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
web_short_path = "reports"         # optional alias; the adapter facet exists without it
```

| 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 |
| `web_short_path` | no | Requested short alias for the base adapter's core-owned served root; collisions receive a persisted suffix |

<!-- [doc->REQ-WEB-SERVING-REGISTRY] [doc->REQ-WEB-URL-NODE-PREFIX] -->
<!-- [doc->REQ-MANIFEST-UNKNOWN-KEY-WARN] -->
**Unknown keys.** A key spt-core does not know is ignored, never refused, so an
adapter written against a newer core still registers. `spt adapter add` names
each one on stderr as `manifest: unknown key [<table>].<key> (ignored)` and
proceeds. A misspelled `web_short_path` therefore registers without an alias
and says so. Tables the contract declares free-form (`[profiles.*]`,
`[strings]`) are the adapter's own vocabulary and are not swept.

**Served output.** Core creates `$SPT_HOME/adapters/<adapter>/web/` before
committing activation, even if the daemon is stopped. Write only intended
served output there; core never exposes the adapter's install tree, manifest,
records, or strings on its behalf. The daemon reconciles one `dir` entry per
active base adapter at startup and after adapter changes.
`http://localhost:5474/<node>/a/<adapter>/` always names that entry;
`web_short_path` adds `/<node>/<assigned-name>/` as an alias. A profile never
adds a separate row or root; the base declaration controls the shared resource.

Aliases must be nonblank single segments, not `.` or `..`, and contain no
path separators, colon, or control characters. The reserved facets `f`, `docs`,
`a`, `m`, `bin`, and `install` are refused case-insensitively before registration
writes and when resolving manifests. Other collisions allocate `reports~1`,
`reports~2`, …; `spt serve list` reports the assigned alias.
An update or repeated registration preserves the live assignment, including
whether it has an alias. A changed/added/removed declaration takes effect on
deactivation followed by reactivation. With the same declaration, reactivation
reclaims the root's prior name and kind; other sources cannot take retired
names. Removing or deactivating an adapter removes exposure, not output bytes;
updates and reactivation also retain them. Redirected roots
and symlinks escaping the served subtree are refused.

## `[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. **Resume is latest-only** — no
verb takes a session argument, and one is refused rather than ignored: core
resolves which session to resume from the endpoint's ledger. 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.

<!-- [doc->REQ-RESUME-HARNESS-SESSION-ID] the third outcome an adapter author must be able to recognize: a resume that starts fresh, and the notice that says so -->
**A resume can legitimately start FRESH — and says so.** `{session_id}` is
always a **harness-reported** id from the endpoint's ledger, never spt-core's own
spawn-time provisional (your harness has never seen that one and cannot resolve
it). If the endpoint has no harness-reported session on record — only a spawn
provisional — spt-core does not hand the provisional to your template: it prints
`RESUME_NO_HARNESS_SESSION:<id>` and starts a fresh session through
`[session.self]`. Your `[session.resume]` command simply does not run that time,
which is correct and is *not* a template failure; the notice on stderr is how you
tell the two apart.

```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.

<!-- [doc->REQ-PSYCHE-ROLE-ABSENT-STATUS] -->
**Not declaring `[session.psyche_resume]` is allowed, and it is loud (since
releases#239).** The role is **load-bearing when declared** — it is the only
role the per-event turn drives — so an endpoint without it simply runs no psyche
turns. That is a supported configuration, not a fault, and spt-core treats it as
one:

- the turn is **skipped**, decided before anything is spawned;
- a single `PSYCHE_ROLE_ABSENT:<id>` line is logged **once per endpoint**, not
  once per fire;
- **no strikes accrue.** The consecutive-failure budget is for a declared role
  that *fails*; a role that was never declared cannot fail and cannot self-heal,
  so spending that budget on it would exhaust a guard built for a different
  mechanism;
- the endpoint's status carries `psyche_role_absent` (visible on
  `spt endpoint list --json`), and it is a **status** field, not the
  `psyche_host_error` failure latch — nothing is being retried, so there is no
  attempt count and the stamp records when the absence was *first noticed*;
- **it clears itself.** Declare the role and the next fire runs a turn and drops
  the status, with no operator gesture.

`[session.echo_commune]` behaves the same way with one deliberate difference:
its absence is *quiet*, because the published contract presents it as an
optional template, while a missing `psyche_resume` silently disabling every
psyche turn is exactly the kind of degradation that has to announce itself.

**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"]
```

<!-- [doc->REQ-IO-SHORTFORM-GATE] the manifest half of the compliance handshake: the fields, the absent-is-off default, and the one-switch scope over both shortform markers -->
## `[io]` — IO-funnel compliance

**Opt-in, and absent means off.** Declaring `[io]` is how an adapter tells
spt-core that its own local tag parsers are gone as of this release, so core may
parse its ingest:

```toml
[io]
compliance = true      # core may parse this adapter's ingest
shortform = false      # optional: stay compliant, keep core-side shortform off
```

- **`compliance`** *(bool, default `false`)* — the declaration itself. Absent or
  `false`, core parses **nothing** of this adapter's ingest.
- **`shortform`** *(bool, optional; absent ⇒ enabled)* — the exotic-harness
  opt-out. It is **one switch over both shortform markers**, the `@<…@>` dispatch
  tag and the `;;` seal mint, because they are one feature with one suppression
  grammar. Setting it `false` disables that reader and leaves the compliance
  declaration standing.

**Declare compliance in the release that deletes your own parser, not before.**
That ordering is the whole point of the default: an adapter that ships its own
tag parser today stays the only parser until one change removes it and declares
here, so no version exists in which both parse the same text and send it twice.

The grammar these fields gate — what a tag looks like, how backticks and fenced
blocks suppress it, what happens with no controller attached, and where the
outcomes surface — is the frame contract's
[shortform section](../shells/frames.md#shortform-sending-from-inside-a-turn).

<!-- [doc->REQ-NOW-SIGNAL-SPEC] the manifest-carried tuning `--spec-manifest` reads: the fields, narrows-never-invents, and the degrade-never-refuse posture -->
### `[io.now_signal]` — standing now-signal tuning

What `spt api now-signal --spec-manifest` reads. An adapter knows which
categories its surface can render, which are noise in it, and what its injection
budget is; this is where it says so:

```toml
[io.now_signal]
without = ["SHELLS"]           # suppress a category
max_lines = 4                  # cap each category's output per poll
# only = ["DISPATCH_RESULTS"]  # or narrow to an explicit set
```

- **`only`** *(array of category names; empty ⇒ no narrowing)* — when non-empty,
  the only categories rendered.
- **`without`** *(array of category names)* — categories to suppress. If a
  category appears in both, **suppression wins**.
- **`max_lines`** *(integer, optional)* — a per-poll cap on the lines a single
  category may print.

**It narrows and tunes; it never invents.** A name outside the category
vocabulary is ignored rather than conjured — and ignored rather than *refused*,
so a manifest may carry a deferred category's name ahead of core building it and
still load. Absent `[io.now_signal]`, a `--spec-manifest` poll renders the
default picture; so does a malformed one, because this verb runs on a hook at
every turn boundary and a config typo must not break a working session. The
category list itself is in [the frame
contract](../shells/frames.md#the-v1-categories).

## `[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.

<!-- [doc->REQ-INJECT-MULTILINE-INTEGRITY] the readiness probe and the re-drive are PTY writes an adapter's editor will see; they were undocumented until an adapter chased a draft erasure through them (omp-spt, 2026-09-13) -->
**Every byte spt-core writes on this leg, so your editor is never surprised.** Three
things reach the PTY on an idle delivery and nothing else: **(1) a readiness probe**
before the delivery's first byte on an echoing PTY — the DSR cursor-position query
`ESC [ 6 n`, repeated every ~40 ms until the session produces output or a bounded
deadline elapses (a non-echoing ConPTY, where the probe is never observable, latches
it off); **(2) the keystroke-commands your binary emitted**, verbatim; **(3) a
re-drive** — the same payload typed again, after another probe — if the typed
head does not echo back. spt-core never sends a clear-line or clear-input of its
own; if a draft in your editor vanishes on delivery, the erasure is either your
emitted sequence or your editor's reading of the probe (a TUI that treats an
unrecognised CSI, or a bare `ESC`, as clear-input). If you declare no translation
binary, spt-core never touches the PTY on message arrival and idle messages spool.
"Idle" is spt-core's view of the *agent*: a human composing a draft while the agent
is idle is on this leg.

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.)

## `[service]` — a daemon-supervised resident service

<!-- [doc->REQ-RESIDENT-SERVICE] -->

**Opt-in (since v0.44.0).** Declares a **resident service**: a binary the
**daemon** supervises on the adapter's behalf, core-owned from birth. It is
spawned **job-neutrally by the daemon**, so it is never a shell's child (a
tree-kill of the shell cannot reach it) and never inside a launching terminal's
Job Object (closing the terminal cannot sweep it). It has **no perch, no
identity, and no address** — a service needing a two-way agent-facing surface
has one at its adapter's endpoint/shell layer. It runs independent of any
agent's liveness.

```toml
[service]
command = "{adapter_dir}/gw-hub serve"   # opaque; program token + args
start = "boot"                            # "boot" | "bind" — REQUIRED
stop_grace_ms = 30000                     # optional; defaults to 30s
```

- **`command`** — an **opaque** command string (program token plus args), like
  every other command seam. Its program token resolves against the adapter
  **install dir** before `PATH`, and args support adapter-static
  `{adapter_dir}` / `{adapter_name}` substitution only. Must be non-empty: a
  declared service means spt-core owns and supervises a process.
- **`start`** — **required**, no default. `"boot"` is **desired-state-running,
  not an event**: the supervisor reconciles the service toward running at daemon
  boot, at **adapter registration against a live daemon** (installing or
  registering an adapter never requires restarting spt to bring its service up),
  at update-hold release, and at first shell bind as a defensive ensure.
  `"bind"` starts lazily at the adapter's first shell bind. Both are supervised
  identically once running. The key is required rather than defaulted because
  the choice decides whether a least-trusted third-party binary rises with the
  daemon itself — an adapter author says so explicitly.
- **Registration reports what it did, and never fails over the service.** After
  a successful `spt adapter add` or `spt adapter update`, the CLI asks the daemon
  to reconcile that adapter and prints one line per option — started, already
  running, held, deferred to first bind, or not started with the reason. If the
  daemon is **not running**, the registration still succeeds and the CLI says so:
  the service is declared and starts at the next daemon boot. A registration is
  never refused because a service could not be started, and the notice is not
  optional — without it a `start = "boot"` service would silently not exist until
  something else restarted the daemon.
- **`stop_grace_ms`** — how long a cooperative exit has before the supervisor
  force-kills. Defaults to `30000`. Must be `> 0`: a zero grace would leave no
  window to exit in.
- **Cardinality** is one supervised instance per registered adapter-option
  (`<adapter>[:profile]`). The supervisor threads the option name and a
  per-option runtime dir into the service's spawn environment, so an adapter can
  scope its own guards and config per option. An adapter may keep its own file
  lock as a private double-start guard; spt-core neither reads nor depends on it.
- **The spawn contract, by name.** A supervised service is started with these
  environment variables, and they are the whole interface:

  | variable | value |
  |---|---|
  | `SPT_SERVICE_OPTION` | the adapter-option this instance serves, e.g. `hub` or `hub:staging` |
  | `SPT_SERVICE_DIR` | this instance's private runtime directory, created before the spawn |
  | `SPT_BIN` | absolute path to the `spt` executable to call (see below) |
  | `SPT_HOME` | the state root that `spt` must be run against |

  The option is the raw string an operator types, not a path encoding. The
  runtime dir is per-option, so two options of one adapter never share a
  directory — scope any private lock or config file inside it.
- **Calling the `spt` CLI from a supervised service.** Invoke `$SPT_BIN`, not a
  bare `spt`. A service is spawned by the daemon, not by a login shell, so its
  `PATH` is whatever the daemon inherited — a daemon started by the platform
  service manager or a scheduler has no reason to carry the install dir, and a
  bare `spt` would work on the author's box and be silently missing in the
  field. `SPT_BIN` also names the *running daemon's own image*, so the CLI the
  service calls is never a different build than the supervisor that owns it.
  `SPT_HOME` is likewise pinned to the home the daemon resolved rather than left
  for the child to re-derive from a per-account platform default. Both are
  absolute. `SPT_BIN` is the one variable here that can be **absent** — if core
  cannot read its own image path it says nothing rather than handing out a
  guess, so treat an unset `SPT_BIN` as "the CLI is not available" and report
  it.
- **A supervised service has no session identity.** Core *removes*
  `SPT_ENDPOINT_ID`, `OWL_SESSION_ID` and `SPT_AGENT_ID` from the spawn: a
  service is a node-scoped process with no session and no perch, so its `spt
  send` presents as the anonymous `cli@<node>` origin. Without the scrub a daemon
  that happened to be started from inside an agent's shell would hand that
  agent's identity to every service on the node, and their messages would claim
  to be from that agent — with the replies routed back to it. A service that
  needs a perch identity registers one; it never inherits one.
- **The runtime dir's core-owned filenames, also by name.** Three names inside
  `SPT_SERVICE_DIR` are contract; everything else in there is the adapter's own.

  | file | written by | meaning |
  |---|---|---|
  | `stop-requested` | spt-core | the quiesce request — its *existence* is the whole message (see below) |
  | `status-advisory` | **the service** (optional) | one **advisory** line surfaced by `spt adapter service status` |
  | `startup.capture` | spt-core | the service's own stdout+stderr from its startup window — the evidence a `STARTUP_FAULT` carries |

  `status-advisory` is the one file in this contract an adapter writes and core
  reads. It is **display-only and never consulted for a decision** — no
  reconcile, hold, kill or adoption reads it — because core deciding on it would
  put a least-trusted binary's self-report in the control path, and a service
  that stopped updating it would silently become whatever it last claimed. The
  read is **bounded** (the first line, capped) and an unreadable file is simply
  no advisory: nothing here ever requires reading a file the service holds an
  exclusive OS lock on. Write it however suits the service — a whole-file
  rewrite each cycle is the intended shape.

  `startup.capture` holds **both** streams on one handle (an adapter reporting
  its fault on stdout must not produce a diagnostic-free `STARTUP_FAULT`). Core
  truncates it at every spawn and again once a run outlives the startup
  threshold, so it is evidence and never a log to accumulate in — a service that
  wants a durable log writes its own file beside it.
- **Quiesce is cooperative exit plus a deadline.** The supervisor creates the
  file **`stop-requested`** inside `SPT_SERVICE_DIR`. Its *existence* is the
  entire message: there is no content to parse, no reply to send, and no
  channel to open — a service that already has a loop just stats that one path
  each cycle and exits when it is safe to. The kernel-observed exit **is** the
  acknowledgement, so "not ready yet" is expressed by not-yet-exiting and no
  busy record exists to go stale in either direction. Past `stop_grace_ms` the
  supervisor force-kills. **Delay is possible; veto is not.**
- **Update holds the service.** An adapter update is an ordered supervisor
  operation: quiesce → **hold** (stopped, and never relaunched while held) →
  swap bits → start the new bits → release. Crash-relaunch applies only when not
  held, because a supervisor that eagerly relaunched mid-swap would re-pin the
  old executable and turn a diagnosable failure into an unwinnable race.
- **Liveness is derived, never recorded.** The supervisor is the parent and
  holds the child handle, so exit is kernel-observed and no "running" record
  exists to go stale.
- **Consecutive fast exits are a configuration fault, not a crash.** A service
  that exits repeatedly within the startup threshold trips a loud
  `STARTUP_FAULT` diagnostic carrying its captured startup output, rather than
  being ground silently through relaunch backoff. A run that outlives the
  startup threshold resets that counter.
- **The service can invoke the spt CLI** (`spt send` and friends) from its
  supervised environment, under the usual identityless `cli@<node>` from-label.
  It takes no inbound spt traffic.
- **Operator surface:** `spt adapter service list` and
  `spt adapter service status <adapter[:profile]>` (see the
  [CLI reference](../cli/reference.md#spt-adapter-service)). These live under
  `spt adapter` — the resident service belongs to the adapter, whereas `spt
  daemon`'s "service" wording refers to the OS service manager hosting the
  daemon itself. Both are **read-only** and both are **answered by the daemon
  or not at all**: the supervisor holds the child handles, so service state
  exists only in the daemon's memory. With no daemon running the CLI says
  exactly that — it never derives "running" from the pid file in the runtime
  dir, which exists so the *next* daemon can kill an orphan and is not a
  liveness record. Each row reports the declared start trigger, whether the
  service is running, whether an update is holding it, any relaunch suppression
  **with the captured startup output behind it**, and the service's own
  advisory line when it wrote one.

## `[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.

<!-- [doc->REQ-ADAPTER-UPDATE-PRUNES-STRINGS] -->
**What an update does to files you stop shipping.** An update replaces only the
files whose content changed, and what happens to a file your new release no
longer contains depends on where it lives:

- **`strings/` mirrors your archive.** A file under `strings/` that the new
  release does not ship is **removed** once the update has landed, along with any
  directory that leaves empty. Rename `skills/a.md` to `skills/a/SKILL.md` and
  the old `a.md` is gone after the update — a harness that scans `strings/` never
  finds a skill you retired.
- **Everything else is additive.** A binary (or any other file outside
  `strings/`) that you drop stays on the node until the next clean install. A
  running process is never pulled out from under an update, so do not rely on an
  update to delete an old executable; stop referencing it instead.

<!-- [doc->REQ-ADAPTER-ENTRY-EXEC-BIT] -->
**Ship your binaries executable.** On Linux and macOS, every binary your
manifest runs must carry its exec bit inside the `.spt` archive — an archive
packed on Windows often drops it (mode `0644`). spt-core keeps the modes your
archive carries, with one safety net: after every install or update it checks
the binaries your manifest **declares** — the program of each command spt-core
runs (`[service]`, `[message-idle-translation-binary]`, `[digest].extractor`,
`[session.*]` roles, `[update]` / `[update.post]`, `[shell]`) when that program
resolves to a file inside your install — and if one arrived without its exec
bit, spt-core sets it and prints

```text
ADAPTER_ENTRY_EXEC_FORCED:<adapter>: <path> extracted 0644 — packaging defect upstream
```

Treat that line as a bug report against your packaging: the node keeps working,
but the next node that installs a different way may not. The check runs on
every update run, even when no file changed, so a node that already installed
the broken mode heals on its next `spt adapter update`. Files your manifest does
not run are never touched.

### `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.

<!-- [doc->REQ-ADAPTER-UPDATE-PARALLEL] -->
**Your update runs beside other adapters' updates.** A sweep updates every
selected adapter at once, one thread each. Your own steps stay in order —
fetch, verify, swap, re-register, then your `[update.post]` — but your
post-step may run while another adapter's post-step is running, so it must
not assume it has the node to itself (no shared fixed temp paths, no global
locks it expects to be uncontested). spt-core serializes the swap-and-register
step across adapters itself; you do not need to.

**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, and an owner that comes online after that sweep restores it on that transition (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.
