Adapter patterns & pitfalls
The integration checklist 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, and the spt api 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 ({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 manifestsourceis 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).
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; 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 pollemits 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< > "and&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:sendtoken can arrive asC:/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 returnsNO_SUCH_TOPIC:<topic>. Don’t assume ahow-toexists for every verb — probe, and fall through.- For any verb without a topic,
spt <verb> --helpis the source-of-truth — it always exists and tracks the shipped binary. A skill body that says “the verb list isspt <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
sourceor a[history].locate_template. The JSON schema accepts[digest]with just anextractor, butspt adapter addrejects 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:--ina directory (locate the session) and--ina direct file (thedigest-proof --samplepath).- If the harness can relocate its state at runtime, resolve it in the binary.
The manifest
sourceis 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:
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_initfills exactly four keys:{id, session_id, psyche_dir, psyche_prompt}.{id}is overridden by spt-core to<parent>-psychebefore 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>-psycheperch, 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.commandis 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 gracefulendpoint shutdowntears 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 whoamireads), 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
sptstate — 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 softadapter remove(leave the registry clean). Gate it behind an opt-in env flag and a minimumsptversion — it mutates the node-local registry. - Two author-time tools need no live session:
spt api --adapter <a> --manifest <file> capabilityreports the manifest’s hostable types without a full registry add — assert it advertises the type your bringup spawns. (A cleanaddalready proves the cross-field shape, since add is manifest-first;capabilityis 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 recentspt; 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 — every contract surface grouped by necessity.
- Reference: the manifest reference and the
spt apisurface. - Ship it: the install-on-demand bootstrap.
- Driven surfaces: Shells — the
kind = "shell"flavour of this same contract.