Adapter patterns & pitfalls
The 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, and the spt api 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 ({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 manifestsourceas 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).
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; 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} --adapter {adapter_name} | Seed the endpoint — 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 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. 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:sendtoken can arrive asC:/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 returnsNO_SUCH_TOPIC:<topic>).- For any verb,
spt <verb> --helpis the always-present source-of-truth — it 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 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
sourceor a[history].locate_template.spt adapter addrequires this even though the JSON schema alone would accept a bareextractor; 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:--ina directory (locate the session) and--ina direct file (thedigest-proof --samplepath). - 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
sourceas 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: 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 a profile selected at
seed time (spt api --adapter <adapter>:<profile> seed) drives the spawn
decision all the way through — the bound profile governs the full runtime
lifecycle, beyond bringup argv.
psyche_initfills exactly four keys:{id, session_id, psyche_dir, psyche_prompt}. spt-core overrides{id}to<parent>-psychebefore 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>-psycheperch, 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.commandis adapter-authored and opaque to spt-core. Declare the seam and build the runner, and let the daemon own spawn and teardown (a gracefulendpoint shutdowntears 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.
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 whoamireads), 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
sptstate — 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 softadapter remove(leaving the registry clean). Gate it behind an opt-in env flag and a minimumsptversion, since it mutates the node-local registry. - Two author-time tools work without a live session:
spt api --adapter <a> --manifest <file> capabilityreports the manifest’s hostable types from the manifest alone — 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 passing proof means it works at runtime. (Use a recentspt— current binaries fill the full 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.