Echo-commune — the I/O contract
When a session ends without a graceful signoff, its context would be lost. The echo-commune recovers it: spt-core runs the adapter’s bounded summarizer over the session, captures the brief the summarizer prints, and files it as the session’s context delta — the same delta a hand-written commune would have carried.
This page is the adapter-facing I/O contract for that mechanism: the role
you declare, the keys spt-core fills, what it does (and does not) feed the
summarizer, how the summarizer locates the harness, and the drop-file
protocol spt-core uses to file the result. It is the companion to the
[session.echo_commune] role
in the manifest reference.
The echo-commune is spt-core’s. The adapter supplies one command template and one watched directory — spt-core owns the spawn, the keys, the file-drop, and the ingest. Everything below is the seam between those halves.
The role
[session.echo_commune] is one outbound role template. Its fields are the
standard role shape:
[session.echo_commune]
command = "my-harness run --agent summarize --session {session_id}"
recursion_guard_env = "SPT_ECHO_COMMUNE" # set on the child so its own hooks bail
env_remove = ["MY_HARNESS_SESSION_ID"] # stripped from the child's env
keys = ["id", "session_id"] # the keys this template expects filled
| Field | Required | Meaning |
|---|---|---|
command | yes | Opaque command line with {key} placeholders. Model, tools, flags — all inside the string; spt-core never parses it. |
cwd | no | Working directory for the child (substitutable). A role cwd wins over the endpoint default. |
recursion_guard_env | no | Env var name set on the summarizer child so its harness hooks bail — no echo-of-an-echo. |
detach | no (default false) | Spawn detached. |
env_remove | no | Env vars stripped from the child’s inherited environment. |
keys | no | The substitution keys spt-core fills for this role (your declared expectation list). |
The child runs bounded — a timeout caps it, and a non-zero exit files nothing (the failure is loud, never a half-written delta).
Keys spt-core fills
For an echo-commune spawn spt-core fills its base catalog. Template only the
keys you are given; a {placeholder} spt-core does not supply for this role
fails the spawn with a one-line error naming the missing key.
| Key | spt-core fills it with |
|---|---|
{id} | The endpoint id being summarized. Always filled. |
{session_id} | The harness session id — filled when one is known. |
{node} | This node’s advertised label — filled only when non-empty (a {node} reference with no value fails loud rather than resolving an empty token). |
{subnet} | The endpoint’s home-subnet label (local when unhomed) — filled only when non-empty. |
{adapter_dir} | The adapter’s install dir — adapter-static, always available (lets the command point at the adapter’s own packed summarizer binary). |
{adapter_name} | The adapter’s declared name. |
{VAR} | Any manifest-declared [env] direction = "read" var captured at bind — see Self-locating the harness. |
This is the same base catalog the Psyche and notification roles build on;
it deliberately does not include {session_name} (a [session.self] key)
or the Psyche-only {psyche_context_file} / {parent_session_id}.
History is not fed on stdin
spt-core has a stdin channel for the summarizer — but you must not depend on it carrying the transcript. The rule, field-proven against the reference Claude Code adapter:
- If the manifest declares a
[history]strategy that yields records, spt-core normalizes them and pipes them to the summarizer on stdin. - With no
[history]section, or anativehistory store that is still empty, there are no records — so stdin is empty. This is the field case: the reference adapter’s history is native and typically empty at echo time, so the summarizer receives an empty stdin.
The load-bearing consequence for an adapter author: the echo-commune command
must self-source the session it summarizes (locate and read the transcript
itself), exactly as a [digest] fetcher
extractor does. Do not write a summarizer that reads its transcript from
stdin. What spt-core reliably supplies is the command template with the key
catalog filled (and the child’s env — see below); the transcript is the
summarizer’s to find.
Self-locating the harness
A summarizer that self-sources its transcript needs to find the harness’s config/log root. spt-core carries that in through the manifest’s read-env allowlist, so the value survives the daemon boundary (the echo child runs in the daemon context, where the original launch environment is long gone).
[env.CLAUDE_CONFIG_DIR]
direction = "read"
value = "~/.claude" # fallback when the launch env didn't set it
- Declare each locator var with
direction = "read". spt-core captures it from the launch environment at bind — an explicit allowlist, never the whole environment, and only when the ambient value is actually present. - The captured value is written onto the perch record, so it is available when the echo (or digest, or Psyche) child spawns later.
- At spawn spt-core injects it as a
{VAR}substitution key. Resolution order: the captured ambient value wins (a relocating profile — e.g. a wrapper that setsCLAUDE_CONFIG_DIRto a private dir) → else the directive’s ownvaluefallback (the base harness default, e.g.~/.claude) → else the var is omitted, so a{VAR}reference fails loud rather than resolving a wrong path. A leading~expands to the home dir.
Reference the captured key in the echo command the same way [digest] does:
[session.echo_commune]
command = "my-harness-summarize --session {session_id} --config-dir {CLAUDE_CONFIG_DIR}"
keys = ["session_id", "CLAUDE_CONFIG_DIR"]
Drop-file protocol
spt-core files the summarizer’s output as a drop file — the exact same channel a hand-written commune uses — then ingests and deletes it. Three invariants define the contract:
1. spt-core is the single writer. The filename is fixed by spt-core —
<endpoint_id>-commune.md — and the adapter declares only the directory
([session] commune_dir). The mind never writes this file; spt-core writes it
atomically (with a bounded access-denied-only retry for transient
antivirus/indexer locks) and is the sole deleter. Never have the summarizer
write the drop file itself.
2. The directory resolves per-endpoint (hardened in v0.29.0). A
commune_dir may be absolute or relative:
- Absolute → used as-is.
- Relative → resolved against the endpoint’s own recorded working directory, read fresh at drop time — never against the daemon’s process cwd.
- Relative with no recorded cwd → spt-core skips the drop and warns once
(per endpoint, per daemon run). It never guesses and never falls back to the
daemon’s cwd. This is the fix for a real outage: under a service-launched
daemon whose process cwd was a system directory, a relative drop dir once
resolved there and failed with a permission error on every write. The loud
skip makes a mis-declared relative dir a diagnosable signal instead of a
silent failure. Declare an absolute
commune_dir, or ensure the endpoint’s cwd is recorded, to avoid the skip.
A missing [session.echo_commune] role is likewise a loud once-skip, not a
retried fault.
3. Ingest deletes the drop. On its next pulse tick spt-core reads the drop, routes it into the durable context tiers, and deletes the file — whether the content was written or suppressed as a stale snapshot (both mean “consumed”). A read/write error leaves the file in place to retry on the next pass. The file disappearing is the success signal.
What spt-core expects on stdout
The summarizer’s stdout is the brief — the cheap-model synthesis of the
session, as plain text. spt-core does not require a structured format at write
time; it stamps a provenance header (Source: echo-commune) and writes the
result as the commune drop file.
On the later ingest tick that body is parsed with the two-slice envelope grammar, the same one a hand-written commune uses:
<live-context>…</live-context>→ the live tier (who the agent is and what it is doing; follows the endpoint everywhere).<project-context>…</project-context>→ the project tier (scoped to the current project).- An untagged body routes whole to the live tier.
Every write is precedence-guarded — a stale snapshot arriving inside another
writer’s protection window is suppressed (but still consumed and deleted). The
checkpoint sentinel !!checkpoint!!, if the brief carries one, is stripped
before both presentation and the durable write, so it never persists in the
stored context.
In one line
Declare [session.echo_commune] with a command that self-sources its
transcript (found via a direction = "read" locator key) and prints the
brief to stdout; declare an absolute commune_dir; let spt-core do the
spawn, the file-drop, the ingest, and the delete. That is the whole contract.