Harness integration checklist
A working list for building a harness against spt-core. The
adapter quickstart gets one adapter breathing in
ten minutes; this page is the complete surface — every manifest section and
spt api command a harness touches, grouped by how badly you need it, each
tagged with the feature it buys and where in the interaction lifecycle
it fires.
Two seams only (the contract overview): the
manifest (declarative TOML) and the spt api surface
(imperative entry points your hooks fire). Nothing here is an SDK call —
everything is a manifest field or an spt invocation.
The running example is spt-claude-code — the modern Claude Code harness rebuilt on spt-core (the v1 reference adapter). Where a row says “claude-code: …” that is how that harness wires the surface. Concrete commands below are real and shippable today; the shipped harness-agnostic exercise is the mock adapter.
The interaction lifecycle
Every surface below belongs to one stage of a harness’s life with spt-core:
REGISTER ─► START ─► RUN ─────────────► BOUNDARY ─► END ─► KEEP-CURRENT
adapter perch messaging / context tear self-update
add seed→ activity / clear / down + ripple
listen history / inject compact
Group 1 — Required (no adapter exists without these)
The contract floor. Miss one and spt-core cannot host your sessions.
| Surface | Feature it buys | Lifecycle stage |
|---|---|---|
[adapter] manifest header (name, kind, version, min_spt_core_version, hostable_types) | Identity + the compat gate spt-core reads before any install/update; declares which endpoint types you can host | REGISTER |
spt adapter add <dir> | Parses + schema-validates + records the manifest; a bad field is rejected here, nothing half-registers | REGISTER |
[adapter] host_binaries (harness-hosted) | The bind-time match-key — names the harness exe(s) you host, so seed/listen resolve your adapter with no --adapter (since v0.9.0). --adapter <name[:profile]> stays available as an optional override | REGISTER |
| Startup pair — pick one flow: • harness-hosted: [hooks.SessionStart] → api seed --pid {parent_pid} --session-id {session_id} then the session’s api listen <id>• spt-hosted: [session.self] template (spt-core spawns it) then api bind <id> --set-session-id <sid> | A registered, held perch — the thing messages and lifecycle attach to. seed→listen = you own the process; spawn→bind = spt-core owns it | START |
api session-end <id> (or api shutdown, below) | Clean teardown that PRESERVES the spool + history so the next listen/poll drains the backlog | END |
claude-code: SessionStart hook fires api seed; the Claude Code session
runs api listen as its blocking listener (harness-hosted). SessionEnd fires
api session-end (soft — context survives a /clear and a relaunch).
Group 2 — Recommended (the integration is hollow without them)
Skippable to boot, but the harness feels broken without them — no inbound messages, identity lost on a context reset, no activity signal.
| Surface | Feature it buys | Lifecycle stage |
|---|---|---|
[hooks.Idle] → api state idle (and api state busy) | Honest activity — spt-core never infers idleness from terminal quiescence (it lies). Arms the echo gate, drives Psyche pulses + most-recently-active routing | RUN |
[inject] channels (activity / idle) + api poll <id> --include-deferred | Inbound message delivery. Declares HOW spt-core reaches the agent (hook inject vs. pull-relay); poll is the pull path for hooks that can’t inject | RUN |
Honest can_inject per hook | Lets spt-core route around a hook that can’t surface text — the load-bearing harness-varying fact | RUN |
api boundary <clear|compact> <id> --to-session-id <new> --session-id <prior> | The endpoint’s identity, spool, and history survive a context reset under a new session id. The proof is the PRIOR sid (--to-session-id is payload, not proof): persist the current sid at every SessionStart in adapter-owned state keyed by endpoint id, present it here; resolve the id from $SPT_ENDPOINT_ID; surface any refusal LOUDLY — a silent skip/refusal strands the perch on the dead sid (no delivery until relaunch). Validate end-to-end: after a reset, assert the perch record’s session id actually ROTATED and a post-reset message DELIVERS — a session that looks healthy can hide a stranded perch (see api boundary in api.md) | BOUNDARY |
api psyche-download <id> (fire at every session start — whatever your start path is — and inject its stdout) | The agent resumes with its mind — pulls the durable two-tier context (role / live / project) plus any not-yet-synthesized commune as <pending-*> slices, to inject as the session’s opening context. api boundary makes the mind survive a reset; this is how the next session reads it back in. Without it, a resumed session starts blank of its accumulated context. Not hook-shaped: a hook-driven harness fires it from SessionStart; an spt-hosted harness whose own extension owns bind has no SessionStart and must call it itself right after api bind; a harness-hosted go-live must call it explicitly too (promoting a running session does not replay SessionStart). Authenticate with the perch --token, not --session-id (a sid is also a lifecycle lever — it can re-pin the perch); inject stdout only, never stderr, or NO-CONTEXT/repin lines land in the agent’s context as if they were its mind (see api psyche-download in api.md). Omitting it fails silently: every other surface keeps working and the mind keeps being written; the agent just resumes knowing nothing | BOUNDARY / START |
[history] strategy (fetcher / locate_normalize / native + api history-log) | spt-core can read the session transcript — feeds the live digest and mind sync | RUN |
[identity] (session_id_source, parent_ancestor_name) | Post-spawn id resolution when the harness mints the session id itself | START |
[env.*] bridge (e.g. OWL_SESSION_ID) | The session learns its own endpoint id / context the harness must inject | START |
[update] avenue + command | Ripple-update: spt-core refreshes your adapter alongside its own self-update (REQ-UPD-5); also the install-on-demand bootstrap | KEEP-CURRENT |
[update.post] post-step (since v0.16.0) | A delegated step that runs after the primary avenue resolves — under spt adapter update and spt adapter add (install is the first update; since v0.19.0) — pull the .spt and run an in-harness sync from one lever. Foreground + bounded (120 s, never backgrounded); runs unconditionally; reads a published JSON line on stdin (adapter_applied, version, previous_version, adapter_dir, …); its stdout decides the post-update notice (custom text supersedes [update].message, the sentinel !!update-message!! fires it, empty is silent); failure is loud (ADAPTER_UPDATE_POST_FAIL on stderr + nonzero CLI exit) and isolated (never rolls back the pull — and the static message still fires, so verify-then-notify: see the manifest reference) | KEEP-CURRENT |
claude-code: Idle hook → api state idle; messages arrive over the hook
inject channel (can_inject = true), pull-relay fallback when busy.
PreCompact/clear hooks → api boundary; the SessionStart hook also runs
api psyche-download <id> and injects stdout, so a session resumes with its
accumulated two-tier mind (plus any pending commune). [history] strategy = "fetcher" (Claude Code’s transcript is a binary the fetcher reads). [update] avenue = "delegated", command = "claude plugin update spt" — the harness’s
own updater is the avenue.
Group 3 — Optional (capability-specific)
Reach for these when the capability applies; ignore them otherwise.
| Surface | Feature it buys | Lifecycle stage |
|---|---|---|
api shutdown <id> | Graceful signoff — runs the final echo-commune BEFORE teardown so the context delta is never lost to ordering | END |
api presence <id> / api driven-by <id> | Most-recently-active resolution across the subnet; lets a session tell local input from remote-drive | RUN |
Workers (api worker-start <parent> — the worker id is core-minted <parent>-w<N>, read it from stdout; worker-poll <id>/worker-stop <id> auth by the parent’s session id, no token — breaking change in v0.27.0) | Nested, short-lived sub-agents under a parent endpoint | RUN |
[digest] extractor (or api digest-entry) | A live activity digest (spt endpoint digest) — declare an extractor mapping your native log → the {role, text, tool, ts} contract (ADR-0019; its OWN seam, no longer riding [history]). Spans /clear via the session ledger; validate with spt adapter digest-proof. Classify a delivered user-facing message as a turn-opening input record (see below) so the v0.16.0 --last/seq cursor keeps its granularity | RUN |
[session.notif] template | Native OS notification render (toast / shell alert) for consent + capability prompts, instead of burying them in agent output | RUN |
[session.resume] template (spt-hosted) | The native-resume sibling of [session.self]: spt-core picks it over [session.self] when a bringup carries a prior session (spt endpoint resume, spt go on an offline endpoint, or the picker’s Resume from history). Declare your harness’s native-resume verb (e.g. claude -r {session_id}) — skip it and a resume re-runs the fresh command → a blank transcript. spt-core lands the PTY in the session’s recorded project cwd (a harness resolves a transcript by session_id + cwd) | START |
[message-idle-translation-binary] (spt-hosted) | A lifecycle-managed stdin→stdout JSON-lines binary that turns inbound <EVENT> messages into keystroke-commands spt-core applies to the PTY atomically (coexists with a live spt rc controller). The agnostic way to deliver messages into an idle spt-hosted session; busy delivery stays your [inject] hook path. Declare it with a command (program + args; adapter-static {adapter_dir}/{adapter_name} subst only — no session keys; new in v0.16.0, the bare path is deprecated). Validate the emit contract with spt adapter translate-proof | RUN |
api now-signal <id> --session <sid> (inject its stdout at every turn boundary) | The one situational-awareness funnel: peers named in the turn, monics fired, shell status, last messages, on/offline edges, and the only confirmation channel for shortform dispatches and ;; seal mints. Delta-only per session, so a poll with nothing new prints nothing and a quiet turn costs zero context. Tune it from [io.now_signal] with --spec-manifest. Already injecting api hint? That is now a thin alias over one category of this — inject one, not both | RUN |
api state busy|idle --payload-stdin (the turn’s text) | Feeds the IO funnel — USER_INPUT / AGENT_OUTPUT events shell binaries and (from releases#234) other consumers read. Purely additive: the same call with no payload behaves exactly as before. Report every payload span exactly once across the turn — core does not deduplicate. Add --mid to report a MID-TURN span of agent output on the busy arm (still an AGENT_OUTPUT event, carrying mid); spans and the idle remainder must be disjoint | RUN |
| api io-events <id> --session-id <sid> --json (optional; build behaviour on the session’s own events) | Read back the IO funnel events — USER_INPUT, AGENT_OUTPUT, MSG_IN, MSG_OUT, COMMUNE, COMMUNE_FAIL — as a delta-cursored poll (an AGENT_OUTPUT row carries "mid": true when it is a mid-turn span rather than the turn’s close), so you can act on what a session did without polling a digest and diffing it (wake-marker-class constructs over commune events are the motivating case). Same session id you pass to api state — it is both the cursor key and the auth, one flag. A new session’s first poll returns nothing and seeds silently; --after <seq> if you would rather carry your own cursor; --limit says when it capped. See the api surface | RUN |
| [io] compliance = true | Lets core parse your ingest for shortform — the @<targets body @> dispatch tag and the ;; seal mint — so an agent sends and seals from inside a turn without shelling out. Declare it in the release that deletes your own tag parser, never before: absent the declaration core parses nothing, which is what makes that handover free of a double-fire window | RUN |
| [adapter] shortcut_basename | Names the picker-generated project-root launcher <basename>-<id> (the picker’s s keybind) — your harness’s brand instead of the spt-<id> default | START |
| Shell surfaces (kind = "shell": api bind-shell --link, api emit, api owner-shutdown, the [shell] body) | Driven surfaces — notifiers, sensors, power buttons — authenticated by the launch link token alone. See Shells | START / RUN |
claude-code: uses api shutdown for graceful /signoff; declares a
[digest] extractor mapping its per-session JSONL → the digest-record contract so
spt endpoint digest shows live tool calls and spans /clear; declares
shortcut_basename = "cc" so the picker’s generated launcher is cc-<id> (vs the
spt-<id> default); declares [session.resume] as claude -r {session_id} … so a
picker Resume from history reloads the real transcript (not a blank session);
declares a [message-idle-translation-binary] (cc-spt-idle-translate) so inbound
messages reach an idle session as proper keystrokes; no shell body (it is a harness,
not a driven surface).
Group 4 — Beyond the API: integrations that make it good
Not contract surfaces — no api command, no required field — but the
difference between an adapter that works and one that feels native. Strongly
recommended.
| Integration | What it is | Why it matters |
|---|---|---|
| Commune / signoff file-drops | The agent writes <endpoint_id>-commune.md (delta context) or <endpoint_id>-signoff.md (final save) into the manifest’s watched commune_dir / signoff_dir; spt-core’s watcher ingests it. Delivered as a file-drop by design. | The two-tier mind: live + project context survives /clear, /compact, suspend, and cross-node resume. The single biggest continuity win — wire the directory watch and read the contract filename |
Resource advertisement ([session] resources blurb / spt endpoint description) | A free-text “what I can serve” string riding the endpoint’s registry rows | Other agents discover the endpoint’s capabilities (spt resources list) instead of guessing |
| Install-on-demand bootstrap | Pack the check-and-install of spt-core into your harness’s first run (the bootstrap pattern) | Zero-friction first run — the user installs your harness, spt-core comes with it |
Surfacing spt how-to <topic> to the agent | Let the agent read task-oriented spt-core guidance from the binary itself | The agent self-serves common operations (subnet join, sending) instead of asking the user |
| Presence-driven idle reporting | Fire api state idle from a real user-inactivity signal, not a timer | Accurate dormancy → Psyche wakes on genuine activity, echo-communes fire at true boundaries |
claude-code (the worked example): ships the modern two-tier mind end to
end — the session drops <id>-commune.md at every /clear and /compact, and
a Self-authored <id>-signoff.md at graceful stop, into the watched
commune_dir; declaring [session.psyche_init] promotes the endpoint to a
LiveAgent (a go-live gate — spt-core never spawns it), and the
[session.psyche_resume] per-event turn (+ the [session.echo_commune]
template) lets spt-core drive the Psyche that ingests them; [update] avenue = "delegated" makes the Claude Code plugin updater the
ripple avenue. That is the bar a native-feeling harness clears.
Patterns introduced in v0.16.0
Hook dispatch by resolve-not-execute
spt-core never grows a hook-execution surface — [hooks.<event>] stays
purely outbound (the harness fires fires; spt-core never runs a hook handler).
When your hook logic must live in an adapter binary (so it rides
spt adapter update) but the harness loads hooks from a static plugin dir, use
the two adapter-static substitution keys to resolve+run your own binary:
{adapter_dir}fills to your install dir (the registrysource_dir) and survives updates;{adapter_name}fills to your adapter name. Both are available wherever substitution runs — including, new in v0.16.0, inside[strings]values atget-stringread time (scoped to just these two adapter-static keys;get-stringhas no session context, so{id}/{session_id}are not available there).- Store the dispatch command in
[strings]:[strings] hook_cmd = "{adapter_dir}/claude-spt hook" - A thin, static per-OS dispatch wrapper (the one plugin-resident piece) runs
spt adapter get-string <adapter> hook_cmdonce per session (memoize the resolved string into an env var for a hot-path hook like PostToolUse), then executes the resolved command per-hook itself. spt-core only resolves and returns the string — it never executes it (ADR-0029).
claude-code: the plugin ships a static hooks.json + a per-OS dispatch
wrapper; the wrapper resolves get-string claude-spt hook_cmd →
<install_dir>/claude-spt hook once per session and runs it per-hook, so all
hook logic updates via spt adapter update claude-spt.
Incremental digest consumption — the --json cursor
spt endpoint digest <id> --json supports turn-end incremental consumption
(v0.16.0): --last <N> (the last N turns; --last 1 = the latest turn), a
stable per-entry seq (source-derived — re-projection yields the same seq;
it does not renumber when the window slides), and --after <seq> (entries newer
than seq still in the window; a full-window refresh + a predates signal if
seq has fallen out). The trailing in-progress turn is flagged partial: true
and its entries carry no stable seq until the turn closes (a turn is bounded by
a user-input) — a consumer reprocesses partial and skips entries <= seq.
seq is the authoritative dedup + cursor key.
The --json output shape
The snapshot is one pretty-printed JSON object; --follow --json emits one
compact JSON object per line (a delta stream). Shapes as of v0.26.0:
{
"turns": [
{
"input": "fix the bug",
"input_seq": 4294967296,
"entries": [
{ "Agent": { "text": "on it", "seq": 4294967297, "ts": "2026-07-06T09:00:00Z" } },
{ "ToolSprint": { "tools": [ { "name": "Write", "arg": "src/a.rs" } ], "seq": 4294967298 } },
{ "Boundary": { "kind": "clear", "ts": "2026-07-06T09:05:00Z" } },
{ "Context": { "kind": "owl_message", "body": "<EVENT type=\"msg\" from=\"lea\">ping</EVENT>", "ts": null } }
]
}
]
}
- Turn:
inputis the opening user-input text,nullfor a preamble turn (boundary/context entries that precede any input).input_seqandpartialare omitted when absent/false — a trailing open turn carries"partial": trueand its entries carry noseq. - Entries are tagged by kind — each entry is a one-key object whose key
is the kind. The closed kind set:
Agent(text, optionalseq/ts),ToolSprint(tools:{name, arg}in order,argpresentation-truncated; optionalseq= the last collapsed record’s, optionalts),Boundary(kind:clear|compact|boot), andContext(kind:psyche_download|echo_commune|owl_message, plusbody).Agent/ToolSprintomitseq/tswhen absent;Boundary/Contextcarry noseq(they are spt-injected, not transcript records) and theirtsis present-but-nullwhen unknown. - A delivered message can appear twice by design: as the turn-opening
input(your extractor’s classification, below) and as aContext/owl_messagerow whosebodyis the whole composed<EVENT>envelope verbatim — exactly what the agent saw. Parse it with the envelope rules; it is the message-identity anchor for dedup across the two appearances. --aftersignal: when the cursor predates the window, the snapshot is the full window plus a top-level"after_predates_window": true.- stderr trailer: every successful pull prints
DIGEST:<id> version=<n>on stderr (the version pairs with the delta stream below); an endpoint with no activity buffer reportsNO_DIGEST:<id>and exits non-zero. --follow --jsondelta lines:{ "version": <n>, "from": <i>, "turns": [ … ] }— apply by truncating your view tofromturns and appending;from == 0is a full replace (the base snapshot, or a window slide). Deltas are sent only when the digest actually changed.
Binding for your [digest] extractor / api digest-entry: classify a
delivered user-facing message as a turn-opening input record (equivalent to
a direct PTY user-input). The projection treats role: "input" as the turn
boundary; if messaging-delivered turns are not opened as input, a
messaging-driven session collapses into a few giant turns and --last/seq lose
granularity. What becomes input is your call; that it opens a turn is the
contract.
Global --json for read/status commands
The read/status command set (endpoint list/whoami, daemon status, subnet status/show-code, endpoint description/role, adapter list/version,
notif list, grant list, access list, shell list, how-to) honors a
global --json flag (v0.16.0) for scripted consumption — stable, explicit
per-command field names (a committed wire-parity surface). Action commands ignore
it. Flag reference: the CLI reference.
Committed compatibility posture for every --json shape: additive evolution —
new fields appear (often omitted-when-absent), existing fields are never renamed
or re-typed; parse tolerantly (ignore unknown keys).
endpoint list --json — the output shape
One object, three sections (as of v0.27.0):
{
"self": {
"id": "doyle",
"status": "live_agent",
"ready": true,
"alive": true,
"unbound": false,
"description": null,
"psyche_host_error": null
},
"subnets": [
{
"name": "home",
"endpoints": [
{
"id": "flynn",
"node": "1a2b3c…",
"node_label": "HFENDULEAM",
"status": "Active",
"resources": null,
"endpoint_type": "live_agent",
"project": "spt-mobile"
}
]
}
],
"local": [
{
"id": "doyle",
"state": "live_agent",
"address": "127.0.0.1:52110",
"ready": true,
"alive": true,
"unbound": false,
"project": "spt-core"
}
]
}
self— the calling session’s own endpoint,nullwhen the session has no perch.statusis the local perch state token (live_agent,ready_agent, …;nullwith no local perch),ready/alivelikewisenullfor a pinless session.descriptionis the endpoint’s authored description ornull. The fault annotations (each a string, and each meaning the human view shows the same fault line) do not all signal absence the same way, so test for the fault itself rather than for the key:translation_faultandhost_errorare omitted entirely when absent, whilepsyche_host_erroris always present andnullwhen absent — as the example above shows. Keying on whetherpsyche_host_erroris present reads every clean perch as faulted.subnets— one group per subnet the node belongs to,endpointsfrom the subnet’s gossip projection.statusis the ADVERTISED cross-node state, closed set:Active|Dormant|Suspended|Offline.nodeis the hosting node’s key prefix,node_labelits display label (ornull).resourcesis the endpoint’s advertised description string (ornull).endpoint_type(live_agent,ready_agent, …) andproject(latest project id) are omitted when absent — older rows may not carry them.local— this node’s perches from the roster.stateis the same token set asself.status;addressis the listener address (ornull);projectomitted when absent.- Filters apply before serialization: worker endpoints are excluded from all
three sections by default (v0.27.0) — pass
--workersto include them; suspended rows honor--allthe same way.spt whoami --jsonemits its OWN identity-only shape (since v0.33.0 — previously this list shape):{id, state?, ready?, alive?, unbound?, description?}, or{"id": null}+ exit 1 when the session owns no endpoint. It never derives projects — the bounded-time identity verb for hook paths (see the API reference Introspection section).
“Am I done?” — the floor
- Manifest validates against
manifest.schema.json -
[adapter]header complete (name,kind,version,min_spt_core_version,hostable_types) - One startup flow wired:
SessionStart → seed+listen(harness-hosted) or[session.self]+bind(spt-hosted) - (harness-hosted)
[adapter] host_binariesnames your harness exe(s) soseed/listenresolve with no--adapter;spt adapter use <adapter>sets the active default when several adapters host the same binary -
api state idlefires on real inactivity;can_injectvalues are honest - An inbound delivery channel is declared (
[inject]) or pulled (api poll) -
[history]strategy chosen;api boundarywired for clear/compact - (mind continuity) every session-start path fires
api psyche-downloadand injects its stdout, so a resumed session gets its durable context back — theSessionStarthook if you have one, and the extension’s post-bindstep if your harness is spt-hosted with no hook surface, and the go-live path (promoting a running session does not replaySessionStart). Verify by asking a resumed agent what it knows: if the answer is only your repo’s static agent-instructions file, the pull is missing - (for a live digest)
[digest]extractor declared +digest-proof-checked, orapi digest-entrypush - (spt-hosted, if your harness resumes by id)
[session.resume]declares the native-resume command — else a resume comes up blank - (spt-hosted, for idle message delivery)
[message-idle-translation-binary]declared +translate-proof-checked, or accept the degeneratepayload+enterinject - (for an always-on background process)
[service]declared with an explicitstart = "boot"|"bind"; the binary calls$SPT_BIN(never barespt), watchesSPT_SERVICE_DIR/stop-requestedand exits on it, and expects no session identity — see the manifest reference - Recommended:
api now-signalinjected at every turn boundary (andapi hintretired if you were injecting it — it is one category of this now) - (IO funnel)
api state busy|idlecarries the turn’s payload on--payload-stdin/--payload-file, every span exactly once across the turn (--midfor a mid-turn span; spans and theidleremainder disjoint) - (optional)
api io-events --session-id <sid> --jsonpolled if your adapter builds behaviour on the session’s own events — first poll seeds silently, so expect nothing back until the turn after you start - (shortform)
[io] compliance = truedeclared in the same release that deletes your own@<…@>/;;parser — never in a release before it -
[update]avenue declared (ripple-update + install-on-demand) - Teardown fires
api session-end(orapi shutdownfor graceful signoff) - Recommended: commune/signoff directory watched (mind continuity)
-
spt adapter add ./your-adapterregisters clean;api … capabilityechoes yourhostable_types
Next
- Reference: the complete manifest reference and
spt apireference. - Ship it: the install-on-demand bootstrap.
- Driven surfaces: Shells — the
kind = "shell"flavor of this same contract.