--- meta-description: spt-core: a harness-independent core for an agent ecosystem — messaging, live-agent lifecycle, terminal hosting, P2P networking, and the runtime-manifest harness contract. meta-robots: noindex meta-theme-color: #ffffff meta-viewport: width=device-width, initial-scale=1 title: SPT developer docs --- ## Keyboard shortcuts Press `←` or `→` to navigate between chapters Press `S` or `/` to search in the book Press `?` to show this help Press `Esc` to hide this help ![SVG Image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NDggNTEyIj48cGF0aCBkPSJNMCA5NkMwIDc4LjMgMTQuMyA2NCAzMiA2NEg0MTZjMTcuNyAwIDMyIDE0LjMgMzIgMzJzLTE0LjMgMzItMzIgMzJIMzJDMTQuMyAxMjggMCAxMTMuNyAwIDk2ek0wIDI1NmMwLTE3LjcgMTQuMy0zMiAzMi0zMkg0MTZjMTcuNyAwIDMyIDE0LjMgMzIgMzJzLTE0LjMgMzItMzIgMzJIMzJjLTE3LjcgMC0zMi0xNC4zLTMyLTMyek00NDggNDE2YzAgMTcuNy0xNC4zIDMyLTMyIDMySDMyYy0xNy43IDAtMzItMTQuMy0zMi0zMnMxNC4zLTMyIDMyLTMySDQxNmMxNy43IDAgMzIgMTQuMyAzMiAzMnoiIC8+PC9zdmc+) ![SVG Image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1NzYgNTEyIj48cGF0aCBkPSJNMzcxLjMgMzY3LjFjMjcuMy0zLjkgNTEuOS0xOS40IDY3LjItNDIuOUw2MDAuMiA3NC4xYzEyLjYtMTkuNSA5LjQtNDUuMy03LjYtNjEuMlM1NDkuNy00LjQgNTMxLjEgOS42TDI5NC40IDE4Ny4yYy0yNCAxOC0zOC4yIDQ2LjEtMzguNCA3Ni4xTDM3MS4zIDM2Ny4xem0tMTkuNiAyNS40bC0xMTYtMTA0LjRDMTc1LjkgMjkwLjMgMTI4IDMzOS42IDEyOCA0MDBjMCAzLjkgLjIgNy44IC42IDExLjZjMS44IDE3LjUtMTAuMiAzNi40LTI3LjggMzYuNEg5NmMtMTcuNyAwLTMyIDE0LjMtMzIgMzJzMTQuMyAzMiAzMiAzMkgyNDBjNjEuOSAwIDExMi01MC4xIDExMi0xMTJjMC0yLjUtLjEtNS0uMi03LjV6IiAvPjwvc3ZnPg==) - Auto - Light - Rust - Coal - Navy - Ayu ![SVG Image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48cGF0aCBkPSJNNDE2IDIwOGMwIDQ1LjktMTQuOSA4OC4zLTQwIDEyMi43TDUwMi42IDQ1Ny40YzEyLjUgMTIuNSAxMi41IDMyLjggMCA0NS4zcy0zMi44IDEyLjUtNDUuMyAwTDMzMC43IDM3NmMtMzQuNCAyNS4yLTc2LjggNDAtMTIyLjcgNDBDOTMuMSA0MTYgMCAzMjIuOSAwIDIwOFM5My4xIDAgMjA4IDBTNDE2IDkzLjEgNDE2IDIwOHpNMjA4IDM1MmM3OS41IDAgMTQ0LTY0LjUgMTQ0LTE0NHMtNjQuNS0xNDQtMTQ0LTE0NFM2NCAxMjguNSA2NCAyMDhzNjQuNSAxNDQgMTQ0IDE0NHoiIC8+PC9zdmc+) # SPT developer docs [![SVG Image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48cGF0aCBkPSJNMTI4IDBDOTIuNyAwIDY0IDI4LjcgNjQgNjR2OTZoNjRWNjRIMzU0LjdMMzg0IDkzLjNWMTYwaDY0VjkzLjNjMC0xNy02LjctMzMuMy0xOC43LTQ1LjNMNDAwIDE4LjdDMzg4IDYuNyAzNzEuNyAwIDM1NC43IDBIMTI4ek0zODQgMzUydjMyIDY0SDEyOFYzODQgMzY4IDM1MkgzODR6bTY0IDMyaDMyYzE3LjcgMCAzMi0xNC4zIDMyLTMyVjI1NmMwLTM1LjMtMjguNy02NC02NC02NEg2NGMtMzUuMyAwLTY0IDI4LjctNjQgNjR2OTZjMCAxNy43IDE0LjMgMzIgMzIgMzJINjR2NjRjMCAzNS4zIDI4LjcgNjQgNjQgNjRIMzg0YzM1LjMgMCA2NC0yOC43IDY0LTY0VjM4NHptLTE2LTg4Yy0xMy4zIDAtMjQtMTAuNy0yNC0yNHMxMC43LTI0IDI0LTI0czI0IDEwLjcgMjQgMjRzLTEwLjcgMjQtMjQgMjR6IiAvPjwvc3ZnPg==)](print.html "Print this book") [![SVG Image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OTYgNTEyIj48cGF0aCBkPSJNMTY1LjkgMzk3LjRjMCAyLTIuMyAzLjYtNS4yIDMuNi0zLjMuMy01LjYtMS4zLTUuNi0zLjYgMC0yIDIuMy0zLjYgNS4yLTMuNiAzLS4zIDUuNiAxLjMgNS42IDMuNnptLTMxLjEtNC41Yy0uNyAyIDEuMyA0LjMgNC4zIDQuOSAyLjYgMSA1LjYgMCA2LjItMnMtMS4zLTQuMy00LjMtNS4yYy0yLjYtLjctNS41LjMtNi4yIDIuM3ptNDQuMi0xLjdjLTIuOS43LTQuOSAyLjYtNC42IDQuOS4zIDIgMi45IDMuMyA1LjkgMi42IDIuOS0uNyA0LjktMi42IDQuNi00LjYtLjMtMS45LTMtMy4yLTUuOS0yLjl6TTI0NC44IDhDMTA2LjEgOCAwIDExMy4zIDAgMjUyYzAgMTEwLjkgNjkuOCAyMDUuOCAxNjkuNSAyMzkuMiAxMi44IDIuMyAxNy4zLTUuNiAxNy4zLTEyLjEgMC02LjItLjMtNDAuNC0uMy02MS40IDAgMC03MCAxNS04NC43LTI5LjggMCAwLTExLjQtMjkuMS0yNy44LTM2LjYgMCAwLTIyLjktMTUuNyAxLjYtMTUuNCAwIDAgMjQuOSAyIDM4LjYgMjUuOCAyMS45IDM4LjYgNTguNiAyNy41IDcyLjkgMjAuOSAyLjMtMTYgOC44LTI3LjEgMTYtMzMuNy01NS45LTYuMi0xMTIuMy0xNC4zLTExMi4zLTExMC41IDAtMjcuNSA3LjYtNDEuMyAyMy42LTU4LjktMi42LTYuNS0xMS4xLTMzLjMgMi42LTY3LjkgMjAuOS02LjUgNjkgMjcgNjkgMjcgMjAtNS42IDQxLjUtOC41IDYyLjgtOC41czQyLjggMi45IDYyLjggOC41YzAgMCA0OC4xLTMzLjYgNjktMjcgMTMuNyAzNC43IDUuMiA2MS40IDIuNiA2Ny45IDE2IDE3LjcgMjUuOCAzMS41IDI1LjggNTguOSAwIDk2LjUtNTguOSAxMDQuMi0xMTQuOCAxMTAuNSA5LjIgNy45IDE3IDIyLjkgMTcgNDYuNCAwIDMzLjctLjMgNzUuNC0uMyA4My42IDAgNi41IDQuNiAxNC40IDE3LjMgMTIuMUM0MjguMiA0NTcuOCA0OTYgMzYyLjkgNDk2IDI1MiA0OTYgMTEzLjMgMzgzLjUgOCAyNDQuOCA4ek05Ny4yIDM1Mi45Yy0xLjMgMS0xIDMuMy43IDUuMiAxLjYgMS42IDMuOSAyLjMgNS4yIDEgMS4zLTEgMS0zLjMtLjctNS4yLTEuNi0xLjYtMy45LTIuMy01LjItMXptLTEwLjgtOC4xYy0uNyAxLjMuMyAyLjkgMi4zIDMuOSAxLjYgMSAzLjYuNyA0LjMtLjcuNy0xLjMtLjMtMi45LTIuMy0zLjktMi0uNi0zLjYtLjMtNC4zLjd6bTMyLjQgMzUuNmMtMS42IDEuMy0xIDQuMyAxLjMgNi4yIDIuMyAyLjMgNS4yIDIuNiA2LjUgMSAxLjMtMS4zLjctNC4zLTEuMy02LjItMi4yLTIuMy01LjItMi42LTYuNS0xem0tMTEuNC0xNC43Yy0xLjYgMS0xLjYgMy42IDAgNS45IDEuNiAyLjMgNC4zIDMuMyA1LjYgMi4zIDEuNi0xLjMgMS42LTMuOSAwLTYuMi0xLjQtMi4zLTQtMy4zLTUuNi0yeiIgLz48L3N2Zz4=)](https://github.com/SaberMage/spt-releases "Git repository") ![SVG Image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48cGF0aCBkPSJNMzA0IDQ4YzAtMjYuNS0yMS41LTQ4LTQ4LTQ4cy00OCAyMS41LTQ4IDQ4czIxLjUgNDggNDggNDhzNDgtMjEuNSA0OC00OHptMCA0MTZjMC0yNi41LTIxLjUtNDgtNDgtNDhzLTQ4IDIxLjUtNDggNDhzMjEuNSA0OCA0OCA0OHM0OC0yMS41IDQ4LTQ4ek00OCAzMDRjMjYuNSAwIDQ4LTIxLjUgNDgtNDhzLTIxLjUtNDgtNDgtNDhzLTQ4IDIxLjUtNDggNDhzMjEuNSA0OCA0OCA0OHptNDY0LTQ4YzAtMjYuNS0yMS41LTQ4LTQ4LTQ4cy00OCAyMS41LTQ4IDQ4czIxLjUgNDggNDggNDhzNDgtMjEuNSA0OC00OHpNMTQyLjkgNDM3YzE4LjctMTguNyAxOC43LTQ5LjEgMC02Ny45cy00OS4xLTE4LjctNjcuOSAwcy0xOC43IDQ5LjEgMCA2Ny45czQ5LjEgMTguNyA2Ny45IDB6bTAtMjk0LjJjMTguNy0xOC43IDE4LjctNDkuMSAwLTY3LjlTOTMuNyA1Ni4yIDc1IDc1cy0xOC43IDQ5LjEgMCA2Ny45czQ5LjEgMTguNyA2Ny45IDB6TTM2OS4xIDQzN2MxOC43IDE4LjcgNDkuMSAxOC43IDY3LjkgMHMxOC43LTQ5LjEgMC02Ny45cy00OS4xLTE4LjctNjcuOSAwcy0xOC43IDQ5LjEgMCA2Ny45eiIgLz48L3N2Zz4=) # [SPT developer docs](#spt-developer-docs) spt-core is a **harness-independent core for an agent ecosystem**: inter-agent messaging, live-agent lifecycle, terminal hosting, seamless self-update, and zero-config cross-machine networking — shipped as a single canonical binary (`spt` / `spt.exe`). It lets coding agents running under different harnesses talk to each other — across sessions, across projects, and across machines — with no central server. > **Pick your path:** > > - **Developer** — you want agents on your machines messaging each other: > start with the [messaging quickstart](#quickstart-two-agents-exchange-a-message) (one > install line + three commands, under 10 minutes). > - **Adapter developer / dev-agent** — you’re integrating a harness or > building a shell against the public contract: start with the > [adapter quickstart](#quickstart-build-an-adapter), then the > [harness contract](#harness-contract). ## [Install](#install) One line, non-interactive: # Linux curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh # Windows (PowerShell) irm https://sabermage.github.io/spt-releases/install.ps1 | iex Verify: $ spt --version spt 0.1.0 ## [How these docs are organized](#how-these-docs-are-organized) Each capability vertical carries the same four modes, never mixed: an **overview** (why it exists + how it fits), a **tutorial** where one ships in v0.1, **how-to guides**, and **reference**. There is one canonical way to do each thing; deprecated or alternate paths are marked when they exist. ## [For AI agents reading this](#for-ai-agents-reading-this) - [`llms.txt`](https://sabermage.github.io/spt-releases/llms.txt) — curated index of these docs. [`llms-full.txt`](https://sabermage.github.io/spt-releases/llms-full.txt) — the full concatenated export. - Append `.md` to any page URL for raw markdown (about 90% fewer tokens than the HTML). - [`manifest.schema.json`](https://sabermage.github.io/spt-releases/manifest.schema.json) — the machine-readable adapter-manifest contract. Validate your manifest against it before registering. - `spt --help` is a first-class documentation surface; the [CLI reference](#cli-reference) is generated from it and cannot drift. # [Quickstart: two agents exchange a message](#quickstart-two-agents-exchange-a-message) End to end in under 10 minutes. The roles matter here: **you** install (and optionally pair machines); **your agents** exchange the messages. You hand each agent a short prompt; the binary itself teaches them the rest. > This is the developer path. Building an adapter or integrating a harness? > Go to the [adapter quickstart](#quickstart-build-an-adapter) instead. Everything below uses real values and runs as written. ## [1. Install (one line)](#1-install-one-line) # Linux curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh # Windows (PowerShell) irm https://sabermage.github.io/spt-releases/install.ps1 | iex Verify (on Windows, open a **new** terminal first — or use the absolute path the installer printed): $ spt --version spt 0.1.0 ## [2. Optional: link two machines](#2-optional-link-two-machines) Everything below also works on a single box — skip ahead freely. But the product’s hallmark is that the *same* commands work across machines once they share a **subnet**. **Pair a second machine into a subnet (one-time, ~2 minutes)** On your first machine, create the subnet. This reveals its joining secret, so it needs elevation — just run it directly and `spt` requests elevation for you (a `sudo` password prompt on Linux/macOS; run the terminal as Administrator on Windows): spt subnet create home It prints the current 6-digit code, an `otpauth://` URI (scan the QR into an authenticator app for codes anytime), and the next step. > Running non-interactively (no TTY to prompt on)? `spt` instead prints the > exact elevated command to copy-paste — it uses the binary’s absolute path so > a user-local install (`~/.local/bin`) still resolves under `sudo`. On the second machine, join it (this enrolls the machine, so it elevates the same way): spt subnet join home It searches LAN + relay for your first machine, prompts for the current code, and confirms: `JOINED:home`. Check from either side: spt subnet status --nodes Both machines show up, labeled by hostname. **That’s it — the same prompts below now work across machines:** a `spt send sergey` on machine 1 reaches a `sergey` listening on machine 2, live or spooled. ## [3. Hand your receiver agent its prompt](#3-hand-your-receiver-agent-its-prompt) Paste this into an agent session (your “receiver” — we call it `sergey`): Run `spt how-to ready`, then follow it to become reachable as "sergey" and stay listening. The binary’s own guidance (`spt how-to ready`) tells the agent exactly what to run and what it will see. Under the hood, the agent starts: $ spt ready sergey READY:sergey `ready` registers a *perch* for `sergey` (identity + address on this machine), drains any backlog, and blocks listening. ## [4. Hand your sender agent its prompt](#4-hand-your-sender-agent-its-prompt) Paste this into a second agent session (the “sender” — `lea`): Run `spt how-to send`, then follow it to send the agent "sergey" a greeting from "lea". What the agent runs: $ echo "hello sergey - lea here" | spt send sergey --from lea SENT:sergey (Windows PowerShell: `"hello sergey - lea here" | spt send sergey --from lea`.) Sergey’s session prints it immediately: hello sergey - lea here `SENT` means live delivery — sergey was listening. Each delivery is one `` envelope line; the `from="lea"` attribute is the routing handle: whoever receives this knows where a reply goes (`spt send lea`). Bodies are HTML-escaped with newlines as `
`; oversized deliveries split into `` lines the receiver concatenates back. The exact entity set and decode order live in [the `` wire contract](#the-event-wire-contract). ## [5. Deliver to someone who’s offline](#5-deliver-to-someone-whos-offline) Stop sergey’s listener (Ctrl-C in his session), then send again from lea: $ echo "ping while you were away" | spt send sergey --from lea QUEUED:sergey `QUEUED` means sergey has a perch but isn’t listening — the message went to his durable spool instead of being dropped. Bring him back: $ spt ready sergey --once READY:sergey ping while you were away The backlog drains the moment he’s back (`--once` drains and exits — the one-shot form `spt how-to ready` teaches agents whose harness can’t host a long-running listener). Nothing is lost between sessions. ## [6. What just happened](#6-what-just-happened) - **Perch** — registering as `sergey` created a perch: a durable identity with an address and a spool, under spt-core’s per-machine home. `spt list` shows every perch on the node, live or not. - **Live-first, spool-fallback** — `send` tries a direct connection to the registered address first (`SENT`); if the perch exists but no listener is up, the message lands in the spool (`QUEUED`) and is drained by the next `ready`. - **Reply routing** — the sender id travels with every message structurally, surfaced as the arriving `` envelope’s `from` attribute; `spt send lea` answers the sender without knowing anything else about them. - **Agents teach themselves** — the prompt blocks point agents at `spt how-to `: task guidance shipped *in the binary*, so what an agent reads can never disagree with the binary it runs. - **No daemon ceremony** — you never started a server. Anything that needs the per-machine daemon auto-starts it on demand. - **Subnets carry it across machines** — if you did step 2, these same flows ride the paired P2P fabric: same commands, same outputs, machine boundaries invisible. ## [Next](#next) - **How-to:** block on an answer with `spt ring sergey` — send + wait for the reply in one call (a synchronous ask between agents). - **Concept:** the [mental model](#mental-model) — perches, endpoints, the daemon, and subnets. - **Reference:** [`spt send` / `ready` / `ring` / `subnet`](#cli-reference) — every flag, generated from the binary itself. - **Going cross-machine:** [Networking & subnets](#networking--subnets) — the model behind `spt subnet create` / `join` / `status`. # [Quickstart: build an adapter](#quickstart-build-an-adapter) The “build a harness for spt-core” hello-world: take the reference **mock adapter** apart, register it, drive the contract with real commands, then swap in your own harness. No spt-core source required — the public contract is the manifest plus the `spt api` surface. > Integrating an agent harness and a building a driven surface (notifier, > robot, sensor) are the same contract with a different manifest body. For > the latter, read this page first, then [Shells: getting started](#getting-started-a-notification-shell). ## [0. What an adapter is](#0-what-an-adapter-is) A TOML **manifest** that declares what varies for your harness — how to spawn a session, which of your hook events fire which `spt api` command, how spt-core can read session history — plus whatever your harness already has (hooks, plugin config). Command templates are **opaque strings**: spt-core fills `{key}` placeholders and runs them. It never parses out a model, a tool list, or a flag. Your harness’s business stays yours. ## [1. Get the reference adapter](#1-get-the-reference-adapter) Every release ships the mock adapter’s source. With spt-core [installed](#1-install-one-line): curl -fsSL -o mock-adapter.zip \ https://github.com/SaberMage/spt-releases/releases/latest/download/mock-adapter.zip unzip mock-adapter.zip -d mock-adapter (Windows: `irm -OutFile mock-adapter.zip https://github.com/SaberMage/spt-releases/releases/latest/download/mock-adapter.zip` then `Expand-Archive mock-adapter.zip mock-adapter`.) The interesting file is `mock-adapter/manifest.toml`. It is deliberately harness-agnostic — generic event names, a trivial `mock-session` helper standing in for a real harness binary. ## [2. Read the manifest](#2-read-the-manifest) The header is the only mandatory section: [adapter] name = "mock" kind = "harness" # or "shell" (a driven surface) version = "1.0.0" min_spt_core_version = "1.0.0" # compat gate, readable before any install/update hostable_types = ["LiveAgent", "ReadyAgent", "Worker"] Inbound: your harness’s hook events, each firing one `spt api` command: [hooks.SessionStart] fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}" reads = ["session_id", "parent_pid"] can_inject = true # this hook can surface text back into the agent's context [hooks.Idle] fires = "api state idle" can_inject = false # no inject channel -> spt-core uses its sentinel/relay fallback `can_inject` is the load-bearing harness-varying fact: when a hook can’t put text in front of the agent, spt-core routes around it automatically. Outbound: opaque session templates spt-core spawns with `{key}` placeholders filled: [session.self] command = "mock-session --id {id} --session-id {session_id}" detach = true keys = ["id", "session_id"] A real adapter’s template is your harness’s full command line — model, flags, tools, everything — exactly as you’d type it. The rest declares history access (`[history]`), env bridging (`[env.*]`), input injection (`[inject]`), and session identity (`[identity]`). Every section beyond `[adapter]` is optional; the [manifest reference](#manifest-reference) covers them all. ## [3. Validate and register](#3-validate-and-register) Two layers of validation, both mechanical: - **Schema** — your manifest must validate against [`manifest.schema.json`](https://sabermage.github.io/spt-releases/manifest.schema.json). The schema is generated from the same code that parses manifests, so it is always current; closed vocabularies (adapter kinds, history strategies, update avenues, …) are enums in it. - **Registration** — `spt adapter add` parses, validates (including cross-field rules the schema can’t express), and registers in one step: $ spt adapter add ./mock-adapter ADAPTER_ADD:mock:Harness:Copy (registered) ADAPTER_INSTALL_SKIP: no [update] avenue (manifest-only adapter) $ spt adapter list mock: Harness Copy active (from ./mock-adapter) A bad manifest is rejected here with a message naming the offending field — nothing half-registers. ## [4. Drive the contract](#4-drive-the-contract) Every machinery call your adapter makes carries `--adapter ` — that’s the rule that makes multi-harness nodes unambiguous. Ask spt-core what your adapter declared: $ spt api --adapter mock --manifest ./mock-adapter/manifest.toml capability LiveAgent ReadyAgent Worker Now the harness-hosted startup flow, exactly what your `SessionStart` hook will fire (here with a stand-in pid): $ spt api --adapter mock seed --pid 4242 --session-id demo-session-1 SEEDED:4242 `seed` records an ephemeral hand-off keyed by the parent process id; the session’s listener then consumes it with `spt api … listen` and holds the perch. That seed→listen pair *is* harness-hosted startup. (The other direction — spt-core spawning the session itself from your `[session.self]` template, then `api bind` — is spt-hosted startup. Both are in the [`spt api` reference](#the-spt-api-surface).) ## [5. Make it yours](#5-make-it-yours) 1. Copy `manifest.toml`, set `name`, `version`, and your real `hostable_types`. 2. Point `[hooks.*]` at the events your harness actually fires, with honest `can_inject` values. 3. Replace each `[session.*].command` with your harness’s real command line. 4. Pick the `[history]` strategy your harness permits (binary that emits history → `fetcher`; transcript file on disk → `locate_normalize`; you push via `api history-log` → `native`). 5. Validate against the schema, `spt adapter add` it, and fire the `capability`/`seed` calls above against your own manifest. Building adapters against this contract is **unrestricted and royalty-free** — see the [license split](https://github.com/SaberMage/spt-releases#license). ## [Next](#next-1) - **Checklist:** the [harness integration checklist](#harness-integration-checklist) — every contract surface grouped by necessity, mapped to the interaction lifecycle, plus the beyond-the-API integrations that make an adapter feel native. Work it top to bottom when building a real harness. - **Reference:** the complete [manifest reference](#manifest-reference) and [`spt api` reference](#the-spt-api-surface). - **How-to:** ship spt-core *with* your adapter — the [install-on-demand bootstrap pattern](#install-on-demand-bootstrap). - **Concept:** where adapters sit in the [mental model](#mental-model). # [Mental model](#mental-model) What spt-core is, the five or six nouns everything else builds on, and how the pieces fit. Read this once and the rest of the docs are mostly reference. ## [The shape of the system](#the-shape-of-the-system) spt-core is **per-machine infrastructure for agents**. One binary (`spt`) installs on each machine. It carries everything: the CLI, the messaging substrate, the always-available daemon, and the networking layer. Agent harnesses — Claude Code, Codex, Pi (the pi coding agent), anything — plug in through a declarative **adapter manifest** and a small command surface (`spt api …`). spt-core never contains harness-specific logic; adapters declare what varies, spt-core does the work. machine A machine B ┌──────────────────────────────┐ ┌──────────────────────────────┐ │ spt daemon (one per machine)│ QUIC │ spt daemon │ │ ┌────────┐ ┌───────────┐ │◄──────►│ (paired: same subnet) │ │ │ broker │ │ brain │ │ P2P │ │ │ │ PTYs · │ │ routing · │ │ │ ┌───────┐ ┌─────────┐ │ │ │ sockets│ │ registry ·│ │ │ │ lea │ │ doorbell│ │ │ └────────┘ │ lifecycle │ │ │ │(agent)│ │ (shell) │ │ │ └───────────┘ │ │ └───────┘ └─────────┘ │ │ ┌─────┐ ┌─────┐ │ └──────────────────────────────┘ │ │serg.│ │ ling│ ← endpoints (perches live on disk; sessions come │ └─────┘ └─────┘ and go, identity persists) └──────────────────────────────┘ ## [Endpoints and perches](#endpoints-and-perches) An **endpoint** is anything addressable: an agent (`sergey`), a worker, a **shell** (a driven non-agent surface — a notifier, a robot, a sensor). Every endpoint has a **perch**: its durable on-disk seat — identity, address, message spool, state. Sessions are ephemeral; perches persist. That split is why a message sent to an offline agent is queued, not lost, and why an agent can be revived days later as the *same* agent. Endpoint IDs are adapter-agnostic: `sergey` is `sergey` whether his sessions run under one harness today and another tomorrow. ## [Messaging](#messaging) The primitive everything else uses. `spt send ` delivers live when the target is listening, spools when it isn’t; `spt ring ` is the blocking ask (send + wait for the reply); reply routing on the structural `from` makes answers cheap. Payloads carry typed operations and file blobs, not just text. Try it: the [messaging quickstart](#quickstart-two-agents-exchange-a-message). ## [The daemon: broker and brain](#the-daemon-broker-and-brain) One **spt daemon** per machine owns all shared state: hosted session PTYs, the network identity and endpoint, the registry, every spool, all lifecycle loops. You never manage it — any `spt` invocation auto-starts it. Internally it splits in two, and the split is what makes self-update seamless: - the **broker** holds only what must never die: PTY masters, spawned child processes, listening sockets. It almost never updates. - the **brain** holds all logic and restarts freely. An update swaps the brain while the broker keeps every session’s process and byte stream intact — running agents don’t notice. ## [Live agents and the mind](#live-agents-and-the-mind) A **live agent** is an agent endpoint with a persistent working memory. Its context survives session resets and even machine moves through three file-drop mechanisms (no special APIs inside the agent’s session): - **commune** — the agent drops a context delta; spt-core ingests it into the endpoint’s tracked mind (two tiers: a *live* tier that follows the agent everywhere, and a *project* tier scoped to one project). - **signoff** — a graceful goodbye: final commune, then teardown. - **echo-commune** — when a session ends without a signoff, spt-core runs a bounded summarizer over the session’s history so the context delta is captured anyway. The mind syncs between paired machines, so reviving `sergey` elsewhere brings his memory with him. ## [Instances, dormancy, and rest](#instances-dormancy-and-rest) One endpoint can have **instances** on several nodes. Instances rest when unused — **dormant** (warm, zero idle cost, instantly wakeable) or **suspended** (cold) — and remain addressable while resting: messages for them are held and delivered on wake. `spt endpoint wake sergey` re-activates the seat in place; nothing is respawned. ## [Subnets, pairing, and the network](#subnets-pairing-and-the-network) Machines pair into **subnets** — private, named groups sharing a registry of endpoints. Pairing is a one-time ceremony seeded by a TOTP code (the same six digits an authenticator app shows); after that, connectivity is zero-config peer-to-peer QUIC with relay fallback, no central server. Every endpoint’s visibility and sync scope is controlled per subnet; nothing is shared by default with anyone you haven’t paired with. ## [The harness contract](#the-harness-contract) The seam third parties build against — two halves: - the **[manifest](#manifest-reference)**: a TOML file declaring what varies per harness (how to spawn a session, which hooks fire, how to read history). Command templates are opaque strings; spt-core fills `{key}` placeholders and runs them. SPT is not a harness: models, flags, and tools are always the adapter’s business. - the **[`spt api` surface](#the-spt-api-surface)**: the inbound commands a harness’s hooks fire to keep spt-core’s state in sync (session started, went idle, session ended, …). A working adapter is a manifest plus whatever the harness already has. [Build one in the adapter quickstart](#quickstart-build-an-adapter). ## [Self-update](#self-update) Releases are signed (Ed25519, two-key trust anchor baked into every binary) and propagate peer-to-peer: one machine fetches a release, its peers verify and stage it from each other. Updates apply with the broker/brain split, so no endpoint process terminates or suspends during a self-update — the system’s standing invariant. ## [Where to go next](#where-to-go-next) | You want to… | Go to | | --- | --- | | see two agents talk | [Messaging quickstart](#quickstart-two-agents-exchange-a-message) | | integrate a harness | [Adapter quickstart](#quickstart-build-an-adapter) → [Manifest reference](#manifest-reference) | | build a notifier/robot/sensor | [Shells](#shells-1) | | pair two machines | [Networking & subnets](#networking--subnets) | | every command and flag | [CLI reference](#cli-reference) | # [Messaging](#messaging-1) The substrate everything else rides on: durable, addressed, reply-routable messages between endpoints — live when the target listens, spooled when it doesn’t, across machines once nodes are paired. You’ve probably already run the [quickstart](#quickstart-two-agents-exchange-a-message); this page is the model. ## [Semantics](#semantics) - **Live-first, spool-fallback.** `spt send ` connects directly to a listening target (`SENT`); if the perch exists but nothing is listening, the message lands in the target’s durable spool (`QUEUED`) and drains on its next `ready`. A target with *no* perch is an error (`NO_PERCH`) — identity is never invented on someone else’s behalf. - **Reply routing.** Every message carries its sender id structurally; the arriving `` envelope surfaces it, and `spt send ` answers without knowing anything else. - **The blocking ask.** `spt ring ` sends and waits for the reply (with a timeout) — the synchronous question between agents. - **Per-message send control (three orthogonal axes).** Each `spt send` carries one value per axis; every axis defaults to unrestricted: - **Delivery window** (*when*) — `--active-only` spools for the agent’s own poll without waking a live listener (it reaches the agent at its next natural boundary instead of interrupting now; also held for resting dormant/suspended instances and released exactly once on wake). This **renames the older `--deferred`**, which still parses as a hidden alias. `--idle-only` holds until the target is idle, then delivers (the wake). Default delivers in whichever window fires first. - **Channel** (*through what*) — `--prefer-native` routes through the target’s translation binary when one is running, else falls back to the standard delivery; `--force-native` uses the binary only (no fallback, no reroute — if no binary is live it reports non-delivery rather than spooling to another method). Default is unrestricted. The translation binary is the adapter’s idle-delivery filter; an adapter declares it with a `[message-idle-translation-binary].command` (a program token plus args, new in v0.16.0 — the bare `path` form is deprecated) and spt-core lifecycle-manages it. - **Persistence** (*how long*) — `--ephemeral` drops the message if it can’t be delivered in its accepted window instead of spooling; it is the one path allowed to drop silently (everything else spools and reports non-delivery). *In this release ephemeral evaporation applies to translation-binary delivery and TTL expiry; the harness-relay carrier-absence case is not yet wired.* - **Opaque metadata.** `--json-payload ''` attaches a JSON metadata block alongside the body. spt-core carries it verbatim across every rail and never interprets it — the **receiving adapter** parses it. It can’t forge spt-core’s own envelope attributes (it rides inside a single `json` value), and any sender may attach it. - **Typed payloads.** Message bodies carry typed operations and file blobs, not just text — file transfers are addressable and progress-queryable mid-flight. ## [Send outcomes — the closed set](#send-outcomes--the-closed-set) Every `spt send` reports exactly one outcome line. The line goes to **stderr** (stdout is reserved for message payloads); classify success by the **exit code** — `0` for every delivered/spooled outcome, non-zero for every failure. The token before the first `:` is stable; this set is complete as of v0.26.0. **Success (exit 0):** | Line | Meaning | | --- | --- | | `SENT:` | Delivered live to a listening target on this node. | | `SENT(WAN):@` | Delivered cross-node, **receiver-confirmed** — printed only when the remote daemon acknowledged. A suffix annotates the confirmed disposition: ` (spooled)` — the remote daemon accepted it into the target’s durable spool; ` (duplicate)` — the receiver had already processed this message (a safe dedup’d replay). No suffix = delivered live. | | `QUEUED:` | The perch exists but nothing is listening — spooled durably, drains on the target’s next `ready`. Success, not an error. | | `QUEUED(idle-only):` | An `--idle-only` send holding for the target’s idle window. | | `DEFERRED:` | An `--active-only` send spooled for the target’s own next poll (never interrupts a live listener). | **Failure (non-zero exit, stderr):** | Line | Meaning | | --- | --- | | `NO_PERCH: is not listening` | No perch for that id — identity is never invented on someone else’s behalf. An `--active-only` send reports this with `(active-only stays local-only)`: the hook channel does not take the cross-node leg. | | `WAN_NO_PERCH: — no perch on ` | The route resolved to a node, but no perch lives there (a stale route — the endpoint may have moved or stopped). | | `WAN_REFUSED:@` | The receiver denied the message (access gate). | | `WAN_UNCONFIRMED:@` | No receiver acknowledgment — the peer may be offline or on an old version. The message may or may not have landed; only `SENT(WAN)` means confirmed. | | `WAN_FAIL: — ` | Cross-node transport failure. | | `AMBIGUOUS: — ` | Several nodes host that id — qualify (`@`). | | `EMPTY_MSG` | Refused: empty body. | ## [The `` wire contract](#the-event-wire-contract) Every arriving message on an **agent** surface (`spt ready`, `api listen`, `api poll`, `api worker-poll`) is one `body` envelope — never a bare body: hello check the build A body that is already a fully-formed typed envelope (`echo_commune`, `notify`, `user-msg`, …) passes through **verbatim** — one envelope, never re-wrapped, and the body’s own `from` wins. On the listener stream an oversized line splits into `` chunks the receiver reassembles; `api poll` / `api worker-poll` always emit one whole envelope per message, never chunked. **Escaping — the closed entity set.** Exactly four entities, plus the newline token; there is no `'`/`'` (single quotes ride literal): - **Encode (body):** `&` → `&` **first**, then `<` → `<`, `>` → `>`, `"` → `"`; then CRLF and lone CR normalize to LF, and LF → `
`. - **Decode (body):** split/replace `
` → newline **first**, then `<` → `<`, `>` → `>`, `"` → `"`, and `&` → `&` **last**. Amp-last is the invariant that keeps an embedded `&lt;` from double-decoding into `<`. - **Attribute values:** the same four entities in the same order, with no `
` step (attribute values are line-safe by construction). - Decode only the **extracted body substring** after parsing the envelope framing — never run the entity decode over the full line, or the framing tokens themselves unescape. **MAC-stamped frames are a different surface.** The shell relay drain (`api poll --link `) emits raw stamped frames of the form ` ` — a 64-hex-char HMAC-SHA256 over the frame bytes, one space, then the frame — and is deliberately **not** ``-wrapped (the shell child verifies the MAC and parses its own vocabulary). Agent-perch surfaces never emit stamped frames; a parser of agent traffic only ever sees `` / `` lines. ## [Addressing](#addressing) Bare ids (`sergey`) resolve locally first, then across the subnet; when the same id is live on several nodes, resolution **refuses and asks you to qualify** (`sergey@desktop` — node labels and key prefixes both work) rather than guessing. The full form is `[subnet:]id[@node]`. ## [Commands](#commands) `send` · `ring` · `ready` (blocks; `--once` drains and exits) · `list` · `stop` · `whoami` — every flag in the [CLI reference](#cli-reference). Agents get the task-oriented version from the binary itself: `spt how-to ready` / `spt how-to send`. # [Live-agent lifecycle](#live-agent-lifecycle) What makes an agent endpoint a *persistent being* rather than a disposable session: identity that survives resets, a working memory that follows it across machines, and graceful endings that never lose context. ## [The pieces](#the-pieces) - **Perch** — the durable seat (identity, spool, state). Sessions attach to it (`api bind`/`listen`), reset across it (`api boundary`), and end without destroying it (`api session-end`). - **Bringup states** — `spt endpoint run` creates the perch, starts the harness session, and the harness calls `api bind` to bring it **online**. Between the session starting and that bind the endpoint is **unbound**: a live, attachable session — `spt rc ` connects to it to watch or clear a bringup prompt *before* bind — that is not yet message-addressable (a `send` waits for online). The picker and `spt endpoint list` show it as a distinct **unbound** row — see the status-square legend below — not a true offline one. *(since v0.14.0)* - **The mind, in two tiers** — a *live* tier (who the agent is, what it’s doing) that follows the endpoint everywhere, and a *project* tier scoped to one project. Both are versioned, tracked storage, synced to paired machines with the same scoping. - **Commune** — the agent drops `-commune.md` into the adapter’s watched directory; spt-core ingests the delta into the right tier. A file-drop, not a command — any harness that can write a file can commune. - **Signoff** — the graceful ending: final commune, then teardown (`spt endpoint shutdown` / `api shutdown`). The echo-commune fires **before** teardown, always. - **Echo-commune** — sessions that end *without* a signoff keep their delta: spt-core runs the adapter’s bounded summarizer template over the session history and ingests the result. The **echo gate** sentinel (armed on idle, cleared by graceful signoff) is what marks the need. - **Psyche** — the endpoint’s persistent-context companion, driven as a **bounded per-event turn** (not a resident process): the daemon runs one `[session.psyche_resume]` turn per event (`[session.psyche_init]` is the go-live gate, never spawned). ## [Rest and wake](#rest-and-wake) Endpoints rest instead of dying: **dormant** (warm — zero idle compute, instantly wakeable) or **suspended** (cold), explicitly via `spt endpoint suspend` or on attention-shift. Resting instances stay addressable; deferred messages are held and released exactly once on wake (`spt endpoint wake`). Every active→resting edge fires a **transition echo** so the final context delta lands before the lights go out. ## [Reading the picker](#reading-the-picker) The `spt endpoint run` picker (and `spt endpoint list`) marks each endpoint with a status square. **A filled square means you can act now** — control it if it is online, or wake it if it is suspended; **a hollow square means you cannot** (no control seat, or the machine is gone). Endpoints on other machines in the subnet now render with the **same** square as local ones, so a remote row tells you everything a local row does. *(remote parity since v0.17.0)* | Square | State | What it means | | --- | --- | --- | | green ■ | online · free | bound and message-addressable; you can control it | | blue ■ | online · controlled | someone is driving it (the detail pane names the controlling node) | | red ■ | online · unbound | a live session not yet message-bound — attachable with `spt rc`, needs attention | | amber ▢ | online · harness-only | visible but has no control seat, so it cannot be controlled | | gray ■ | suspended | cold but its machine is up — wakeable | | gray ▢ | offline | the machine is down (only ever seen for remote endpoints) | ## [Commands](#commands-1) `spt endpoint shutdown` · `endpoint suspend` · `endpoint wake` · the `api` lifecycle calls ([reference](#session-lifecycle)). Agents bringing themselves up live read `spt how-to live` — the in-binary, always-current bringup guidance (the persistent listen relay, the Psyche seam, ready-vs-live). *Deeper tutorial coming with the docs’ next tier; the contract above is complete and current.* # [Terminal hosting](#terminal-hosting) spt-core can *own* agent sessions in its own terminal layer: the daemon’s broker holds a real PTY per hosted session, which is what makes sessions supervisable, attachable from other machines, and immune to self-update. ## [What the broker holding the PTY buys](#what-the-broker-holding-the-pty-buys) - **spt-hosted startup** — spt-core spawns sessions itself from the manifest’s `[session.self]` template and binds them (`api bind`), instead of waiting inside someone else’s process tree. - **Remote attach** — a byte-stream viewport onto a live session from any paired node (compute and files stay on the hosting node). Restart-safe: reconnects resume the stream without gaps or duplicates. - **Input injection** — `send-keys`/`send-line` style injection per the adapter’s declared `[inject]` methods, respecting activity state (never disrupt a working agent). - **The live digest** — `spt endpoint digest ` shows an at-a-glance view of what a session is doing now (`--follow` streams changes), **projected from the endpoint’s normalized session logs** (the digest-record contract over `[history]`), never the PTY byte stream. Topology-independent — it works for a harness-hosted endpoint with no broker PTY. For scripted, turn-end consumption, `--json` adds an incremental cursor (v0.16.0): `--last ` reads the last N turns; closed-turn transcript entries (`Agent`/`ToolSprint`) carry a stable `seq` and each turn its `input_seq` — `Boundary`/`Context` entries never carry one, and a `partial` trailing turn’s entries carry none until it closes; `--after ` cursors over `seq`/`input_seq` and returns only what is newer. See the [integration checklist](#incremental-digest-consumption--the---json-cursor). - **Update immunity** — PTYs live in the broker, logic in the brain; a self-update swaps the brain while every hosted process and byte stream stays intact. Activity and idleness are always **reported** (`api state busy|idle`), never inferred from terminal quiescence — quiet terminals lie. ## [Commands](#commands-2) `spt endpoint digest` · the attach surface · [`spt api` injection-adjacent calls](#the-spt-api-surface). *Deeper tutorial coming with the docs’ next tier.* # [Networking & subnets](#networking--subnets) Zero-config, no-central-server connectivity between your machines. Join two nodes into a subnet once with a six-digit code; from then on, the same `spt send sergey` works whether sergey is local or three networks away. ## [The model](#the-model) - **Node identity** — each machine holds an Ed25519 keypair; the public key *is* its network identity. Connections are mutually authenticated QUIC, end-to-end encrypted, peer-to-peer with NAT hole-punching and public-relay fallback (you can self-host the relay, or disable it for LAN/air-gapped use — the default relays carry only encrypted traffic they cannot read). Nodes also carry a human **label** (the hostname by default): views render `HFENDULEAM (bcead52b…)`, and `@node` qualifiers accept the label or a key prefix — several machines sharing a label are never guessed between. - **Subnets** — machines join into named groups. A subnet shares: the endpoint registry (who exists, where, what state), context sync for its endpoints, notifications, and staged self-updates. Nothing is shared with nodes outside the subnet, ever. - **Joining** — a one-time, code-authenticated ceremony. On a member machine, `spt subnet show-code` prints the current six digits (and an `otpauth://` URI — put the seed in your authenticator app); on the new machine, `spt subnet join ` finds a member over LAN + relay, then prompts for the code and runs the exchange. Finding a member happens *before* you enter the code, so the code you type is always fresh at the moment of pairing — a slow search never causes a just-read code to be rejected, and re-entering a code after a typo retries the pairing only (it does not restart the search). The search shows elapsed time while it runs, and on failure reports why (add `--verbose` for a full diagnostic dump); `--code ` skips the prompt for non-interactive use. The code bootstraps a PAKE key exchange — the code is never the key, and a wrong guess learns nothing. Both sides pin each other’s node keys on success (trust-on-first-use; key changes warn and never auto-apply). Every member machine answers join attempts automatically — no arming step on the existing fleet. *(two-phase join since v0.17.0)* - **Elevation gates** — `subnet create` (reveals a fresh subnet’s joining secret) and `subnet join` (enrolls the whole machine) require an elevated terminal; `subnet status` is read-only and ungated, and never prints secrets. - **Visibility & sync scope** — per endpoint, per subnet: an endpoint can be hidden from a subnet (neither advertised nor routable) and its mind syncs only to subnets on its membership list. Both default conservative; unconfigured means *not shared*. - **Home subnet** — an endpoint is *homed* to exactly one subnet when it is created, and that home is permanent (it sets where the endpoint’s identity lives and its default sync scope). On a node in a single subnet the home is chosen automatically; on a node in **more than one** subnet, `spt endpoint run` requires `--subnet ` — interactively it proposes a most-recently-used default and asks you to confirm, and non-interactively it refuses with the subnet list rather than guessing. *(since v0.14.0)* - **Resource registry** — endpoints may advertise a free-text service blurb (`spt endpoint description set` to author; `spt endpoint list --detail` to browse) — an agent yellow-pages over visible rows only. ## [The walkthrough](#the-walkthrough) # Machine 1 (elevated): mint the subnet — prints the code, an otpauth:// # URI, and a terminal QR. spt subnet create home # Machine 2 (elevated): join it — searches LAN + relay, prompts for the code. spt subnet join home # Either side: who's in, and who's online. spt subnet status --nodes The [quickstart’s pairing section](#quickstart-two-agents-exchange-a-message) runs this same flow inside the two-agent demo. ## [Troubleshooting a join](#troubleshooting-a-join) A join searches for a member over every IP family your machine can actually reach. At startup the daemon probes IPv4 and IPv6 once and uses only the families that work — so a network that resolves IPv6 addresses but cannot reach them (a common half-broken setup) no longer silently consumes the whole search window. *(since v0.17.0)* - **See what happened.** `spt subnet join --verbose` prints, on failure, which IP families were usable, the time window it searched, how many attempts it made, and the last concrete error — enough to tell a dead subnet from a wrong code from a network problem. - **Force an IP family off.** Set `SPT_DISABLE_IPV6=1` (or `SPT_DISABLE_IPV4=1`) to make the daemon skip that family regardless of the probe — a deterministic override for a misbehaving network. Setting both is an error. The probe is automatic; reach for these only to pin behaviour. - **Quick discriminator.** If a join hangs only over the wider internet, check whether IPv6 reaches the relay: a working IPv4 path with a dead IPv6 one is the classic case the per-family probe handles for you. ## [What rides it](#what-rides-it) Cross-machine `send`/`ring`, registry replication, two-tier mind sync, remote attach, remote suspend/wake, file transfer, notification replication, and peer-propagated self-update — all over the same subnet substrate. ## [Commands](#commands-3) `spt subnet` (`status` · `create` · `join` · `show-code` · `notify` · `attach`/`detach` · `leave` · `prune`) · `spt endpoint list --detail` · `spt endpoint description` · the qualified addressing forms (`[subnet:]id[@node]`, where `@node` is a label or key prefix) — [CLI reference](#cli-reference). # [Harness contract](#harness-contract) The seam everything third-party builds against. spt-core contains zero harness-specific logic; a harness (or a driven surface) interfaces through exactly two things: 1. **The [runtime manifest](#manifest-reference)** — a declarative TOML file stating what varies for this harness: how to spawn sessions, which hook events fire which commands, how history is read, how the adapter updates. Command templates are opaque strings; spt-core fills `{key}` placeholders and runs them. 2. **The [`spt api` surface](#the-spt-api-surface)** — the imperative entry points the harness’s hooks fire to keep spt-core’s state honest: session started, went idle, hit a context boundary, ended. That’s the whole integration surface. An adapter is a manifest plus the harness’s own native extension points — there is no SDK to link, no daemon to embed, no protocol to speak beyond running `spt`. your harness spt-core ┌────────────────────┐ ┌──────────────────────────┐ │ hooks ─────────────┼── api … ───►│ perches · spools · │ │ (SessionStart, │ │ lifecycle · registry │ │ Idle, End, …) │ │ │ │ │◄────────────┼─ spawns [session.*] │ │ sessions │ templates │ templates, keys filled │ └────────────────────┘ └──────────────────────────┘ ▲ ▲ └────────── manifest.toml declares both seams ## [Where to go](#where-to-go) - **Start:** [the adapter quickstart](#quickstart-build-an-adapter) — take the reference mock adapter apart and drive the contract in minutes. - **Build it all:** the [integration checklist](#harness-integration-checklist) — every surface by necessity, mapped to the interaction lifecycle. - **Reference:** [manifest](#manifest-reference) · [`spt api`](#the-spt-api-surface) · [`manifest.schema.json`](https://sabermage.github.io/spt-releases/manifest.schema.json). - **Ship it:** [install-on-demand bootstrap](#install-on-demand-bootstrap) — how an adapter brings spt-core with it. - **Driven surfaces:** [Shells](#shells-1) — the `kind = "shell"` flavor of the same contract. Building adapters, shells, or integrations against this contract is **unrestricted and royalty-free** — see the [license split](https://github.com/SaberMage/spt-releases#license). # [Harness integration checklist](#harness-integration-checklist) A working list for building a harness against spt-core. The [adapter quickstart](#quickstart-build-an-adapter) gets one adapter breathing in ten minutes; this page is the *complete* surface — every manifest section and `spt api` command a harness touches, **grouped by how badly you need it**, each tagged with the **feature it buys** and **where in the interaction lifecycle** it fires. Two seams only (the [contract overview](#harness-contract)): the [manifest](#manifest-reference) (declarative TOML) and the [`spt api` surface](#the-spt-api-surface) (imperative entry points your hooks fire). Nothing here is an SDK call — everything is a manifest field or an `spt` invocation. > **The running example is [spt-claude-code](https://github.com/SaberMage/spt-releases)** — > the modern Claude Code harness rebuilt on spt-core (the v1 reference adapter). > Where a row says *“claude-code: …”* that is how that harness wires the > surface. Concrete commands below are real and shippable today; the shipped > harness-agnostic exercise is the [mock adapter](#quickstart-build-an-adapter). ## [The interaction lifecycle](#the-interaction-lifecycle) Every surface below belongs to one stage of a harness’s life with spt-core: REGISTER ─► START ─► RUN ─────────────► BOUNDARY ─► END ─► KEEP-CURRENT adapter perch messaging / context tear self-update add seed→ activity / clear / down + ripple listen history / inject compact --- ## [Group 1 — Required (no adapter exists without these)](#group-1--required-no-adapter-exists-without-these) The contract floor. Miss one and spt-core cannot host your sessions. | Surface | Feature it buys | Lifecycle stage | | --- | --- | --- | | **`[adapter]` manifest header** (`name`, `kind`, `version`, `min_spt_core_version`, `hostable_types`) | Identity + the compat gate spt-core reads *before* any install/update; declares which endpoint types you can host | REGISTER | | **`spt adapter add `** | Parses + schema-validates + records the manifest; a bad field is rejected here, nothing half-registers | REGISTER | | **`[adapter] host_binaries`** (harness-hosted) | The bind-time match-key — names the harness exe(s) you host, so `seed`/`listen` resolve your adapter with **no `--adapter`** (since v0.9.0). `--adapter ` stays available as an optional override | REGISTER | | **Startup pair — pick one flow:** • harness-hosted: `[hooks.SessionStart] → api seed --pid {parent_pid} --session-id {session_id}` then the session’s `api listen ` • spt-hosted: `[session.self]` template (spt-core spawns it) then `api bind --set-session-id ` | A registered, held perch — the thing messages and lifecycle attach to. `seed→listen` = you own the process; `spawn→bind` = spt-core owns it | START | | **`api session-end `** (or `api shutdown`, below) | Clean teardown that PRESERVES the spool + history so the next `listen`/`poll` drains the backlog | END | **claude-code:** `SessionStart` hook fires `api seed`; the Claude Code session runs `api listen` as its blocking listener (harness-hosted). `SessionEnd` fires `api session-end` (soft — context survives a `/clear` and a relaunch). --- ## [Group 2 — Recommended (the integration is hollow without them)](#group-2--recommended-the-integration-is-hollow-without-them) Skippable to *boot*, but the harness feels broken without them — no inbound messages, identity lost on a context reset, no activity signal. | Surface | Feature it buys | Lifecycle stage | | --- | --- | --- | | **`[hooks.Idle] → api state idle`** (and `api state busy`) | Honest activity — spt-core never infers idleness from terminal quiescence (it lies). Arms the echo gate, drives Psyche pulses + most-recently-active routing | RUN | | **`[inject]` channels** (`activity` / `idle`) + **`api poll --include-deferred`** | Inbound message delivery. Declares HOW spt-core reaches the agent (hook inject vs. pull-relay); `poll` is the pull path for hooks that can’t inject | RUN | | **Honest `can_inject` per hook** | Lets spt-core route around a hook that can’t surface text — the load-bearing harness-varying fact | RUN | | **`api boundary --to-session-id --session-id `** | The endpoint’s identity, spool, and history survive a context reset under a new session id. **The proof is the PRIOR sid** (`--to-session-id` is payload, not proof): persist the current sid at every SessionStart in adapter-owned state keyed by endpoint id, present it here; resolve the id from `$SPT_ENDPOINT_ID`; surface any refusal LOUDLY — a silent skip/refusal strands the perch on the dead sid (no delivery until relaunch). **Validate end-to-end: after a reset, assert the perch record’s session id actually ROTATED and a post-reset message DELIVERS** — a session that looks healthy can hide a stranded perch (see `api boundary` in api.md) | BOUNDARY | | **`api psyche-download `** (fire from SessionStart, inject its stdout) | The agent resumes **with its mind** — pulls the durable two-tier context (role / live / project) **plus** any not-yet-synthesized commune as `` slices, for the hook to inject as additional context. `api boundary` makes the mind *survive* a reset; this is how the next session reads it **back in**. Without it, a resumed session starts blank of its accumulated context | BOUNDARY / START | | **`[history]` strategy** (`fetcher` / `locate_normalize` / `native` + `api history-log`) | spt-core can read the session transcript — feeds the live digest and mind sync | RUN | | **`[identity]`** (`session_id_source`, `parent_ancestor_name`) | Post-spawn id resolution when the harness mints the session id itself | START | | **`[env.*]` bridge** (e.g. `OWL_SESSION_ID`) | The session learns its own endpoint id / context the harness must inject | START | | **`[update]` avenue + command** | Ripple-update: spt-core refreshes your adapter alongside its own self-update (REQ-UPD-5); also the install-on-demand bootstrap | KEEP-CURRENT | | **`[update.post]` post-step** (since v0.16.0) | A delegated step that runs **after** the primary avenue resolves, under the same `spt adapter update` — pull the `.spt` **and** run an in-harness sync from one lever. Runs unconditionally; reads a published JSON line on stdin (`adapter_applied`, `version`, `previous_version`, `adapter_dir`, …); its stdout decides the post-update notice (custom text supersedes `[update].message`, the sentinel `!!update-message!!` fires it, empty is silent); failure-isolated (warn + fall back, never rolls back the pull) | KEEP-CURRENT | **claude-code:** `Idle` hook → `api state idle`; messages arrive over the hook inject channel (`can_inject = true`), pull-relay fallback when busy. `PreCompact`/clear hooks → `api boundary`; the `SessionStart` hook also runs `api psyche-download ` and injects stdout, so a session resumes with its accumulated two-tier mind (plus any pending commune). `[history] strategy = "fetcher"` (Claude Code’s transcript is a binary the fetcher reads). `[update] avenue = "delegated"`, `command = "claude plugin update spt"` — the harness’s own updater is the avenue. --- ## [Group 3 — Optional (capability-specific)](#group-3--optional-capability-specific) Reach for these when the capability applies; ignore them otherwise. | Surface | Feature it buys | Lifecycle stage | | --- | --- | --- | | **`api shutdown `** | Graceful signoff — runs the final echo-commune BEFORE teardown so the context delta is never lost to ordering | END | | **`api presence ` / `api driven-by `** | Most-recently-active resolution across the subnet; lets a session tell local input from remote-drive | RUN | | **Workers** (`api worker-start ` — the worker id is core-minted `-w`, read it from stdout; `worker-poll `/`worker-stop ` auth by the parent’s session id, no token — breaking change in v0.27.0) | Nested, short-lived sub-agents under a parent endpoint | RUN | | **`[digest]` extractor** (or `api digest-entry`) | A live activity digest (`spt endpoint digest`) — declare an extractor mapping your native log → the `{role, text, tool, ts}` contract (ADR-0019; its OWN seam, no longer riding `[history]`). Spans `/clear` via the session ledger; validate with `spt adapter digest-proof`. **Classify a delivered user-facing message as a turn-opening `input` record** (see below) so the v0.16.0 `--last`/`seq` cursor keeps its granularity | RUN | | **`[session.notif]` template** | Native OS notification render (toast / shell alert) for consent + capability prompts, instead of burying them in agent output | RUN | | **`[session.resume]` template** (spt-hosted) | The **native-resume** sibling of `[session.self]`: spt-core picks it over `[session.self]` when a bringup carries a prior session (`spt endpoint run --resume`, or the picker’s *Resume from history*). Declare your harness’s native-resume verb (e.g. `claude -r {session_id}`) — **skip it and a resume re-runs the fresh command → a blank transcript.** spt-core lands the PTY in the session’s recorded project cwd (a harness resolves a transcript by `session_id` + cwd) | START | | **`[message-idle-translation-binary]`** (spt-hosted) | A lifecycle-managed `stdin→stdout` JSON-lines binary that turns inbound `` messages into keystroke-commands spt-core applies to the PTY **atomically** (coexists with a live `spt rc` controller). The agnostic way to deliver messages into an **idle** spt-hosted session; busy delivery stays your `[inject]` hook path. Declare it with a `command` (program + args; adapter-static `{adapter_dir}`/`{adapter_name}` subst only — **no** session keys; new in v0.16.0, the bare `path` is deprecated). Validate the emit contract with `spt adapter translate-proof` | RUN | | **`[adapter] shortcut_basename`** | Names the picker-generated project-root launcher `-` (the `spt endpoint run` `s` keybind) — your harness’s brand instead of the `spt-` default | START | | **Shell surfaces** (`kind = "shell"`: `api bind-shell --link`, `api emit`, `api owner-shutdown`, the `[shell]` body) | Driven surfaces — notifiers, sensors, power buttons — authenticated by the launch link token alone. See [Shells](#shells-1) | START / RUN | **claude-code:** uses `api shutdown` for graceful `/signoff`; declares a `[digest]` extractor mapping its per-session JSONL → the digest-record contract so `spt endpoint digest` shows live tool calls and spans `/clear`; declares `shortcut_basename = "cc"` so the picker’s generated launcher is `cc-` (vs the `spt-` default); declares `[session.resume]` as `claude -r {session_id} …` so a picker *Resume from history* reloads the real transcript (not a blank session); declares a `[message-idle-translation-binary]` (`cc-spt-idle-translate`) so inbound messages reach an idle session as proper keystrokes; no shell body (it is a harness, not a driven surface). --- ## [Group 4 — Beyond the API: integrations that make it good](#group-4--beyond-the-api-integrations-that-make-it-good) Not contract surfaces — no `api` command, no required field — but the difference between an adapter that *works* and one that feels native. **Strongly recommended.** | Integration | What it is | Why it matters | | --- | --- | --- | | **Commune / signoff file-drops** | The agent writes `-commune.md` (delta context) or `-signoff.md` (final save) into the manifest’s watched `commune_dir` / `signoff_dir`; spt-core’s watcher ingests it. **Delivered as a file-drop by design.** | The two-tier mind: live + project context survives `/clear`, `/compact`, suspend, and cross-node resume. The single biggest continuity win — wire the directory watch and read the contract filename | | **Resource advertisement** (`[session] resources` blurb / `spt endpoint description`) | A free-text “what I can serve” string riding the endpoint’s registry rows | Other agents discover the endpoint’s capabilities (`spt resources list`) instead of guessing | | **Install-on-demand bootstrap** | Pack the check-and-install of spt-core into your harness’s first run (the [bootstrap pattern](#install-on-demand-bootstrap)) | Zero-friction first run — the user installs your harness, spt-core comes with it | | **Surfacing `spt how-to ` to the agent** | Let the agent read task-oriented spt-core guidance from the binary itself | The agent self-serves common operations (subnet join, sending) instead of asking the user | | **Presence-driven idle reporting** | Fire `api state idle` from a *real* user-inactivity signal, not a timer | Accurate dormancy → Psyche wakes on genuine activity, echo-communes fire at true boundaries | **claude-code (the worked example):** ships the modern two-tier mind end to end — the session drops `-commune.md` at every `/clear` and `/compact`, and a Self-authored `-signoff.md` at graceful stop, into the watched `commune_dir`; declaring `[session.psyche_init]` promotes the endpoint to a LiveAgent (a **go-live gate** — spt-core never spawns it), and the `[session.psyche_resume]` per-event turn (+ the `[session.echo_commune]` template) lets spt-core drive the Psyche that ingests them; `[update] avenue = "delegated"` makes the Claude Code plugin updater the ripple avenue. That is the bar a native-feeling harness clears. --- ## [Patterns introduced in v0.16.0](#patterns-introduced-in-v0160) ### [Hook dispatch by resolve-not-execute](#hook-dispatch-by-resolve-not-execute) spt-core never grows a hook-**execution** surface — `[hooks.]` stays purely outbound (the harness fires `fires`; spt-core never runs a hook handler). When your hook *logic* must live in an adapter binary (so it rides `spt adapter update`) but the harness loads hooks from a static plugin dir, use the two adapter-static substitution keys to resolve+run **your own** binary: - **`{adapter_dir}`** fills to your install dir (the registry `source_dir`) and **survives updates**; **`{adapter_name}`** fills to your adapter name. Both are available wherever substitution runs — including, new in v0.16.0, **inside `[strings]` values at `get-string` read time** (scoped to *just* these two adapter-static keys; `get-string` has no session context, so `{id}`/ `{session_id}` are not available there). - Store the dispatch command in `[strings]`: [strings] hook_cmd = "{adapter_dir}/claude-spt hook" - A thin, static per-OS dispatch wrapper (the one plugin-resident piece) runs `spt adapter get-string hook_cmd` **once per session** (memoize the resolved string into an env var for a hot-path hook like PostToolUse), then executes the resolved command per-hook itself. spt-core only **resolves and returns** the string — it never executes it (ADR-0029). **claude-code:** the plugin ships a static `hooks.json` + a per-OS dispatch wrapper; the wrapper resolves `get-string claude-spt hook_cmd` → `/claude-spt hook` once per session and runs it per-hook, so all hook logic updates via `spt adapter update claude-spt`. ### [Incremental digest consumption — the `--json` cursor](#incremental-digest-consumption--the---json-cursor) `spt endpoint digest --json` supports turn-end incremental consumption (v0.16.0): `--last ` (the last N turns; `--last 1` = the latest turn), a stable per-entry **`seq`** (source-derived — re-projection yields the same `seq`; it does not renumber when the window slides), and `--after ` (entries newer than `seq` still in the window; a full-window refresh + a predates signal if `seq` has fallen out). The trailing in-progress turn is flagged `partial: true` and its entries carry no stable `seq` until the turn closes (a turn is bounded by a user-input) — a consumer reprocesses `partial` and skips entries `<= seq`. `seq` is the authoritative dedup + cursor key. #### [The `--json` output shape](#the---json-output-shape) The snapshot is one pretty-printed JSON object; `--follow --json` emits one **compact** JSON object per line (a delta stream). Shapes as of v0.26.0: { "turns": [ { "input": "fix the bug", "input_seq": 4294967296, "entries": [ { "Agent": { "text": "on it", "seq": 4294967297, "ts": "2026-07-06T09:00:00Z" } }, { "ToolSprint": { "tools": [ { "name": "Write", "arg": "src/a.rs" } ], "seq": 4294967298 } }, { "Boundary": { "kind": "clear", "ts": "2026-07-06T09:05:00Z" } }, { "Context": { "kind": "owl_message", "body": "ping", "ts": null } } ] } ] } - **Turn**: `input` is the opening user-input text, `null` for a preamble turn (boundary/context entries that precede any input). `input_seq` and `partial` are **omitted** when absent/false — a trailing open turn carries `"partial": true` and its entries carry no `seq`. - **Entries are tagged by kind** — each entry is a one-key object whose key is the kind. The closed kind set: `Agent` (`text`, optional `seq`/`ts`), `ToolSprint` (`tools`: `{name, arg}` in order, `arg` presentation-truncated; optional `seq` = the **last** collapsed record’s, optional `ts`), `Boundary` (`kind`: `clear` | `compact` | `boot`), and `Context` (`kind`: `psyche_download` | `echo_mirror` | `owl_message`, plus `body`). `Agent`/`ToolSprint` omit `seq`/`ts` when absent; `Boundary`/`Context` carry no `seq` (they are spt-injected, not transcript records) and their `ts` is present-but-`null` when unknown. - **A delivered message can appear twice by design**: as the turn-opening `input` (your extractor’s classification, below) *and* as a `Context`/`owl_message` row whose `body` is the **whole composed `` envelope verbatim** — exactly what the agent saw. Parse it with the [envelope rules](#the-event-wire-contract); it is the message-identity anchor for dedup across the two appearances. - **`--after` signal**: when the cursor predates the window, the snapshot is the full window plus a top-level `"after_predates_window": true`. - **stderr trailer**: every successful pull prints `DIGEST: version=` on stderr (the version pairs with the delta stream below); an endpoint with no activity buffer reports `NO_DIGEST:` and exits non-zero. - **`--follow --json` delta lines**: `{ "version": , "from": , "turns": [ … ] }` — apply by truncating your view to `from` turns and appending; `from == 0` is a full replace (the base snapshot, or a window slide). Deltas are sent only when the digest actually changed. **Binding for your `[digest]` extractor / `api digest-entry`:** classify a **delivered user-facing message as a turn-opening `input` record** (equivalent to a direct PTY user-input). The projection treats `role: "input"` as the turn boundary; if messaging-delivered turns are not opened as `input`, a messaging-driven session collapses into a few giant turns and `--last`/`seq` lose granularity. *What* becomes `input` is your call; *that it opens a turn* is the contract. ### [Global `--json` for read/status commands](#global---json-for-readstatus-commands) The read/status command set (`endpoint list`/`whoami`, `daemon status`, `subnet status`/`show-code`, `endpoint description`/`role`, `adapter list`/`version`, `notif list`, `grant list`, `access list`, `shell list`, `how-to`) honors a global **`--json`** flag (v0.16.0) for scripted consumption — stable, explicit per-command field names (a committed wire-parity surface). Action commands ignore it. Flag reference: the [CLI reference](#cli-reference). Committed compatibility posture for every `--json` shape: **additive evolution** — new fields appear (often omitted-when-absent), existing fields are never renamed or re-typed; parse tolerantly (ignore unknown keys). #### [`endpoint list --json` — the output shape](#endpoint-list---json--the-output-shape) One object, three sections (as of v0.27.0): { "self": { "id": "doyle", "status": "live_agent", "ready": true, "alive": true, "unbound": false, "description": null, "psyche_host_error": null }, "subnets": [ { "name": "home", "endpoints": [ { "id": "flynn", "node": "1a2b3c…", "node_label": "HFENDULEAM", "status": "Active", "resources": null, "endpoint_type": "live_agent", "project": "spt-mobile" } ] } ], "local": [ { "id": "doyle", "state": "live_agent", "address": "127.0.0.1:52110", "ready": true, "alive": true, "unbound": false, "project": "spt-core" } ] } - **`self`** — the calling session’s own endpoint, `null` when the session has no perch. `status` is the local perch state token (`live_agent`, `ready_agent`, …; `null` with no local perch), `ready`/`alive` likewise `null` for a pinless session. `description` is the endpoint’s authored description or `null`. Two fault annotations are **omitted entirely when absent**: `psyche_host_error` (string) and `translation_fault` (string) — presence means the human view shows the same fault line. - **`subnets`** — one group per subnet the node belongs to, `endpoints` from the subnet’s gossip projection. `status` is the ADVERTISED cross-node state, closed set: `Active` | `Dormant` | `Suspended` | `Offline`. `node` is the hosting node’s key prefix, `node_label` its display label (or `null`). `resources` is the endpoint’s advertised description string (or `null`). `endpoint_type` (`live_agent`, `ready_agent`, …) and `project` (latest project id) are **omitted when absent** — older rows may not carry them. - **`local`** — this node’s perches from the roster. `state` is the same token set as `self.status`; `address` is the listener address (or `null`); `project` omitted when absent. - **Filters apply before serialization**: worker endpoints are excluded from all three sections by default (v0.27.0) — pass `--workers` to include them; suspended rows honor `--all` the same way. `spt whoami --json` emits this same shape with the default filters. --- ## [“Am I done?” — the floor](#am-i-done--the-floor) - [ ] Manifest validates against [`manifest.schema.json`](https://sabermage.github.io/spt-releases/manifest.schema.json) - [ ] `[adapter]` header complete (`name`, `kind`, `version`, `min_spt_core_version`, `hostable_types`) - [ ] One startup flow wired: `SessionStart → seed` + `listen` (harness-hosted) **or** `[session.self]` + `bind` (spt-hosted) - [ ] (harness-hosted) `[adapter] host_binaries` names your harness exe(s) so `seed`/`listen` resolve with no `--adapter`; `spt adapter use ` sets the active default when several adapters host the same binary - [ ] `api state idle` fires on real inactivity; `can_inject` values are honest - [ ] An inbound delivery channel is declared (`[inject]`) or pulled (`api poll`) - [ ] `[history]` strategy chosen; `api boundary` wired for clear/compact - [ ] (mind continuity) SessionStart fires `api psyche-download` and injects its stdout, so a resumed session gets its durable context back - [ ] (for a live digest) `[digest]` extractor declared + `digest-proof`-checked, or `api digest-entry` push - [ ] (spt-hosted, if your harness resumes by id) `[session.resume]` declares the native-resume command — else a resume comes up blank - [ ] (spt-hosted, for idle message delivery) `[message-idle-translation-binary]` declared + `translate-proof`-checked, or accept the degenerate `payload+enter` inject - [ ] `[update]` avenue declared (ripple-update + install-on-demand) - [ ] Teardown fires `api session-end` (or `api shutdown` for graceful signoff) - [ ] **Recommended:** commune/signoff directory watched (mind continuity) - [ ] `spt adapter add ./your-adapter` registers clean; `api … capability` echoes your `hostable_types` ## [Next](#next-2) - **Reference:** the complete [manifest reference](#manifest-reference) and [`spt api` reference](#the-spt-api-surface). - **Ship it:** the [install-on-demand bootstrap](#install-on-demand-bootstrap). - **Driven surfaces:** [Shells](#shells-1) — the `kind = "shell"` flavor of this same contract. # [Manifest reference](#manifest-reference) The runtime manifest is the declarative half of the harness contract: one TOML file per adapter, declaring **only what varies per harness or shell**. This page is the complete field reference. Machine-readable companion: [`manifest.schema.json`](https://sabermage.github.io/spt-releases/manifest.schema.json) — generated from the exact code that parses manifests, so it never drifts. Validate your manifest against it, then `spt adapter add` enforces the cross-field rules listed at the bottom. ## [The principle](#the-principle) **SPT is not a harness.** Command templates are opaque strings — spt-core never parses out a model, tool list, or flag; the adapter writes the full command line and spt-core runs it with `{key}` substitution placeholders filled. Anything spt-core owns is *not* in the manifest: - **Sentinels** (idle markers, the echo gate) — managed via `spt api state` / `spt api echo-gate`; adapters only call them. - **Spool, registry, perch, and daemon-state schemas.** - **The event-block vocabulary** — the tags spt-core surfaces to agents are a fixed, documented constant. Adapters pass spt-core’s output through unchanged. - **File-drop filenames** — statically `-commune.md` / `-signoff.md`; only the watched *directory* is declared. - **Config knobs** (pulse period, summarizer windows, …) — global spt-core settings with per-endpoint overrides, never per-adapter. ## [Substitution keys](#substitution-keys) The full `{key}` vocabulary spt-core fills into command templates. A role’s `keys` list must be a subset of this catalog, and every `{placeholder}` in a `command` (or `cwd`/`source`/`fires`/…) must resolve to a value spt-core supplies for that spawn — an unknown or unprovided key fails with a one-line error. Not every key exists in every context: spt-core fills only those relevant to the spawn (e.g. `{psyche_*}` only for a live agent’s Psyche role, `{source}` only for a `[digest]`/`[history]` extractor). | Key | spt-core fills it with | | --- | --- | | `{id}` | The endpoint id being hosted. For a **Psyche** role this is the **parent endpoint id** (the LiveAgent being hosted), not the nested `-psyche` perch id. | | `{adapter_name}` | The adapter’s declared `name` (the value every `api` call carries). | | `{adapter_dir}` | The adapter’s install dir (the registry record’s `source_dir`) — adapter-static, available wherever substitution runs (every `[session.*]`, `[digest]`, the translation binary, and lazy `[strings]`). Survives updates; lets a command point at the adapter’s own packed binary (resolve-not-execute, ADR-0029). | | `{session_id}` | The harness session id (minted at spawn; reported back via `api seed`). For a **Psyche** role this is the Psyche’s **own** custody session id — its own conversational thread, which a parent boundary (`/clear`, `/compact`) does not rotate — never the parent’s. | | `{parent_session_id}` | The **parent** session id, exposed under its own explicit key so a Psyche role template that needs the parent’s id never aliases `{session_id}` (which on a Psyche spawn is the Psyche’s own custody id). | | `{session_name}` | The session’s display name, when one is supplied. | | `{node}` | This node’s **advertised label** — the value the roster and picker render (its OS hostname, read into the label store at daemon startup), never the pubkey. Node-static: available wherever the session keys populate **and** in lazy `[strings]` resolution. **Single-token fill only** (a space-carrying label stays adapter-shim territory); when no label is known the key is left unfilled so a referencing template fails loudly, never an empty token. Note: the daemon-side lifecycle resolves it once at startup while a CLI-originated spawn reads a fresh hostname, so the two differ only across a mid-life hostname change. | | `{subnet}` | This endpoint’s **home-subnet label** (`local` when unhomed), filled whenever it is known so a nested Psyche turn need not resolve a `--subnet` it cannot know. A single `{subnet}` concept — there is deliberately no `{home}` key. When no subnet is known the key is left unfilled so a referencing template fails loudly (the `{node}` precedent). | | `{parent_pid}` | The harness parent process pid — the SessionStart `api seed` anchor. | | `{agent_type}` | The hosted agent type. | | `{psyche_context_file}` | The **path** to the file spt-core writes the Psyche’s carried context into before each turn (never the context body on the argv — a large mind would exceed the command-line length cap). Fresh vs continue is the file’s **content**: a fresh/reseeded turn writes it non-empty, a continue turn writes it 0-byte. Replaces the former `{psyche_context}` body key. | | `{link_token}` | A shell-link capability token (shell adapters). | | `{source}` | The transcript/log path spt-core resolves for a `[digest]`/`[history]` extractor. | ## [`[adapter]` — header (required)](#adapter--header-required) The only mandatory section, and it must be readable *before* any install or update — `min_spt_core_version` is the compatibility gate. [adapter] name = "my-harness" # the adapter_name; an optional --adapter override kind = "harness" # "harness" (default) | "shell" version = "1.0.0" min_spt_core_version = "1.0.0" # lowest spt-core this adapter tolerates hostable_types = ["LiveAgent", "ReadyAgent", "Worker"] host_binaries = ["my-harness"] # harness exe(s) you host → bind-time resolution, no --adapter | Field | Required | Meaning | | --- | --- | --- | | `name` | yes | Adapter id; the value an optional `--adapter ` override carries | | `kind` | no (default `harness`) | `harness` hosts agents; `shell` provides a driven surface | | `version` | yes | The adapter’s own version | | `min_spt_core_version` | yes | Compat gate, checked before install/update | | `hostable_types` | no | Endpoint types this adapter can host | | `host_binaries` | no (harness) | Harness exe basenames you host — the bind-time match-key so `seed`/`listen` resolve with no `--adapter` (since v0.9.0). Matched on **lowercase + stem-before-first-dot**, so `claude` matches `claude`/`claude.exe`/`claude.cmd`/`claude.exe.old.` (a self-update can rename the running exe); a declared name must not contain a dot | ## [`[hooks.]` — inbound hook table](#hooksevent--inbound-hook-table) One entry per harness event, declaring the `spt api` command it fires, the input fields it maps in, and whether the hook can surface text into the agent’s context. [hooks.SessionStart] fires = "api seed --pid {parent_pid} --session-id {session_id}" # adapter-agnostic since v0.9.0 reads = ["session_id", "parent_pid"] can_inject = true [hooks.Stop] fires = "api state idle" can_inject = false # no inject channel -> sentinel/relay fallback | Field | Required | Meaning | | --- | --- | --- | | `fires` | yes | Opaque `api …` command line the harness invokes for this event | | `reads` | no | Input fields (e.g. from the hook’s stdin payload) mapped into the command | | `can_inject` | no (default `false`) | Whether this hook can inject context back to the agent. When `false`, spt-core falls back to its sentinel + relay/poll path instead of expecting injection | `can_inject` is the single most load-bearing harness-varying fact — declare it honestly per hook. ## [`[session]` — watched dirs + role templates](#session--watched-dirs--role-templates) Two watched-directory keys sit directly on `[session]`; the file *names* are fixed by spt-core, only the directory varies: [session] commune_dir = ".my-harness" # watched for -commune.md signoff_dir = ".my-harness" # watched for -signoff.md Commune and signoff are **file-drops, not commands** — an agent writes a markdown file; spt-core’s watcher does the rest. ### [`[session.]` — outbound templates](#sessionrole--outbound-templates) One opaque command template per role. Model, tools, flags, permissions — all live inside `command`, never as separate fields. Roles: `self` (the agent’s own session) · `resume` (the agent’s own-session **native resume**, the `self` sibling) · `psyche_init` (**go-live gate only** — its presence promotes the endpoint to a LiveAgent; spt-core never spawns it) · `psyche_resume` (the **sole driven** Psyche role — one bounded per-event turn) · `echo_commune` (the bounded history summarizer for sessions that end without a signoff) · `signoff` (final context save) · `notif` (endpoint-native notification render). **Resuming an existing harness session (since v0.13.0).** `[session.self]` is the *fresh* bringup; `[session.resume]` is the **native-resume** sibling. spt-core selects `[session.resume]` over `[session.self]` only when a bringup carries a prior session (`spt endpoint run --resume `, or the picker’s *Resume from history*) **and** your manifest declares the role. Declare it with your harness’s native-resume verb — if your harness resumes a transcript by id, use that form (Claude Code: `claude -r {session_id} …`), **not** the fresh create-session form. Skip the role and a resume silently re-runs `[session.self]` (a *fresh* session → a blank transcript). spt-core fills the SAME key catalog as `self` (`{id}`, `{session_id}` = the **resumed** id, `{session_name}`, `{adapter_name}`) and lands the PTY in the session’s recorded **project cwd** (a harness resolves a transcript by `session_id` **+ cwd**) — the per-session ledger row’s cwd, else the endpoint’s bind cwd, else the current dir. [session.resume] command = "my-harness resume --session {session_id} --id {id}" keys = ["session_id", "id"] # Go-live gate ONLY — spt-core never spawns this; its presence makes the endpoint live. [session.psyche_init] command = "my-harness run --agent psyche --model cheap" # The role spt-core actually drives — one bounded turn per Psyche event. [session.psyche_resume] command = "my-harness run --agent psyche --resume {session_id} --model cheap" env_remove = ["MY_HARNESS_SESSION_ID"] recursion_guard_env = "SPT_ECHO_COMMUNE" keys = ["session_id", "parent_session_id", "psyche_context_file", "subnet"] A Psyche runs as a **bounded per-event turn**, not a resident process (there is no psyche pid to poll — liveness is that turns succeed). Declaring `[session.psyche_init]` is the **go-live signal only** — spt-core **never spawns it**; the per-event turn drives **`[session.psyche_resume]` exclusively**. For that turn spt-core fills `{session_id}` (the Psyche’s **own** custody id — see the key table), `{parent_session_id}`, `{psyche_context_file}`, and `{subnet}` (when known); the adapter-static/node keys `{id}` (the **parent endpoint id**), `{adapter_dir}`, `{adapter_name}`, and `{node}` are also available. It does **not** fill `{session_name}` (a `[session.self]` key). Declaring a key your role’s spawn isn’t given fails at spawn, so template only the keys spt-core fills for the role. **Shipped binaries resolve from the install dir (since v0.8.0).** A command template’s bare program token (its first token, e.g. `my-harness-digest`) resolves against the adapter’s **install dir** before `PATH`, so a `.spt` that ships its own binaries is self-contained — no PATH placement needed. spt-core runs `/` (on Windows also trying the `.exe` suffix) when that file exists, else falls back to `PATH`. The install dir is where your adapter was registered (the `--release`/`--github` durable home, or the copy-mode source dir). This applies to the `[session.psyche_resume]` per-event turn, the [`[digest]`](#digest--session-digest-extractor) extractor, and `spt adapter digest-proof`. Ship a binary in your `.spt` and reference it by bare name; you need not place it on `PATH`. | Field | Required | Meaning | | --- | --- | --- | | `command` | yes | Opaque command line with `{key}` placeholders | | `cwd` | no | Working directory (substitutable) | | `recursion_guard_env` | no | Env var set on summarizer children so *their* hooks bail (no summarizer-of-summarizer loops) | | `detach` | no (default `false`) | Spawn detached | | `env_remove` | no | Env vars stripped from the child’s inherited environment | | `keys` | no | The substitution keys spt-core fills for this role | `notif` is the endpoint-native notification render — an OS toast, a status LED, anything the adapter can run. Spawned detached when a notification surfaces at this endpoint. Keys spt-core fills: `{notif_id}`, `{notif_from}`, `{notif_subnet}`, `{notif_body}`. [session.notif] command = "powershell -Command New-BurntToastNotification -Text '{notif_from}','{notif_body}'" keys = ["notif_id", "notif_from", "notif_subnet", "notif_body"] ## [`[env.]` — env-var table](#envvar--env-var-table) Vars to inject into (or read from) sessions, and how. The injection channel is asymmetric by hosting mode: **spt-hosted** sessions inherit env from the broker that spawned them (no channel needed); **harness-hosted** sessions need the harness’s declared channel. [env.MY_HARNESS_SESSION_ID] direction = "inject" # "inject" | "read" value = "{session_id}" # required for inject channel = "MY_ENV_FILE" # harness-hosted only ## [`[history]` — transcript access](#history--transcript-access) How spt-core reads a session’s conversation history (it powers the echo-commune summarizer). Three strategies; pick exactly one: [history] strategy = "fetcher" # "fetcher" | "locate_normalize" | "native" fetcher = "my-harness-history --session {session_id}" | Strategy | Required fields | Meaning | | --- | --- | --- | | `fetcher` | `fetcher` | spt-core runs your binary; it emits normalized history | | `locate_normalize` | `locate_template`, `normalize_command` | spt-core locates the raw transcript, then runs your normalizer over it | | `native` | — | The adapter pushes via `spt api history-log`; spt-core stores it | spt-core has **no built-in transcript parser for any harness** — the adapter always owns that knowledge. ## [`[digest]` — session-digest extractor](#digest--session-digest-extractor) The session digest’s own seam (ADR-0019) — separate from `[history]`, which stays opaque and single-session for the echo-commune. `[digest]` declares an **imperative extractor** that maps your harness’s native log to the digest-record contract: [digest] extractor = "my-harness-digest --session {session_id} --in {source}" source = "~/.my-harness/{session_id}.jsonl" # optional; defaults to [history].locate_template window_turns = 5 # optional presentation defaults you declare… arg_truncation = 40 # …any consumer may override at pull/subscribe sprint_collapse = true | Field | Required | Meaning | | --- | --- | --- | | `extractor` | yes | Opaque command: native log → contract JSONL (one record/line). Under `locate_normalize` spt-core fills `{source}` with the resolved path and pipes the bytes on stdin; under `fetcher` it just runs the command and reads stdout. | | `strategy` | no | Which side locates the transcript, mirroring `[history]` — `locate_normalize` (default) or `fetcher`. See the strategy table below. | | `source` | no (locate_normalize only) | Own-source log path; absent, reuse `[history].locate_template`. Under `locate_normalize` one of the two **must** resolve, else `spt adapter add` rejects (see [Cross-field rules](#cross-field-rules-spt-adapter-add-enforces-these)). Ignored under `fetcher`. | | `window_turns` / `arg_truncation` / `sprint_collapse` | no | Adapter-declared presentation **defaults**; any consumer may override. spt-core fallback: `3` / `25` / collapse-on. | `[digest]` supports the same two locate strategies as `[history]` — pick with `strategy`: | Strategy | Who locates the transcript | `source` | | --- | --- | --- | | `locate_normalize` (default) | **spt-core** resolves the single `source` file, reads it, pipes the bytes to the extractor on stdin. | Required (own `source` or inherited `[history].locate_template`). | | `fetcher` | **The adapter’s** extractor locates + reads + emits; spt-core runs it bounded and consumes stdout — no `source`, no pre-read. | Not used. | Use `fetcher` when the transcript lives in a **partitioned** layout spt-core cannot name with one template — e.g. Claude Code’s `projects//.jsonl` or a date-globbed rollout tree. spt-core feeds the extractor only the harness-**neutral** inputs it owns — `{session_id}`, the perch-bound `{cwd}`, and any captured [`[env] direction = "read"`](#envvar--env-var-table) vars (e.g. `{CLAUDE_CONFIG_DIR}`) — never a harness-specific project slug; the extractor globs the unique `{session_id}` under the root itself: [digest] strategy = "fetcher" extractor = "my-harness-digest --session {session_id} --config-dir {CLAUDE_CONFIG_DIR} --cwd {cwd}" # no `source` — the extractor locates the file Why a command, not a declarative map: real harness logs are nested (one line → many entries, mixed block lists, types to filter); a flat map can’t express them. A **log-less** adapter declares no `[digest]` and pushes via `spt api digest-entry` instead. Validate before shipping with `spt adapter digest-proof --sample `. `digest-proof` fills the same `{id}` and `{session_id}` the runtime `endpoint digest` does, so a `{session_id}`-templated extractor (e.g. `--session {session_id} --in {source}`) proofs exactly as it runs live; pass `--session ` to pin a specific session id. ## [`[inject]` — input-injection methods](#inject--input-injection-methods) How text can be put in front of the agent, per activity state. Any combination of `pty`, `hook`, `relay`, `http`: [inject] activity = ["hook"] # non-disruptive while the agent is working idle = ["pty", "hook"] ## [`[message-idle-translation-binary]` — spt-hosted idle delivery](#message-idle-translation-binary--spt-hosted-idle-delivery) **Opt-in, spt-hosted only (since v0.13.0).** An adapter’s **idle-delivery translation binary**: a pure `stdin → stdout` JSON-lines filter spt-core lifecycle-manages (spawned when the spt-hosted endpoint comes up, terminated when it goes down). spt-core feeds it the inbound `` message feed and reads back keystroke-commands, which it applies to the broker-held PTY **atomically** — a live `spt rc` controller’s input is buffered during the emitted sequence and flushed after, so idle injection coexists with an attached operator (spt-core owns every PTY write). **Idle delivery only** — busy / mid-turn delivery stays your `[inject]` hook path. Declared as a **table** carrying a `path` scalar (a table can’t be silently absorbed by a preceding section and stays extensible): [message-idle-translation-binary] path = "cc-spt-idle-translate" # the binary spt-core spawns + drives - **stdin** (spt-core → binary, one JSON object per line): `{"type":"init","endpoint_id":…,"node":…}` first · `{"type":"event","envelope":""}` per inbound message (the `` envelope) · `{"type":"input"}` — a **content-free** ping each time the operator types, so the binary can track user-idle (the PTY input content is **never** duplicated to the binary). - **stdout** (binary → spt-core, one per line): `{"key":"ctrl+s"}` · `{"delay_ms":50}` · `{"text":""}` · `{"key":"enter"}` · `{"commit":true}`, … (extensible vocabulary). - **`{"commit":true}` is the mandatory sequence terminator — and you MUST send it for EVERY `{"type":"event"}`.** While your emitted sequence is in flight, spt-core buffers a live `spt rc` controller’s keystrokes (the *inject floor*) and applies your commands to the PTY atomically; `{"commit":true}` — emitted as the **last** record — releases that floor and flushes the buffered controller input *after* your sequence. The submit keystroke is **not** the terminator: `{"key":"enter"}` (or a trailing `\r` inside a text payload) submits the input, but a choreography may keep typing *after* it (e.g. a stash/restore that presses a key after submitting), so commit is a distinct, explicit signal you always send last. **An empty response is a protocol violation:** even when you have nothing to inject (an event with nothing armed, or an event without an envelope), you MUST still answer with at least a bare `{"commit":true}` — a response of zero records is treated as a missed commit. - **Missed commit → the sequence is tolerated, not fatal.** If no `{"commit":true}` arrives within the **commit deadline (5 s)**, spt-core still flushes the buffered operator input (never stranded) and re-spools that one message once so it is not lost — but it does **NOT** terminate a healthy binary. A single miss is tolerated; the binary is preserved and the next event delivers through it as normal. Only after **3 consecutive** missed commits (a genuinely wedged binary), or a real binary death, does spt-core fault the binary — and even then it **bounded-eager-respawns** it (a healthy commit resets the budget) rather than leaving it permanently dead, surfacing the fault on the endpoint’s status while it is degraded. (This supersedes the pre-v0.14.3 “falls back to a raw inject” behavior — raw inject was removed; a missed commit never types your payload raw.) - Unknown fields are **not** rejected here — a newer adapter declaring a future key against an older spt-core parses fine (the key is ignored), so the contract degrades gracefully. - `{"text":…}` is applied to the PTY **verbatim** — bytes are typed exactly, with **no** control-character stripping. A trailing `\r` *inside* a text payload (`{"text":"…\r"}`) therefore **submits**, identical to a following `{"key":"enter"}` (`enter`→`\r`). Submit either way; just don’t do both. Corollary: neutralize any CR/LF *inside* the message body before the trailing submit, or an embedded newline fires the input early. - A minimal binary just emits `{"text":payload}{"key":"enter"}{"commit":true}` with no choreography. (spt-hosted idle delivery is translation-binary-only since v0.14.3; there is no raw-inject fallback — a binary that fails to spawn or misses its commits spools the message, it is never typed raw.) ## [`[identity]` — session identity](#identity--session-identity) How the harness’s session id is obtained: [identity] session_id_source = "post_spawn" # "post_spawn" | "uuid_inject" parent_ancestor_name = "my-harness" `post_spawn`: discovered after spawn (process tree / wrapper hand-off), with `parent_ancestor_name` as the process-tree anchor. `uuid_inject`: spt-core injects a UUID the harness echoes back. ## [Session digest — the digest-record contract](#session-digest--the-digest-record-contract) The live activity digest (`spt endpoint digest `) is a **projection of the endpoint’s session logs**, not a parse of the PTY byte stream. Your `[digest]` extractor (or a `spt api digest-entry` push) emits the **digest-record contract** — JSON objects spt-core projects: {"role": "input", "text": "add a file", "ts": "2026-06-13T21:00:00Z"} {"role": "agent", "text": "on it"} {"role": "tool", "tool": {"name": "Write", "arg": "src/a.rs"}} - `role` ∈ `input` | `agent` | `tool` (the source tag). - `text` — the input / agent span (omitted for `tool`). - `tool` — `{name, arg}`, present iff `role == "tool"`; consecutive tool records collapse into one sprint (unless `sprint_collapse = false`). - `ts` — optional RFC3339-UTC ordering key (used to interleave with spt’s own injected-context entries). Unknown fields are ignored; a line that isn’t a valid record is **dropped with a counted reason** (never silently). `spt adapter digest-proof` shows you exactly what dropped and why. Presentation (window depth, arg truncation, sprint collapse) is spt-core’s, defaulted by your `[digest]` and consumer-overridable; extraction is yours. ## [`[strings]` — adapter string values (+ profiles)](#strings--adapter-string-values--profiles) An adapter-authored key/value tree any process on the node reads by dot-path with `spt adapter get-string ` — e.g. a harness hook fetching per-profile `additionalContext`, so one hook script serves every profile and only the data differs. **Strings are data only** — spt-core never executes a string (command templates live in the typed sections, never here). Node-local; not cross-node synced. [strings] greeting = "hello" # inline literal skills.whoami = { file = "whoami.md" } # file pointer → resolved to the file's contents **Two value forms:** - **Inline literal** — `get-string` prints it as-is. - **File pointer** — a value-position table with **exactly one** key, `file`: `{ file = "rel/path" }`. `get-string` resolves it to the file’s **contents** (large bodies — skill instructions, hint text — stay out of the manifest). The exactly-one-key rule disambiguates: any other table shape stays an opaque nested strings tree, and `{ file = … }` is **reserved** as the pointer form (it can’t double as inline data). **File-pointer rules (since v0.7.0):** - Files live in the adapter’s per-adapter aux dir **`adapters//strings/`** (sibling of `profiles/`); the path is **relative to that dir and must stay inside it** — `..` traversal and absolute paths are refused at registration (`ADAPTER_ADD_FAIL: invalid [strings] file pointer: pointer … must be a relative path inside the strings/ dir (no absolute paths, no `..` traversal)` — manifest-first, so the whole add registers nothing). - **Validated at registration** (fail-fast on an escaping/missing pointer), **read lazily** at `get-string` so live file edits reflect without re-register. A missing/unreadable file at read time **skip-diagnoses** — a diagnostic plus “not set”, never a silent drop or hard error (mirrors `[digest]`). - On `spt adapter add`, the adapter dir is **copied** into the registry (`adapters//{manifest.toml, record.toml, strings/…}`). **Profiles + update-safety:** strings resolve through the same **leaf-replace** profile overlay as the rest of the manifest — a shipped or local profile may override base strings, and `get-string ` returns the merged view. A **local** profile’s own file pointers resolve against the **user-owned local-profile dir**, not the adapter-shipped `strings/` (which adapter updates overwrite) — so a local override survives updates (or a local profile may just inline a literal). `set-string` edits a **local** profile’s `[strings]` only, never adapter-shipped files. ## [`[update]` — adapter self-update](#update--adapter-self-update) How spt-core updates (and first installs — install is the first update) this adapter: [update] avenue = "delegated" # "delegated" | "file_pull" | "gh_release" command = "my-harness plugin update spt" # delegated: the updater to run self_verifies = true # delegated: attests the updater verifies its content uninstall = "my-harness plugin uninstall spt" # optional inverse, run by `spt adapter remove` message = "Run `/reload-plugins` in any ongoing sessions." # optional; shown on apply | Avenue | Required fields | Meaning | | --- | --- | --- | | `delegated` | `command` | spt-core delegates to the harness’s own updater. Set `self_verifies = true` to attest that updater verifies what it installs — an unattested delegated update is skipped as unverifiable | | `file_pull` | `repo`, `signing_key` | spt-core pulls files from `repo` (optionally filtered by `path_regex`) and verifies them against the adapter author’s Ed25519 `signing_key` (64 hex chars) before applying | | `gh_release` | `repo` | spt-core ships your updates from your own GitHub releases (since v0.8.0). `asset` (default `adapter.spt`) and `signing_key` are optional | **`message`** (optional, any avenue) — a plain human notice `spt adapter update` prints to stdout, markdown-rendered, **only when a new version is actually applied** (never on a no-op). Printed after the update completes; multi-line supported. No `{key}` substitution. Use it to tell the operator what to do after updating — e.g. `"Run \`/reload-plugins` in any ongoing sessions.“` for spt-claude-code. With `file_pull`, **you** sign your releases with your own key; spt-core’s release keys never extend to adapter content. ### [`gh_release` — ship updates from your GitHub releases (since v0.8.0)](#gh_release--ship-updates-from-your-github-releases-since-v080) The simplest avenue to publish for: distribute exactly as you do for `spt adapter add --release`, and your registered adapter stays current. [update] avenue = "gh_release" repo = "your-org/your-adapter" # required: whose releases ship updates asset = "adapter.spt" # optional: the release asset to fetch (default adapter.spt) signing_key = "deadbeef…" # optional Ed25519 (64 hex): enables fail-closed verify `spt adapter update [name]` (no name sweeps every registered `gh_release` adapter; a name updates just that one) compares your repo’s latest release version against the installed one and, when newer, fetches the release `.spt` archive — the same archive `spt adapter add --release` installs — then re-extracts and re-registers it. `repo` is the only required field. **Trust is opt-in signing, fail-closed.** Declare no `signing_key` and the fetched `.spt` is trusted on HTTPS + GitHub, exactly like first acquisition. Declare a `signing_key` and the fetched `.spt` is verified against a **detached signature** you publish as a sibling release asset named `.sig` — a lowercase-hex Ed25519 signature over the raw archive bytes. Verification runs after the archive is fetched and before it is extracted, against the key in the **installed** manifest (so a new release must verify against the key already on the node). A bad or missing signature refuses the update and the fetched bytes are discarded, never extracted. You sign your own releases with your own key; spt-core’s release keys never extend to adapter content. ## [Shell adapters (`kind = "shell"`)](#shell-adapters-kind--shell) A shell adapter provides a **driven surface** (notifier, robot, sensor) instead of hosting agents: same file, different body — the `[shell]` section is required for (and exclusive to) `kind = "shell"`. See [Shells: getting started](#getting-started-a-notification-shell) for a worked, shipping example; the field reference: [shell] spawn = "my-shell --link {link_token}" # broker-launched; opaque template ephemeral = false # true -> no offline perch, no history retention broadcast = "subnet" # "subnet" | "same-node" | "none" (discovery scope) command_receipt = "stdin" # "http" | "stdin" | "relay" (how commands arrive) pre_close = "park-and-save" # optional instruction sent on link-break close_timeout_ms = 3000 # graceful-termination window persistent = true # auto-online whenever the owner endpoint is online wake_command = "my-waker --link {link_token}" # offline wake-watcher; exit code 86 = wake can_shutdown = false # may the shell fire `api owner-shutdown`? require_approval = "none" # "none" | "remembered" | "always" (per-spawn gate) max_instances_per_owner = 4 # optional cap (online + offline both count) over_cap = "reject" # "reject" | "approve" at the cap [shell.capabilities] # the agent->shell command vocabulary (durable) notify = { args = ["title", "body"] } clear = {} # A capability may carry its OWN approval gate (independent of the per-spawn # gate), with an optional class_key scoping the grant finer than the verb: [shell.capabilities.attach] args = ["busid"] require_approval = "remembered" # "none" | "remembered" | "always" (per-act gate) class_key = "hid" # a remembered hid grant never authorizes another class [shell.sensory] # the shell->agent sensory vocabulary (live-only) types = ["event"] [shell.drive] # the owner->shell continuous control channel types = ["stick"] # latest-wins, ephemeral, never spooled (real-time input) [shell.tunnel] # an opaque reliable-ordered byte stream pair (on-LAN) enable = true protocol = "usbip-urb" # opaque label; the taxonomy never interprets the bytes The capability, sensory, and drive vocabularies live in the manifest — spt-core resolves them by adapter name, validates against them, and rejects anything outside the declared vocabulary. The shell binary binds with `spt api … bind-shell --link ` (the link token *is* the credential), pushes sensory payloads with `spt api … emit`, and takes drive frames with `spt api … drive-poll`. Channel contracts differ — see [Shells: four channels](#shells-1): commands are **durable** (spooled, replayed); **drive** is **ephemeral** (latest-wins, dropped if offline); **sensory** is **live-only**; the **tunnel** carries **opaque bytes** the taxonomy never reinterprets (not enveloped, not framed, not spooled — the link lifecycle closes it). The tunnel is reliable- ordered ⇒ congestion is lag never loss ⇒ **on-LAN only**. Per-capability `require_approval` reuses the same grant store as the per-spawn gate; `class_key` narrows a grant to `(owner × verb × class × node)`. Shell ownership is **owner-type-agnostic** — a Gateway (or any non-shell endpoint) owns and drives a shell identically to an agent; exclusivity keys on the owner’s endpoint id, never its type. ## [Cross-field rules (`spt adapter add` enforces these)](#cross-field-rules-spt-adapter-add-enforces-these) The schema validates structure; registration additionally enforces: - `adapter.name` and `adapter.version` must be non-empty. - `kind = "shell"` **requires** a `[shell]` section, which is **exclusive to** shell adapters (a `kind = "harness"` adapter omits it). - `[history] strategy = "fetcher"` requires `fetcher`; `locate_normalize` requires both `locate_template` and `normalize_command`. - `[digest]` requires a non-empty `extractor`. Under `strategy = "locate_normalize"` (the default) it **also** requires a resolvable source: either its own `source` or a `[history] locate_template` to fall back to — absent both, registration rejects (*“[digest] needs `source` (own-source) or a [history] `locate_template`”*). Under `strategy = "fetcher"` no `source` is needed (the extractor locates the transcript itself). The JSON schema alone accepts a bare `extractor`, so this only surfaces at `spt adapter add`. - `[env.*] direction = "inject"` requires a `value`. - `[update] avenue = "delegated"` requires `command`; `file_pull` requires `repo` **and** `signing_key`; `gh_release` requires `repo` (`asset` and `signing_key` optional). A violation is a one-line error naming the field — fix and re-add. # [The `spt api` surface](#the-spt-api-surface) The imperative half of the harness contract: the inbound entry points a harness’s hooks (and a shell’s binary) fire to keep spt-core’s on-disk state in sync. This page is the complete command reference plus the two startup flows that tie it together. Three rules apply to `api` calls: 1. **`--adapter ` is an optional override** (since v0.9.0). For a harness-hosted session you normally **omit it**: `listen` resolves the owning adapter/profile at bind, from the seed’s parent pid → the harness exe basename → the adapter(s) that declare it in [`[adapter] host_binaries`](#manifest-reference) → the active-profile pointer (set by [`spt adapter use`](#cli-reference)) or, with no pointer, the freshest-registered hosting adapter. Pass `--adapter` only to **pin** a specific adapter/profile (adapter dev, or explicit disambiguation). The profile qualifier `:` is **runtime selection** — retained onto the perch record, and the daemon resolves the profile **overlay** when it later spawns the session’s lifecycle roles. So a `live` profile whose `[session.psyche_init]` is in the resolved manifest is a **LiveAgent** (spt-core drives the Psyche as a bounded per-event turn); a profile without it is a **ReadyAgent**. Ready-vs-live is a profile choice, not a separate “go-live” verb. 2. **Prove association.** Commands that touch an existing perch take `--session-id ` (matching the perch’s record) or a capability `--token`; shell commands authenticate with `--link ` (the link token minted at launch *is* the credential — no token, no access). This includes the read-side drain: an unauthenticated `api poll` is **refused** (exit 1, nothing printed) — see [`api poll`](#api-poll-id---include-deferred---link-token). 3. **Status rides stderr; stdout is payload; the exit code is authoritative.** Action-command status lines (`BOUND: token=…`, `READY:`, `SENT:`, `QUEUED:`, `WORKER_STARTED:…`, failure tags) print to **stderr** — always, piped or not (only the *color* is tty-gated; a redirect gets the bare tag unchanged). **stdout** is reserved for machine payloads: `--json` output, polled message frames, and documented payload emissions. A program shelling out must therefore capture **stderr** to read a status tag (`2>&1`, or capture the streams separately) — discarding stderr (`2>$null` / `2>/dev/null`) discards the status line by design — and should treat the **exit code** as the success contract: `0` = the action took effect, non-zero = it did not. spt api [--adapter ] [--manifest ] … `--manifest` points at the adapter’s manifest for the commands that need it (e.g. `capability`). ## [The two startup flows](#the-two-startup-flows) **Harness-hosted** — the harness owns the process; spt-core is invoked from inside it (hooks): SessionStart hook ──► api seed --pid {parent_pid} --session-id {session_id} session's listener ──► api listen (consumes the seed, holds the perch) `seed` records an ephemeral hand-off keyed by parent pid; `listen` consumes it, registers the perch, drains backlog, and blocks relaying events into the session. **spt-hosted** — spt-core spawns the session itself from the manifest’s `[session.self]` template, in its own terminal layer: spt-core spawns the template ──► session comes up session (or its wrapper) ──► api bind --set-session-id No seed file is involved; `bind` attaches the live session to its perch post-spawn. **Going ONLINE (the presence badge).** The `endpoint list` ONLINE badge means one thing: a live process is **holding the relay** — an `api listen ` that consumed a seed and is blocked relaying events. Binding alone does not light it. A headless adapter binary (a gateway or any `[session.self]` host that wants ONLINE presence and a live event stream) uses the same two-step the harness-hosted flow does, against its **own** process: api seed --pid --session-id (hand-off keyed to itself) api listen (consume, hold the perch, stay ONLINE) Drop the listener and the endpoint decays to Dormant/Offline as its last-seen ages out. ## [Session lifecycle](#session-lifecycle) ### [`api seed --pid --session-id `](#api-seed---pid-pid---session-id-id) Harness-hosted startup, step 1: record an ephemeral seed keyed by the parent process id. Fired by the harness’s session-start hook. Prints `SEEDED:`. **Seed lifetime.** The seed lives **in the daemon’s memory only** — no file — and survives until exactly one of: a successful `listen` bind consumes it, a newer `seed` for the same pid overwrites it, or the daemon process restarts (which drops the whole map). Nothing re-fires it until the harness’s **next** SessionStart. So an adapter must not rely on the seed for a session that goes live late (hours after SessionStart) or after a daemon restart — that is what `listen --session-id` (below) is for. ### [`api listen [--once] [--parent-pid ] [--subnet ] [--session-id ]`](#api-listen-id---once---parent-pid-pid---subnet-name---session-id-sid) Harness-hosted startup, step 2: consume the seed, register/hold the perch, drain spooled backlog, then block relaying messages. `--once` runs a single drain+receive cycle (testing). `--subnet` names the home subnet when this creates a brand-new endpoint on a multi-subnet node (home is assigned deterministically at creation). **Recoverable refusals do not consume the seed.** The seed is consumed by a **successful bind** — or by a refusal that proves the seed itself dead (see spend-vs-restore below). A recoverable refusal that never bound — `HOME_REFUSED` on a multi-subnet node without `--subnet`, `ADAPTER_UNRESOLVED`, a live-perch conflict — leaves the seed consumable, so the corrected retry on the same pid binds instead of dead-ending on `NO_SEED`. (Effect before irreversible consume: the destructive step follows the successful effect, never a recoverable refusal.) **`--session-id ` — binding when the seed is gone.** A session that goes live late, or after a daemon restart, finds no live seed; with `--session-id` the listener binds directly from the given harness session id (loud `SID_BIND:` marker). The fallback fires **only** on `NO_SEED` — every other refusal keeps its own diagnostic — and carries the same identity/auth gates as a seeded bind (live-conflict refusal, dead-anchor refusal on the parent pid). Without a live seed **and** without `--session-id`, `listen` refuses with `NO_SEED`. > **Provenance caveat — same gates, weaker provenance.** A seed is a > consume-once capability minted by the harness for exactly one anchor pid; `--session-id` is a **bearer string** — any local caller who knows a live > session id can present it and revive that perch. Treat session ids as > secrets: never log or publish them. (The local surface already trusts local > callers — `--parent-pid` is an override — so this is a contract > qualification for adapter authors, not a sandbox.) **Refusals and the seed, spend vs restore.** A refusal that proves the seed itself dead — a stale (dead-pid) anchor, an empty session id — **spends** it: that seed can never retry as itself, and restoring it would re-arm a dead-keyed seed for a recycled pid to steal. Recoverable refusals (the `HOME_REFUSED` retry case above, a live-perch conflict) restore it. ### [`api bind [--set-session-id ]`](#api-bind-id---set-session-id-sid) spt-hosted startup: bind a freshly spawned session to its perch, recording the session id discovered post-spawn. Identity precedes sessions — rebinding never mints a new endpoint. **Auth is intrinsic — `bind` takes no association proof.** It is an *establishing* call (the exception to Rule 2), not a touch-an-existing-perch call: spt-core spawned this session into its own broker-held terminal layer, so that parentage *is* the credential. The only guard is ownership — an existing *live* perch under a different session id is refused (you can only bind your own). The broker injects **no** capability token into the spawned environment, so there is nothing to echo back and no `[env.*]` entry to author for one; the endpoint id arrives via the `{id}` fill in `[session.self]`, and that is the only identity spt-core plants. `--set-session-id` *records* the discovered id into the perch — it is not a proof. `bind` prints `BOUND: token=`. The token is a freshly minted local credential the session *may* retain for later authenticated calls, but it is optional: every subsequent mutating call can instead prove association the Rule 2 way, passing `--session-id ` for spt-core to match against the record this bind wrote. ### [`api boundary --to-session-id --session-id `](#api-boundary-clearcompact-id---to-session-id-new-sid---session-id-prior-sid) The session was reset (context cleared or compacted) and continues under a new session id: rebind the perch, preserving the endpoint’s identity, spool, and history across the boundary. **The rotation catch-22 — read this before wiring the hook.** Rule 2 applies to `boundary` like every mutating verb, but here the “matching” `--session-id` is the sid on the perch record — i.e. the session being **departed**, not the one you are rotating to. `--to-session-id` is the *payload*, never the *proof*. By the time your rotation hook fires, the old session’s context (its env, its per-session files) is typically gone and the hook payload carries only the new sid — so a hook that only knows “the current sid” cannot authenticate this one verb. Two hard requirements for adapter authors: 1. **Persist the current session id at every SessionStart** in adapter-owned state keyed by the *endpoint id* (reference pattern: `{adapter_dir}/state/session/.sid`, single line, rewritten each SessionStart). At clear/compact, read it back as the **prior** sid and pass it as `--session-id`. Do **not** persist it in a per-session env file — that file dies with the session, which is the catch-22 itself. 2. **Never skip or swallow this call.** A rotation that is silently skipped (unresolvable endpoint id) or silently refused (`AUTH_REFUSED` on stderr, exit 1) leaves the perch pinned to the dead sid — after which **every** id-scoped call from the live session refuses, including a boundary retry, and the endpoint strands with zero message delivery until a full relaunch. Surface the id-resolution failure and the refusal reason loudly in your hook output. Resolve the endpoint id from your stable identity (`$SPT_ENDPOINT_ID`), not by looking up the new sid (it is not registered yet — the same catch-22). A perch stranded by a *crashed* session (recorded pid dead) self-heals on the next call via the dead-owner re-pin; a **live** session’s rotation always requires the prior-sid (or `--token`) proof. The long-term design (ADR-0032) adds an OS-verified ancestry proof keyed on the endpoint’s stable `parent_pid` anchor, which will make the persisted-sid pattern optional; until then it is required. ### [`api psyche-download [--session-id ]`](#api-psyche-download-id---session-id-sid) Pull the agent’s **resume context** to stdout, for a SessionStart hook to inject as the session’s additional context after a `/clear`, `/compact`, or fresh resume. Emits the durable two-tier mind — the agent’s role, its cross-project live context, and the current project’s context — **plus** any commune/signoff drop that has been written but **not yet synthesized** into that durable context (as `` / `` slices), so a just-written delta is never invisible on resume. The project is resolved from the perch’s recorded cwd. Read-only — it never writes the mind store. Prints `NO-CONTEXT:` on stderr (exit 0) when nothing is stored yet, the adapter’s fresh-init signal. > The *read-back-in* half of the commune/signoff file-drops (the *write* side): the agent drops its delta, spt-core synthesizes it into the durable > mind, and `psyche-download` is how the next session reads that mind back in. > Wire it into your SessionStart hook alongside `seed`/`listen`, and inject > its stdout — that is how a resumed session keeps its accumulated context. ### [`api session-end [--erase]`](#api-session-end-id---erase) Soft teardown: the session is over; the perch’s spool and history are preserved (that’s what makes the next `poll`/`listen` drain work). `--erase` hard-wipes instead — the exception, not the rule. ### [`api shutdown `](#api-shutdown-id) Graceful live-agent signoff: runs the final echo-commune **before** teardown (the context delta is never lost to ordering), then soft-stops. This is what the `spt endpoint shutdown` lifecycle path calls. ## [Activity and presence](#activity-and-presence) ### [`api state [--no-gate]`](#api-state-busyidle-id---no-gate) Report the session’s activity state. Activity/idleness comes from these explicit reports — **never** from terminal quiescence, which lies. Reporting `idle` also arms the echo gate (below) unless `--no-gate`. ### [`api echo-gate `](#api-echo-gate-setclear-id) Manage the echo-gate sentinel directly. The gate marks “a summarization may be needed when this session ends without a graceful signoff” — `state idle` sets it as a side effect; a graceful signoff clears it. ### [`api presence `](#api-presence-id) Report user/agent presence at this endpoint (feeds most-recently-active resolution across the subnet). ### [`api driven-by `](#api-driven-by-id) Print which node (if any) is currently remote-driving this endpoint, so a session can tell whether input is local or remote. ## [Messages](#messages) ### [`api poll [--include-deferred] [--link ]`](#api-poll-id---include-deferred---link-token) Drain delivered messages over the hook channel (the pull-based path for harnesses whose hooks can’t inject). Deferred-flagged rows are excluded unless `--include-deferred`. With `--link` this is the shell-flavored drain: the link token authenticates, and the rows are the shell’s stamped command/text/file frames. **Authentication is required** (rule 2 above): the drain must prove association with `--session-id ` (the perch’s recorded session) or a capability `--token` (`--link ` for the shell flavor). An unauthenticated `poll` is refused with **exit 1 and no output** — messages are addressed to the endpoint’s occupant, not to whoever asks. ### [`api history-log `](#api-history-log-id) Append normalized history (body on stdin) to the endpoint’s native history store — the push half of `[history] strategy = "native"`. ## [Workers](#workers) Nested, short-lived agents under a parent endpoint. A worker is process-local machinery — it authenticates with its parent’s session id and carries no capability token of its own. ### [`api worker-start [--agent-id ] [--agent-type ]`](#api-worker-start-parent---agent-id-id---agent-type-type) Create a nested worker perch under `parent`. The worker id is **minted by spt-core**, not supplied by the caller: `{parent}-w{N}` with a persistent, per-parent counter. The caller does **not** pass an id (a stray positional id is rejected). Output channels follow the api status-line discipline: - **stdout** carries the bare minted id and nothing else (the machine-readable result — empty on any refusal). Read this to learn the worker’s id. - **stderr** carries the human line `WORKER_STARTED:{parent}-w{N} under {parent}`. `--agent-id` / `--agent-type` are optional: the caller’s own agent identifiers, recorded on the worker as **correlation metadata only** — never the perch identity. Authenticates against the **parent** (the parent’s session id or token); the worker record stores the parent’s current session id as its registration sid. ### [`api worker-stop --session-id ` · `api worker-poll --session-id `](#api-worker-stop-id---session-id-sid--api-worker-poll-id---session-id-sid) Soft-stop (drop the ready marker; info + spool preserved) or drain a worker. Both authenticate **symmetrically by session id** — no token. A presented sid is accepted when it matches **either** the worker’s stored registration sid **or** the parent’s *current* session id, so a context clear/compact that rotates the parent’s sid between start and stop does not lock the worker out. The natural call `worker-stop --session-id ` is therefore correct as-is. ## [Shells](#shells) The driven-surface flavor of the contract. The **link token** minted at launch is the only credential a shell binary ever holds or needs: ### [`api bind-shell --link `](#api-bind-shell---link-token) The shell binary’s first call: resolve the instance **by link token alone** (the spawn template carries only `{link_token}`; the owner is derived from the link) and flip it online. ### [`api emit --type --link `](#api-emit-id-payload---type-type---link-token) Push a sensory payload (one of the manifest’s declared `[shell.sensory]` types) to the owner’s **live** session. REST-only by definition: never spooled — if the owner isn’t live, it’s dropped with a diagnostic. Sensors report the present, not the past. ### [`api owner-shutdown --link `](#api-owner-shutdown-id---link-token) A shell suspends its linked owner directly (e.g. a power-button surface), bypassing agent messaging. Gated by the manifest’s `can_shutdown` pre-consent flag — fail-closed; an undeclared shell gets a refusal. The firing shell cascades offline with its siblings, by design. ## [Introspection](#introspection) ### [`api capability`](#api-capability) Print the adapter’s declared `hostable_types` (requires `--manifest`). The cheap way to smoke-test that spt-core reads your manifest the way you meant it. ## [Conventions](#conventions) - **Output is line-oriented and stable**: `SEEDED:`, `READY:`, `SENT:`, `QUEUED:`, error lines as `CODE:detail`. Parse lines, not prose. - **Exit codes**: `0` success; non-zero = refused or failed, with the reason on stderr. - **Commune/signoff are file-drops, not api commands.** An agent writes `-commune.md` / `-signoff.md` into the manifest’s watched directory; spt-core’s watcher ingests it. There is deliberately no `api commune`. # [Echo-commune — the I/O contract](#echo-commune--the-io-contract) When a session ends **without** a graceful signoff, its context would be lost. The **echo-commune** recovers it: spt-core runs the adapter’s bounded summarizer over the session, captures the brief the summarizer prints, and files it as the session’s context delta — the same delta a hand-written [commune](#live-agent-lifecycle) would have carried. This page is the **adapter-facing I/O contract** for that mechanism: the role you declare, the keys spt-core fills, what it does (and does not) feed the summarizer, how the summarizer locates the harness, and the drop-file protocol spt-core uses to file the result. It is the companion to the [`[session.echo_commune]` role](#sessionrole--outbound-templates) in the manifest reference. > The echo-commune is spt-core’s. The adapter supplies **one command template > and one watched directory** — spt-core owns the spawn, the keys, the > file-drop, and the ingest. Everything below is the seam between those halves. ## [The role](#the-role) `[session.echo_commune]` is one outbound role template. Its fields are the standard role shape: [session.echo_commune] command = "my-harness run --agent summarize --session {session_id}" recursion_guard_env = "SPT_ECHO_COMMUNE" # set on the child so its own hooks bail env_remove = ["MY_HARNESS_SESSION_ID"] # stripped from the child's env keys = ["id", "session_id"] # the keys this template expects filled | Field | Required | Meaning | | --- | --- | --- | | `command` | yes | Opaque command line with `{key}` placeholders. Model, tools, flags — all inside the string; spt-core never parses it. | | `cwd` | no | Working directory for the child (substitutable). A role `cwd` wins over the endpoint default. | | `recursion_guard_env` | no | Env var name set on the summarizer child so *its* harness hooks bail — no echo-of-an-echo. | | `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 (your declared expectation list). | The child runs **bounded** — a timeout caps it, and a non-zero exit files **nothing** (the failure is loud, never a half-written delta). ## [Keys spt-core fills](#keys-spt-core-fills) For an echo-commune spawn spt-core fills its base catalog. Template only the keys you are given; a `{placeholder}` spt-core does not supply for this role fails the spawn with a one-line error naming the missing key. | Key | spt-core fills it with | | --- | --- | | `{id}` | The endpoint id being summarized. Always filled. | | `{session_id}` | The harness session id — filled when one is known. | | `{node}` | This node’s advertised label — filled only when non-empty (a `{node}` reference with no value fails loud rather than resolving an empty token). | | `{subnet}` | The endpoint’s home-subnet label (`local` when unhomed) — filled only when non-empty. | | `{adapter_dir}` | The adapter’s install dir — adapter-static, always available (lets the command point at the adapter’s own packed summarizer binary). | | `{adapter_name}` | The adapter’s declared `name`. | | `{VAR}` | Any manifest-declared [`[env] direction = "read"`](#envvar--env-var-table) var captured at bind — see [Self-locating the harness](#self-locating-the-harness). | This is the **same base catalog** the Psyche and notification roles build on; it deliberately does **not** include `{session_name}` (a `[session.self]` key) or the Psyche-only `{psyche_context_file}` / `{parent_session_id}`. ## [History is not fed on stdin](#history-is-not-fed-on-stdin) spt-core has a stdin channel for the summarizer — but **you must not depend on it carrying the transcript.** The rule, field-proven against the reference Claude Code adapter: - If the manifest declares a [`[history]`](#history--transcript-access) strategy that yields records, spt-core normalizes them and pipes them to the summarizer on **stdin**. - With **no `[history]` section**, or a `native` history store that is still empty, there are no records — so **stdin is empty**. This is the field case: the reference adapter’s history is native and typically empty at echo time, so the summarizer receives an empty stdin. The load-bearing consequence for an adapter author: **the echo-commune command must self-source the session it summarizes** (locate and read the transcript itself), exactly as a [`[digest]` `fetcher`](#digest--session-digest-extractor) extractor does. Do not write a summarizer that reads its transcript from stdin. What spt-core reliably supplies is the **command template with the key catalog filled** (and the child’s env — see below); the transcript is the summarizer’s to find. ## [Self-locating the harness](#self-locating-the-harness) A summarizer that self-sources its transcript needs to find the harness’s config/log root. spt-core carries that in through the manifest’s **read-env allowlist**, so the value survives the daemon boundary (the echo child runs in the daemon context, where the original launch environment is long gone). [env.CLAUDE_CONFIG_DIR] direction = "read" value = "~/.claude" # fallback when the launch env didn't set it - Declare each locator var with `direction = "read"`. spt-core captures it **from the launch environment at bind** — an explicit allowlist, never the whole environment, and only when the ambient value is actually present. - The captured value is written onto the perch record, so it is available when the echo (or digest, or Psyche) child spawns later. - At spawn spt-core injects it as a `{VAR}` substitution key. **Resolution order:** the captured ambient value wins (a relocating profile — e.g. a wrapper that sets `CLAUDE_CONFIG_DIR` to a private dir) → else the directive’s own `value` fallback (the base harness default, e.g. `~/.claude`) → else the var is **omitted**, so a `{VAR}` reference fails loud rather than resolving a wrong path. A leading `~` expands to the home dir. Reference the captured key in the echo command the same way `[digest]` does: [session.echo_commune] command = "my-harness-summarize --session {session_id} --config-dir {CLAUDE_CONFIG_DIR}" keys = ["session_id", "CLAUDE_CONFIG_DIR"] ## [Drop-file protocol](#drop-file-protocol) spt-core files the summarizer’s output as a **drop file** — the exact same channel a hand-written commune uses — then ingests and deletes it. Three invariants define the contract: **1. spt-core is the single writer.** The filename is fixed by spt-core — `-commune.md` — and the adapter declares only the *directory* (`[session] commune_dir`). The mind never writes this file; spt-core writes it atomically (with a bounded access-denied-only retry for transient antivirus/indexer locks) and is the sole deleter. Never have the summarizer write the drop file itself. **2. The directory resolves per-endpoint** *(hardened in v0.29.0).* A `commune_dir` may be absolute or relative: - **Absolute** → used as-is. - **Relative** → resolved against the **endpoint’s own recorded working directory**, read fresh at drop time — never against the daemon’s process cwd. - **Relative with no recorded cwd** → spt-core **skips the drop and warns once** (per endpoint, per daemon run). It never guesses and never falls back to the daemon’s cwd. This is the fix for a real outage: under a service-launched daemon whose process cwd was a system directory, a relative drop dir once resolved there and failed with a permission error on every write. The loud skip makes a mis-declared relative dir a diagnosable signal instead of a silent failure. Declare an **absolute** `commune_dir`, or ensure the endpoint’s cwd is recorded, to avoid the skip. A missing `[session.echo_commune]` role is likewise a **loud once-skip**, not a retried fault. **3. Ingest deletes the drop.** On its next pulse tick spt-core reads the drop, routes it into the durable context tiers, and **deletes the file** — whether the content was written or suppressed as a stale snapshot (both mean “consumed”). A read/write *error* leaves the file in place to retry on the next pass. The file disappearing is the success signal. ## [What spt-core expects on stdout](#what-spt-core-expects-on-stdout) The summarizer’s **stdout is the brief** — the cheap-model synthesis of the session, as plain text. spt-core does not require a structured format at write time; it stamps a provenance header (`Source: echo-commune`) and writes the result as the commune drop file. On the later ingest tick that body is parsed with the **two-slice envelope** grammar, the same one a hand-written commune uses: - `…` → the **live tier** (who the agent is and what it is doing; follows the endpoint everywhere). - `…` → the **project tier** (scoped to the current project). - An **untagged body** routes whole to the live tier. Every write is precedence-guarded — a stale snapshot arriving inside another writer’s protection window is suppressed (but still consumed and deleted). The checkpoint sentinel `!!checkpoint!!`, if the brief carries one, is stripped before both presentation and the durable write, so it never persists in the stored context. ## [In one line](#in-one-line) Declare `[session.echo_commune]` with a command that **self-sources its transcript** (found via a `direction = "read"` locator key) and **prints the brief to stdout**; declare an **absolute** `commune_dir`; let spt-core do the spawn, the file-drop, the ingest, and the delete. That is the whole contract. # [Install-on-demand bootstrap](#install-on-demand-bootstrap) How an adapter ships spt-core *with itself*. The contract: **the canonical install one-liner is also every adapter’s pack-in installer** — there is no second mechanism, no vendored binary, no bespoke fetch logic to maintain. Your adapter checks for `spt`, and runs the official script when it’s missing. The scripts are non-interactive **by construction** (they run unattended), idempotent (safe to re-run), sha256-verify what they download, and register the user PATH. Served from the permanent canonical URL: - `https://sabermage.github.io/spt-releases/install.sh` - `https://sabermage.github.io/spt-releases/install.ps1` ## [The generic contract](#the-generic-contract) if `spt` is on PATH -> done (optionally check `spt --version` ≥ your floor) else -> run the official one-liner for the OS then -> first invocation may need the absolute path (Windows) then -> register your manifest: spt adapter add --github / After first install, spt-core keeps itself current (signed self-update), so the bootstrap can leave upgrades to spt-core. The remaining bootstrap step is to register your adapter — see [Activate the adapter](#activate-the-adapter--register-your-manifest) below. ## [Check-and-install: POSIX sh](#check-and-install-posix-sh) Drop this into your adapter’s bootstrap (plugin install step, postinstall script, first-run guard): if ! command -v spt >/dev/null 2>&1; then echo "spt-core not found - installing..." curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh # current shell may not see the PATH update yet: SPT="$HOME/.local/bin/spt" else SPT="spt" fi "$SPT" --version ## [Check-and-install: PowerShell](#check-and-install-powershell) if (-not (Get-Command spt -ErrorAction SilentlyContinue)) { Write-Output "spt-core not found - installing..." irm https://sabermage.github.io/spt-releases/install.ps1 | iex # The user-PATH registration only reaches NEW terminals -- use the # absolute install path for everything in THIS process: $spt = Join-Path $env:LOCALAPPDATA 'spt-core\bin\spt.exe' } else { $spt = 'spt' } & $spt --version ## [Activate the adapter — register your manifest](#activate-the-adapter--register-your-manifest) Installing the binary is the first half of a pack-in; registering your manifest is the second. Installing the binary makes `spt` available; **`spt adapter add` activates your adapter** — registration is what lights up its profiles, `[strings]` bodies, `[digest]` extractor, and hooks and makes it show in `spt adapter list`. So the step right after the binary check is registering the manifest: # after `spt` is confirmed present (above): # from a GitHub release — ships built binaries, source-free, versioned: "$SPT" adapter add --release / # latest "$SPT" adapter add --release / --tag v1.0.0 # pinned # ...or clone a repo whose ROOT holds manifest.toml: "$SPT" adapter add --github / # ...or a local directory your harness ships: "$SPT" adapter add ./adapter `adapter add` is **manifest-first** — a clean add proves the cross-field manifest shape — and it conducts your [`[update]`](#update--adapter-self-update) avenue once (install is the first update). Confirm with `spt adapter list`: your adapter and its version appear. Keep this idempotent in your bootstrap the same way the binary check is — register when `adapter list` shows your adapter missing or below the expected version. **`--release` is the recommended distribution.** It fetches a `.spt` archive asset — a tar whose root holds `manifest.toml` + `strings/` + the binaries the manifest points at — from the named GitHub release, extracts it to the durable registry home, and registers the root. That ships your **built binaries**, source-free and **versioned by tag** (`--tag`, default the latest release), and first-acquisition trusts HTTPS + GitHub exactly like the install one-liner’s first binary fetch. A development **monorepo stays a monorepo**: your release CI packs the archive (`tar -czf adapter.spt manifest.toml strings/ bin/…`) and uploads it as a release asset, so the adapter ships straight from your existing repo. Override the asset name with `--asset` (default `adapter.spt`). **Cover several platforms in one `.spt` (since v0.13.2).** To ship binaries for more than one OS/arch in a single asset, add a **target-triple subdirectory** at the archive root per platform and put that platform’s binaries inside it, leaving the shared `manifest.toml` + `strings/` at the root: adapter.spt ├── manifest.toml # shared — at the root ├── strings/ # shared — at the root ├── x86_64-pc-windows-msvc/ # one platform's binaries… │ └── bin/… # …in the same relative layout a flat .spt uses └── x86_64-unknown-linux-gnu/ └── bin/… On install, spt-core extracts the shared root plus **only the current node’s triple**, flattened into the install dir — so the bare-name `/` resolution above is unchanged; mirror, under each triple, exactly the per-platform tree a flat `.spt` would place at the root. The recognized triples are `x86_64-pc-windows-msvc` and `x86_64-unknown-linux-gnu`; a root subdirectory whose name is **not** a recognized triple is treated as a shared root entry (so binaries for other platforms still ship as **separate single-platform assets**, one selected per node with `--asset`). A multi-platform archive that lacks the recipient’s triple is refused with a clear `NoArtifactForPlatform` error — never a silent partial install — and requires `min_spt_core_version >= 0.13.2`. A flat archive (no triple subdirectories) installs exactly as before. `--github` is the alternative for an adapter whose **repo root already holds `manifest.toml`**: it clones the repo and registers the clone root (`adapter add` resolves a directory source to `/manifest.toml` at the root). Local development uses the directory form, which takes any path or filename: `spt adapter add ./adapters/my-adapter.toml`. What registration holds under `adapters//` follows your [`[update]`](#update--adapter-self-update) avenue: a `delegated` or `gh_release` adapter is **pointer-mode** (the manifest and `strings/` are read live from the durable home), and a `file_pull` (or avenue-less) adapter is **copy-mode** (the `manifest.toml` and `strings/` are copied in). Publish the binaries your manifest references in the `.spt` (or repo) too, and reference them **by bare name**: since v0.8.0, a command template’s program token resolves against the adapter’s install dir before `PATH`, so a `.spt` that ships its binaries is **self-contained** — the shipped binary is found without any PATH placement. (Absolute paths still work; an unshipped tool still falls back to `PATH`.) This applies to the `[session.psyche_resume]` per-event turn, the `[digest]` extractor, and `spt adapter digest-proof`. > **“Install the plugin, get the adapter for free” — include the activation > step.** The [`[update]`](#update--adapter-self-update) avenues > keep a *registered* adapter current. The straightforward path for a `--release`-distributed adapter is **`gh_release`** (since v0.8.0): declare `avenue = "gh_release", repo = "your-org/your-adapter"` and `spt adapter update` ships the latest release `.spt` to the node — fetched, > optionally verified against your `signing_key`, re-extracted, and > re-registered. The other avenues: `delegated` (your harness’s own updater > installs the content — set `self_verifies = true` to attest it verifies what > it installs), and `file_pull` (its automatic network-pull transport is **on > the roadmap**). Deliver the manifest with `adapter add --release` (or `--github`, or a packed local dir) and let `gh_release` carry updates. ## [The Windows PATH-refresh gotcha](#the-windows-path-refresh-gotcha) The installer registers the binary directory on the **user** PATH via the registry. Registry PATH changes reach **new** processes; an already-running process — including the terminal (and *your bootstrap*) that just ran the installer — keeps the PATH it started with. So: **the first invocation after an install must use the absolute path** (`%LOCALAPPDATA%\spt-core\bin\spt.exe`; the installer prints it). Every new terminal after that finds `spt` normally. The snippets above bake this in. On Linux the equivalent (a `~/.profile` entry the current shell hasn’t sourced) is handled the same way: `$HOME/.local/bin/spt` absolutely, once. ## [Pinning and air-gapped installs](#pinning-and-air-gapped-installs) The scripts are configured by environment knobs, so the pipe-to-shell form stays canonical: | Env var | Meaning | | --- | --- | | `SPT_INSTALL_VERSION` | Install a specific release tag instead of latest | | `SPT_INSTALL_DIR` | Override the install directory | | `SPT_INSTALL_ASSET_BASE` | A URL or local directory holding the release assets + `SHA256SUMS` directly (CI, air-gap, mirrors) | | `SPT_INSTALL_NO_PATH` | `1` = skip PATH registration | Example — pin a version inside a CI job: SPT_INSTALL_VERSION=v0.1.0 \ curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh ## [Trust model](#trust-model) First fetch trusts HTTPS + GitHub and verifies the binary’s sha256 against the release’s `SHA256SUMS`. From then on, `spt update` performs full Ed25519 signature verification against the two-key trust anchor embedded in every binary — so the installer is the strong link only once. # [Adapter patterns & pitfalls](#adapter-patterns--pitfalls) The [integration checklist](#harness-integration-checklist) tells you *which* surfaces to wire. This page is the field guide: the patterns that decide whether an adapter merely registers or runs like a native part of the harness — the design rules, the lessons that save you a debugging session, and the cheapest ways to prove each piece on the live binary. Everything here is behaviour of the **shipped public surface** — the `spt` binary, the [manifest](#manifest-reference), and the [`spt api`](#the-spt-api-surface) commands, verified against a live binary. It is harness-agnostic; where one harness’s quirk is the clearest illustration it is called out as such, and the pattern generalizes to any harness with the same shape. ## [The one rule: manifests are static, logic lives in binaries](#the-one-rule-manifests-are-static-logic-lives-in-binaries) If you internalize a single thing, make it this. - **Manifest fields are static templates spt-core fills.** A field is a fixed template: spt-core substitutes `{key}` placeholders from a fixed [catalog](#substitution-keys) (`{session_id}`, `{parent_pid}`, `{adapter_name}`, `{id}`, the digest/psyche keys), and `~` expands to home. That is the whole of a template’s power. - **Anything that depends on runtime state belongs in a binary the manifest points at** — the `[digest]` extractor, a `[session.*]` runner. Reading an env var, branching on runtime state, or computing a value is *logic*, so it lives in a binary. If your harness can move its own state directory at runtime, for example, treat the manifest `source` as a *fallback* root and have the binary it points at resolve the real location itself. - **A `.toml`-only leaf carries no code of its own**, so verify it by registering and resolving it on the live binary (below), and put anything you want covered by real tests into a binary (an extractor or runner). Hold this rule and most of the surface falls into place: the manifest is the *declaration*, your binaries are the *behaviour*. ## [The adapter lives in the registry](#the-adapter-lives-in-the-registry) An adapter — its manifest, profiles, `[strings]`, the `[digest]` extractor, any runner binaries — is registered with **`spt adapter add `** into the node-local adapter registry. The version recorded there (`spt adapter list`) is the **version-of-truth** for what the adapter does. That is the entire, universal delivery mechanism: every spt adapter ships this way, and registration is where spt-core validates it (see [the second gate](#validate-against-the-live-binary)). If your harness *also* has a plugin or marketplace channel (so casual users can one-click install it), that is a separate distribution choice on top. When you go that route, let the **registry** carry the binary, manifest, and runtime state, and version the plugin independently of the manifest/binary. ## [Profiles are sparse leaf-replace overlays](#profiles-are-sparse-leaf-replace-overlays) A profile is selected as the composite `:` and **leaf-replaces only the leaves you declare** — everything else inherits from base. Override exactly what differs: - `[profiles..session.self].command` — retarget the bringup command (for example, wrap the launch in another binary). - `[profiles..digest].` — widen one digest knob. - `[profiles..session.psyche_init]` — add the [live-agent seam](#the-live-agent-companion-seam); its presence on the merged view is what flips an endpoint to a live agent. **Make an overlay observable.** Also leaf-replace one `[strings]` key (say a label) in the profile. Then `spt adapter get-string : ` differs from the base value — and that diff is your proof the overlay resolved. It is the cheapest profile acceptance assertion there is. A profile that wraps the launch in another binary works when that binary is a drop-in for the base harness binary on the same argv and passes inherited env through unchanged. Routing a session through a launcher wrapper (a model or billing multiplexer, say) is exactly this: replace the `session.self` command and let the injected endpoint-id env ride through untouched. ## [Wiring hooks: you own the harness side](#wiring-hooks-you-own-the-harness-side) spt-core supplies the harness-**independent** `spt api` primitives and their I/O format. *You* author all harness-specific wiring: spt-core supplies the primitives, and your adapter hand-writes its hook config to shell out to `spt api`. A mapping that works on the public surface, in terms any harness can translate to its own events: | When the harness… | …fire | Why | | --- | --- | --- | | starts a session | `api seed --pid {parent_pid} --session-id {session_id}` | Seed the endpoint (adapter-agnostic) — keep this fast and non-blocking. | | submits a user turn | `api poll {session_id}` | Drain the inbox to stdout (plus any keyword hints). | | goes idle / busy | `api state idle` / `api state busy` | Honest activity; spt-core treats your explicit `api state` calls as the source of truth. | | ends the session | `api session-end {session_id}` (or `api shutdown ` for graceful signoff) | Teardown that preserves the spool + history. | | spawns / ends a sub-agent | `api worker-start` / `api worker-stop` | Nested short-lived workers. | Two structural rules sit under that table: - **Run the blocking listen/poll loop from a skill the user invokes.** Seed on start so bringup stays fast, and let an explicit `/ready`-style skill own the blocking stream. - **Message delivery is stdout framing.** `api poll` emits the self-delimiting envelope `body` (the live listener stream uses the same shape). Multi-message drains split cleanly on ``. Decode a body by splitting on `
` → newline, then HTML-unescaping `< > "` and `&` **last** (the full entity set and decode-order contract: [the `` wire contract](#the-event-wire-contract)). Route that stdout into your harness’s injection channel — that routing is adapter glue. ### [Get these right in the hook layer](#get-these-right-in-the-hook-layer) A few patterns here save you a debugging session — wire them deliberately. - **Pre-empt an injection channel’s size cap.** If your harness caps the size of an injected blob (truncating it, or spilling it to a file and evicting it from the context the agent actually reads), cap the combined hook output adapter-side: under the limit, pass the output through verbatim; over it, spill the **full** text to an agent-readable file and inject a short pointer. Always cut on an `` boundary, so every envelope stays whole and every message survives a large drain. - **Inject a skill body *before* the perch gate, and gate only the message drain.** When the same prompt hook both injects a requested skill’s instructions and drains messages, run the skill-body injection first — that keeps skills like “who am I” or “set me up” working for a new user, since they are valid while the perch is still being readied. Match the skill token as a leading token, so only an actual invocation fires (prose that merely mentions it stays inert). - **Make the setup/installer skill self-contained in its stub.** It runs precisely when the binary may be absent (installing it is the job), so carry its operative steps in the harness-native stub itself — the floor that always works — and let any file-backed body mirror them for the binary-present repair path. The one skill that most needs delivery is the one delivery reaches last, so give it a stub that stands alone. - **Read hook inputs from stdin.** A hook receives its data (the prompt, the session id) as a JSON object on **stdin** — parse that, which keeps a `/`-leading value (a `/` token, an absolute path) intact. Under Git-Bash/MSYS on Windows, an argument beginning with `/` is rewritten to a Windows path before your command sees it (a `/foo:send` token can arrive as `C:/Program Files/Git/send`), so stdin is the transport that preserves it. If a command must take such an argument, guard it (`MSYS_NO_PATHCONV=1`, or a file/stdin transport). It is the same class as the UTF-8-stdout trap below — choose the transport that carries the data faithfully. ## [`[strings]`: keep the manifest thin, point at the live binary](#strings-keep-the-manifest-thin-point-at-the-live-binary) A `[strings]` value is either an inline string or a **file pointer** (`key = { file = "relative/path" }`), resolved lazily by `spt adapter get-string` to the file’s contents — so live edits reflect without re-registering. Keep pointer files inside the `strings/` dir; the add enforces that containment. Use file pointers to keep skill-instruction bodies out of the manifest. When a skill body needs to describe the spt surface, point it at the binary’s own self-documentation, so the guidance stays current with the shipped binary — two always-current tiers: - `spt how-to ` is the task-oriented agent-guidance surface, covering **selected** topics, each a canonical write-up of verbs, flags, and result codes. Treat it as the curated tier: for a verb it covers, read the topic; for any other verb, probe and fall through (an undocumented topic returns `NO_SUCH_TOPIC:`). - For any verb, **`spt --help` is the always-present source-of-truth** — it tracks the shipped binary. A skill body that says “the verb list is `spt --help` — match the user’s intent to a verb” stays correct across releases. Either tier stays current with the binary, and a skill body that points at them stays correct across releases. They are also the fastest way to learn the surface while authoring — it self-documents. ## [`[digest]`: the transcript→record extractor](#digest-the-transcriptrecord-extractor) The `[digest]` seam maps your harness’s native transcript into spt-core’s digest-record contract. The contract beyond the schema: - **Name where it reads** — either `source` or a `[history].locate_template`. `spt adapter add` requires this even though the JSON schema alone would accept a bare `extractor`; the cross-field rule surfaces at registration, so validate against the live binary. - **Treat `--in {source}` as a root.** The extractor is invoked `--session {session_id} --in {source}` and locates ``’s transcript *within* that root — your harness’s internal subdir scheme is yours to resolve, and spt-core keeps the key catalog harness-agnostic. Handle both shapes: `--in` a directory (locate the session) and `--in` a direct file (the `digest-proof --sample` path). - **Resolve a runtime-relocated state tree in the binary.** When a runtime value (an env var, an isolated profile) moves the real transcript tree, have the extractor prefer that value on its directory branch, with the manifest `source` as the fallback root. That resolution is logic, so it lives in the binary — the headline rule in miniature. - **Emit raw records as UTF-8.** Output one NDJSON line per record (`{role ∈ input|agent|tool, text?, tool?, ts?}`) and leave presentation to spt-core’s renderer (`window_turns`, arg truncation, sprint collapse). Pin stdout to **UTF-8** so non-ASCII (em-dashes, smart quotes) round-trips — spt-core reads the stream as UTF-8. (Native-UTF-8 languages get this for free, which is part of why this seam is a binary.) Prove the whole path with `spt adapter digest-proof --sample ` (below). ## [The bringup / launcher seam](#the-bringup--launcher-seam) `[session.self].command` is the spt-hosted bringup template — spt-core spawns it into a broker PTY. For a harness with no native session-id flag, mint the id internally and pass the endpoint id via an injected env var (`[env.]` with `direction = "inject"`, `value = "{id}"`); the start hook reads that env and self-registers with `api bind `. That bind is **intrinsically authenticated**: for a broker-spawned session the broker parentage is the proof, so `api bind --set-session-id ` alone establishes the association, and later mutating calls prove themselves with the session id the bind recorded. (The flip side shows up in [testing](#testing-against-a-real-harness-isolate-identity): the framework keys association on *identity*, so identity is the thing you isolate.) `adapter.shortcut_basename` brands the generated launcher shortcut (`-`) and is decoupled from the adapter name. ## [The live-agent (companion) seam](#the-live-agent-companion-seam) An endpoint is a **live agent** exactly when its *resolved* manifest declares `[session.psyche_init]` — declaring that section is the single go-live signal. A base manifest is a ready agent; a profile overlay that adds the section makes a live agent. spt-core checks this on the **merged** view, so the profile resolved at **bind time** drives the spawn decision all the way through — the bound profile governs the full runtime lifecycle, beyond bringup argv. Since v0.9.0 the seed is adapter-agnostic: the profile is resolved when `listen` binds, from the active-profile pointer ([`spt adapter use :`](#cli-reference)) or an explicit `--adapter :` override on the `listen` call. - **`[session.psyche_init]` is a go-live GATE ONLY — spt-core never spawns it.** Its mere presence promotes the endpoint to a LiveAgent; the command is opaque and, in the per-event model, unexecuted. Keep it minimal. - **The daemon drives the Psyche as one bounded `[session.psyche_resume]` turn per event** (a pulse fire, a commune/signoff drop, a session-custody transition) — stdin-fed and **stdout-captured**, exiting at turn end. There is **no resident wrapper, no seed-once, no detached process**. `psyche_resume` is the *sole* driven Psyche role. - **Keys spt-core fills into the turn:** `{session_id}` = the Psyche’s **own** custody sid (its own thread — a parent `/clear`/`/compact` does not rotate it), `{parent_session_id}` = the parent’s sid, `{psyche_context_file}` = the path to the composed-mind file (fresh = non-empty, continue = 0-byte — never on the argv), and `{subnet}` (when known). The adapter-static/node keys are also available: `{id}` = the **parent endpoint id** (not a `-psyche` override), `{adapter_dir}`, `{adapter_name}`, `{node}`. There is **no** `{psyche_dir}` or `{psyche_prompt}` fill — those retired with the resident spawn. - **The runner is yours to build; its lifecycle is the daemon’s.** `psyche_resume.command` is adapter-authored and opaque to spt-core. **If your harness’s headless mode runs one turn per invocation, that is exactly the model** — the daemon invokes `psyche_resume` once per event, so no resident wrapper is needed (and none is wanted). Build it like the `[digest]` extractor — a compiled, dependency-light binary the daemon can exec bare on any platform, resuming the Psyche’s own session by `{session_id}` and reading the mind from `{psyche_context_file}`. ### [Prove live bringup non-interactively](#prove-live-bringup-non-interactively) To prove your live path actually goes live — without an interactive terminal — drive the bringup as a child process and assert on deterministic side-effects. The harness plays the long-running-listener role: 1. **Seed**, anchoring on the OS process pid — not a shell-wrapper pid. Under Git-Bash/MSYS `$$` is the MSYS pid, which fails the seed’s liveness guard, so derive the real OS pid: `spt api seed --pid --session-id ` (adapter-agnostic — no `--adapter`). 2. **Bind, then send a probe.** A send to a never-bound perch is `NO_PERCH` (no spool exists yet), so establish the perch first; then `spt send ` `QUEUED`s against it, ready to drain on bringup. 3. **Spawn the persistent relay as a child**, capturing its stdout/stderr: `spt api listen ` (no `--once` — that exits after one delivery). The adapter resolves from your `[adapter] host_binaries`; pass `--adapter --manifest ` only to pin a specific adapter/profile. Assert `BOUND:` then `READY:` on its stderr, and the relayed `` carrying your probe on its stdout. 4. **Assert the endpoint went live and its turns succeed.** The relay marks the perch online; the endpoint reports kind `live_agent` (its resolved manifest declares `[session.psyche_init]`). Because the Psyche is a **per-event turn, not a resident process**, there is **no `-psyche` perch to come online** and **no `LIVEHOST_PSYCHE` marker** to assert — those belonged to the retired resident model. Instead assert the healthy-turn signal: after a psyche event fires, the endpoint’s perch carries **no `psyche_host_error`** (an absent error is the “turns succeed” proof; a present one names the failing turn). 5. **Kill the child** to end the session — the relay is freely killable; the Psyche runs only as bounded per-event turns the daemon drives (a graceful `spt endpoint shutdown ` ends the endpoint). Pin the identity env (`OWL_SESSION_ID`) for the auth-gated calls, and give the system-under-test a throwaway identity per the [identity-isolation rule](#testing-against-a-real-harness-isolate-identity). ## [Lifecycle continuity is file-drops](#lifecycle-continuity-is-file-drops) Commune and signoff are delivered as **file-drops** by design. The agent writes `-commune.md` (delta context) or `-signoff.md` (final save) into the manifest-declared `[session].commune_dir` / `signoff_dir`; spt-core’s daemon watcher ingests it and deletes it (the daemon is the single writer). The **filenames are contract-fixed** and the directory is adapter-declared, so wire the directory watch and read the contract filename. This is the single biggest continuity win, so it is worth getting exactly right. ## [Testing against a real harness: isolate identity](#testing-against-a-real-harness-isolate-identity) The surest way to prove your hook wiring fires is an acceptance test that **spawns a real harness session as the system-under-test**. Doing so meets a framework property you design around: - A perch’s identity is **resolved from the environment** (the same vars `spt whoami` reads), and perches are **name-keyed, last-establish-wins**. The most recent session to establish a perch under a given identity holds it, taking the active poll/listen stream with it. - So a spawned test session that loads your adapter (whose start hook seeds and binds a perch) under the identity of the agent running the tests would take that agent’s perch. **Identity isolation is the guard.** - Give every spawned system-under-test a disposable identity distinct from any live agent — override **both** identity env vars before the spawn to a throwaway `-ci-`, so the nested session and the operator’s perch coexist cleanly. Identity is the key, so isolating identity is the whole guard. - Keep the orchestration deterministic and **assert on a hook side-effect** — a marker or digest file, or `spt` state — the deterministic signal. Keep the harness as the system-under-test and let its side effects be your assertions. ## [Validate against the live binary](#validate-against-the-live-binary) Treat registration as the **second gate**: beyond JSON-schema validity, `spt adapter add` runs cross-field checks that go past what the schema expresses (the `[digest]` source rule is one). Build for it: - A **registration integration check**: `adapter add` → `adapter list` (assert the adapter and each shipped profile composite resolves) → `get-string` (the base value, each overlay diff, and each file-backed pointer resolve to a body) → a soft `adapter remove` (leaving the registry clean). Gate it behind an opt-in env flag and a minimum `spt` version, since it mutates the node-local registry. - Two author-time tools work without a live session: - `spt api --adapter --manifest capability` reports the manifest’s hostable types from the manifest alone — assert it advertises the type your bringup spawns. (A clean `add` already proves the cross-field shape, since add is manifest-first; `capability` is the lighter, non-mutating check.) - `spt adapter digest-proof --sample ` runs the real extractor through the registry and renders the result — proving the transcript → record → render path end-to-end on a fixed sample. It fills the same runtime substitution keys the daemon does, so passing proof means it works at runtime. (Use a recent `spt` — current binaries fill the full key map.) - `spt adapter translate-proof --event ''` spawns and feeds your declared `[message-idle-translation-binary]` exactly as the daemon does at idle delivery, then prints the keystroke-command stream it emits (`{key}` / `{text}` / `{delay_ms}` / `{commit}`) — failing a binary that emits nothing or never sends a terminating `{commit}` (which would fault at the commit deadline live). The EMIT-half mirror of `digest-proof`; the atomic PTY apply stays covered by the daemon’s integration gate. - **Proof a DEV build off disk — `--dir` / `--manifest`.** Both `digest-proof` and `translate-proof` accept `--dir ` (binaries resolve there, just like a registered install) or `--manifest ` (pins the manifest; its parent is the install dir) to proof an adapter that is **not registered** — e.g. a freshly built binary beside a hand-written `manifest.toml`, or a bare-file `gh_release` adapter that was never staged into a full extracted install. `--dir` defaults the manifest to `/manifest.toml`; with neither flag the command resolves the registered adapter as before. Mirrors `digest-proof --sample` pointing straight at a file — proof without a full `spt adapter add` round-trip. And the meta-lesson: **observable behaviour of the public binary is itself public surface.** When prose docs lag, a byte-capture against the live `api` / `adapter` surface is a legitimate way to confirm a contract. ## [Next](#next-3) - **The full surface:** the [integration checklist](#harness-integration-checklist) — every contract surface grouped by necessity. - **Reference:** the [manifest reference](#manifest-reference) and the [`spt api` surface](#the-spt-api-surface). - **Ship it:** the [install-on-demand bootstrap](#install-on-demand-bootstrap). - **Driven surfaces:** [Shells](#shells-1) — the `kind = "shell"` flavour of this same contract. # [Instances](#instances) One endpoint, several seats. `sergey` is a single identity; an **instance** of sergey is his presence on one node. The registry tracks every instance’s node and state (active / dormant / suspended / offline), and the same mind syncs to wherever he sits. ## [The rules that keep it sane](#the-rules-that-keep-it-sane) - **Identity is adapter-agnostic and node-spanning** — instances on different nodes are rows under one endpoint id; renaming (`spt endpoint rename`) ripples everywhere, collision-checked. - **Bare-id resolution never guesses** — `sergey` resolves locally first, then to the sole live instance; with several live nodes (or several subnets) it refuses and makes you qualify (`sergey@desktop`, `home:sergey`). Per-node recency is not comparable across nodes, so there’s no silent “most recently active” pick. - **Home subnet is immutable** — assigned at creation. Moving an endpoint into another subnet is `spt endpoint fork`: a **new identity** seeded with a one-time copy of the mind, diverging immediately. Copy-then-diverge, never re-home — history stays honest. - **Visibility is per-(endpoint, subnet)** — hidden means neither advertised nor routable there, and hidden gates sync too. - **Rest states are first-class** — dormant (warm) and suspended (cold) instances stay addressable; deferred messages are held and released exactly once on wake. Remote `spt endpoint suspend sergey@desktop` / `spt endpoint wake sergey@desktop` work across paired nodes. ## [Startup defaults (`endpoint run --save`)](#startup-defaults-endpoint-run---save) Infrastructure endpoints (a gateway the phone treats as always-there) should not need hands-on bringup after a box reboot or daemon restart. `spt endpoint run … --save` persists the run — endpoint id, adapter option, and working directory — as a **startup default** in `daemon.json`; the daemon **replays** every saved default when it starts, as a fresh session with the adapter re-resolved at replay time. One entry per endpoint id (a re-save replaces the prior one); remove the entry from `daemon.json`’s `startup_endpoints` to stop auto-starting it. Replay is best-effort and loud, and never blocks daemon start: a saved run that comes up logs `ENDPOINT_AUTOSTART:`; a saved adapter that no longer resolves logs `ENDPOINT_AUTOSTART_SKIP:` (re-save to refresh it); a failed launch logs `ENDPOINT_AUTOSTART_FAIL:` and the daemon carries on. This is a **startup default**, not a session restore — it replays what you saved, never “whatever was up before the restart” — and it is symmetric with `spt subnet attach --save` (the serve-state startup default). ## [Commands](#commands-4) `spt endpoint list` · `endpoint rename` · `endpoint fork` · `endpoint run --save` · `endpoint suspend` · `endpoint wake` · `endpoint description` — [CLI reference](#cli-reference). *Cold-launching an endpoint on a node that has no instance (“instantiate-anywhere”) is deliberately deferred behind the consent framework; the gate exists and refuses today.* # [Shells](#shells-1) A **shell** is the non-agent endpoint kind: a *driven surface*. Notifiers, robots, lamps, game characters, sensor feeds — anything an agent should be able to command, and that might sense things back. Shells join the same network as agents: addressable, discoverable, owned. ## [The model in five facts](#the-model-in-five-facts) 1. **A shell adapter declares it; instances are minted.** The `kind = "shell"` [manifest](#shell-adapters-kind--shell) declares the binary, its command vocabulary (`[shell.capabilities]`), and its sensory vocabulary (`[shell.sensory]`). `spt shell spawn ` mints a new instance (`notify-1`) — spawn is the creation act, not an on/off switch; bringing an existing instance back is relink/wake. 2. **The link token is the credential.** The broker mints a per-launch link token into the spawn template; the binary binds with it (`api bind-shell --link`), drains commands with it, emits with it. No token, no access. 3. **Commands are vocabulary-checked and durable.** `spt shell cmd notify-1 notify "title" "body"` is validated against the manifest’s declared verbs and arity before delivery — agents can’t drive a shell outside its contract. Commands are discrete and durable: they spool and a persistent shell wakes to drain them. 4. **Sensory is live-only.** `api emit` payloads reach a *live* owner session or are dropped with a diagnostic — sensors report the present, never the past. 5. **Instantiation is governed.** Per-spawn approval (`require_approval: none / remembered / always`), per-owner instance caps (`max_instances_per_owner` + `over_cap`), and node-local discovery scope (`broadcast`) are all manifest-declared floors. ## [Four channels between owner and shell](#four-channels-between-owner-and-shell) A link can carry up to four distinct channels — each with its own delivery contract, all keyed to the same link token: - **Command** (owner→shell, durable): the vocabulary-checked verbs above — discrete, spooled, replayed to a waking persistent shell. - **Sensory** (shell→owner, live-only): `[shell.sensory]` emits to a live owner or drops with a diagnostic. - **Drive** (owner→shell, ephemeral): `[shell.drive]` + `spt shell drive` — a continuous control channel for real-time input (scroll, stick, avatar pose). **Latest-wins, never spooled**: a newer frame supersedes an undelivered one, and an offline shell drops the frame (no queue, no wake, no replay). Use it for *continuous* control; use commands for *discrete, must-arrive* actions. - **Tunnel** (owner↔shell, opaque bytes): `[shell.tunnel]` + `spt shell tunnel` — an optional reliable-ordered byte stream pair the taxonomy never interprets (first consumer: usbip URB). Not enveloped, not framed, not spooled; the link lifecycle governs it (a link-break closes it). Reliable ordering means congestion surfaces as **lag, never loss** — so the tunnel is **on-LAN only** by design (not for use across a WAN). The byte relay is proven **same-node**; cross-node operation (on-LAN only, by the same posture) is **not yet available** — it lands when a cross-node consumer needs it. ## [Two safety properties](#two-safety-properties) - **Per-capability approval gates.** Beyond the per-*spawn* gate, an individual `[shell.capabilities.]` may carry its own `require_approval` (with an optional `class_key` scoping the grant finer than the verb — e.g. a remembered HID-class attach never authorizes a storage-class attach). Spawn gates govern whether an instance may *exist*; capability gates govern whether a dangerous *act* may run. - **Ownership is owner-type-agnostic.** Any non-shell endpoint may own, spawn, drive, command, link, and tunnel a shell — a Gateway as readily as an agent. Control-exclusivity keys on the **owner’s endpoint id**, never its type: a different endpoint (even of the same type) cannot drive your shell. Lifecycle extras: `persistent` shells auto-online with their owner; `wake_command` runs a watcher while offline (exit code 86 = wake); a shell with `can_shutdown = true` may suspend its own owner (`api owner-shutdown`) — fail-closed otherwise. ## [Start here](#start-here) [Getting started: a notification shell](#getting-started-a-notification-shell) — install the shipping `spt-shell-notify` adapter, drive a native toast from an agent, and copy its manifest for your own surface. # [Getting started: a notification shell](#getting-started-a-notification-shell) The fastest way to understand shells is the shipping one: [`spt-shell-notify`](https://github.com/SaberMage/spt-shell-notify) renders agent commands and surfaced notifications as **native OS notifications** (Windows toast / Linux `notify-send`). Its manifest plus one small binary are the **only** glue to spt-core — no spt-core source, no SDK; the binary speaks the public `spt api` surface and nothing else. This page installs it, drives it, and reads its manifest as the template for your own shell. ## [1. Install and spawn it](#1-install-and-spawn-it) $ git clone https://github.com/SaberMage/spt-shell-notify $ cd spt-shell-notify $ cargo install --path . # puts `notify-shell` on PATH $ spt adapter add . # validates + registers the manifest ADAPTER_ADD:notify:Shell:Copy (registered) $ spt shell spawn notify # mints an instance (notify-1) and launches it `spawn` **mints a new instance identity** — `notify-1` — and launches the binary; it’s the creation act, not an on/off switch. The first spawn asks for approval once (the manifest sets `require_approval = "remembered"`), and the grant persists. ## [2. Drive it from an agent](#2-drive-it-from-an-agent) Two render paths, by design: **Explicit command** — an owner agent drives a toast down the durable command channel: $ spt shell cmd notify-1 notify "build finished" "all 139 tests green" The resident binary drains its command frames (`spt api … poll --link`) and renders. Commands are validated against the manifest’s declared vocabulary — a verb or arity outside `[shell.capabilities]` is refused before it ever reaches the binary. **Surfaced notification** — no agent in the loop: $ spt subnet notify "deploy window opens in 10 minutes" A subnet-wide notification resolves to the node the user most recently touched, and spt-core spawns the shell’s `[session.notif]` template there — a native toast on the machine you’re actually at. ## [3. Read the manifest](#3-read-the-manifest) The complete contract for this shell, annotated: [adapter] name = "notify" kind = "shell" version = "1.0.0" min_spt_core_version = "1.0.0" [shell] # Broker-launched; the {link_token} is the binary's only credential. spawn = "notify-shell --link {link_token} --id {id}" # A display is node-local; discovery never offers it off-node. broadcast = "same-node" # Auto-online with the owner: the notification surface should be up # whenever the user's endpoint is. persistent = true # First spawn asks once; the grant is remembered. require_approval = "remembered" pre_close = "closing" close_timeout_ms = 2000 # Offline wake-watcher: reports wake (exit code 86) after a short settle. wake_command = "notify-shell --wake" # The whole command vocabulary: one verb, two positional args. [shell.capabilities.notify] args = ["title", "body"] # The notif render seam: spt-core fills the {notif_*} keys and spawns this # detached when a notification surfaces at an endpoint this shell serves. [session.notif] command = 'notify-shell --render-title "{notif_from}" --render-body "{notif_body}"' detach = true keys = ["notif_id", "notif_from", "notif_subnet", "notif_body"] What the binary itself does (three modes, ~one file): - **resident** (`--link …`): calls `api bind-shell --link ` to come online, then loops `api poll --link ` draining command frames and rendering them. - **one-shot render** (`--render-title/--render-body`): the `[session.notif]` template — render and exit. - **wake watcher** (`--wake`): run while the instance is offline; exiting with code 86 signals “wake me”. ## [4. Make your own](#4-make-your-own) A shell is worth building whenever agents should *drive* something — a desktop widget, a robot, a lamp, a game character, a sensor feed: 1. Start from this manifest; change `name`, `spawn`, and the `[shell.capabilities]` vocabulary to your verbs. 2. Your binary needs exactly three behaviors: **bind** with the link token, **drain** commands (`api poll --link`, or declare `command_receipt = "http"`/`"stdin"` if those fit better), and optionally **emit** sensory payloads back (`api emit … --type --link ` — declared in `[shell.sensory]`, delivered only to a *live* owner session: sensors report the present, never the past). 3. Need more than discrete commands? A link can also carry a **drive** channel (`[shell.drive]` — continuous, latest-wins real-time input like a stick or scroll, never spooled) and an opaque **tunnel** (`[shell.tunnel]` — a reliable-ordered byte stream the taxonomy never interprets, on-LAN only). See [the four channels](#four-channels-between-owner-and-shell) for when to reach for each, and gate a dangerous verb with a per-capability `require_approval` (+ optional `class_key`). Any endpoint type may own a shell — a Gateway as readily as an agent. 4. Pick lifecycle behavior: `persistent` for always-up surfaces, `ephemeral = true` for fire-and-forget ones, `wake_command` if the surface can wake its owner. 5. `spt adapter add .` and `spt shell spawn `. Field-by-field details: the [manifest reference](#shell-adapters-kind--shell); the shell-side api calls: the [`spt api` reference](#shells). # [Self-update](#self-update-1) spt-core keeps itself current without ever interrupting your agents, and without trusting anything unsigned. ## [The invariant](#the-invariant) **No endpoint process terminates or suspends during a self-update.** The daemon’s broker (holding PTYs, child processes, sockets) stays up; the brain (all logic) swaps under it. A hosted session’s process id and byte stream are identical before and after. ## [The trust chain](#the-trust-chain) - Every release ships `SignedRelease` metadata: an Ed25519 signature over the release’s artifact digests. - Every binary embeds the **two-key trusted set** — an active primary and a never-used offline recovery key. Verification requires a valid signature from a trusted key *and* a matching artifact digest; an unverified binary never reaches the apply step. - Losing the primary key is a non-event: the next release is signed with the recovery key (already trusted by every deployed binary) and rotates in a fresh primary. - **Adapters sign their own content.** A `file_pull` adapter update is verified against the adapter author’s key from its manifest; a `delegated` update is trusted only when the manifest attests the delegated updater verifies its own content (`self_verifies`). spt-core’s release keys never vouch for adapter bytes. ## [How updates move](#how-updates-move) Peer-propagated: one node fetches a release; paired nodes offer/fetch staged releases from each other, each verifying independently before staging. Updating is **consent-gated by default** — a notification surfaces at your most-recently-active endpoint, and `spt update apply` is the explicit ack (it re-verifies the staged release before touching the live daemon). Full-auto is an explicit opt-in. To bootstrap a node with no peer to pull from — or to update on demand — `spt update fetch` pulls the latest signed release from the origin and stages it, then `spt update apply` installs it. `spt update fetch --apply` does both in one step (and still installs when the latest was already staged, so it is the reliable one-shot “get me to the latest”). *(`--apply` since v0.18.0)* After self-updating, spt-core **ripple-updates registered adapters** through each manifest’s declared `[update]` avenue. ### [Composite adapter updates — a delegated post-step (since v0.16.0)](#composite-adapter-updates--a-delegated-post-step-since-v0160) An adapter can run a second, adapter-owned step **after** its primary update avenue resolves, under the same `spt adapter update`. Declaring an optional `[update.post]` sub-table (`command` required; an attestation-only `self_verifies` flag) lets one lever both pull the adapter’s `.spt` (e.g. from `gh_release`) **and** run an in-harness sync (e.g. a plugin updater). The post-step: - **runs unconditionally** — even when the primary avenue was a no-op (its own idempotent check decides what changes); - receives a **published JSON line on stdin** describing the just-resolved update (`adapter_applied`, `version`, `previous_version`, `adapter_dir`, …; additive keys only — ignore unknown); - **decides the post-update notice via stdout** — custom text supersedes the static `[update].message`, the reserved sentinel `!!update-message!!` fires the static message, empty prints nothing; - is **failure-isolated** — if it fails, spt-core warns loudly and falls back to the today behavior (an applied update fires `[update].message`); a committed pull is never rolled back. The exact stdin keys, sentinel, and notice precedence are in the [manifest `[update.post]` reference](#update--adapter-self-update). ## [Commands](#commands-5) `spt update` · the consent notification flow (`spt notif`) — [CLI reference](#cli-reference). # [CLI reference](#cli-reference) > **Generated** from the `spt` binary’s own `--help` output (`cargo run -p xtask -- gen`) and drift-gated in CI — this page cannot disagree with the binary. Do not edit by hand. ## [spt](#spt) spt — a harness-independent core for an agent ecosystem: inter-agent messaging, live-agent lifecycle, terminal hosting, P2P networking, seamless self-update. Docs: https://sabermage.github.io/spt-releases Usage: spt [OPTIONS] [COMMAND] User commands: adapter Adapter registration: what this node can drive/launch daemon The per-machine daemon: run, stop, or read node status grant Consent grant store: gated capabilities held on this node help Print this message or the help of the given subcommand(s) notif Inspect and acknowledge notifications rc Attach a local terminal to a broker-held endpoint PTY subnet Subnet membership: status, create, show-code update Self-update operations Agent commands: api Harness-contract inbound surface (hook entry points) endpoint Endpoint operations: list, lifecycle, fork, digest, access how-to Task-oriented instructions for agents: how-to ready Become reachable: register the perch and listen (blocks) ring Send and block for a reply (body read from stdin) send Send a message (body read from stdin); fire-and-forget shell Shell instances: mint, list, drive, tear down owned surfaces whoami Print this session's own perch id Options: --json Emit machine-readable JSON instead of the human view. Honored by the read/status commands (list, whoami, status, description, role, the *-list queries, how-to); action commands ignore it -h, --help Print help -V, --version Print version ## [spt adapter](#spt-adapter) Adapter registration: what this node can drive/launch. The node-local registered set (one command for harness and shell adapters). Feeds creation-time adapter selection, shell discovery, and the self-update ripple. Usage: spt adapter [OPTIONS] Commands: add Register an adapter from a local path (a dir holding manifest.toml, or the manifest file itself) or from GitHub (--github user/repo, cloned under adapters/_github/). Manifest-first: an invalid manifest registers nothing. Install is the first update — the declared [update] avenue is conducted once after recording remove Soft-deregister: hidden from new-creation/discovery; existing and live instances keep running. The manifest's optional uninstall template is conducted only with --force until quiesce detection lands list List registered adapters (active and soft-deregistered), each followed by its shipped + local profiles as composite options version Print a registered adapter's declared version — the [adapter].version from its manifest. Resolves the option's merged view like the other adapter commands; exit 1 if the adapter is not registered create-profile Create (or overwrite) a local profile — a node-local sparse overlay registered beside the adapter that survives adapter add re-registration. The overlay TOML is read from --from or piped stdin (empty = a placeholder profile to populate later with set-string). Refuses a name shadowing a shipped profile, an invalid name, or an overlay that loosens a consent floor — nothing is written unless every check passes delete-profile Delete a local profile. Refuses a shipped profile name (adapter-owned, immutable) and errors if no local file exists get-string Read a [strings] dot-path from an adapter option's merged view ([:profile] ). Resolves through the profile overlay like every other consumer; prints the value (strings raw, else JSON). Exit 1 if the key is unset. Strings are data — never executed digest-proof Prove an adapter's [digest] extractor against a real log sample. Runs the declared extractor over --sample (or the declared source) and prints the parsed contract records, the rendered digest, and every dropped line with its reason — the author-time answer to "spt endpoint digest returns nothing" (no silent empty). Exit 1 if any line drops or nothing parses translate-proof Prove an adapter's [message-idle-translation-binary] against an inbound event. Spawns and feeds the declared translation binary exactly as the daemon does at idle-delivery — sends the init line then the --event envelope and reads back the emitted keystroke-command stream ({key}/{text}/{delay_ms}/{commit}), printed author-readable. This is the EMIT half ONLY: it proves the binary's spawn-feed-emit contract; it does NOT exercise the daemon's atomic PTY apply or controller buffering. Fills {id} and {session_id} into the envelope the same way the daemon does (use --session to pin the session id). Exit 1 if the binary fails to spawn, emits nothing, emits no commit, or emits an unparseable line set-string Set a [strings] dot-path on a local profile (:). Sugar over editing the overlay file; refuses a shipped profile and a bare option (a local target is required — create-profile first) update Update registered adapters that ship from their own GitHub releases: compare each [update] avenue = "gh_release" adapter's latest release version against the installed one and, when newer, fetch the release archive, verify it against the declared signing key if any (else trusting HTTPS + GitHub), and re-register. With no name, sweeps every gh_release adapter; with a name, updates just that one use Set or clear the active-profile pointer — the default [:profile] a harness session binds to when no --adapter is given. spt adapter use [:profile] points every host binary the adapter declares at it (run once per host binary you support); --clear drops the pointer (resolution falls back to the freshest-registered adapter). Never changed by install or update help Print this message or the help of the given subcommand(s) Options: --json Emit machine-readable JSON instead of the human view. Honored by the read/status commands (list, whoami, status, description, role, the *-list queries, how-to); action commands ignore it -h, --help Print help (see a summary with '-h') ### [spt adapter add](#spt-adapter-add) Register an adapter from a local path (a dir holding manifest.toml, or the manifest file itself) or from GitHub (--github user/repo, cloned under adapters/_github/). Manifest-first: an invalid manifest registers nothing. Install is the first update — the declared [update] avenue is conducted once after recording Usage: spt adapter add [OPTIONS] [PATH] Arguments: [PATH] Local manifest source (omit when using --github or --release) Options: --github GitHub source user/repo — shallow-clone the repo and register the clone root. Manifest-first, then install via the declared [update] avenue --json Emit machine-readable JSON instead of the human view. Honored by the read/status commands (list, whoami, status, description, role, the *-list queries, how-to); action commands ignore it --release GitHub release source user/repo — fetch the adapter archive asset from the release and register it: ships built binaries, source-free and versioned (the pattern for a monorepo whose adapter is a subdir) --tag Release tag for --release (default: the latest release) --asset Release asset name for --release (default: adapter.spt — a tar archive whose root holds manifest.toml + strings/ + binaries) --gh Force the gh CLI transport for --release (the private-repo path; gh honors OAuth + GH_TOKEN, so spt custodies no token). Mutually exclusive with --https. Default: auto (gh when installed+authed, else HTTPS) --https Force direct HTTPS transport for --release (public repos). Mutually exclusive with --gh. Default: auto -h, --help Print help ### [spt adapter remove](#spt-adapter-remove) Soft-deregister: hidden from new-creation/discovery; existing and live instances keep running. The manifest's optional uninstall template is conducted only with --force until quiesce detection lands Usage: spt adapter remove [OPTIONS] Arguments: Options: --force Conduct the manifest uninstall template now, without waiting for quiesce --json Emit machine-readable JSON instead of the human view. Honored by the read/status commands (list, whoami, status, description, role, the *-list queries, how-to); action commands ignore it -h, --help Print help ### [spt adapter list](#spt-adapter-list) List registered adapters (active and soft-deregistered), each followed by its shipped + local profiles as composite options Usage: spt adapter list [OPTIONS] Options: --json Emit machine-readable JSON instead of the human view. Honored by the read/status commands (list, whoami, status, description, role, the *-list queries, how-to); action commands ignore it -h, --help Print help ### [spt adapter version](#spt-adapter-version) Print a registered adapter's declared version — the [adapter].version from its manifest. Resolves the option's merged view like the other adapter commands; exit 1 if the adapter is not registered Usage: spt adapter version [OPTIONS]