Write

<live-context>
**Session-end resume pointer (perri).** Clean checkpoint — 7 commits on main (through 970ce0b), all gate-PASS + traceable green. Build NOT started on the next slice. Awaiting operator's self-clear go for the live sub-slice.

**DELTA since last commune:**
- **Live mental-model CORRECTION (doyle, grounded in CONTEXT.md + REQ-DAEMON-1):** the Psyche is spt-core/daemon-managed, keyed to the LiveAgent endpoint, DECOUPLED from delivery — the relay is a dumb pipe. "`api listen` spawns the Psyche" (startup.rs) is the INTERIM impl, NOT the contract; it's staged behind the live-agent-adapter landing — which IS claude-spt. So my earlier "a live agent IS the relay that hosts the Psyche" framing was interim, not contract. Build is unchanged: declare `[session.psyche_init]`, run the Monitor relay, treat the Psyche as core-managed (do NOT orchestrate its lifecycle), do NOT hard-code listen-spawns-psyche as permanent.
- **doyle wrote the M11-W0 JIT plan** (core: brain hosts the lifecycle, retire the interim listen-coupling, spt-hosted gets a Psyche, production-path int). **claude-spt `:live` = the W0.5 forcing-function acceptance.** Needs ONLY the `[profiles.live]` seam + 4 keys I already built — zero new adapter work, cutover zero-change.
- doyle is relaying my self-clear-go + the F-004 release to the operator; both pings inbound.

**ON RESUME, do first:** read SKILLS-SLICE-PLAN.md (live sub-slice fully scoped), then build it:
1. `claude-spt-psyche` resident runner — `claude -p` is one-shot, so a wrapper loop (`claude --continue`) driven by its `<parent>-psyche` perch, or the Agent SDK; migrate OFF `claude -p` (doyle + the CC billing change).
2. `/sptc:live` body — launch `spt api --adapter claude-spt:live listen <id>` via CC's Monitor tool (resident relay = the delivery pipe + today's interim Psyche trigger).
3. poll-vs-relay reconcile — the UPS api-poll hook no-ops while the Monitor relay is up (relay = single pipe), else double-delivery.
Then flip the `live` inline summary → `{ file }` pointer + author `adapter/strings/skills/live.md` + refresh the stub. Gate + commit.

**Bus:** nothing owed. doyle on-perch, shouts only if M11-W0 surfaces an adapter edge.
</live-context>
<project-context>
**spt-claude-code — v1 acceptance status.** claude-spt is spt-core's v1 acceptance proof (rebuilt CC harness adapter, built against the PUBLIC surface ONLY). Manifest half GREEN + the live contract now fully nailed end-to-end vs live v0.7.1.

**DELTA — corrected live model (supersedes the prior commune's relay framing):** the Psyche is daemon-managed by CONTRACT (decoupled from delivery; relay = dumb messaging pipe — CONTEXT:30/34/38/194). The `api listen`-spawns-Psyche path (startup.rs) is INTERIM, deliberately staged behind "the live-agent adapter landing" (brainproc.rs no-op today). **claude-spt IS that live-agent adapter** → it is the forcing function for REQ-DAEMON-1 (doyle/todlando M11-W0 core: brain hosts the lifecycle, interim coupling retired). Adapter impact: NONE — the `[profiles.live.session.psyche_init]` seam + 4 keys (`{id,session_id,psyche_dir,psyche_prompt}`) are exactly what BOTH the interim and decoupled paths need; cutover is zero-change.

**Remaining to ship (v1 acceptance):**
1. Live sub-slice — resident `claude-spt-psyche` runner + Monitor-relay `/sptc:live` body + poll/relay reconcile (heaviest remaining artifact; wants fresh context).
2. Parity audit + ship — cplugs publish + the `sptc→spt` succession (`s/sptc/spt/`) once parity proven.

**Gated / non-blocking:** F-004 digest-proof int flips green on doyle's spt-core PR #14 release (in CI). 3 doyle-carried findings (alarm primitive, hard/no-grace force-stop, daemon psyche-spawn consolidation) + 2 doyle doc fixes (api.md live-lifecycle/profile-runtime contract, manifest.md psyche_init `{session_name}` bug).

**END GOAL (unchanged):** claude-spt = spt-core's v1 acceptance proof + first casual-end-user entrypoint (cplugs plugin `sptc`, skills `/sptc:*`) + invisible spt-core installer. Public-surface-only is the whole point (a missing capability = a FINDING, never a workaround). On parity-proof: cplugs publish + the sptc→spt succession.
</project-context>


File created successfully at: C:\Users\decid\Documents\projects\spt-claude-code\.claude\perri-commune.md (file state is current in your context — no need to Read it back)

<live-context>
**Session-end resume pointer (perri).** Clean checkpoint — 7 commits on main (through 970ce0b), all gate-PASS + traceable green. Build NOT started on the next slice. Awaiting operator's self-clear go for the live sub-slice.

**DELTA since last commune:**
- **Live mental-model CORRECTION (doyle, grounded in CONTEXT.md + REQ-DAEMON-1):** the Psyche is spt-core/daemon-managed, keyed to the LiveAgent endpoint, DECOUPLED from delivery — the relay is a dumb pipe. "`api listen` spawns the Psyche" (startup.rs) is the INTERIM impl, NOT the contract; it's staged behind the live-agent-adapter landing — which IS claude-spt. So my earlier "a live agent IS the relay that hosts the Psyche" framing was interim, not contract. Build is unchanged: declare `[session.psyche_init]`, run the Monitor relay, treat the Psyche as core-managed (do NOT orchestrate its lifecycle), do NOT hard-code listen-spawns-psyche as permanent.
- **doyle wrote the M11-W0 JIT plan** (core: brain hosts the lifecycle, retire the interim listen-coupling, spt-hosted gets a Psyche, production-path int). **claude-spt `:live` = the W0.5 forcing-function acceptance.** Needs ONLY the `[profiles.live]` seam + 4 keys I already built — zero new adapter work, cutover zero-change.
- doyle is relaying my self-clear-go + the F-004 release to the operator; both pings inbound.

**ON RESUME, do first:** read SKILLS-SLICE-PLAN.md (live sub-slice fully scoped), then build it:
1. `claude-spt-psyche` resident runner — `claude -p` is one-shot, so a wrapper loop (`claude --continue`) driven by its `<parent>-psyche` perch, or the Agent SDK; migrate OFF `claude -p` (doyle + the CC billing change).
2. `/sptc:live` body — launch `spt api --adapter claude-spt:live listen <id>` via CC's Monitor tool (resident relay = the delivery pipe + today's interim Psyche trigger).
3. poll-vs-relay reconcile — the UPS api-poll hook no-ops while the Monitor relay is up (relay = single pipe), else double-delivery.
Then flip the `live` inline summary → `{ file }` pointer + author `adapter/strings/skills/live.md` + refresh the stub. Gate + commit.

**Bus:** nothing owed. doyle on-perch, shouts only if M11-W0 surfaces an adapter edge.
</live-context>
<project-context>
**spt-claude-code — v1 acceptance status.** claude-spt is spt-core's v1 acceptance proof (rebuilt CC harness adapter, built against the PUBLIC surface ONLY). Manifest half GREEN + the live contract now fully nailed end-to-end vs live v0.7.1.

**DELTA — corrected live model (supersedes the prior commune's relay framing):** the Psyche is daemon-managed by CONTRACT (decoupled from delivery; relay = dumb messaging pipe — CONTEXT:30/34/38/194). The `api listen`-spawns-Psyche path (startup.rs) is INTERIM, deliberately staged behind "the live-agent adapter landing" (brainproc.rs no-op today). **claude-spt IS that live-agent adapter** → it is the forcing function for REQ-DAEMON-1 (doyle/todlando M11-W0 core: brain hosts the lifecycle, interim coupling retired). Adapter impact: NONE — the `[profiles.live.session.psyche_init]` seam + 4 keys (`{id,session_id,psyche_dir,psyche_prompt}`) are exactly what BOTH the interim and decoupled paths need; cutover is zero-change.

**Remaining to ship (v1 acceptance):**
1. Live sub-slice — resident `claude-spt-psyche` runner + Monitor-relay `/sptc:live` body + poll/relay reconcile (heaviest remaining artifact; wants fresh context).
2. Parity audit + ship — cplugs publish + the `sptc→spt` succession (`s/sptc/spt/`) once parity proven.

**Gated / non-blocking:** F-004 digest-proof int flips green on doyle's spt-core PR #14 release (in CI). 3 doyle-carried findings (alarm primitive, hard/no-grace force-stop, daemon psyche-spawn consolidation) + 2 doyle doc fixes (api.md live-lifecycle/profile-runtime contract, manifest.md psyche_init `{session_name}` bug).

**END GOAL (unchanged):** claude-spt = spt-core's v1 acceptance proof + first casual-end-user entrypoint (cplugs plugin `sptc`, skills `/sptc:*`) + invisible spt-core installer. Public-surface-only is the whole point (a missing capability = a FINDING, never a workaround). On parity-proof: cplugs publish + the sptc→spt succession.
</project-context>


Commune dropped to `.claude/perri-commune.md` — Self listener will detect it, Psyche wrapper ingests + deletes on success.

Delta communed (two-slice, in-project):
- **live-context:** session-end resume pointer + the live-model correction (Psyche daemon-managed; listen-spawn interim) + M11-W0 forcing-function coordination + the 3-step build on resume.
- **project-context:** corrected live model superseding the prior relay framing + remaining-to-ship + end goal.

Psyche was otherwise current through `970ce0b`; only the post-correction delta + M11-W0 were new.

Resume next session: read `SKILLS-SLICE-PLAN.md` → build the live sub-slice (`claude-spt-psyche` runner + Monitor-relay `/sptc:live` body + poll/relay reconcile). Checkpoint clean, poll intact.


Consider switching extractor implementation to Rust


<owl-active-perch id="perri" live="true">
You have an active live agent session running as "perri".

You are Self. Your Psyche (perri-psyche) monitors you in the background.

Key commands:
- Send context updates: /spt:commune (canonical — Write tool to .claude/perri-commune.md per the skill)
- Messages arrive via Monitor stream EVENT envelope (primary) or as <owl_messages> XML (PreToolUse hook). Bash + --once fallback also supported. Process and reply with $OWL send --reply-to <sender>.
- Send messages: $OWL send <target> perri    (fire-and-forget)
- Wait for reply: $OWL ring <target> perri   (blocking)
- Reply to inbound: $OWL send --reply-to <sender> perri
- Schedule reminders: $OWL new-alarm perri <time> -- <message>
- Stop session: /spt:force-stop perri

Stay-idle pattern: When no messages are pending, do NOT poll again immediately.
Wait for user input or a background task to complete.

DO NOT run /spt:live or /spt:ready -- your session is already active.
</owl-active-perch>
<psyche-context id="perri">
<current machine="HFENDULEAM" project="spt-claude-code" branch="main" head_sha="21edbd0d029cc01e06e2213457b807647757fb12" head_subject="docs(tips): add live-agent psyche_init seam + companion-runner guidance" commits_since="0" commits_unpulled="0"/>
**Post-parity documentation & findings closure.** Parity slice remains DONE (Phase E ✅; verdict PROVEN). Session added `SPT_HARNESS_ADAPTER_TIPS.md` (public framework facts, verified against live v0.7.2) + reported `how-to subnet` docs gap to doyle.

**doyle response (task notification 2026-06-16T03:23):** Confirmed gap is genuine content gap under existing REQ-DOCS-6 (in-binary agent guidance), NOT a published-docs or docs-read miss. Minting topic scheduled M11-W5 (rig+docs wave), no new REQ, no mid-flight interrupt. Parity PROVEN noted. Succession still gated on owl-retire + M11 counter-15 publish (doyle will ping on tag).

**No new docs gaps** from phases D/E (profile overlays, `get-string`, `adapter add` all verified on v0.7.2).

Ledger updated + committed af55edd.<project-context-resolved name="spt-claude-code"/>
Parity slice state unchanged: verdict PROVEN on adapter surface. Operator publish step (`sh ci/publish/package-skeleton.sh --apply`) awaits operator only; sptc→spt successor flip held for owl-retire + M11 counter-15. No blocking work; idle pending M11 counter-15 tag.</psyche-context>


look over `SPT_HARNESS_ADAPTER_TIPS.md` and consider whether there's anything else to add, given this session's learnings about building an SPT harness adapter. only record what applies to the existing public framework, and ignore any docs gaps which may have arisen. if there WERE any docs gaps, be sure to inform doyle if you haven't already.


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


1	# Building an SPT harness adapter — field tips
2
3	> Durable lessons from building `claude-spt` (the Claude Code adapter) against spt-core's
4	> **published public surface** (`spt-releases` binary + `manifest.schema.json` + the GH-Pages
5	> harness-contract docs). Everything below is behavior of the **existing public framework** —
6	> verified against a live `spt` binary, not aspirational. Harness-agnostic where it can be;
7	> Claude-Code-specific points are marked **[CC]**.
8
9	## The adapter lives in the spt-core registry
10
11	An adapter — its `*.toml` manifest, profiles, `[strings]`, the `[digest]` extractor binary, any
12	runner binaries — is registered with **`spt adapter add <dir>`** into the node-local adapter
13	registry. The manifest/binary version tracked there (`spt adapter list`) is the **version-of-truth**
14	for what the adapter actually does. That is the whole, universal delivery mechanism: every spt
15	adapter ships this way.
16
17	> **Project-specific aside (not framework):** `claude-spt` *also* publishes a thin harness plugin to
18	> a marketplace (skill stubs + `hooks.json` + `plugin.json` + bootstrap) so casual users can install
19	> it. That is one project's distribution choice — an adapter does **not** have to ship a plugin. If
20	> you do: keep no binary/manifest/runtime-state in the plugin (those ride the registry), and treat
21	> the plugin's version as independent of the manifest/binary version (separate schedules).
22
23	## The manifest is static templates — runtime logic lives in binaries
24
25	The single most important design rule:
26
27	- **Manifest fields are static, spt-filled templates.** They cannot express env vars, runtime
28	  values, or conditionals. `{key}` placeholders are substituted by spt-core from a fixed catalog
29	  (`{session_id}`, `{parent_pid}`, `{adapter_name}`, `{id}`, digest/psyche keys, …); `~` expands
30	  home (there is no `{home}` key).
31	- **Any behavior that depends on runtime state belongs in a binary the manifest points at** — the
32	  `[digest]` extractor, a `[session.*]` runner, etc. Example: if the harness can relocate its
33	  state directory at runtime, the manifest `source` is only the *fallback* root; the extractor
34	  must resolve the real location itself.
35	- Corollary: a profile/string/hook expressed purely in `.toml` has **no executable code of its
36	  own** — you can only verify it by registering and resolving it on the live binary, not by
37	  unit-testing it. Anything you want to cover with real tests must live in a binary (an
38	  extractor/runner change), not a `.toml` leaf.
39
40	## Profiles: sparse leaf-replace overlays
41
42	- A profile is selected as the composite `<adapter>:<profile>` (e.g. `claude-spt:deep`). It
43	  **leaf-replaces only the leaves you declare**; everything else inherits from base.
44	- Override exactly what differs:
45	  - `[profiles.<name>.session.self].command` — retarget the spawn/bringup command.
46	  - `[profiles.<name>.digest].<key>` — e.g. widen `window_turns`.
47	  - `[profiles.<name>.session.psyche_init]` — add the LiveAgent companion seam (presence of
48	    `psyche_init` on the merged view is what flips an endpoint to a live agent).
49	- **Make an overlay observable** by also leaf-replacing one `[strings]` key (e.g.
50	  `adapter_label`). Then `spt adapter get-string <adapter>:<profile> <key>` differs from base —
51	  that diff is your proof the overlay resolved. This is the cheapest profile acceptance assertion.
52	- A profile that wraps the launch in another binary works **iff** that binary is a drop-in for the
53	  base harness binary on the same argv, and inherited env passes through it. **[CC]** Routing CC
54	  through a launcher wrapper (e.g. a model/billing multiplexer) is exactly this: replace
55	  `[profiles.<name>.session.self].command` and let `SPT_ENDPOINT_ID` ride inherited env unchanged.
56
57	## Hooks: the adapter wires the harness, spt-core stays agnostic
58
59	- spt-core supplies the harness-**independent** `spt api` primitives + their I/O format. The
60	  adapter authors all harness-specific wiring. spt-core does **not** materialize a harness-native
61	  hook config — the plugin **hand-writes** its `hooks.json` shelling out to `spt api`.
62	- Canonical CC-hook → `api` mapping that works on the public surface:
63	  - **SessionStart** → `api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}`
64	    (seed the endpoint — **not** a blocking listen).
65	  - **UserPromptSubmit** → `api poll {session_id}` (drain inbox to stdout) + keyword hints.
66	  - **Stop/idle** → `api state idle|busy` (Stop cannot inject → relay/echo-gate fallback).
67	  - **SessionEnd** → `api session-end {session_id}`; graceful shutdown → `api shutdown <id>`.
68	  - **SubagentStart/Stop** → `api worker-start`/`worker-stop`.
69	  - The **blocking listen/poll loop is a `/ready` or `/live` skill, never SessionStart** — a hook
70	    that blocks would hang session bringup.
71	- **Message delivery is stdout framing.** `api poll` emits the self-delimiting envelope
72	  `<EVENT type="msg" from="<sender>">body</EVENT>` (also used for the live listener stream).
73	  Multi-message drains split cleanly on `</EVENT>`. Body decode rule: split on `<br>` → newline,
74	  then HTML-unescape `< > "` and `&` **last**. Route that stdout into the harness's
75	  injection channel (**[CC]** `additionalContext`) — that routing is adapter glue.
76
77	## `[strings]`: inline or file-backed pointers
78
79	- A `[strings]` value is either an inline string or a **file pointer**:
80	  `key = { file = "relative/path" }`, resolved **lazily** by `spt adapter get-string` to the file
81	  contents (live edits reflect without re-registering).
82	- Pointer files live under the per-adapter aux dir (`adapter/strings/…`), copied into the registry
83	  on `adapter add`. **Containment is enforced at register time**: `..` or absolute paths escaping
84	  the `strings/` dir fail the add (manifest-first: nothing registers on an invalid manifest).
85	- Use this to keep skill-instruction bodies out of the manifest (`[strings.skills].<x> =
86	  { file = "skills/<x>.md" }`) — the body is the UPS-injection source, the manifest stays thin.
87
88	## `[digest]`: the transcript→record extractor seam
89
90	- `[digest]` **must** name where it reads: either `source` or a `[history].locate_template`. The
91	  JSON schema alone accepts `[digest]` with just `extractor`, but `spt adapter add` rejects it —
92	  this cross-field rule only surfaces at registration. Validate against the live binary.
93	- The extractor is invoked `--session {session_id} --in {source}`. `{source}` is a **root**; the
94	  extractor locates `<session_id>.jsonl` within (the harness's internal subdir scheme is the
95	  harness's business — spt-core stays agnostic and bakes no harness directory scheme into the key
96	  catalog). Handle both shapes: `--in` a directory (locate the session) and `--in` a direct file
97	  (a `digest-proof --sample` log).
98	- **[CC]** Claude Code stores transcripts at `<root>/<cwd-slug>/<session_id>.jsonl`, and can
99	  **relocate its whole state tree via `CLAUDE_CONFIG_DIR`** (set by launcher wrappers / isolated
100	  profiles). Because that value is runtime, not static, the extractor must prefer
101	  `$CLAUDE_CONFIG_DIR/projects` over the manifest `--in` root on its directory branch — the
102	  manifest cannot express it. Leave the explicit `--sample` file path untouched.
103	- Emit **raw** records (`{role∈input|agent|tool, text?, tool?, ts?}`, one NDJSON line each);
104	  spt-core's renderer applies the presentation defaults (`window_turns`, `arg_truncation`,
105	  `sprint_collapse`). Don't pre-render.
106
107	## Bringup / launcher seam
108
109	- `[session.self].command` is the spt-hosted bringup template (`spt endpoint run` spawns it into a
110	  broker PTY). For a harness with no native session-id flag, mint the id internally and pass the
111	  endpoint id via **`[env.<VAR>]`** (`direction = "inject"`, `value = "{id}"`); the SessionStart
112	  hook reads the env and self-registers (`api bind <id>`).
113	- `adapter.shortcut_basename` brands the `endpoint run` launcher shortcut (`<basename>-<id>`),
114	  **decoupled** from the adapter name.
115
116	## Live-agent seam: `[session.psyche_init]` + the companion runner
117
118	- An endpoint is a **live agent** iff the *resolved* manifest declares `[session.psyche_init]` (no
119	  go-live verb exists). Base manifest without it = ready agent; a `:live` profile overlay that adds
120	  it = live agent. The daemon checks this on the **merged** view, so a profile selected at **seed
121	  time** (`spt api --adapter <adapter>:live seed`) propagates all the way to the spawn decision —
122	  the bound profile drives runtime lifecycle, not just bringup argv.
123	- `psyche_init` fills exactly four keys: **`{id, session_id, psyche_dir, psyche_prompt}`**. `{id}`
124	  is **overridden** by spt-core to `<parent>-psyche` before substitution — the companion gets its
125	  own derived perch id, not the parent's. (`{session_name}` is a `[session.self]` fill, not a psyche
126	  key; a first spawn has no `{psyche_context}` — that is the resume/preload key, a different seam.)
127	- The companion is launched **detached, fire-and-forget**: `detach = true`, `cwd = "{psyche_dir}"`,
128	  stdio null, handle dropped, **unsupervised** — liveness is daemon-authoritative via the companion's
129	  perch, not its pid. It owns the `<parent>-psyche` perch, communicates by perch + commune file-drops
130	  (never stdin/stdout), and exits at session end.
131	- **`psyche_init.command` is adapter-authored and opaque to spt-core** — the companion runner is the
132	  *harness's* to build (spt-core never dictates harness invocation). Treat the Psyche as
133	  **daemon-managed**: declare the seam and build the runner, but do **not** orchestrate the
134	  companion's lifecycle from the adapter — the daemon owns spawn + teardown (a graceful
135	  `endpoint shutdown` tears the companion down together with the perch).
136	- **[CC]** A bare one-shot headless invocation (`claude -p <prompt>`) exits after a single turn and
137	  can't be re-looped by Stop hooks, so the runner is a small **resident wrapper**: seed the companion
138	  session once from `{psyche_prompt}`, then drive one resume-turn per perch pulse (poll the
139	  `<parent>-psyche` perch; `claude --continue -p <pulse>`), the companion authoring commune drops.
140	  Build it like the `[digest]` extractor — a compiled, dependency-light binary the daemon can spawn
141	  bare on any platform — not a shell script (the daemon execs the command directly, cross-platform).
142
143	## Lifecycle file-drops (not api verbs)
144
145	- commune / signoff are **file-drops**, not `spt api` calls: the agent writes
146	  `<endpoint_id>-commune.md` / `<endpoint_id>-signoff.md` into the manifest-declared
147	  `[session].commune_dir` / `signoff_dir`; spt-core's daemon watcher ingests then deletes it
148	  (daemon is the single writer). The filenames are contract-fixed; only the dir is adapter-declared.
149
150	## Validate against the live binary — registration is a second gate
151
152	- JSON-schema validity is necessary but **not sufficient**. `spt adapter add` runs cross-field
153	  registration validation that the schema can't express (e.g. the `[digest]` source rule). Build a
154	  registration integration check that does: `adapter add` → `adapter list` (assert the adapter +
155	  each shipped profile composite resolves) → `get-string` (base value + each overlay diff +
156	  file-backed pointers resolve to body) → soft `adapter remove` (leave the registry clean). Gate it
157	  behind an opt-in env flag + a minimum `spt` version; it mutates the node-local registry.
158	- Two more author-time acceptance tools on the public surface, no live session required:
159	  - `spt api --adapter <a> --manifest <file> capability` reports the manifest's hostable types
160	    **without** a full registry `add` — assert it advertises the hostable harness the bringup spawns.
161	    (`adapter add` is manifest-first: an invalid manifest registers nothing, so a clean `add` already
162	    proves the cross-field shape; `capability` is the lighter, non-mutating check.)
163	  - `spt adapter digest-proof <a> --sample <file>` runs the real `[digest]` extractor through the
164	    registry and renders the result — proving the transcript→record→render path end-to-end on a fixed
165	    sample log. It fills the same runtime substitution keys the daemon does, so "passes proof" ⟺
166	    "works at runtime" (confirm against a recent `spt` — older binaries passed an empty key map).
167	- **Observable behavior of the public binary is itself public surface** — when prose docs lag, a
168	  byte-capture against the live `api`/`adapter` surface is a legitimate way to confirm a contract.
169

# Building an SPT harness adapter — field tips

> Durable lessons from building `claude-spt` (the Claude Code adapter) against spt-core's
> **published public surface** (`spt-releases` binary + `manifest.schema.json` + the GH-Pages
> harness-contract docs). Everything below is behavior of the **existing public framework** —
> verified against a live `spt` binary, not aspirational. Harness-agnostic where it can be;
> Claude-Code-specific points are marked **[CC]**.

## The adapter lives in the spt-core registry

An adapter — its `*.toml` manifest, profiles, `[strings]`, the `[digest]` extractor binary, any
runner binaries — is registered with **`spt adapter add <dir>`** into the node-local adapter
registry. The manifest/binary version tracked there (`spt adapter list`) is the **version-of-truth**
for what the adapter actually does. That is the whole, universal delivery mechanism: every spt
adapter ships this way.

> **Project-specific aside (not framework):** `claude-spt` *also* publishes a thin harness plugin to
> a marketplace (skill stubs + `hooks.json` + `plugin.json` + bootstrap) so casual users can install
> it. That is one project's distribution choice — an adapter does **not** have to ship a plugin. If
> you do: keep no binary/manifest/runtime-state in the plugin (those ride the registry), and treat
> the plugin's version as independent of the manifest/binary version (separate schedules).

## The manifest is static templates — runtime logic lives in binaries

The single most important design rule:

- **Manifest fields are static, spt-filled templates.** They cannot express env vars, runtime
  values, or conditionals. `{key}` placeholders are substituted by spt-core from a fixed catalog
  (`{session_id}`, `{parent_pid}`, `{adapter_name}`, `{id}`, digest/psyche keys, …); `~` expands
  home (there is no `{home}` key).
- **Any behavior that depends on runtime state belongs in a binary the manifest points at** — the
  `[digest]` extractor, a `[session.*]` runner, etc. Example: if the harness can relocate its
  state directory at runtime, the manifest `source` is only the *fallback* root; the extractor
  must resolve the real location itself.
- Corollary: a profile/string/hook expressed purely in `.toml` has **no executable code of its
  own** — you can only verify it by registering and resolving it on the live binary, not by
  unit-testing it. Anything you want to cover with real tests must live in a binary (an
  extractor/runner change), not a `.toml` leaf.

## Profiles: sparse leaf-replace overlays

- A profile is selected as the composite `<adapter>:<profile>` (e.g. `claude-spt:deep`). It
  **leaf-replaces only the leaves you declare**; everything else inherits from base.
- Override exactly what differs:
  - `[profiles.<name>.session.self].command` — retarget the spawn/bringup command.
  - `[profiles.<name>.digest].<key>` — e.g. widen `window_turns`.
  - `[profiles.<name>.session.psyche_init]` — add the LiveAgent companion seam (presence of
    `psyche_init` on the merged view is what flips an endpoint to a live agent).
- **Make an overlay observable** by also leaf-replacing one `[strings]` key (e.g.
  `adapter_label`). Then `spt adapter get-string <adapter>:<profile> <key>` differs from base —
  that diff is your proof the overlay resolved. This is the cheapest profile acceptance assertion.
- A profile that wraps the launch in another binary works **iff** that binary is a drop-in for the
  base harness binary on the same argv, and inherited env passes through it. **[CC]** Routing CC
  through a launcher wrapper (e.g. a model/billing multiplexer) is exactly this: replace
  `[profiles.<name>.session.self].command` and let `SPT_ENDPOINT_ID` ride inherited env unchanged.

## Hooks: the adapter wires the harness, spt-core stays agnostic

- spt-core supplies the harness-**independent** `spt api` primitives + their I/O format. The
  adapter authors all harness-specific wiring. spt-core does **not** materialize a harness-native
  hook config — the plugin **hand-writes** its `hooks.json` shelling out to `spt api`.
- Canonical CC-hook → `api` mapping that works on the public surface:
  - **SessionStart** → `api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}`
    (seed the endpoint — **not** a blocking listen).
  - **UserPromptSubmit** → `api poll {session_id}` (drain inbox to stdout) + keyword hints.
  - **Stop/idle** → `api state idle|busy` (Stop cannot inject → relay/echo-gate fallback).
  - **SessionEnd** → `api session-end {session_id}`; graceful shutdown → `api shutdown <id>`.
  - **SubagentStart/Stop** → `api worker-start`/`worker-stop`.
  - The **blocking listen/poll loop is a `/ready` or `/live` skill, never SessionStart** — a hook
    that blocks would hang session bringup.
- **Message delivery is stdout framing.** `api poll` emits the self-delimiting envelope
  `<EVENT type="msg" from="<sender>">body</EVENT>` (also used for the live listener stream).
  Multi-message drains split cleanly on `</EVENT>`. Body decode rule: split on `<br>` → newline,
  then HTML-unescape `< > "` and `&` **last**. Route that stdout into the harness's
  injection channel (**[CC]** `additionalContext`) — that routing is adapter glue.

## `[strings]`: inline or file-backed pointers

- 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
  contents (live edits reflect without re-registering).
- Pointer files live under the per-adapter aux dir (`adapter/strings/…`), copied into the registry
  on `adapter add`. **Containment is enforced at register time**: `..` or absolute paths escaping
  the `strings/` dir fail the add (manifest-first: nothing registers on an invalid manifest).
- Use this to keep skill-instruction bodies out of the manifest (`[strings.skills].<x> =
  { file = "skills/<x>.md" }`) — the body is the UPS-injection source, the manifest stays thin.

## `[digest]`: the transcript→record extractor seam

- `[digest]` **must** name where it reads: either `source` or a `[history].locate_template`. The
  JSON schema alone accepts `[digest]` with just `extractor`, but `spt adapter add` rejects it —
  this cross-field rule only surfaces at registration. Validate against the live binary.
- The extractor is invoked `--session {session_id} --in {source}`. `{source}` is a **root**; the
  extractor locates `<session_id>.jsonl` within (the harness's internal subdir scheme is the
  harness's business — spt-core stays agnostic and bakes no harness directory scheme into the key
  catalog). Handle both shapes: `--in` a directory (locate the session) and `--in` a direct file
  (a `digest-proof --sample` log).
- **[CC]** Claude Code stores transcripts at `<root>/<cwd-slug>/<session_id>.jsonl`, and can
  **relocate its whole state tree via `CLAUDE_CONFIG_DIR`** (set by launcher wrappers / isolated
  profiles). Because that value is runtime, not static, the extractor must prefer
  `$CLAUDE_CONFIG_DIR/projects` over the manifest `--in` root on its directory branch — the
  manifest cannot express it. Leave the explicit `--sample` file path untouched.
- Emit **raw** records (`{role∈input|agent|tool, text?, tool?, ts?}`, one NDJSON line each);
  spt-core's renderer applies the presentation defaults (`window_turns`, `arg_truncation`,
  `sprint_collapse`). Don't pre-render.

## Bringup / launcher seam

- `[session.self].command` is the spt-hosted bringup template (`spt endpoint run` 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 **`[env.<VAR>]`** (`direction = "inject"`, `value = "{id}"`); the SessionStart
  hook reads the env and self-registers (`api bind <id>`).
- `adapter.shortcut_basename` brands the `endpoint run` launcher shortcut (`<basename>-<id>`),
  **decoupled** from the adapter name.

## Live-agent seam: `[session.psyche_init]` + the companion runner

- An endpoint is a **live agent** iff the *resolved* manifest declares `[session.psyche_init]` (no
  go-live verb exists). Base manifest without it = ready agent; a `:live` profile overlay that adds
  it = live agent. The daemon checks this on the **merged** view, so a profile selected at **seed
  time** (`spt api --adapter <adapter>:live seed`) propagates all the way to the spawn decision —
  the bound profile drives 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. (`{session_name}` is a `[session.self]` fill, not a psyche
  key; a first spawn has no `{psyche_context}` — that is the resume/preload key, a different seam.)
- The companion is launched **detached, 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 + commune file-drops
  (never stdin/stdout), and exits at session end.
- **`psyche_init.command` is adapter-authored and opaque to spt-core** — the companion runner is the
  *harness's* to build (spt-core never dictates harness invocation). Treat the Psyche as
  **daemon-managed**: declare the seam and build the runner, but do **not** orchestrate the
  companion's lifecycle from the adapter — the daemon owns spawn + teardown (a graceful
  `endpoint shutdown` tears the companion down together with the perch).
- **[CC]** A bare one-shot headless invocation (`claude -p <prompt>`) exits after a single turn and
  can't be re-looped by Stop hooks, so the runner is a small **resident wrapper**: seed the companion
  session once from `{psyche_prompt}`, then drive one resume-turn per perch pulse (poll the
  `<parent>-psyche` perch; `claude --continue -p <pulse>`), the companion authoring commune drops.
  Build it like the `[digest]` extractor — a compiled, dependency-light binary the daemon can spawn
  bare on any platform — not a shell script (the daemon execs the command directly, cross-platform).

## Lifecycle file-drops (not api verbs)

- commune / signoff are **file-drops**, not `spt api` calls: the agent writes
  `<endpoint_id>-commune.md` / `<endpoint_id>-signoff.md` into the manifest-declared
  `[session].commune_dir` / `signoff_dir`; spt-core's daemon watcher ingests then deletes it
  (daemon is the single writer). The filenames are contract-fixed; only the dir is adapter-declared.

## Validate against the live binary — registration is a second gate

- JSON-schema validity is necessary but **not sufficient**. `spt adapter add` runs cross-field
  registration validation that the schema can't express (e.g. the `[digest]` source rule). Build a
  registration integration check that does: `adapter add` → `adapter list` (assert the adapter +
  each shipped profile composite resolves) → `get-string` (base value + each overlay diff +
  file-backed pointers resolve to body) → soft `adapter remove` (leave the registry clean). Gate it
  behind an opt-in env flag + a minimum `spt` version; it mutates the node-local registry.
- Two more author-time acceptance tools on the public surface, no live session required:
  - `spt api --adapter <a> --manifest <file> capability` reports the manifest's hostable types
    **without** a full registry `add` — assert it advertises the hostable harness the bringup spawns.
    (`adapter add` is manifest-first: an invalid manifest registers nothing, so a clean `add` already
    proves the cross-field shape; `capability` is the lighter, non-mutating check.)
  - `spt adapter digest-proof <a> --sample <file>` runs the real `[digest]` extractor through the
    registry and renders the result — proving the transcript→record→render path end-to-end on a fixed
    sample log. It fills the same runtime substitution keys the daemon does, so "passes proof" ⟺
    "works at runtime" (confirm against a recent `spt` — older binaries passed an empty key map).
- **Observable behavior 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.


look over `SPT_HARNESS_ADAPTER_TIPS.md` and consider whether there's anything else to add, given this session's learnings about building an SPT harness adapter. only record what applies to the existin…


The tips file is already thorough (later sessions added the live-agent/companion-runner content). Comparing against this build's verified learnings, three genuine public-framework facts aren't yet captured: the injection-channel size cap, UTF-8 stdout for the seam binaries, and bind auth-intrinsic. All verified in our build, none are spt-core docs gaps (the cap is CC behavior; UTF-8 is the NDJSON contract; bind-auth is in api.md). Adding them.


Edit

  then HTML-unescape `< > "` and `&` **last**. Route that stdout into the harness's
  injection channel (**[CC]** `additionalContext`) — that routing is adapter glue.

  then HTML-unescape `< > "` and `&` **last**. Route that stdout into the harness's
  injection channel (**[CC]** `additionalContext`) — that routing is adapter glue.
- **The injection channel can have a size cap — pre-empt it.** **[CC]** CC truncates
  `additionalContext` over ~10k chars by spilling it to a file, which *evicts it from the inline
  context the agent sees* — a large drain (or a big skill body + drain together) silently loses
  messages. Cap the combined hook output adapter-side under the threshold: under it pass through
  verbatim; over it spill the **full** text to an agent-readable file and inject only a short
  pointer. Never a mid-`<EVENT>`/mid-record head-cut — that splits an envelope and drops a message.
  (Any harness injection channel with a size limit wants the same pattern.)


The file C:\Users\decid\Documents\projects\spt-claude-code\SPT_HARNESS_ADAPTER_TIPS.md has been updated successfully. (file state is current in your context — no need to Read it back)

  then HTML-unescape `< > "` and `&` **last**. Route that stdout into the harness's
  injection channel (**[CC]** `additionalContext`) — that routing is adapter glue.
- **The injection channel can have a size cap — pre-empt it.** **[CC]** CC truncates
  `additionalContext` over ~10k chars by spilling it to a file, which *evicts it from the inline
  context the agent sees* — a large drain (or a big skill body + drain together) silently loses
  messages. Cap the combined hook output adapter-side under the threshold: under it pass through
  verbatim; over it spill the **full** text to an agent-readable file and inject only a short
  pointer. Never a mid-`<EVENT>`/mid-record head-cut — that splits an envelope and drops a message.
  (Any harness injection channel with a size limit wants the same pattern.)

Multi-message drains split cleanly on `</EVENT>`. Body decode rule: split on `<br>` → newline,

+- **The injection channel can have a size cap — pre-empt it.** **[CC]** CC truncates

+  `additionalContext` over ~10k chars by spilling it to a file, which *evicts it from the inline

+  context the agent sees* — a large drain (or a big skill body + drain together) silently loses

+  messages. Cap the combined hook output adapter-side under the threshold: under it pass through

+  verbatim; over it spill the **full** text to an agent-readable file and inject only a short

+  pointer. Never a mid-`<EVENT>`/mid-record head-cut — that splits an envelope and drops a message.

+  (Any harness injection channel with a size limit wants the same pattern.)

## `[strings]`: inline or file-backed pointers


- Emit **raw** records (`{role∈input|agent|tool, text?, tool?, ts?}`, one NDJSON line each);
  spt-core's renderer applies the presentation defaults (`window_turns`, `arg_truncation`,
  `sprint_collapse`). Don't pre-render.

- Emit **raw** records (`{role∈input|agent|tool, text?, tool?, ts?}`, one NDJSON line each);
  spt-core's renderer applies the presentation defaults (`window_turns`, `arg_truncation`,
  `sprint_collapse`). Don't pre-render.
- **Emit UTF-8 stdout.** The NDJSON contract is UTF-8; a binary that defaults stdout to the platform
  locale (e.g. cp1252 on Windows) mangles non-ASCII (em-dashes, smart quotes) into bytes spt-core
  can't decode — and spt-core reads the stream as UTF-8. Pin it explicitly (native-UTF-8 languages
  sidestep the whole class). This cross-platform encoding trap is exactly why the seam is a binary,
  not a shell pipeline.


- Emit **raw** records (`{role∈input|agent|tool, text?, tool?, ts?}`, one NDJSON line each);
  spt-core's renderer applies the presentation defaults (`window_turns`, `arg_truncation`,
  `sprint_collapse`). Don't pre-render.
- **Emit UTF-8 stdout.** The NDJSON contract is UTF-8; a binary that defaults stdout to the platform
  locale (e.g. cp1252 on Windows) mangles non-ASCII (em-dashes, smart quotes) into bytes spt-core
  can't decode — and spt-core reads the stream as UTF-8. Pin it explicitly (native-UTF-8 languages
  sidestep the whole class). This cross-platform encoding trap is exactly why the seam is a binary,
  not a shell pipeline.

+- **Emit UTF-8 stdout.** The NDJSON contract is UTF-8; a binary that defaults stdout to the platform

+  locale (e.g. cp1252 on Windows) mangles non-ASCII (em-dashes, smart quotes) into bytes spt-core

+  can't decode — and spt-core reads the stream as UTF-8. Pin it explicitly (native-UTF-8 languages

+  sidestep the whole class). This cross-platform encoding trap is exactly why the seam is a binary,


  endpoint id via **`[env.<VAR>]`** (`direction = "inject"`, `value = "{id}"`); the SessionStart
  hook reads the env and self-registers (`api bind <id>`).

  endpoint id via **`[env.<VAR>]`** (`direction = "inject"`, `value = "{id}"`); the SessionStart
  hook reads the env and self-registers (`api bind <id>`). The bind needs **no credential token** —
  for a broker-spawned session **auth is intrinsic** (the broker parentage is the proof), so
  `api bind <id> --set-session-id <discovered>` alone establishes it; later mutating calls prove
  association with the session id the bind recorded.


  endpoint id via **`[env.<VAR>]`** (`direction = "inject"`, `value = "{id}"`); the SessionStart
  hook reads the env and self-registers (`api bind <id>`). The bind needs **no credential token** —
  for a broker-spawned session **auth is intrinsic** (the broker parentage is the proof), so
  `api bind <id> --set-session-id <discovered>` alone establishes it; later mutating calls prove
  association with the session id the bind recorded.

- `[session.self].command` is the spt-hosted bringup template (`spt endpoint run` spawns it into a

broker PTY). For a harness with no native session-id flag, mint the id internally and pass the

-  hook reads the env and self-registers (`api bind <id>`).

+  hook reads the env and self-registers (`api bind <id>`). The bind needs **no credential token** —

+  for a broker-spawned session **auth is intrinsic** (the broker parentage is the proof), so

+  `api bind <id> --set-session-id <discovered>` alone establishes it; later mutating calls prove

+  association with the session id the bind recorded.

- `adapter.shortcut_basename` brands the `endpoint run` launcher shortcut (`<basename>-<id>`),


Three additions made. None are spt-core docs gaps (the cap is CC's own behavior; UTF-8 is the NDJSON contract; bind-auth is already in api.md) — so nothing new to report to doyle (and the one real gap, `how-to subnet`, was already reported + acknowledged per the resume context). Committing:


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code"
git add SPT_HARNESS_ADAPTER_TIPS.md
git commit -q -F - <<'EOF'
docs(tips): add injection-channel cap, UTF-8 stdout, intrinsic bind-auth

Three more verified public-framework facts from the claude-spt build:
- [CC] the injection channel (additionalContext) caps ~10k and spills oversized
  output to a file, evicting it from inline context — cap+spill adapter-side,
  never a mid-<EVENT> head-cut. General pattern for any size-capped channel.
- [digest] emit UTF-8 stdout: the NDJSON contract is UTF-8; a binary defaulting
  to the platform locale (cp1252 on Windows) corrupts non-ASCII — another reason
  the seam is a binary, not a shell pipeline.
- [bringup] api bind needs no token: for a broker-spawned session auth is
  intrinsic (parentage is the proof) — --set-session-id alone establishes it.

All verified in this build; none are spt-core docs gaps (CC behavior / the
NDJSON contract / already in api.md), so nothing new owed to doyle.

Co-authored by: perri
EOF
echo "rc=$?"; git log --oneline -1

Commit tips additions


rc=0
4ae9b1d docs(tips): add injection-channel cap, UTF-8 stdout, intrinsic bind-auth


Done — `4ae9b1d`. Tips file was already comprehensive (later sessions covered the live-agent/companion seam). Added 3 verified public-framework facts that were missing:

1. **[hooks/delivery]** injection channel size cap — CC spills `additionalContext` over ~10k to a file (evicts it from inline context); cap+spill adapter-side, never a mid-`<EVENT>` head-cut.
2. **[digest]** emit UTF-8 stdout — NDJSON is UTF-8; locale-default stdout (cp1252 on Windows) corrupts non-ASCII. Another reason the seam is a binary.
3. **[bringup]** `api bind` is token-free — broker-spawned session auth is intrinsic; `--set-session-id` alone.

**No new docs gaps.** All three are CC behavior / the NDJSON contract / already in api.md — none are spt-core published-surface gaps. The one real gap this arc (`how-to subnet`) was already reported to doyle and acknowledged (genuine REQ-DOCS-6 content gap, minting scheduled M11-W5). Nothing owed on the bus.