# Adapter patterns & pitfalls

The [integration checklist](integration-checklist.md) tells you *which* surfaces
to wire. This page is the field guide: the **non-obvious lessons** that decide
whether an adapter merely registers or actually works — design rules, the traps
that bite, 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.md), and the [`spt api`](api.md) commands — not
aspiration. It is harness-agnostic; where one harness's quirk is the clearest
illustration it is called out as such, but the pattern generalizes to any
harness with the same shape.

## 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 cannot read
  an env var, branch on runtime state, or compute a value. spt-core substitutes
  `{key}` placeholders from a fixed [catalog](manifest.md#substitution-keys)
  (`{session_id}`, `{parent_pid}`, `{adapter_name}`, `{id}`, the digest/psyche
  keys); `~` expands to home (there is no `{home}` key). 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. If your
  harness can move its own state directory at runtime, for example, the manifest
  `source` is only a *fallback* root; the binary it points at must resolve the
  real location itself, because the manifest cannot express "wherever the harness
  put it this time."
- **Corollary — a `.toml`-only leaf has no code of its own**, so you can only
  verify it by registering and resolving it on the live binary (below), never by
  unit-testing it. Anything you want covered by real tests must live in a binary
  (an extractor or runner), not a manifest leaf.

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

An adapter — its manifest, profiles, `[strings]`, the `[digest]` extractor, any
runner binaries — is registered with **`spt adapter add <dir>`** 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 — not part
of the contract. If you go that route: keep no binary, manifest, or runtime
state in the plugin (those ride the registry), and version the plugin
independently of the manifest/binary.

## Profiles are sparse leaf-replace overlays

A profile is selected as the composite `<adapter>:<profile>` and
**leaf-replaces only the leaves you declare** — everything else inherits from
base. Override exactly what differs:

- `[profiles.<name>.session.self].command` — retarget the bringup command (for
  example, wrap the launch in another binary).
- `[profiles.<name>.digest].<key>` — widen one digest knob.
- `[profiles.<name>.session.psyche_init]` — add the [live-agent
  seam](#the-live-agent-companion-seam); its mere 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 <adapter>:<profile> <key>`
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 **only if** that binary
is a drop-in for the base harness binary on the same argv and lets inherited env
pass 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

spt-core supplies the harness-**independent** `spt api` primitives and their I/O
format. *You* author all harness-specific wiring — spt-core never materializes a
harness-native hook config. 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} --adapter {adapter_name}` | Seed the endpoint. **Not** a blocking listen. |
| 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 never infers idleness from quiescence. |
| ends the session | `api session-end {session_id}` (or `api shutdown <id>` 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:

- **The blocking listen/poll loop is a skill the user runs, never a startup
  hook.** A hook that blocks hangs session bringup. Seed on start; let an
  explicit `/ready`-style skill own the blocking stream.
- **Message delivery is stdout framing.** `api poll` emits the self-delimiting
  envelope `<EVENT type="msg" from="<sender>">body</EVENT>` (the live listener
  stream uses the same shape). Multi-message drains split cleanly on `</EVENT>`.
  Decode a body by splitting on `<br>` → newline, then HTML-unescaping
  `&lt; &gt; &quot;` and `&amp;` **last**. Routing that stdout into your harness's
  injection channel is adapter glue.

### Traps in the hook layer

These are the ones that cost a debugging session if you meet them blind.

- **If your injection channel has a size cap, pre-empt it.** Some harnesses
  truncate or spill an over-large injected blob to a file — which *evicts it from
  the context the agent actually sees*, silently dropping messages when a big
  drain (or a big skill body plus a drain) exceeds the threshold. Cap the
  combined hook output adapter-side: under the limit, pass through verbatim; over
  it, spill the **full** text to an agent-readable file and inject only a short
  pointer. Never cut mid-`<EVENT>` — that splits an envelope and drops a message.
- **Inject a skill body *before* the perch gate; gate only the message drain.**
  If the same prompt hook both injects a requested skill's instructions and
  drains messages, run the skill-body injection first. Skills like "who am I" or
  "set me up" are valid with **no readied perch**, so gating their injection on a
  bound perch silently breaks exactly the skills a new user reaches for first.
  (Match the skill token as a leading token so prose merely *mentioning* it does
  not fire.)
- **The setup/installer skill must be self-contained in its stub.** Skill bodies
  are usually delivered by resolving them through `spt adapter get-string` — but
  the setup skill runs precisely when the binary may be **absent** (installing it
  is the job). A skill whose precondition is "spt is missing" cannot source its
  own instructions from spt. Carry its operative steps in the harness-native stub
  (the floor); let any file-backed body only *mirror* them for the
  binary-present repair path. The one skill that most needs delivery is the one
  delivery cannot reach — plan for it.
- **Read hook inputs from stdin, never from a `/`-leading argv.** A hook receives
  its data (the prompt, the session id) as a JSON object on **stdin** — parse
  that. Do not reconstruct a `/`-leading value (a `/<skill>` token, an absolute
  path) from a positional argument: under Git-Bash/MSYS on Windows, any argument
  beginning with `/` is silently rewritten to a Windows path before your command
  sees it (a `/foo:send` token can arrive as `C:/Program Files/Git/send`).
  Reading from stdin is immune. If a command genuinely must take such an
  argument, guard it (`MSYS_NO_PATHCONV=1`, or a file/stdin transport). Same
  class as the UTF-8-stdout trap below — a shell quirk that silently corrupts
  data, dodged by choosing a transport that isn't subject to it.

## `[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.
Containment is enforced at register time: a pointer that escapes the `strings/`
dir (via `..` or an absolute path) fails the add. 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 rather than hand-copying a summary that drifts** — two
always-current tiers:

- `spt how-to <topic>` is the task-oriented agent-guidance surface, but it covers
  only **selected** topics, each a canonical write-up of verbs, flags, and result
  codes. It is not exhaustive: an undocumented topic returns
  `NO_SUCH_TOPIC:<topic>`. Don't assume a `how-to` exists for every verb — probe,
  and fall through.
- For any verb without a topic, **`spt <verb> --help` is the source-of-truth** —
  it always exists and tracks the shipped binary. A skill body that says "the
  verb list is `spt <noun> --help` — match the user's intent to a verb" stays
  correct across releases.

Either tier beats a copied summary. They are also the fastest way to learn the
surface while authoring — it self-documents.

## `[digest]`: the transcript→record extractor

The `[digest]` seam maps your harness's native transcript into spt-core's
digest-record contract. The lessons that aren't in the schema:

- **It must name where it reads** — either `source` or a
  `[history].locate_template`. The JSON schema accepts `[digest]` with just an
  `extractor`, but `spt adapter add` **rejects** it; this cross-field rule only
  surfaces at registration. Validate against the live binary, not the schema
  alone.
- **`--in {source}` is a root, not a file.** The extractor is invoked
  `--session {session_id} --in {source}` and locates `<session_id>`'s transcript
  *within* that root — your harness's internal subdir scheme is yours to know;
  spt-core bakes no harness directory layout into the key catalog. Handle both
  shapes: `--in` a directory (locate the session) and `--in` a direct file (the
  `digest-proof --sample` path).
- **If the harness can relocate its state at runtime, resolve it in the binary.**
  The manifest `source` is only the fallback root; when a runtime value (an env
  var, an isolated profile) moves the real transcript tree, the extractor must
  prefer that value on its directory branch. The manifest can't express it — this
  is the "logic lives in binaries" rule in miniature.
- **Emit raw records, UTF-8.** Output one NDJSON line per record
  (`{role ∈ input|agent|tool, text?, tool?, ts?}`); spt-core's renderer applies
  the presentation defaults (`window_turns`, arg truncation, sprint collapse) —
  don't pre-render. And pin stdout to **UTF-8**: a binary that defaults stdout to
  the platform locale (cp1252 on Windows, say) mangles em-dashes and smart quotes
  into bytes spt-core can't decode, because it reads the stream as UTF-8.
  (Native-UTF-8 languages sidestep the whole class — which is part of why this
  seam is a binary, not a shell pipeline.)

Prove the whole path with `spt adapter digest-proof <adapter> --sample <file>`
(below).

## 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.<VAR>]` with `direction = "inject"`, `value = "{id}"`); the start hook
reads that env and self-registers with `api bind <id>`.

That bind needs **no credential token**: for a broker-spawned session, **auth is
intrinsic** — the broker parentage is the proof. `api bind <id>
--set-session-id <discovered>` 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 must
isolate.)

`adapter.shortcut_basename` brands the generated launcher shortcut
(`<basename>-<id>`) and is decoupled from the adapter name.

## The live-agent (companion) seam

An endpoint is a **live agent** if — and only if — its *resolved* manifest
declares `[session.psyche_init]`. There is no go-live verb. A base manifest
without it is a ready agent; a profile overlay that adds it makes a live agent.
spt-core checks this on the **merged** view, so a profile selected at **seed
time** (`spt api --adapter <adapter>:<profile> seed`) drives the spawn decision
all the way through — the bound profile governs runtime lifecycle, not just
bringup argv.

- `psyche_init` fills exactly four keys: `{id, session_id, psyche_dir,
  psyche_prompt}`. `{id}` is **overridden** by spt-core to `<parent>-psyche`
  before substitution — the companion gets its own derived perch id, not the
  parent's. (The resume/preload key `{psyche_context}` is a different seam; a
  first spawn has none.)
- The companion is launched **detached and fire-and-forget**: `detach = true`,
  `cwd = "{psyche_dir}"`, stdio null, handle dropped, **unsupervised**. Liveness
  is daemon-authoritative via the companion's perch, not its pid. It owns the
  `<parent>-psyche` perch, communicates by perch and commune file-drops (never
  stdin/stdout), and exits at session end.
- **The companion runner is yours to build, but its lifecycle is the daemon's.**
  `psyche_init.command` is adapter-authored and opaque to spt-core. Declare the
  seam and build the runner, but do **not** orchestrate the companion from the
  adapter — the daemon owns spawn and teardown (a graceful `endpoint shutdown`
  tears the companion down with the perch).
- **If your harness's headless mode runs one turn and exits**, a bare one-shot
  invocation can't be re-driven by a stop hook — so make the runner a small
  **resident wrapper**: seed the companion once from `{psyche_prompt}`, then
  drive one resume-turn per perch pulse, with the companion authoring commune
  drops. Build it like the `[digest]` extractor — a compiled, dependency-light
  binary the daemon can exec bare on any platform — not a shell script.

## Lifecycle continuity is file-drops, not `api`

Commune and signoff are **file-drops**, deliberately *not* `spt api` calls. The
agent writes `<endpoint_id>-commune.md` (delta context) or
`<endpoint_id>-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**; only the directory is adapter-declared — wire the directory
watch, never hard-code the filename. This is the single biggest continuity win,
so it is worth getting exactly right.

## Testing against a real harness: isolate identity

The only way to prove your hook wiring actually fires is an acceptance test that
**spawns a real harness session as the system-under-test**. Doing so trips a
framework property you must 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**. A
  second session that establishes a perch under an identity already held
  **displaces** the first — taking its active poll/listen stream with it.
- So a spawned test session that loads your adapter (whose start hook seeds and
  binds a perch) and **inherits the identity of the agent running the tests**
  tears that agent's perch out from under it.
- **The guard is identity isolation.** Give every spawned system-under-test a
  disposable identity distinct from any live agent — override **both** identity
  env vars before the spawn (a throwaway `<adapter>-ci-<n>`), never inherit the
  operator's. Under a distinct identity the nested session and the operator's
  perch coexist cleanly. Identity is the key, so identity isolation is the whole
  guard.
- Keep the orchestration deterministic and **assert on a hook side-effect** — a
  marker or digest file, or `spt` state — never on model output. The harness is
  the system-under-test, not the test runner.

## Validate against the live binary

JSON-schema validity is necessary but **not sufficient**: `spt adapter add` runs
cross-field registration checks the schema can't express (the `[digest]` source
rule is one). Registration is a second gate — 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` (leave the registry clean). Gate it behind an opt-in
  env flag and a minimum `spt` version — it mutates the node-local registry.
- Two author-time tools need no live session:
  - `spt api --adapter <a> --manifest <file> capability` reports the manifest's
    hostable types **without** a full registry add — 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 <a> --sample <file>` 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 "passes proof" means
    "works at runtime." (Confirm against a recent `spt`; older binaries passed an
    empty key map.)

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

- **The full surface:** the [integration checklist](integration-checklist.md) —
  every contract surface grouped by necessity.
- **Reference:** the [manifest reference](manifest.md) and the
  [`spt api` surface](api.md).
- **Ship it:** the [install-on-demand bootstrap](install-on-demand.md).
- **Driven surfaces:** [Shells](../shells/overview.md) — the `kind = "shell"`
  flavour of this same contract.
