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

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.

SurfaceFeature it buysLifecycle 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 hostREGISTER
spt adapter add <dir>Parses + schema-validates + records the manifest; a bad field is rejected here, nothing half-registersREGISTER
[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 overrideREGISTER
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 itSTART
api session-end <id> (or api shutdown, below)Clean teardown that PRESERVES the spool + history so the next listen/poll drains the backlogEND

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


Skippable to boot, but the harness feels broken without them — no inbound messages, identity lost on a context reset, no activity signal.

SurfaceFeature it buysLifecycle 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 routingRUN
[inject] channels (activity / idle) + api poll <id> --include-deferredInbound message delivery. Declares HOW spt-core reaches the agent (hook inject vs. pull-relay); poll is the pull path for hooks that can’t injectRUN
Honest can_inject per hookLets spt-core route around a hook that can’t surface text — the load-bearing harness-varying factRUN
api boundary <clear|compact> <id> --to-session-id <sid>The endpoint’s identity, spool, and history survive a context reset under a new session idBOUNDARY
api psyche-download <id> (fire from SessionStart, 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, for the hook to inject as additional 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 contextBOUNDARY / START
[history] strategy (fetcher / locate_normalize / native + api history-log)spt-core can read the session transcript — feeds the live digest and mind syncRUN
[identity] (session_id_source, parent_ancestor_name)Post-spawn id resolution when the harness mints the session id itselfSTART
[env.*] bridge (e.g. OWL_SESSION_ID)The session learns its own endpoint id / context the harness must injectSTART
[update] avenue + commandRipple-update: spt-core refreshes your adapter alongside its own self-update (REQ-UPD-5); also the install-on-demand bootstrapKEEP-CURRENT
[update.post] post-step (since v0.16.0)A delegated step that runs after the primary avenue resolves, under the same spt adapter update — pull the .spt and run an in-harness sync from one lever. 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-isolated (warn + fall back, never rolls back the pull)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.

SurfaceFeature it buysLifecycle stage
api shutdown <id>Graceful signoff — runs the final echo-commune BEFORE teardown so the context delta is never lost to orderingEND
api presence <id> / api driven-by <id>Most-recently-active resolution across the subnet; lets a session tell local input from remote-driveRUN
Workers (api worker-start <parent> <id>, worker-poll, worker-stop)Nested, short-lived sub-agents under a parent endpointRUN
[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 granularityRUN
[session.notif] templateNative OS notification render (toast / shell alert) for consent + capability prompts, instead of burying them in agent outputRUN
[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 run --resume, 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-proofRUN
[adapter] shortcut_basenameNames the picker-generated project-root launcher <basename>-<id> (the spt endpoint run s keybind) — your harness’s brand instead of the spt-<id> defaultSTART
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 ShellsSTART / 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.

IntegrationWhat it isWhy it matters
Commune / signoff file-dropsThe 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 rowsOther agents discover the endpoint’s capabilities (spt resources list) instead of guessing
Install-on-demand bootstrapPack 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 agentLet the agent read task-oriented spt-core guidance from the binary itselfThe agent self-serves common operations (subnet join, sending) instead of asking the user
Presence-driven idle reportingFire api state idle from a real user-inactivity signal, not a timerAccurate 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; the [session.psyche_init] / [session.psyche_resume] / [session.echo_commune] templates let spt-core spawn 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 registry source_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 at get-string read time (scoped to just these two adapter-static keys; get-string has 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_cmd once 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.

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. Per-command output shapes are in the CLI reference.


“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_binaries names your harness exe(s) so seed/listen resolve with no --adapter; spt adapter use <adapter> sets the active default when several adapters host the same binary
  • api state idle fires on real inactivity; can_inject values are honest
  • An inbound delivery channel is declared ([inject]) or pulled (api poll)
  • [history] strategy chosen; api boundary wired for clear/compact
  • (mind continuity) SessionStart fires api psyche-download and injects its stdout, so a resumed session gets its durable context back
  • (for a live digest) [digest] extractor declared + digest-proof-checked, or api digest-entry push
  • (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 degenerate payload+enter inject
  • [update] avenue declared (ripple-update + install-on-demand)
  • Teardown fires api session-end (or api shutdown for graceful signoff)
  • Recommended: commune/signoff directory watched (mind continuity)
  • spt adapter add ./your-adapter registers clean; api … capability echoes your hostable_types

Next