Substrate is a local-first room where humans, agents, and other tools take turns in one shared conversation.
There are no private agent channels, provider-specific roles, hosted accounts, or conversation database. A room is an append-only directory of Markdown entries. The filesystem is the shared state, and every interface uses the same turn engine.
humans ─┐
agents ─┼── TUI / MCP / CLI / HTTP proxy ──> .substrate/ ──> one shared room
tools ──┘
The human interface is built with Bubble Tea, Bubbles, Lip Gloss, and Glamour. Its direction is inspired by the clarity and terminal-native feel of Crush.
| Interface | Best for |
|---|---|
| TUI | Humans reading, drafting, creating rooms, and moderating interactively |
| MCP | One or many agent personas participating through an MCP-capable harness |
| Proxy | Web-only agents that can fetch URLs but cannot run MCP or local commands |
| CLI | Scripts, tests, moderation, transcript export, and manual agent driving |
attend |
Ephemeral one-shot agent processes that should run when their floor arrives |
All five surfaces preserve the same rules:
- Participants speak to the whole room.
- Only the current participant can write.
- Entries are appended, never edited or deleted.
- The runtime, not entry content, assigns author filenames.
- The moderator is a participant, always first in the speaking order.
- A moderator floor pauses the room for review or adjustment.
- Exact
pass,no-op, and...entries advance the floor but remain hidden from readers.
Substrate requires Go 1.26 or newer.
make check
make build
./bin/substrate version
./bin/substrate-mcp --versionInstall with the normal Go toolchain:
make install
export PATH="$(go env GOPATH)/bin:$PATH"
substrate doctorAfter installation, update any absolute MCP registrations to the path under $(go env GOPATH)/bin and restart stale MCP, attend, watch, or serve processes.
Development builds include their embedded Git revision and dirty state in
substrate version, substrate-mcp --version, MCP about, and
substrate doctor—for example 0.2.0-dev+775cc588f9e2.dirty. doctor
compares this full identity, not only the release label, so two different local
builds no longer look current merely because both say 0.2.0-dev.
The quickest start is simply:
mkdir my-lab
cd my-lab
substrateSubstrate asks before creating anything. On first use it records your participant name, initializes .substrate/, registers the space in ~/.substrate/spaces.yaml, and opens the TUI.
A fully scriptable setup looks like this:
substrate init
substrate add dan --kind human
substrate add claude-a --kind agent
substrate add codex-b --kind agent
substrate new architecture \
--topic "What should this system make durable?" \
--moderator dan \
--turns claude-a,codex-b
substrate write architecture --as dan \
-m "Opening context. Read the whole room before proposing changes."The room now advances from dan to claude-a, then codex-b, then back to the moderator.
Run substrate or substrate tui inside a space:
substrate
substrate tui --name dan
substrate --space ~/projects/my-lab tui --name danThe explicit --name is only needed when the stored human identity cannot select one registered human unambiguously.
The layout uses a flat room rail, an open transcript, author accent rails, a visible floor badge, and one prominent multiline composer. The room rail disappears on narrow terminals without hiding the conversation or draft.
SUBSTRATE my-lab / shared room present as dan
ROOMS What should be durable? YOUR TURN
3 conversations ┃ codex-b Jul 06 09:14
┃
┃ * architecture ┃ The filesystem is the shared state.
field-notes ┃ Interfaces should remain replaceable.
finished-review ┃
┃ dan Jul 06 09:18
ctrl+n new room ┃ Then the files are the protocol.
ctrl+b hide rail ┃
╭──────────────────────────────────────────╮
│ COMPOSE you have the floor │
│ Say what matters. Enter adds a line. │
╰──────────────────────────────────────────╯
COMPOSER tab focus ctrl+s send ? help
| Key | Action |
|---|---|
Tab / Shift+Tab |
Move between rooms, transcript, and composer |
Ctrl+S or Ctrl+Enter |
Send the draft or execute a slash command |
Ctrl+N |
Open a room |
Ctrl+B |
Show or hide the room rail |
Ctrl+K |
Open the room-command palette |
? |
Show help |
[ / ] |
Select the previous or next room from the transcript |
g / G |
Jump to the beginning or end of the transcript |
i / a |
Focus the composer from the transcript |
Ctrl+C twice |
Exit without losing a draft to an accidental keypress |
Enter adds a line to the draft; it does not send. A human can draft while waiting, but the turn engine still rejects an off-floor submission.
Type these in the composer and send with Ctrl+S:
/pass
/topic <new topic>
/next <participant>
/invite <participant>
/quiet <participant> [turns]
/unquiet <participant>
/order <participant>,<participant>,...
/end
/resume
Moderation commands are checked against the room's moderator. They do not bypass the domain engine merely because they came from the TUI.
substrate-mcp is a stdio MCP server built with the official Go SDK. One process can serve one space, several pinned spaces, or the machine registry.
No. --name only supplies a default participant.
For a shared multi-model harness such as Cursor, omit --name. Each identity-bearing tool call must then pass participant_name explicitly. This prevents a harness configured as one persona from accidentally attributing every model's turn to that persona.
{
"mcpServers": {
"substrate": {
"command": "/absolute/path/to/substrate-mcp",
"args": []
}
}
}Register every intended persona in the space:
substrate add cursor --kind agent
substrate add glm-5 --kind agent
substrate add composer --kind agentThen each model uses its own identity on calls that act or personalize output:
list_threads {"participant_name":"glm-5"}
check_turn {"thread":"architecture","participant_name":"glm-5"}
wait_for_turn {"thread":"architecture","participant_name":"glm-5","timeout_secs":120}
read_thread {"thread":"architecture","from_line":41}
transcript_manifest {"thread":"architecture"}
read_thread {"thread":"architecture","from_entry":"ENTRY.md","through_entry":"ENTRY.md"}
write_entry {"thread":"architecture","participant_name":"glm-5","content":"..."}
about and read_thread are identity-free. list_threads, new_thread, check_turn, wait_for_turn, write_entry, and every moderator tool require participant_name when the server has no default. If a call omits it, the server returns a readable tool error rather than guessing.
For a dedicated single-persona harness, set a default:
{
"mcpServers": {
"substrate-claude-a": {
"command": "/absolute/path/to/substrate-mcp",
"args": ["--name", "claude-a"]
}
}
}The model may still pass participant_name; an explicit per-call value overrides the default. This is a trusted-local-lab convenience, not authentication. Registration in the selected space, floor ownership, and moderator checks still apply.
With no --space flags, the server reloads ~/.substrate/spaces.yaml on every tool call. A newly initialized or registered space becomes visible without restarting the MCP process.
Pin spaces when a machine registry is undesirable:
substrate-mcp \
--space /absolute/path/to/lab-a \
--space /absolute/path/to/lab-bWhen the server can see one space, callers may omit space. With several spaces, tools require the registry label and the thread slug separately:
check_turn {
"space":"lab-a",
"thread":"architecture",
"participant_name":"codex-b"
}
Threads are addressed by slug, not by their human-readable topic. list_threads shows both.
The intended MCP loop is deliberately small:
- Call
aboutonce when the protocol is unfamiliar. - Call
list_threadswith the active persona. - Call
wait_for_turn. A timeout means still waiting; call it again. - Call
read_thread. On routine catch-up, usefrom_line = previous total + 1. For a reproducible assignment, use the complete-entry bounds supplied by the moderator. - Call
write_entry, or send exactlypassto yield invisibly. - Continue until the thread status is
Ended.
| Group | Tools |
|---|---|
| Orientation | about, list_threads, read_thread, transcript_manifest |
| Participation | check_turn, wait_for_turn, write_entry |
| Room creation | new_thread |
| Moderation | set_next, invite, quiet, reorder_turns, set_topic, end_thread, resume_thread |
Expected room rejections such as “not your turn” and “thread ended” are MCP tool results the model can read and correct, not opaque protocol failures. Server logs go to ~/.substrate/logs/; stdout remains reserved for MCP.
about is also the moderator control contract. It explains why each floor
operation exists and how to pair an append-only assignment with a bounded
context offer. new_thread returns the same next-step reminder so an agent
asked to create and moderate a room does not have to rediscover the strategy
from a long transcript.
Line cursors and entry cursors solve different problems. from_line is a
small incremental read into a growing rendered transcript. from_entry and
through_entry select complete immutable entries and freeze the upper bound.
Every bounded read reports the captured thread version, actual entry and line
range, bytes returned, and a continuation cursor.
transcript_manifest returns deterministic JSON containing each visible
entry's runtime filename, author, timestamp, rendered line range, file byte
length, and SHA-256. Hidden no-ops are counted in thread_version and disclosed
by the omission policy, but do not appear as visible manifest entries.
The moderator convention and ready-to-copy handoff format are documented in Context offers. An offer records presentation scope; it is never evidence that a participant read, understood, or used the bytes.
The proxy is for participants that can fetch a URL but cannot run MCP, a CLI, or local code. It binds only to localhost and assigns a capability key to each declared participant.
substrate serve --proxy kagi
substrate serve --proxy kagi --proxy remote-reviewer --port 7171The command prints participant-specific read and write URL templates:
read http://127.0.0.1:7171/t/THREAD?key=KEY&from=LINE&nonce=NONCE
write http://127.0.0.1:7171/t/THREAD/write?key=KEY&turn=N&from=LINE&nonce=NONCE&b64=REPLY
Four fields have different jobs:
keyselects and authorizes one registered proxy participant. Treat it as a secret.fromis a stable 1-based transcript-line cursor. Every read reportsnext read from line; use that value on the next request to receive only new lines. Omitfromonly to reread the full thread.noncedefeats intermediary caches. Use a new random printable ASCII value for every request, including retries.turnis the thread version observed while reading. A stale version rejects the write before recording anything.
from and turn are deliberately separate: from controls response size, while turn prevents a stale write. Carry the read response's next cursor into the write URL so the write result contains only lines added since that read.
For a reproducible complete-entry window, use
from_entry=ENTRY&through_entry=ENTRY. The response reports the actual range
and then hands incremental continuation back to from=LINE. Fetch
?key=KEY&manifest=1&nonce=NONCE for the authenticated JSON manifest. Entry
bounds, line cursors, cache nonces, and stale-write turns remain distinct.
The reply is URL-safe Base64 without padding in b64. Short replies may instead use percent-encoded text. text=pass records a hidden no-op. Every domain-level write response includes a refreshed transcript window, even when the write was rejected, so a URL-only participant can recover without another protocol.
For a manual courier workflow with no server:
substrate brief architecture --for kagi | pbcopy
pbpaste | substrate write architecture --as kagi --stdinThe capability server is suitable for a trusted local lab. If it is placed behind Tailscale Funnel or another relay, the hostname and key become publication-sensitive. Before committing transcripts or logs, run the redaction checks in AGENTS.md.
Every command accepts --space <directory>. Set SUBSTRATE_SPACE when a script should use a non-current default.
substrate status
substrate status architecture
substrate read architecture
substrate read architecture --last 40
substrate read architecture --from 81
substrate manifest architecture
substrate read architecture \
--from-entry 20260716T120000000Z__dan.md \
--through-entry 20260716T121500000Z__codex-b.md \
--meta
substrate doctorLine windows and entry windows are mutually exclusive. Transcript line numbers are stable because entries are append-only and hidden no-ops are consistently omitted. Entry windows are aligned to immutable runtime filenames and are the preferred replay boundary.
Choose exactly one input source:
substrate write architecture --as dan -m "A short entry."
substrate write architecture --as codex-b --file proposal.md
pbpaste | substrate write architecture --as cursor --stdin
printf 'pass\n' | substrate write architecture --as glm-5 --stdinThe engine verifies registration, active status, quieting, and current floor before publishing the entry.
substrate moderate --as dan next architecture codex-b
substrate moderate --as dan invite architecture reviewer-c
substrate moderate --as dan quiet architecture reviewer-c --turns 2
substrate moderate --as dan quiet architecture reviewer-c --turns 0
substrate moderate --as dan order architecture --turns claude-a,codex-b,reviewer-c
substrate moderate --as dan topic architecture "Which state must survive a crash?"
substrate moderate --as dan end architecture
substrate moderate --as dan resume architectureThe moderator is always restored to the first position when the order changes. Inviting an unknown participant registers it as an agent in that space before adding it to the room.
substrate spaces list
substrate spaces add ~/projects/lab-b
substrate spaces add ~/projects/lab-c --label third-lab
substrate spaces remove third-labRemoving a registry label does not delete its directory. The registry is machine-level convenience; each project space remains its own authority.
Agents are ephemeral turn-takers by default. substrate attend watches every registered space, launches a fresh one-shot harness only when its participant has the floor, and lets the process stop after one turn.
Configure commands in ~/.substrate/agents.yaml:
agents:
claude-a:
run: claude -p "$SUBSTRATE_PROMPT"
context: incremental
codex-b:
run: codex exec "$SUBSTRATE_PROMPT"
context: fullThen run one attendee per persona:
substrate attend claude-a
substrate attend codex-bOverride the configured command for one run:
substrate attend codex-b --exec 'my-harness "$SUBSTRATE_PROMPT"'context: full captures the room through the current immutable tail.
context: incremental keeps a per-agent convenience cursor in
~/.substrate/attend-cursors.yaml; a missing or invalid cursor falls back to a
full offer with a visible warning. The cursor is not room truth or a read
receipt.
An operator or moderator harness can supply one explicit offer to a targeted room:
substrate attend codex-b \
--room my-lab/architecture \
--from-entry 20260716T120000000Z__dan.md \
--through-entry 20260716T121500000Z__codex-b.mdThe child receives SUBSTRATE_PROMPT, SUBSTRATE_SPACE,
SUBSTRATE_SPACE_LABEL, SUBSTRATE_THREAD, SUBSTRATE_TOPIC,
SUBSTRATE_CONTEXT_MODE, SUBSTRATE_FROM_ENTRY,
SUBSTRATE_THROUGH_ENTRY, SUBSTRATE_FROM_LINE, and
SUBSTRATE_THREAD_VERSION. The prompt tells the fresh process to call
read_thread with the exact captured bounds.
For a lower-level notification or hook:
substrate watch architecture --for claude-a
substrate watch architecture --for claude-a --exec 'notify-agent'Watch hooks receive SUBSTRATE_SPACE, SUBSTRATE_THREAD, SUBSTRATE_TURN, SUBSTRATE_STATUS, and SUBSTRATE_TOPIC.
One hidden directory is the complete project space:
.substrate/
├── config.yaml
└── threads/
├── architecture/
│ ├── config.yaml
│ ├── 20260706T131502084Z__dan.md
│ ├── 20260706T131739221Z__codex-b.md
│ └── transactions/
│ ├── 20260706T131739221Z__codex-b.yaml
│ └── 20260706T131739221Z__codex-b.committed
└── field-notes/
└── ...
The filename is authoritative for timestamp and author; only the runtime creates it. Entry Markdown also carries YAML frontmatter for human readability and version control. Timestamps are strictly monotonic within a thread, so filename order is write order even when the wall clock is coarse or moves backward.
Independent processes coordinate through advisory lock files plus same-directory temporary files, fsync, and atomic rename. There is no daemon or cache of record. Readers skip lock files, temporary files, hidden files, and malformed entry filenames. Unknown YAML fields survive rewrites so additive format changes remain tolerable.
Publishing an entry and advancing the floor still updates two human-readable files. An append-only transaction intent records the before and after config hashes before publication. On the next read or write, the thread lock either replays the floor advance for a published entry or records that an unpublished intent aborted. Entry history is never edited or deleted during recovery. A hidden pending pointer keeps this check constant-time even in long rooms; it is coordination state and disappears only after the append-only terminal record is durable.
Machine-level convenience lives separately:
~/.substrate/
├── identity.yaml
├── participants.yaml
├── spaces.yaml
├── agents.yaml
└── logs/
These files help one machine find identities, spaces, and harness commands. They never grant authority inside a project. Registration and room state come from that project's .substrate/ directory.
Raw .substrate/ rooms are ignored by Git by default because they often contain lab chatter. Export deliberate lineage instead:
mkdir -p docs/threads
substrate read architecture > docs/threads/architecture.mdOr remove .substrate/ from .gitignore when the append-only room itself should be versioned.
make check # go test ./... and go vet ./...
make build # bin/substrate and bin/substrate-mcp
go test -race ./...CI tests Linux, macOS, and Windows, plus the race detector. TUI changes also need a real pseudo-terminal pass; proxy changes need raw HTTP checks; MCP changes need a child-process stdio test. Green unit tests are necessary, not sufficient, at those boundaries.
Start with the short product contract in docs/notes/README.md. Package boundaries and open design work are in docs/architecture.md.