# Adapter patterns & pitfalls

The [integration checklist](integration-checklist.md) 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.md), and the [`spt api`](api.md) 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

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](manifest.md#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

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. 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

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 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 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

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 <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:

- **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 `<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**. Route that stdout into your harness's
  injection channel — that routing is adapter glue.

### 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 `</EVENT>` 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 `/<skill>` 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

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 <topic>` 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:<topic>`).
- For any verb, **`spt <verb> --help` is the always-present source-of-truth** —
  it 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 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

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 `<session_id>`'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 <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 is **intrinsically authenticated**: for a broker-spawned session the
broker parentage is the proof, so `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 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** 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 <adapter>:<profile>`](../cli/reference.md))
or an explicit `--adapter <adapter>:<profile>` override on the `listen` call.

- `psyche_init` fills exactly four keys: `{id, session_id, psyche_dir,
  psyche_prompt}`. spt-core **overrides** `{id}` to `<parent>-psyche` before
  substitution, so the companion gets its own derived perch id. (The
  resume/preload key `{psyche_context}` is a separate seam; a first spawn fills
  it on resume.)
- The companion is launched **detached and fire-and-forget**: `detach = true`,
  `cwd = "{psyche_dir}"`, stdio null, handle dropped, **unsupervised**. Liveness
  stays daemon-authoritative through the companion's perch. It owns the
  `<parent>-psyche` perch, communicates over its perch and commune file-drops,
  and exits at session end.
- **The companion runner is yours to build; its lifecycle is the daemon's.**
  `psyche_init.command` is adapter-authored and opaque to spt-core. Declare the
  seam and build the runner, and let the daemon own spawn and teardown (a
  graceful `endpoint shutdown` tears the companion down with the perch).
- **If your harness's headless mode runs one turn per invocation**, make the
  runner a small **resident wrapper** so it persists across pulses: 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.

### Prove live bringup non-interactively

To prove your live path actually brings a companion up — 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 <os-pid> --session-id <sid>`
   (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 <id>
   <probe>` `QUEUED`s against it, ready to drain on bringup.
3. **Spawn the persistent relay as a child**, capturing its stdout/stderr:
   `spt api listen <id>` (no `--once` — that exits after one delivery). The adapter
   resolves from your `[adapter] host_binaries`; pass `--adapter <a> --manifest <m>`
   only to pin a specific adapter/profile. Assert `BOUND:<id>` then `READY:<id>` on
   its stderr, and the relayed `<EVENT>` carrying your probe on its stdout.
4. **Assert the companion came up.** The relay marks the perch online; the
   **daemon** hosts the Psyche off that status — liveness is daemon-authoritative,
   the relay process does not spawn it. So assert the companion's `<id>-psyche`
   perch comes online and the endpoint reports kind `live_agent`; with a live
   daemon the host marker `LIVEHOST_PSYCHE:<id>` is on the **daemon's** stderr,
   not the relay child's.
5. **Kill the child** to end the session — the relay is freely killable; the
   Psyche lifecycle is the daemon's (a graceful `spt endpoint shutdown <id>`
   tears it down with the perch).

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

Commune and signoff are delivered as **file-drops** by design. 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** 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

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 `<adapter>-ci-<n>`, 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

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 <a> --manifest <file> 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 <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 passing proof means it
    works at runtime. (Use a recent `spt` — current binaries fill the full key
    map.)
  - `spt adapter translate-proof <a> --event '<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.

<!-- [doc->REQ-ADAPTER-PROOF-DIR-OVERRIDE] -->
  - **Proof a DEV build off disk — `--dir` / `--manifest`.** Both `digest-proof`
    and `translate-proof` accept `--dir <install-dir>` (binaries resolve there,
    just like a registered install) or `--manifest <file>` (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 `<dir>/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

- **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.
