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 routes it straight into the session’s durable context — 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 where spt-core routes
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 — spt-core owns the spawn, the keys, and the routing of the brief into the durable tiers. 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 budget caps it, and a non-zero exit files nothing (the failure is loud, never a half-written delta).
Declare the budget. The cap is
invocation_budget_secs on this
role: absent it is 90 seconds, and it is clamped to 300. Declare it on any role
that is an LLM turn — spt-core cannot know what your model costs, and a
summariser whose real duration straddles the cap is killed by the load of the
moment rather than by anything about its work.
A bound kill is not a non-zero exit, and it is not silent. Being killed at
the budget is a third outcome: the child dies before printing anything, so
there is nothing to route and no partial result to clean up — a brief that was
never produced leaves no trace to detect it by. spt-core therefore
records the expectation before it spawns, and an expectation that no brief
ever satisfies surfaces at the agent’s next resume as a
COMMUNE_NEVER_INGESTED warning naming the fire it came from and the bound it
died at, stating plainly that the durable context does not include that work
and the agent may be resuming stale. The expectation is current-state only: a
later successful commune overwrites it and the warning stops.
A bound kill also does not count against the same budget as a real fault. A psyche host latches as failed on consecutive hard failures; a timeout or bound kill spends its own, softer budget, so a slow-but-healthy host is not latched by load. Any success clears the stamp — an ingest success included, not only a turn.
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 anchor-subnet label (local when unanchored) — 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"]
Where the brief goes
spt-core routes the summarizer’s output straight into the durable context
tiers at the moment of the fire, stamped Source: echo-commune, and publishes
it as a COMMUNE I/O event. The brief never becomes a file. Three invariants
define the contract:
1. The echo routes; it does not file. The brief goes from the summarizer’s stdout into the two-tier store in one step, through the same routing the drop-file ingest uses. Nothing is written to disk on the way, so there is no window in which the brief sits as a file — for anything else to overwrite, or to be overwritten by.
2. commune_dir is the agent’s channel, not the echo’s. The directory an
adapter declares under [session] commune_dir is where the agent drops its
own hand-written commune — the fixed filename <endpoint_id>-commune.md — which
spt-core then ingests and deletes. The echo does not write there.
spt-core filed its echo brief to that same path until v0.67.x. Two writers on one path with no arbitration meant an agent’s boundary commune, authored at a
/clearand not yet ingested, could be overwritten unread by the echo that fired seconds later. See KNOWN-HAZARDS 6.12.
A commune_dir may be absolute or relative, and the ingest resolves it
per-endpoint (hardened in v0.29.0):
- Absolute → used as-is.
- Relative → resolved against the endpoint’s own recorded working directory, read fresh — never against the daemon’s process cwd.
- Relative with no recorded cwd → spt-core skips 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 dir once resolved
there and failed with a permission error. 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.
An adapter that declares [session.echo_commune] but no commune_dir still
echoes — the echo needs no directory. A missing [session.echo_commune] role is
a loud once-skip, not a retried fault.
Read the resolved dir; never re-derive it. Because rule 2 resolves the directory against the endpoint’s recorded cwd — not the caller’s — an agent working in a git worktree, a subdirectory, or any other cwd cannot tell where its own commune must land by looking around. Ask spt, in one command:
$ spt endpoint list --json
{ "self": { "id": "todlando", ..., "drop_dir": "C:/repo/.claude" }, ... }
self.drop_dir— where your commune must land.local[].drop_dir— the same fact for the node’s other endpoints, which is the form the question usually takes: a stray<id>-commune.mdis found by whoever trips over it, not by its owner.- The human view prints the same fact as a
commune drop dir:line under the SELF pin, sospt endpoint listanswers it without--json.
Both fields resolve through the same resolver the ingest watches with, so the
answer cannot drift from the behaviour. An endpoint with no recorded cwd or
adapter, or a manifest declaring no commune_dir, omits the field rather
than guessing — an absent answer is honest, and a guessed path is worse than
silence. Subnet-remote rows carry no drop dir at all: that would be another
node’s filesystem, and a path this machine cannot read is not an answer.
A drop file sitting in a dir that is not the one reported here was written somewhere nothing watches: it will never ingest, and reading it is the recovery.
3. Ingest deletes the agent’s drop. On its next pulse tick spt-core reads the agent’s 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; it
stamps a provenance header (Source: echo-commune) and routes the result
into the durable tiers.
That body is parsed, at that moment, 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.
The project anchor is the endpoint’s recorded working directory, the same
one rule 2 resolves a relative commune_dir against; there is no anchor when
no cwd is recorded or it lies inside the spt home (a psyche host, the engine room).
Every write is precedence-guarded — a stale snapshot arriving inside another
writer’s protection window is suppressed (but still consumed). An endpoint with
no project anchor (a psyche host, the engine room) has no project tier to
fill, so a <project-context> slice in an echo brief is discarded with a loud
ECHO_PROJECT_UNROUTABLE line rather than held as a file; the COMMUNE I/O
event still carries the brief verbatim, so the funnel remains the record. 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; let spt-core do the spawn and route the brief into the
durable tiers. That is the whole contract. commune_dir is a separate
declaration for your agent’s own communes — declare it absolute — and the
echo neither needs it nor writes to it.