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

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 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).

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……fireWhy
starts a sessionapi seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}Seed the endpoint. Not a blocking listen.
submits a user turnapi poll {session_id}Drain the inbox to stdout (plus any keyword hints).
goes idle / busyapi state idle / api state busyHonest activity; spt-core never infers idleness from quiescence.
ends the sessionapi session-end {session_id} (or api shutdown <id> for graceful signoff)Teardown that preserves the spool + history.
spawns / ends a sub-agentapi worker-start / api worker-stopNested 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: 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 addadapter 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