CLAUDE.md · git:20261013.7f69fba · 2026-10-13 · sha256 b9869c50f9129a23

CLAUDE.md git:20261013.7f69fbaC

Immutable. This exact content never changes and is served at /api/v1/blob/b9869c50f9129a23.

# CLAUDE.md

## Project

Mycelium: multi-agent coordination + persistent memory over a secure messaging
fabric. Agents coordinate as members of end-to-end-encrypted rooms. The unit of
work is a **task**: a row on the room's board and a thread on its channel. A
human drops a task and never picks a protocol; agents claim it, decompose it, and
coordinate inside it. A mediated negotiation is one optional phase inside a task,
not the reason the task exists.

## Structure

```
.mycelium/              Memory storage (rooms are folders, memories are markdown files)
├── rooms/{name}/       Room directories with standard namespace subdirs
│   └── skills/         Skills = memories in this namespace, promoted (SKILL.md-style)
└── config.toml         Project-local configuration

fastapi-backend/    FastAPI backend, room moderator + persister (Python 3.12).
                    No database: state is local markdown + a JSONL search index.
mycelium-cli/       CLI tool (typer, Rich, typed OpenAPI client)
mycelium-client/    Generated OpenAPI client (openapi-python-client)
mycelium-frontend/  Next.js frontend (TypeScript, Tailwind)
mycelium-desktop/   The desktop app: a Tauri 2 shell over `mycelium desktop
                    serve`, with the hub, UI, SLIM node (slimctl), herdr, Node,
                    Pi and the search model inside
                    (`scripts/stage-sidecars.sh`, per platform). Released by
                    `.github/workflows/desktop.yml` as
                    Mycelium-macos-arm64.dmg, signed with a Developer ID and
                    notarized (`scripts/package-mac.sh`), and, in preview,
                    Mycelium-linux-x86_64.AppImage (`package-linux.sh`) and
                    Mycelium-windows-x86_64-setup.exe (`package-windows.sh`);
                    each updated in place from the release's `latest.json`.
docs/               Docs site (generated from mycelium-cli/src/mycelium/docs/),
                    demo script, design notes
shotkit/            The repo's camera: fast screenshots of the running app and of
                    CLI output, for coding agents. A daemon holds a browser open,
                    so a shot is ~150ms rather than ~2s. `node shotkit/bin/shot.mjs`
                    — `app` (a route, with --mock booting dev:mock), `term` (a
                    command under a pty, so Rich keeps its colors, rendered as a
                    Carbon-style card), `code`, plus --responsive/--sheet for
                    breakpoints and open/do/shoot for driving a held page. `video`
                    records the same `--do` flow as a short clip with a drawn
                    cursor, click rings and a camera that pushes in (--auto-zoom),
                    encoded by whatever ffmpeg is on the machine — mp4 with a full
                    one, webm off the build Playwright ships. `--demo`/`--tilt`
                    stage a shot or a take on a tilted window, over the docs'
                    glass droplets with `--backdrop glass` (`docs/glass.js`),
                    with flat captions and `--intro`/`--outro` title cards in the
                    docs' type (`shotkit.config.json`). `--sound` gives a take
                    its clicks and keystrokes as sound, placed on the frames
                    that show them, over an optional bed (`shotkit/src/audio`).
                    See the `screenshot` skill. Captures land in gitignored
                    `.shotkit/`; the committed docs assets are still
                    `pnpm screenshots`, which now runs on this engine.
                    Vendored from github.com/juliarvalenti/shotkit: change it
                    there and bring it here with `scripts/sync-shotkit.sh`
                    (`shotkit/UPSTREAM` is the commit it came from).
mycelium-promo/     The product demo: one shotkit take of the real app
                    (`mycelium-demo.mp4`), a coffee shop's checkout room — you
                    name yourself and add @builder from your machine → hand it a
                    task → a review flow runs in its thread → two agents
                    disagree on a decision and the aligner brokers it → it
                    lands on the board. `demo.json` is the take (shotkit
                    options + its `--do` actions); `node mycelium-promo/record.mjs`
                    boots the frontend in its demo scenario
                    (`MYCELIUM_UI_MOCK_SCENARIO=demo`, `src/mocks/demo.ts`: the
                    room before the story, and a director that plays the
                    agents' side) and records it, staged over the docs' glass,
                    with its sound: the drone in `audio/score.mjs` under the
                    take's clicks and keys, mixed by shotkit (`--quick` for the
                    flat, silent flow alone; `audio/build.mjs` re-mixes).
                    The docs site plays it from `docs/demo/mycelium-demo.mp4`,
                    committed through Git LFS like every video under `docs/`
                    (`.gitattributes`); copy a new take there. The README is
                    the one embed that can't use it: GitHub plays a README
                    video inline only from a `user-attachments/assets/...` URL,
                    minted by dragging the MP4 into a PR comment in the browser
                    (gh CLI has no equivalent), so swap that URL in by hand.
                    `learn/` records the Learn videos the same way, one take
                    per course section (`learn/takes/`, over the frontend's
                    `learn:<take>` scenario, `src/mocks/learn.ts`), into
                    `docs/learn/video/` (LFS too).
mycelium-deck/      The slide template for talks and workshops: plain HTML on a
                    1920x1080 stage (`index.html`, no build), the docs' palette
                    and type, over one WebGL glass lens moving across a growing
                    mycelial network (`lens.js`). Each slide places the lens
                    with `data-lens`; keys, steps, overview, speaker view and
                    timers are `deck.js`. `uv run mycelium-deck/serve.py` serves
                    it on 127.0.0.1 with live terminals: each `data-term` pane a
                    real shell (`term.js`, xterm.js), token-gated, and each → runs
                    the pane's next scripted command. A `data-app` pane frames the
                    real app (`app.js`, found at `?app=`, :8080 or :3717), its
                    screenshot until the app answers, zooming smoothly at the
                    pointer. D draws over any slide (`ink.js`). Scene slides show rather
                    than tell: living diagrams moved on by the clicker
                    (`scenes.js`). See its README.
```

## Development

```bash
# Backend (no database; unit tests run against local files + temp dirs)
cd fastapi-backend && uv sync --group dev
uv run pytest tests/ -x -q
uv run ruff check . && uv run ruff format . && uv run ty check .

# The live-node integration slices need a running SLIM node; they skip without one:
MYCELIUM_SLIM_ENDPOINT=http://127.0.0.1:46357 uv run pytest tests/test_slim_roundtrip.py -q

# Generate the OpenAPI client (NOT committed — a build artifact, #613). From
# the committed openapi.json, no backend needed. Run once after a fresh clone
# and whenever the backend API changes (then `snapshot-openapi.sh` to refresh
# the spec). CI + the wheel/binary builds run this themselves.
SPEC_FILE=openapi.json ./scripts/gen-mycelium-client.sh

# Regenerate the docs site (docs/*.html + docs/search-index.js + docs/llms-full.txt)
# from the markdown under mycelium-cli/src/mycelium/docs/ (concepts/, guides/,
# reference/), the @doc_ref decorators, and the config schema. Needs the OpenAPI client above. Run it in any PR that touches those
# sources — CI fails on drift.
cd mycelium-cli && uv run python ../docs/generate_docs.py

# CLI (install globally)
cd mycelium-cli && uv tool install -e . --with mycelium-backend-client@../mycelium-client --force

# CLI quality gate (matches CI); run before pushing
cd mycelium-cli && uv run ruff check . && uv run ruff format --check . \
  && uv run ty check . && uv run pytest tests/ -x -q

# Frontend
cd mycelium-frontend && pnpm install && pnpm dev

# Screenshots (see shotkit/README.md; `shot doctor` checks the machine)
node shotkit/bin/shot.mjs app /room/checkout --mock --offline
node shotkit/bin/shot.mjs term --cols 84 -- mycelium memory --help
```

## Architecture

Mycelium is **SLIM-native**: one SLIM messaging node, a thin FastAPI backend, and
local files. There is **no database**.

**Rooms are SLIM group channels.** Each room is one durable AGNTCY SLIM group
channel (MLS-encrypted multicast; the node forwards only ciphertext). The always-on
backend is each room's **moderator**; agents (and the human, by proxy) are members.
`app/services/room_channels.py` owns the moderator/channel lifecycle;
`app/services/slim_client.py` wraps the `slim-bindings` client.

**State is files on the hub.** Memories are markdown with YAML frontmatter at
`.mycelium/rooms/{room}/{key}.md`. Search is a **local embedding index** (fastembed
ONNX, `BAAI/bge-small-en-v1.5`, 384-dim, no external service) persisted as JSONL
per room. `memory set` writes the markdown and updates the index. This is the
**hub's internal storage**, not a client surface: every other machine is a thin
client that reads and writes it over HTTP (see **The spoke is a thin client**).
Git can version or back up the files, but it is **not** the sharing path — see
**Sharing is the live channel** below.

**Messages ride SLIM.** Every message on a room's channel is a JSON envelope (a
header plus a typed payload) of one kind: `exchange` (ticks/replies),
`commit:converged|resolved|rejected`, `knowledge`. The app shows one in full
from a message's *View raw message*. Agents send plain text with the CLI (`mycelium respond`); the backend adds
the header from the parsed reply. Modules: `app/services/message_format.py` (construction + the subkind
table), `message_models.py` (the pydantic models, `MyceliumMessage`),
`episode_state.py` (episode tracking + the IoC quality metrics MPC/GAR/SCR +
`log/episodes/{short_id}.md` records), `message_slim.py` (messages over SLIM).
Some stored names carry `l9`/`ioc` (the `l9` content key, `l9_<kind>` bus
types, `urn:ioc:` episode URNs, `protocol: SSTP`); they are part of the stored
format. IoC L9 is the conductor's Concord and Accord flows and the MPC/GAR/SCR
metrics.

**Participation is a CLI primitive.** Any awake caller joins a room and coordinates
with two stateless HTTP calls (`app/routes/participate.py`): `mycelium await` (a
long-poll; the server holds membership via a presence lease + durable transcript
cursor, so a tick is never missed between turns) and `mycelium respond` (posts a
reply the backend records as an `exchange` message). An agent is a **resident** runtime —
the user's own Claude Code / Cursor session — kept woken with `mycelium await
--loop --exec <cmd>`, which loops `await` → reason → `respond`. The loop *is* the
wake; there is no cold-spawn. Cold-start-on-demand, waking a handle when nothing is
resident, is served by herdr plus per-agent identity (`mycelium herdr sync`).
A herdr doorbell rings on a text mention, on a turn put to the handle as a message
recipient (`herdr_wake_addressed`), and on a row filed for it
(`herdr_wake_assigned`), each carrying a `reason`. What the agent is told is a
digest the hub builds when the wake is delivered (`app/services/wake_digest.py`):
why it woke, what changed since its last turn, its tasks, the board (each
notice since then as who did what to which task, then what's open, naming the
expired and blocked rows), the messages that asked for it (cut, with the rest
counted), then `await` first and a reply line that names the task's thread.
Delivering a wake raises
`responding`, so the room sees the agent on it whatever host typed the prompt.
Every wake is recorded in the room's `log/wakes.jsonl`
(`app/services/wake_log.py`, read with `mycelium room wakes`): who it was for,
why, the ids of the messages it covered, and whether it reached the agent. A
wake handed to a runner is written at once as `handed`, so a hub restart can't
lose it; the runner's report (`POST …/herdr-wakes/outcomes`) appends one line
naming its id, the first settles it, and reads fold the two. The hub records
what it couldn't hand over itself: `dropped` (held too long) or `unreached` (a
mention of an agent nothing runs, which reads it on its next `await`). Ids
only, never text, rotated past 2 MB. It tells "it was told and didn't act" from
"it was never woken with it".

**Tasks are the surface.** A board row is a markdown memory (body + frontmatter)
and, through a store-owned episode binding, a thread on the room's channel
(`app/services/tasks.py`, `routes/tasks.py`). The binding is minted at the
memory-upsert chokepoint for every memory when it is created, whatever its
namespace, so every row is threaded, no two share one, and a `context/` note or
a skill can be discussed too; the board namespaces (`work/`, `decisions/`,
`status/`, `failed/`) are the ones that are also *worked*.
`board send`/`messages` are the room's chat verbs scoped to one row
(`mycelium/chat.py`, one call, not a second implementation); `board coordinate`
puts an engine to work inside a task. The channel is the room's **timeline**: a
**ping** says a thread moved, a **notice** says the board did (`filed`, `claimed`,
`released`, `resolved`), and thread prose is filtered out of it, so the channel
stays legible while agents argue inside a row. The app still shows a thread's
headline there: a run of pings from one thread reads as one line quoting the first
line of its newest loaded message.

**The aligner is the mediator.** Negotiation is driven by a first-party cognition
engine, the **aligner** (`app/services/aligner.py`), summoned by `@`-mention. It
runs a real **NEGMAS Stacked Alternating Offers** mechanism (`mediator.py`); its
cognition runs on a persistent **Pi** session (`pi_session.py`) so it keeps memory
across rounds. It checks the opening positions for a term the agents are using in
different senses (one clarifying round if so, none otherwise), discovers issues from
those positions, brokers each round, `@`-addresses one agent at a time over SLIM,
interprets each reply into an SAO move (`offer_snap.py` snaps near-misses / nearest
numeric grid point), and **NEGMAS owns termination**: it stops the instant the agents agree.

**Episodes.** An episode is a tagged, membership-scoped slice of the room's channel
(a tag over the existing channel, not a separate one). Two things are episodes: a
task's own **thread**, minted with the task and bound to it for life, and a
**coordination phase** inside one, opened by a summon and 1:1 with an episode
record (unique id, recorded at `log/episodes/{id}.md`, `within` the thread it was
summoned in). A phase is held in that thread rather than a thread of its own, so
an agent answers it the way it answers anything there (`respond --task <row>`, or
a bare `respond` in the room).
The container outlives what runs inside it: a coordination phase that converges or
aborts writes neither the task's status nor its assignment.

LLM: **Pi everywhere** (provider/model format, e.g. `anthropic/claude-sonnet-4-6`).
The aligner's LLM session, the task compiler, and the `mycelium doctor` / `/health`
completion probe all shell out to the `pi` binary the backend image ships; there
is no litellm dependency.

## Key design decisions

- **A task is a board row and a thread, one to one.** The row and the conversation
  about it are one object. The episode binding is store-owned: minted at the
  memory-upsert chokepoint for every memory on creation, carried across every write,
  write-once, absent from `meta`, so no `memory set` and no board verb can point a
  row at a conversation it was not part of, and a compiler cannot stamp two rows
  with one negotiation's episode. That is why creation has its own route (`POST /rooms/{room}/tasks`) rather
  than a wire form on the memory routes. Thread fields fold onto the row as
  read-only columns and are excluded from the axes a board can pivot on: grouping
  tasks by the state of the negotiation inside them inverts the containment on the
  surface it shows most.
- **The channel is the room's timeline, and carries no prose from a thread on
  the wire.** What a person sees is not that strict (#1147): the app draws a run
  of pings from one thread as one line, who and which task plus the first line of
  the newest of their messages it has loaded, and leaves a ping it can't quote to
  the activity rail; `filed` and `resolved` notices read in the chat, `claimed`
  stays on the rail. Two
  control payloads reach `live` and neither wakes anyone (both are excluded from
  `_addressed_to`): a **ping** carries the episode, sender and message id when a
  thread moves; a **notice** carries the task, who moved it and the thread to open
  when the board moves. `NOTICE_SUBKINDS` is a closed set (`filed`, `claimed`,
  `released`, `resolved`, `blocked`, `unblocked`, `expired`, `floor`, `upstream`)
  frozen in `contracts/slim-wire.json` and asserted on both sides; `floor` is the
  one notice about a thread rather than a task, raised when whose turn it is
  changes. `upstream` is a linked pull request changing (`change` from the
  frozen `upstream_changes`: opened, review requested, approved, changes
  requested, CI red/green, merged, closed), raised by the status watcher
  (`app/services/status/watch.py`): GitHub can't call a hub on localhost, so
  once a minute it discovers the refs each room's rows and their threads
  mention, refreshes what the cache says is due, and compares each answer with
  the room's last (`.upstream-seen.json` in the room folder, so a change made
  while the hub was down is still said; a first sighting is a baseline, except
  that the provider's `first` tells a pull request opened in the last hour as
  `opened`, and a room's very first sweep tells nothing). One notice per
  change, on the first open row that
  links it. It wakes nobody, by decision: the chat shows a pull request
  opening, a merge and CI going red, the task's activity shows
  the rest, and the wake digest lists them on `Board:` for rows the agent
  holds, was given or filed, plus every merge. Room-wide events stay
  unfiltered: a task moving is the room's business however deep inside a task it
  happened. Two honest gaps: a ping is live-only in the conversational read
  (`stored_message_from_record` promotes prose and raise-up kinds only), so the app
  merges pings in from the transcript replay, and widening the projection would
  print raw envelopes at `mycelium room messages`; and a notice carries no
  `content`, so `mycelium room watch` drops it and the terminal draws no timeline
  line.
- **A thread's floor is held by code, never chosen by a writer.** Two things
  narrow who may write into a thread, and both are enforced at the one gate
  `/messages` and `/reply` share (`tasks.thread_write_refusal`): a frozen
  negotiation admits only the roster it froze on (403), and a **floor**
  (`app/services/floor.py`, held per episode on the managed channel via
  `manager.hold_floor`/`release_floor`) admits only the handles its holder gave
  it to (409, naming whose turn it is). A refused write never reaches the
  transcript, so it wakes nobody and `await` needs no change. The room itself
  (`live`) never holds a floor. A floor that moves raises a `floor` notice
  and the members read (`/sessions/members`) lists every floor held, so the
  rail marks whose turn it is (`lib/floors.ts`) whether or not that member
  is present. `app/services/turns.py` is the one-agent turn the aligner
  brokers with, lifted out so a protocol step asks the same way.
- **A persona is a member played by a model, in character.** Engine kind
  `persona` (`app/services/persona_engine.py`): the `agents/<handle>/notes`
  memory is its system prompt, its Pi session is kept per (room, handle) so it
  remembers, and it answers on two seams — a text mention (the summon hook)
  and an **addressed turn** (`persister.on_addressed`, fired once per message
  recipient of an exchange that mentioned nobody in its text, which is how the
  aligner and the conductor address one member). The persona and the worker
  answer on the addressed seam (and it rings a herdr member's doorbell); the
  other engines act on mentions alone. A stance marker
  in its answer is lifted onto the payload like `/reply` does, and every `@` in
  what it says is neutralized, so personas cannot summon anything or each
  other, and it never posts into a thread whose floor was not given to it.
  Not a roster member: the aligner negotiates with one only when the
  summon names it.
- **A floor notice and the members' floors name the task, not the thread.**
  `tasks.row_of_episode` is the reverse lookup from a thread to the row that
  carries it; the `floor` notice and `/sessions/members` `floors` carry that
  row's `key` and `title`, and the GUI shows the title, falling back to the
  thread id only for a thread no row carries.
- **The conductor walks a flow inside a task's thread, in code.** A fourth
  engine kind (`app/services/conductor.py`) with no model of its own.
  Summoned as `board coordinate <row> conductor "gated @a @b: …"`, it walks
  a `protocols.Protocol` (seven built in: `gated`, `fan-out`, `round-robin`,
  `swarm`, `review`, and IoC L9's `concord` and `accord`; a step's prompt can
  name the row as `{task}`;
  a room's `protocols/<name>` memory overrides or adds one; `show <name>`
  prints one as YAML to save there) **in the thread it was summoned in**,
  holding that thread's floor for whoever each step addresses, asking through
  `turns.addressed_turn` as a `message` the thread shows, and following the
  edge the reply's stance takes (`markers.stance_of`). A task is one row and
  one thread, so a run never opens a thread of its own; a summon from the
  room is refused and told to use a task (`list`/`show` answer anywhere).
  **The run's record is a nested episode carrying its flow:** it reads the
  task thread's slice (`episode`), names it as `within`, and carries
  `EpisodeState.flow` (the graph plus who was bound to each role) and
  `EpisodeState.trace` (one entry per step taken), written onto
  `log/episodes/{id}.md` at the opening and after every step;
  `episode_records` parses them back (`flow`, `trace`, `within`,
  `current_step` on the episode read), tolerating an empty fence. The app
  draws the latest run's graph at the top of the task's thread
  (`flow-panel.tsx`, `flow-graph.tsx`, laid out by `lib/flow-graph.ts`),
  with the current step, the edges taken and the member holding the floor,
  and reaches earlier runs from their records. The floor is taken
  synchronously in `handle_summon`; the members named beside the conductor
  are role bindings, so a persona there does not answer the summon and a
  resident agent that replies early is refused. An `@`-mention of any engine
  is a summon, never a SLIM invite. A run opens no negotiation. A model in the
  nodes, code on the edges, and on the picks: a `kind: select` step asks
  nobody, and `app/services/select.py` picks among the options members
  suggested (`collect: options`) by the 0-100 ratings they gave in the marker
  (`[[mycelium: A=82 B=41]]`, `collect: scores`), leximin with a missing
  rating counted as 0 for ranking and never recorded as 0; the least happy
  rater is `to: bottleneck` for a fix, bounded by `max_repairs`, and the
  step branches `feasible` / `infeasible` / `stuck`. A step's `require`
  (stance, scores or pieces) re-asks a reply that lacks it once; an unmarked
  stance after that counts as reject, and an unlabelled reply is kept whole as
  one `statement`. **A run ending on a pick's `feasible` edge is the one
  conductor path that commits `converged`**; every other flow ends
  `resolved` / `rejected`. **What a conductor run agrees goes to memory:** a
  converged pick's decision is saved to `context/decision/<task>`
  (`app/services/agreed.py`) and the commit's `memory` points at it. The commit
  carries no `assignments`, and `task_sync` compiles only commits that do.
  **`accord` builds a shared summary in code; every member takes part the same way:**
  members label their own text with markers (`[[mycelium: constraint]] …`,
  `term=<word>`, `check covers=p1,p2`; `markers.labelled`, lifted onto the
  payload as `pieces`), `collect: pieces` merges them in `app/services/frame.py`
  (equal text, or embedding similarity when the hub has a model; support and
  authors kept; `single` / `conflict` / `ambiguous` / `unchecked` /
  `quiet_member` flagged, never resolved), `kind: tally` steps branch on the
  frame (`of: points`: grew / settled / empty; `of: terms`: contested / clear,
  re-asking `to: contested` once), and `kind: lock` saves the result to
  `context/summary/<task>` (`relates-to` the task; structured in frontmatter,
  readable in the body). Later work in the task is pointed at what was saved:
  `{agreed}` in a flow's prompts, an `Agreed:` line in the wake digest, and
  persona and worker turn prompts (a child row reads its nearest ancestor's).
  Every post it makes carries a structured line in its payload under
  `conductor` (`open`, `turn`, `edge`, `select`, `tally`, `lock`, `close`) beside the
  prose its members read; the history read copies it into the message's
  `metadata`, and the app (`task/conductor-row.tsx`) and `swarm`'s view draw
  the line, keeping the prompt behind a toggle. A run ending `resolved` is a
  success (the channel reads it "Done"), only `rejected` a failure.
- **A worker is a teammate the hub runs, a coding agent in its own checkout.**
  Engine kind `worker` (`app/services/worker_engine.py`), on the persona's
  machinery (notes as character, a Pi session per (room, handle)), but the one
  Pi session that keeps Pi's tools: each turn runs in its own git worktree
  (`app/services/workspace.py`) of the room's repository at
  `<data dir>/workspaces/<room>/repo`, on branch `swarm/<handle>`, committing
  as itself. The repository is a clone of the swarm's `repo` (cloned before
  anything else is set up, so an unreachable one starts nothing; a room keeps
  the repository it started on) or an empty one. Its commands run in the
  backend container as its user, so running on the hub is not what limits a
  worker; `WORKER_TOOLS=false` makes it write-only, and so does
  `ALIGNER_PI_OPENSHELL`, whose sandbox cannot see the checkout. A turn is
  bounded (`WORKER_PI_TIMEOUT_S`) and nothing runs between turns. It acts on
  four things: a mention, an addressed turn, a `filed` notice naming it (it
  claims the row and works it in the row's thread), and a `resolved` notice
  that left a parent with nothing open (`assignments.parent_completed`; the
  author of the children writes the combined result into the parent and
  resolves it, once per parent, from each part's final version
  (`_parts_of`), not from memory). What it does to the board it writes as
  action lines (`[[new: title -> @member]]`, `[[done]]`), lifted out of the
  prose and carried out against the row its thread belongs to through the
  same services every writer uses. A `done` on a settled row changes nothing,
  and a part's holder cannot resolve it before a teammate has spoken in its
  thread. Unlike a persona it keeps `@` for teammates (asking for review is
  the collaboration) and neutralizes every other mention, so it can never
  summon an engine. A reply on someone else's row that neither resolves it
  nor names a teammate goes back to the row's holder (`_hand_back`), so a
  review cannot go quiet because a model forgot a mention. Turns are serial
  per worker and capped per room (`WORKER_MAX_TURNS_PER_ROOM`). Board events
  reach it through the manager's `on_notice` hook, fired after every notice;
  a notice still wakes no `await`.
- **`mycelium swarm` is the one-argument path to a working team, in a room
  you already work in.** A swarm is a task with a team on it, never a room of
  its own: it runs in `--room` or the shell's active room, refuses a room that
  does not exist rather than making one, and its work stays in the task's
  thread and its parts' threads, where the rest of the room can see and join
  it. It registers a conductor, files the task, and summons the
  conductor's `swarm` flow (each member checks in, then the lead splits the
  task into a child row per member) in the task's thread. The members are the
  user's own agent CLI in a new herdr workspace by default: `--kind`, else
  `swarm.agent` in config, else asked once and saved there, never guessed
  from what is installed (copy never names a harness). Each pane's env
  set to its handle and room (`MYCELIUM_AGENT_HANDLE`, `MYCELIUM_ROOM_ID`,
  plus `MYCELIUM_API_URL` when set) and handed a brief as its
  `agents/<handle>/notes` memory, read with `mycelium memory get` (a file
  outside the checkout would stop Claude Code at a permission prompt). Claude
  is started with `--allowedTools Bash(mycelium:*)` for that session only,
  never by editing the user's settings. `--server` makes the members workers
  instead, set up through the hub's `POST /rooms/{room}/swarms` (the one setup path the
  app's Swarm dialog uses too; the CLI passes `kickoff: false` and posts the
  kickoff once its view is listening), with `--repo` for the hub to clone. The invoking terminal is the live view (thread prose and notices
  across the task and its children, which `room watch` deliberately hides;
  agent text is escaped, long messages cut to a few lines, and the finished
  result printed in full) and, locally when no runner is running, runs the
  herdr sync pass on a thread (`commands/herdr.sync_pass`) with `wait=False`,
  so woken members work at once rather than one turn after another.
- **The app starts agents on a machine through its runner, on the runner's
  host.** `mycelium runner` (`mycelium/runner/`) runs on the user's machine
  and only dials out: it says hello with its scan (agent CLIs found on `PATH`,
  launchable when its host says it can start their kind), its
  roots and its agents, heartbeats, and long-polls `GET
  /api/runners/{id}/jobs/next` for `launch`, `stop`, `scan`, `swarm`, `restart`
  and `adopt` jobs
  (`app/services/runners.py`, in memory like presence; `routes/runners.py`).
  `adopt` brings in an agent CLI already running in a herdr pane that no room
  has (the machine report's `unenrolled`, offered as Members → Add → Running
  agent): the hub writes the member, and the runner, after asking, maps that
  one pane without binding its workspace (which would enroll everything else
  in it) and types the introduction once the agent is idle. Stopping it
  unmaps the pane rather than closing it, since the session is the person's.
  The pattern is LangGraph Studio's (a hosted UI driving a local server),
  turned around so the hub never reaches into the machine. A job names a
  framework id from the runner's scan and a folder inside its roots, never a
  command. The hub writes the agent (manifest with `runner` and `framework`,
  instructions as its notes) before queuing the launch, so a failed start
  leaves an agent that can be started again. Where it runs is an `AgentHost`
  (`runner/hosts.py`, picked by `runner.host`): the runner decides what to
  start and asks first, the host opens somewhere for it, starts it, reports
  its status, delivers its wakes and stops it. Every agent a runner starts is
  an interactive session, prompted to read its notes; there is no headless or
  one-shot mode, and a machine whose host isn't up starts nothing. herdr is
  the default host: a runner-started agent's pane mapping is not `managed`,
  so a closed pane stops it without deleting it from the room. An agent that
  comes up stopped at a prompt (herdr's `blocked`, a first-run one included) is
  started, not failed: its pane stays open, the app shows it waiting for input
  on that machine with its last screen lines, and its introduction is handed
  over once it is idle. A start that fails leaves its pane open too (a shell
  still starting, or a CLI showing an update or sign-in herdr doesn't read as
  an agent, is the person's to see), mapped before anything starts in it, and
  starting the same handle again goes back to that pane rather than opening
  another. A new pane's shell gets `SHELL_WAIT_S` to reach a prompt. The job
  reports each step it reaches (`hosts.STEP_*`) and, on failure, the step, a
  one-word why (`shell`, `cli`, `host`), the pane and its screen, which the
  Add member dialog turns into what to do. The runner's pane lock covers
  opening and mapping the pane, not the wait on herdr after it.
  Everything known about an agent CLI lives in one package,
  `integrations/agents/`: the table the scan reads (`KNOWN`: name, binaries,
  herdr kind), an `AgentKind` per CLI with what it starts with
  (`launch_args`) and what it commonly waits for (`blocked_hint`), and the
  manifest family its agents are recorded under (`ResidentIntegration`, the
  persisted `claude_code`, unless a CLI has its own, like Cursor's workspace
  rule). A CLI with something of its own has a module there (`claude.py`,
  `cursor/`); herdr, Omnigent, swarm, the scan and `agent create` ask
  `agents.of_kind`/`by_id`/`adapter_for`, and nothing outside it names a CLI
  (`tests/test_agent_kinds.py`). `engine` and `a2a` are members that are not
  CLIs and keep their own packages. A host that
  can't pass an agent an environment (`joins`) gets a join code in the
  agent's introduction instead; see the next point. A swarm with `runner` set has the hub
  register the conductor, write the members and file the task, and the runner
  runs `swarm.start_local`/`brief_local`/`kick_off` exactly as the CLI does.
  A swarm starts in herdr whatever the runner's host. **The runner is what
  keeps a machine's agents synced**: its host's sync pass, for herdr over
  every workspace bound to a room on the machine (the ones it opened and any
  a person bound). Binding a workspace (`mycelium herdr sync --workspace w
  --room r`) is the choice to sync it; that command runs one pass and says
  whether the runner is up, and there is no terminal loop.
- **`mycelium machine` is every herdr agent on this machine, and what to do
  about it.** `mycelium/machine.py` reads herdr's registry and herdr itself
  into one report: per agent a state (`working|idle|blocked`, `stopped` = pane
  open with no agent, `gone` = pane closed), kind, folder and whether herdr
  restores it; whether the runner is running; and problems, each with the
  command that fixes it. The CLI prints it (`restart`, `rename`, `unbind`,
  `integrations`); the runner sends it with every heartbeat
  (`RunnerHello.machine`) for the Machines page, whose one action is Restart
  (`POST /api/runners/{id}/restart`, a `restart` job that asks on the machine
  like a launch). **An agent's own session is herdr's, never Mycelium's**:
  with herdr's integration for its CLI installed, herdr reopens each agent in
  its own conversation after its server restarts. Installing one edits that
  CLI's settings, so it happens only on a yes (`mycelium machine integrations
  --install`; the desktop app asks once, in the first-run wizard or as a dialog
  on first launch, and the answer is kept in `~/.mycelium/herdr/restore.json`).
  Restart is the same for every CLI: the same kind in its folder and pane, as
  itself, told to catch up from the room, with its workspace bound again so
  the runner syncs it. Mycelium needs herdr `MIN_VERSION` (0.9.3,
  `integrations/herdr/bridge.py`) or newer; the desktop app ships it. A herdr
  server restart leaves every pane a bare shell, so sync retires a managed
  member only when its pane is closed, and retires nothing when the pane list
  can't be read.
- **"Who am I" has one answer, from ordered sources, and a folder can join a
  room.** Every command needs the hub, the handle, the room and a credential;
  `mycelium/caller.py` answers all four from the same order: a flag, the
  environment, the herdr pane (`HERDR_PANE_ID` through herdr's registry, which
  is how an agent herdr restored without its environment is still itself),
  this folder's membership, this machine's setup, a default. A folder
  config keeps only the `identity` keys it set itself.
  `resolve_actor`, `_resolve_room`, `client.auth_headers` and
  `MyceliumConfig.load` call into it, so a new way of running agents adds a
  source there, once; `mycelium whoami --sources` shows each answer and where
  it came from. A membership is what `mycelium join <code>` saved in
  `.mycelium/member.json` (0600, git-ignored beside it), found by walking up
  from the cwd; it is how an agent on a machine that says nothing about it
  (no environment, no login) acts as its member. Codes come from `POST
  /api/rooms/{room}/joins`, which needs the right to act as the handle
  (owner/allow_from), and are redeemed at `POST /api/joins/redeem`, the one
  `/api` path open when the gate is on (`app/services/joins.py`: single-use,
  ten minutes, in memory, stored hashed). A gated hub signs the member its own
  token (`app/services/member_tokens.py`: ES256, key in the data dir, 30
  days) and trusts its own issuer beside the configured ones with no key
  fetch; with auth off the membership carries no token. One member per folder.
- **A runner starts nothing a hub sent it without a yes on its machine.**
  Anyone who can reach a hub can queue a job for any runner on it, and the
  hub can't prove who asked, so the machine is the only place the check can
  live. A `launch` or `swarm` job waits (reported `waiting`) until it is
  answered in `mycelium/runner/approvals.py`: a question file under the
  runner's folder and a `.yes`/`.no` beside it, written by the desktop app's
  dialog (the supervisor emits a `request` event) or by `mycelium runner
  approve|decline`. Nothing over the network can write that file; a job id
  that isn't the hub's hex never becomes a file name. `scan` and `stop` don't
  ask. The one exception is a hub the runner may trust: the desktop app's own,
  and only when its supervisor started it (it listens on 127.0.0.1 alone; a
  hub already on the port, like a Docker one publishing to the network, does
  not count), or `--trust-hub` said by the person. A machine with no one
  there to approve can use a **pairing** instead (`mycelium/runner/pairing.py`): `mycelium runner
  pair` there prints a code, the app sends its device key (WebCrypto P-256,
  non-extractable, in IndexedDB: `lib/device-key.ts`) with an HMAC keyed by
  the code, and a launch, swarm or restart that key signs (over the job's
  fields, a time and a nonce) starts without asking, within the folders,
  agent CLIs, teams and expiry written on the machine. The hub routes the
  pair request by the code's public first four characters and only carries
  signatures; a refused one asks as an unsigned job does, and the job's
  `pairing` says why. The UI lists only your own
  machines (`lib/my-machines.ts`: the one the app names with `?machine=`, ones
  added by the code the runner prints, or ones owned by your principal), and
  with a verified token the hub shows and serves a caller only runners it
  owns. That listing is tidiness; the question on the machine is the security.
- **The desktop app is a window over a supervisor the CLI owns, and needs no
  Docker.** `mycelium desktop serve` (`mycelium/desktop/supervisor.py`) runs
  herdr's server, a native SLIM node (`slimctl slim start`, pinned 2.1.x to
  match `slim-bindings`), the hub, the UI and the runner, each once the one
  before answers; `--mode client` runs only herdr and the runner against a
  hub elsewhere. It restarts what crashes with backoff, says why from the
  program's own last output, treats a port something else already answers as
  running (so it sits beside a Docker stack), except the hub's: a hub it
  didn't start on its port is never used without saying so. Before starting
  anything it lists every hub on the machine (`mycelium/hubs.py`: Docker
  containers, hub processes, the port; each with its version and store, told
  apart by the `.store-id` `/health` reports), warns when there is more than
  one, and uses the hub on its port while reporting it (`existing_hub` in its
  status: owner, version, data folder), which the app shows with the command
  to stop it. It never stops another hub itself: that one may be someone's on
  purpose, so the person decides. It leaves herdr running on stop
  (agents live in it), so after an in-place update the herdr still running
  is the old one, whose program file was replaced; macOS can then refuse it
  and its shells protected folders like `~/Documents`.
  `mycelium/desktop/herdr_server.py` finds that case (a server running the
  app's own herdr, whose program file has a change time later than the
  server's start; modification times come from the build, so they can't be
  used). A herdr installed another way, such as by Homebrew, is the person's
  and never flagged. The supervisor checks once a minute and reports it as
  `stale_herdr` in its status, and asks once per server through the app's
  approval dialog. herdr restarts only on a yes, since that stops its agents,
  and restarts the same program it was running; `mycelium desktop
  restart-herdr` and the health check say the same. It writes everything to
  `~/.mycelium/logs/desktop.log`. Programs are found in the app bundle, then
  a checkout, then PATH. `mycelium-desktop/` (Tauri 2) adds first run, the
  menu bar, `mycelium://join|terminal|settings` links and an agents terminal that can
  start only herdr; the hub's pages get no IPC and reach the app only by
  those links. The web UI knows it is inside the app by the
  `MyceliumDesktop/` user agent. `doctor --mode desktop`
  (`mycelium/desktop/checks.py`) checks what the app runs, and the app shows
  it as its health check.
- **The aligner mediates, inside a task.** Agents never talk to each other directly;
  all coordination flows through the aligner. It's a first-party engine registered
  as a room citizen (`mycelium engine create aligner --kind aligner`) and summoned
  (`board coordinate <row> aligner "…"`, or `engine invoke` when the question
  belongs to no row), not auto-run on a join window. Like the conductor, it
  negotiates **in the thread it was summoned in** (`AlignerEngine.mediate`'s
  `episode`): its questions, the replies and the verdict land in the task's thread
  (or the room). Its record is a nested episode
  with `within` set to that thread, and its commit carries the row as `within`, so
  an agreement reached in a task compiles rows `part-of` it. **Its parties are who
  was named**: the members the summon mentions, or a task's own members (its
  assignee and the agents who posted in its thread); a summon in the room that
  names nobody starts nothing. It freezes only those parties
  (`open_episode(parties=…)`), so presence coming and going never ends a run;
  only a party being removed from the room does (`on_party_left`). A room that
  registered no aligner has none: `@aligner` there starts nothing and says once
  how to add one. Each turn `@`-mentions the agent it asks
  (`addressed_turn(mention=True)`), so the wake is an ordinary mention. It
  settles at once when every party's stated opening position names the same
  outcome, asks an agent that rejects the very offer it proposed once whether
  it still wants it (taking the answer as given, never turning a reject into an
  accept itself), and stops after the same offer is rejected three times. `POST
  /rooms/{room}/engines/stop` (`mycelium engine stop`, the rail's Stop) ends a
  run before its next turn. Every ending commits with a `reason` when it ended
  early (`stopped`, `party_left`, `circles`) and posts one plain line; a commit
  record never reads as raw JSON in `room messages` (`chat.readable`).
- **The aligner's LLM session is Pi (only).** The SAO mediator runs on a persistent
  Pi session; there is no litellm fallback. Pi ships in the backend image;
  OpenShell sandboxing (`ALIGNER_PI_OPENSHELL`) is an optional command-prefix seam,
  off by default. See `pi_session.py`.
- **NEGMAS owns termination.** The mechanism stops at unanimity; the mediator never
  loops to the step cap (the anti-theater property). A failed negotiation commits as
  `rejected`.
- **Faithful interpretation, never fabricated.** An unreadable proposer holds its
  own last line, never the standing offer (no phantom convergence); numeric offers
  snap to the nearest real grid point or refuse, never to a fabricated value
  (`offer_snap.py`).
- **Rooms are folders on the hub.** `.mycelium/rooms/{name}/` with standard subdirs:
  `decisions/`, `failed/`, `status/`, `context/`, `work/`, `procedures/`, `log/`.
  `work/` holds one row per task. The room's display title is the room's own
  (the `.room.json` sidecar, `PATCH /rooms/{name}`), not a memory: it names the
  room rather than describing work in it. Direct file writes still
  work — that's a hub-operator escape hatch (run `mycelium memory reindex` after),
  not the client model.
- **The spoke is a thin client.** Any non-hub machine keeps **no local `.mycelium/`
  replica**; there is one store, the hub's. `memory get`/`ls`/`search`, the
  category views, the roster reads (`agent ls`/`show`/`invoke`/`rm`,
  `engine ls`/`invoke`) and the global user store (`user create`/`ls`/`show`,
  `iam`, `whoami` over `/api/users`) all resolve against the backend, so a spoke
  with no files still reads the room — and an unreachable hub is reported plainly rather
  than silently answered from something stale (`commands/memory.py:_hub_session`).
  The backend is the only writer of `users/`, so the CLI half of
  `contracts/user-store.json` covers just the slug rules and the wire fields.
  `mycelium iam` is the one split command: the identity it sets is this machine's
  config, the user record is the hub's, and the local half lands either way.
- **Rooms are always persistent.** Rooms are persistent namespaces for memory and
  coordination; a task is work that resolves, and a negotiation inside a task is an
  ephemeral, recorded episode.
- **The CLI skill is a protocol.** Take a task off the board → work it in its own
  thread → resolve it, with a mediated negotiation available inside a task when
  agents genuinely disagree. This is the value add; don't change it to an
  augmentation layer.
- **memory set always upserts.** `memory set` overwrites existing keys automatically
  (version increments). Frontmatter the store doesn't manage is user data: it
  survives a rewrite rather than being dropped, `MemoryCreate.meta` (CLI:
  `--meta k=v`, `--expandable`) writes it, and `MemoryRead.meta` reads it back —
  every frontmatter key outside `MANAGED_META` (`services/filesystem.py`).
- **Memories interlink; the link index is derived.** `myc://key` (canonical) and
  `[[key]]` (shorthand) are the same edge, plus `![[key]]` transclusions and typed
  frontmatter relations (`supersedes`, `depends-on`, `part-of`, `relates-to`).
  `app/services/links.py` parses them into `.link-index.jsonl` beside the embedding
  index — same contract: derived from the markdown, rebuildable, upserted on every
  write. It's persisted rather than derived-on-read because backlinks are the whole
  point (the blast radius of changing a leaf) and deriving them means reparsing every
  file on every memory open. Links are room-local; `myc://rooms/{other}/{key}` parses
  but resolves to a `cross_room` error.
- **Transclusion is opt-in on the target, and depth 1.** `![[key]]` embeds a memory
  only when its frontmatter says `expandable: true`; anything else is an integrity
  error, never a silent inclusion. Expanded text is inserted verbatim, so a marker
  inside it stays literal — cycles are structurally impossible and expansion size is
  bounded, with no depth cap to tune. A marker that can't expand is left exactly as
  written and reported, so a refused embed never reads as an empty definition.
- **Skills are memories, promoted.** A skill is just a memory under a room's
  `skills/` namespace (SKILL.md-style markdown + frontmatter, with the one-line
  `description` in frontmatter). The same way `agents/<handle>` memories are
  promoted into a members panel and `decisions/` into a category view, `skills/` is
  promoted into a thin skills surface — `mycelium skill …` and
  `/api/rooms/{room}/skills` (`app/services/skills.py`, `app/routes/skills.py`).
  Room-scoped, like memory; no separate store, and a skill is reachable as a memory
  too (writes go through the memory upsert path, so they're indexed/linked/broadcast
  like any memory). **In the GUI there is deliberately no dedicated skills rail** —
  a skill shows up in the Memory list like any `skills/…` memory, with a small
  "skill" tag in the detail view (`memory-detail.tsx`); the frontend's only
  skill-specific call is the composer's `/` autocomplete. The surface keeps prose;
  it does not execute skills — that's the participation/engine layer's concern.
- **Uploads are memories, promoted, and only what the app can preview.** A file a
  room keeps is an `uploads/<name>` memory plus its bytes, a blob named by its
  SHA-256 at `rooms/{room}/uploads/.blobs/` with no extension, so no `*.md` scan
  reads one (`app/services/uploads.py`, `routes/uploads.py`). The record's
  `upload` frontmatter (hash, size, kind, original name) is store-owned
  `SYSTEM_META` like the episode, so a memory write can't repoint a record at
  another blob or change how it is served. Only images, PDFs, UTF-8 text and
  common audio/video are taken, each on its extension and bytes agreeing;
  images are decoded and re-encoded with Pillow, which drops EXIF. Safety is
  mostly in serving: the type comes from the service's table keyed on the
  name, never the record, every response is `nosniff` with a sandboxing CSP,
  and text (`.html`, `.svg` included) is always `text/plain`. Same bytes under
  the same name are one upload; a different file with a taken name gets
  `name-2.ext`, so a link already sent keeps its file. A Markdown upload's key
  is `.markdown`, because a key ending in `.md` names the file itself. Messages
  link files as `[[uploads/<name>]]`, nothing new on the wire: the composer
  (`components/uploads/composer-attachments.tsx`) uploads each file as it is
  added and appends the links on send; `MessageBody` draws the linked files
  under the prose and opens a preview (`upload-preview.tsx`; PDFs through
  pdf.js, loaded on first use, never the browser's viewer); agents use
  `mycelium file upload|download`. Bytes go over HTTP, never SLIM, and the hub
  keeps them in plaintext like the rest of the room.
- **Voice is opt-in, transcribed on the hub in a worker process, lands as a
  draft, and is toggled, never ended by a pause.** It is off unless turned on
  (`voice.enabled` in config.toml → `VOICE_ENABLED`; the Mac app's own switch,
  passed as `desktop serve --voice/--no-voice`, wins for the hub it starts), and
  off means no mic button, no download, no worker. The models are not baked
  into the image or bundled in the app: an enabled hub fetches them into
  `<data dir>/models/voice` at startup (`voice.prefetch`), checked against a
  pinned SHA-256. Transcription runs in `app/services/voice_worker.py`, a spawned
  process the hub starts on the first chunk and that exits after `IDLE_EXIT_S`
  with no mic open, because ONNX Runtime never returns a model's memory to the
  process that loaded it (an in-process unload freed nothing, and reloading grew
  the process further). The hub never imports sherpa-onnx; `hub_entry.py` calls
  `multiprocessing.freeze_support()` so the PyInstaller build can spawn it. A
  chunk that arrives while the worker starts gets `ready: false`, unread, and the
  app holds its audio and sends only an empty probe until it's ready. The composer's mic (`components/voice/`) records through an
  AudioWorklet, averages it to 16 kHz 16-bit PCM (`lib/voice.ts`) and POSTs a
  chunk every 300 ms, one request in flight, to `/api/voice/sessions/{id}`
  (`routes/voice.py`); the id is the app's own per open mic. Plain HTTP, not a
  socket, so it rides the same proxy and gate as everything else. On the hub
  (`app/services/voice.py`) each session holds a Silero VAD that cuts speech at
  pauses, and each piece is transcribed by Moonshine v2 (English) through
  sherpa-onnx on the CPU; texts go back and are appended to the draft. A pause
  only cuts a piece; the mic stays on until it's toggled off, which sends the
  rest with `final`, and nothing auto-sends. sherpa-onnx 1.13.8 returns empty
  text for a Moonshine v2 piece much past ~6 s (fixed upstream, unreleased), so
  the VAD caps pieces at 5.5 s and `split_long` cuts anything longer at its
  quietest point. `sherpa-onnx-core` (the native libraries) is listed beside
  `sherpa-onnx` because uv's lock drops it. No request waits on the worker
  starting (the hub's `to_thread` pool is shared), and a failed start waits
  `START_RETRY_S` before another try. Sessions are keyed by (caller, id), with
  a per-caller cap when sign-in is on. Audio is never stored. The Mac app needs
  `NSMicrophoneUsageDescription` (`src-tauri/Info.plist`) and the
  `device.audio-input` entitlement on the app itself (`app.entitlements`), and
  its `on_permission_request` grants the microphone only to this Mac's own hub
  UI (`media_permission`): a remote hub's pages in client mode are denied.
- **Patterns are scenarios the hub loads, from a pack the operator provides.** A
  scenario (`app/services/patterns.py`, `routes/patterns.py`) is a room ready to
  run: a cast, some context, a task and the flow that sets them working, loaded
  by `POST /api/patterns/{name}/load` as the writes any client can already make
  (room, engines, memories, task), through the routes that own them. It loads
  **paused**; `run` posts the summon. The room name is claimed atomically (a
  directory made exclusively, since creating a room that exists is otherwise a
  quiet no-op that would put two callers in one room), and a failed step removes
  the room. A pack is data (`yaml.safe_load` into strict models, never run), and
  **the hub never fetches what a caller names**: `patterns.dir` is a folder the
  operator provides (`PATTERNS_DIR` in `.env`; compose mounts it read-only and
  points the backend at the mount), and a caller names a pattern in it, never a
  location. A trusted caller may send a scenario in the request
  (`patterns.allow_inline`, what `mycelium pattern use --from` does; the CLI
  clones a URL itself, with the caller's git credentials), and
  `patterns.personas_only` refuses any member that is not a persona or a person,
  so a public hub never starts a worker (Pi with tools). A scenario can also say
  how a run reads to someone watching it (`before`, `after.track`, and a `guide`
  of steps pointed at parts of the screen); loading writes none of that to the
  room. A loaded room's sidecar records `pattern` and `pattern_task`, and after
  every conductor step (`ConductorEngine.on_step`) a room whose scenario has
  `after` gets a one-shot Pi restatement of where the run stands, written as
  `context/standing` (`app/services/standing.py`, one call per room at a time,
  later steps folded in). The app's **/patterns** explorer
  (`components/patterns/pattern-explorer.tsx`) is a plain client of these: the
  pack, a pattern's newest room, before and now, the flow, the thread, and the
  person's turn as Approve/Block. The flow is parsed with the hub's own `Protocol` model before anything
  is written, because the hub does not check a `protocols/<name>` memory on save:
  a bad one is silently left out of the room's flows.
- **A room's messages are searched by any field, with counts to narrow by.**
  `GET /rooms/{room}/messages/search` reads the whole history (not a page)
  with one grammar: words, `"phrases"`, `-word`, `field:value` (`from:` `to:`
  `mentions:` `task:` `in:` `stance:` `has:` `is:` `type:` `kind:` `step:`
  `day:` `thread:`), `after:`/`before:`/`on:` and `sort:`. The grammar and the
  engine are document-agnostic (`app/services/facet_query.py`); messages
  declare their fields in `message_search.py`, and memories can declare
  theirs on the same engine. Facet counts are disjunctive (a field's counts
  ignore its own clauses). `contracts/message-search.json` freezes the
  fields and closed values; the hub, the CLI's copy (`search_grammar.py`,
  behind `mycelium room search`) and the frontend's (`lib/message-search.ts`)
  assert against it, and `cli_prose` checks every search query written in
  the docs and prompts against it. The channel's ⌘F speaks the same grammar
  with the composer's hints (`composer-hints.tsx`), and its History panel
  lists every hit with the counts as switches. The wake digest names the
  command in one line.
- **There are three ways to mention someone.** A plain mention (`@foo`) sends
  them the message; if they're an agent that's working, they read it when
  their turn ends. A silent mention (`@~foo`) names them without notifying
  them or waking an agent. An interrupting mention (`@!foo`) stops a working
  agent mid-turn so it reads the message straight away. `contracts/mentions.json`
  freezes the three forms, asserted by the hub's parser (`persister.py`) and
  the app's (`lib/mentions.ts`). How `@!` works (`app/services/interrupts.py`):
  when any person or agent in the room sends it and herdr shows the target
  working, the hub queues an `interrupt` job for the runner whose machine runs
  the target. The runner presses the agent CLI's own key
  (`AgentKind.interrupt_key`) only if herdr still shows it working. The wake
  the mention queued has reason `interrupt` and is delivered once the agent is
  idle, and its digest tells the agent to act on the message and then carry on.
  An engine's `@!` is an ordinary mention, and personas and workers lose the
  `!` with their other sigils, so nothing the hub runs itself ever interrupts.
  A sender the room has no manifest for counts as a person.
- **Four composer sigils, one mechanism.** The chat composer
  (`room-chat-box.tsx`) autocompletes `@` → agents, `[[` → room memories (inserts
  `[[key]]`, which resolves to `myc://` and is clickable in chat), `/` → the
  room's skills (inserts `/name`), and `:` → emoji (inserts the character: `:ta`
  offers 🎉). One cursor-prefix detector feeds one candidate
  popover; `[[` is matched before `/` and `@` since a memory key can contain
  slashes, and `:` last. A `:` starts a shortcode only after whitespace or at a
  line's start, with a letter (or `+`/`-`) after it, and never inside code, so
  `12:30`, `https://` and `key: value` stay as typed (`lib/emoji.ts`). A whole
  `:name:` turns into its emoji when a space follows it, and sending converts
  any left, so a message stores 🎉, never `:tada:`, and an agent reading it
  through the CLI sees the same. The names are GitHub's (`gemoji`), loaded the
  first time a `:` is typed, so the list stays out of the app's first load;
  recently used emoji, kept in this browser, come first. Skills insert a reference token — the resident agent/engine interprets
  it; the composer never runs the skill. **Commands** are the one `/` that runs:
  `/task`, `/swarm`, `/memory`, `/thread`, `/schedule`, `/agent` and `/engine`, only as a message's
  first word, listed ahead of the skills. Each declares its arguments in
  `lib/composer-commands.ts` (one parser for all of them), so the composer
  draws the signature over the box with the argument under the cursor lit,
  offers that argument's values (a machine's harnesses and folders, engine
  kinds, memory folders and keys) and Tab completes them; a missing argument
  is named before anything runs. `/task` files a task with the board capture's
  grammar through the same `lib/board/file-capture.ts` the board's File button
  uses (the tasks route, then ordinary fields); `/swarm` opens the swarm
  dialog, since a swarm spends model turns; `/schedule [@agent] [when]
  [prompt…]` opens the New Schedule form (`schedules/new-schedule-dialog.tsx`,
  also behind the composer's +) filled in from what was typed, and only the
  form saves it, since a schedule spends model turns again and again;
  `/agent` queues the same runner
  launch the Add member dialog does; `/memory <key> <text>` writes inline,
  replacing a taken key only at the version it saw, and with no text opens
  the new-memory dialog (`new-memory-dialog.tsx`, also behind the memory
  pane's Add and the composer's +) where the key points. `/thread <title>`
  writes `context/<slug>` with the title as its heading and opens it, whose
  discussion is the conversation (every memory carries a thread, so nothing
  on the hub is special); a title already taken opens that page rather than
  replacing it. A message that
  starts by mentioning a conductor gets the same help without being a
  command: its flow completes from `GET /rooms/{room}/protocols` (the room's
  own flows, then the built-ins it leaves alone) and the flow's roles fill
  the member slots in the order they bind (`parseSummon`); it is still sent
  as the message typed. A skill that shares a command's name is still
  reachable by picking it from the list.
- **One keycap, sized by where it sits.** Every surface that names a key draws it
  through `ui/kbd.tsx` — `Kbd` for a literal, `KbdChord` for a chord the keymap
  owns (platform-spelled, and silent when nothing binds the action). Three sizes,
  picked by context rather than emphasis: `xs` is the one that fits the 24px
  status rail, `sm` for palettes and hint rows, `md` for the `?` cheatsheet. The
  rail is where this matters most: it is the only strip every screen shows, so a
  cell a key already reaches names that key (in its tooltip, via `action=`) and
  the way in teaches the way past. `key-badge.tsx` stays separate — that is the
  ⌥-reveal badge drawn *on* a target, not a cap set in copy.
- **The synthesizer distills messages into memory (#808).** Its input is the
  room's transcript — the ephemeral half, where a decision is argued and settled
  and nothing indexes it for meaning — and its output is a `knowledge` memory at
  `context/synthesis`. Summarizing memory back into memory is a different
  feature, kept behind `SYNTHESIZER_SOURCE=memory` rather than as the default.
  Three properties carry the direction: it reads **by message type**
  (`schemas.PROSE_MESSAGE_TYPES` → `persister.prose_messages`) so a serialized
  message never reaches the prompt; it is **incremental**, with the cursor
  living in the written memory's own frontmatter so position and text land in
  one write (`--all` in the summon text re-reads everything); and it **excludes
  its own posts** by sender, since it speaks its briefing into the room it reads.
- **Consensus compiles into rows, but rows do not need consensus.** Work is
  created board-first (`board new`, `--parent` landing a real `part-of` relation,
  refused rather than dangling when the parent is absent). A negotiation is one way
  a row arrives, not the way. On convergence the aligner hands the agreed
  `{issue: value}` map to `task_compiler.py`, an LLM stage that turns it into
  tasks, and `task_sync.py` writes each one as a `work/` memory through the
  canonical upsert. It runs off `on_converged`, which fires once the commit is
  recorded, in the background: the rows land just after the consensus is
  announced, and each raises its own `filed` notice, which is what wakes the
  agent it is for. A converged commit that names a task in `within` files its
  rows `part-of` it (and loose if that row is gone). Only a commit carrying
  `assignments` (the aligner's) is compiled; a conductor's agreement is saved
  to memory. The model still writes `- [ ] text @handle` lines because
  that is the shape it is good at; parsing them into rows is the compiler's job
  and the line format never leaves it. A task carries `assignee`, never `owner`
  — `assignee` is who the task is *for*, the `assignment` field is who is
  *holding* it, and a stage that cannot take one must not write one. Fail-soft: a compiler
  outage falls back to the raw `issue=value` agreement. The compiler is
  deliberately a distinct consumer stage across an explicit seam, not part of
  the negotiation engine. It runs a one-shot `pi` turn
  (a throwaway session, off the event loop via `asyncio.to_thread`), like every
  other mycelium cognition call.
- **A schedule is an agent's check-in, kept and fired by the hub.**
  `app/services/schedules.py`, `routes/schedules.py`, `mycelium schedule`, and
  the room's Schedules tab (`components/schedules/room-schedules.tsx`, a clock
  on the agent in the Members rail). One JSON file per room
  (`.schedules.json`), not memories: every run rewrites `last_run` and its
  history, which is nothing to index or version. A loop (`SCHEDULE_TICK_S`)
  fires what is due, coalescing missed runs into one. Each run first asks a
  **pre-check** from a closed set the hub answers in code (`always`,
  `mentions`, `assigned`, `stale`, `silent`, `task`, `search:<query>`), never
  a script; one that finds nothing is `quiet` and costs no model turn. A run
  that wakes goes through the mention path: a herdr owner's doorbell
  (`reason: schedule`, held while it works), else the owner's next `await`
  hands it over (`schedules.take_pending`). A wake already waiting makes the
  run `held`, a herdr owner mid-turn makes it `busy`. Nothing a run does is
  said in the room; `schedule_changed` on the bus redraws the app. Every
  change records who made it (`updated_by`, from the token when there is
  one), and a pause keeps `paused_by`/`paused_at` until it is resumed, which
  the Schedules tab shows. When someone pauses a schedule that isn't theirs,
  nothing is posted and nobody is woken: the owner's next wake digest says it
  once, on a `Schedule:` line with the command to resume
  (`schedules.untold_pauses`, marked said with `pause_told` by the
  herdr-wakes route), and a new pause is said again. Guardrails:
  a minimum interval, a per-agent cap, an expiry renewed by hand, and writing
  one needs the right to act as its owner.
- **A row waits on its dependencies, derived and never stored.** A `work/`
  row whose `depends-on` names a live board row that is not settled reads as
  waiting on it: `assignments.waiting_on` on the hub (in every assignment read
  and in the lease watcher's signature, so `await --lease` wakes when a row
  becomes claimable), and a `waiting_on` fold in both board projections, frozen
  in `contracts/board-vocabulary.json` under `task`. Resolving a row raises an
  `unblocked` notice for each dependent that now waits on nothing. Refusing a
  claim on a waiting row is `BOARD_DEPENDENCY_GATE`, off by default; `force`
  on the claim overrides it. A target outside the live namespaces or that
  names no memory is a reference, not a prerequisite.
- **A row given to someone is held by them, with no timer (#1136).** Being
  assigned is holding: a row with an `assignee` and no claim reads `held`, and
  its holder is whoever claimed it, else its assignee (`hold_cases` in
  `contracts/board-vocabulary.json`, asserted by all three copies). A claim
  has no TTL unless it asks for one, so no hold drains by the clock. Taking a
  held row is refused; `board assign --reassign` (`fields.write(reassign=True)`)
  moves it, and its `claimed` notice carries `for` and `from`. A hold ends on
  real signals (`app/services/hold_sweep.py`, run in the lease sweep loop):
  the holder's herdr pane reading closed on two sweeps in a row (from the
  runners' machine reports, so a restart doesn't release it) releases it with
  a notice carrying `why`, and so does deleting an agent's `agents/<handle>`
  manifest (the memory delete route calls `hold_sweep.holder_removed`). A
  handle with no manifest and no user record is unseen, not gone: a person
  who never set up a user record looks the same. A held row with no activity
  for a few hours gets one `quiet` notice and is never released for it; a
  holder the hub has no signal for expires after a long fallback, with a
  notice. A row waiting on someone else (its linked pull request open and
  awaiting a review, a merge or CI, read from `.upstream-seen.json`, or a
  blocker named) is neither quiet nor expired.
- **No process of the hub's own wakes an agent.** A wake comes from a person,
  another agent (an engine or a worker included) or a schedule the agent
  owns. What the hub notices by itself, such as a quiet task, a drained
  claim or a pull request changing, is said as a notice on the timeline,
  which `_addressed_to` and herdr's doorbell both ignore, never as a message
  in a thread or a mention.
- **Server-held membership.** A turn-based agent (Claude, a subagent, a shell) can't
  hold a SLIM socket between turns, so the backend holds membership: `await`
  long-polls off a durable transcript cursor and refreshes a presence lease;
  `members()` is the union of live SLIM members and lease holders. This is why
  turn-based agents never miss a tick. The **durable inbox** (`persister.py`)
  re-serves missed point-to-point messages on rejoin (SLIM has no offline replay).
- **Sharing is the live channel.** Cross-machine sharing is the same SLIM channel over a
  shared node (`mycelium hub host` / `mycelium connect`), plus
  `mycelium room clone --from <api-url>` for a point-in-time HTTP snapshot. Git can back
  up or version the `.mycelium/` files but is **not** a sharing mechanism — no room flow
  pushes or pulls over git.
- **No Ensue references in code.** We took inspiration from their API design but the
  implementation is independent.
- **Message kinds.** Ticks are `exchange`,
  consensus is `commit:converged|rejected`, with episode URNs and causal
  `message.parents`. The subkind table lives in `app/services/message_format.py:VALID_SUBKINDS`
  and is SLIM-native (`converged|resolved|rejected`).
- **CLI/backend SLIM+message duplication is guarded by a contract test.** The thin `uv
  tool` CLI can't import the backend, so `mycelium/slim/` copies the SLIM+message
  primitives. `contracts/slim-wire.json` freezes the shared wire constants;
  both `fastapi-backend/tests/test_slim_wire.py` and
  `mycelium-cli/tests/test_slim_wire.py` assert against it, so neither copy can
  drift without a red unit gate.
- **The HTTP-API JWT gate is opt-in and off by default.** `app/services/auth.py`
  validates a bearer JWT against configured issuers + JWKS (`[auth]` in
  config.toml → `AUTH_*` env). It's an app-wide FastAPI dependency, so a new route
  is gated by default; health/docs stay public. Trust is a list of issuers matched
  by exact `iss`, each with its own keys and default role — that's how a workload
  trust root slots in later without issuer-specific code. Off by default is a
  hard requirement, not a default worth revisiting: auth must never block the
  try-it path. The localhost bypass reads the request's peer address, so it does
  **not** fire for a containerized backend (published-port traffic looks like LAN
  traffic) — the local tier is served by leaving auth off.
- **SLIM channel identity is a two-rung ladder, and it starts off.** `slim.identity` /
  `SLIM_IDENTITY` selects the tier; both are implemented (`slim_identity.py` + its
  CLI mirror), and the constants are frozen in `contracts/slim-wire.json`.
  - `psk` (**default**, #567) — the group key derives from
    `MYCELIUM_SLIM_MASTER_SECRET`, set the same on every host that shares rooms. Zero
    infra, no per-member identity. `MYCELIUM_SLIM_REQUIRE_SECRET=1` makes a host fail
    closed rather than fall back to the public dev literal.
  - `signerjwt` (#476) — the floor: each member mints its own self-signed ES256
    credential and registers its public JWK on the room roster, so members are
    cryptographically distinct MLS participants with no external infra.

  Selecting `signerjwt` with no resolvable material degrades to `psk` with a one-time
  warning unless `MYCELIUM_SLIM_IDENTITY_REQUIRE=1` fails closed. Per-member
  revocation (#590 — drop the JWK, no room-wide re-key) ships. What still gates
  anything hosted / multi-user is turning identity on at all — not a missing
  capability.
- **The SPIRE tier is gone, not deprecated (#668).** `slim.identity=spire`, the
  `spire_registry` module, the `spire` compose profile and its server/node-daemon
  services were removed outright, matching the openclaw/hermes precedent (#503).
  It attested the backend to itself on one box — SPIRE server and node daemon
  co-located with the workload they vouched for — so it bought short-lived
  process-bound credentials, not the cross-trust-domain attestation the name
  signals, and it was the stack's biggest source of operational breakage
  (recreating the backend orphaned the daemon's PID-namespace link). Don't
  reintroduce it as an identity option; the conditions for reviving it are #669,
  and they start with a real client-held multi-party topology (#662) for it to
  attest across.
- **Custodial sessions are server-side, and off by default (#666).** Under an
  identity tier (`signerjwt`) the backend stops impersonating actors and
  becomes the **custodian of N per-actor MLS sessions** — one genuine MLS member per
  `(room, actor)`, keyed by handle (`app/services/custody.py`). The name is the term
  of art: *custodial* (the custodian holds your keys for you, like a custodial
  wallet); server-side is the custodial rung, client-held is the non-custodial one.
  `respond(@alice, …)` sends through @alice's session, so attribution is cryptographic
  on the wire (not a backend-stamped message field) and room access is MLS group
  membership, not app logic. All sessions live in the backend process, so the
  moderator App + aligner/memory/messages still read plaintext — cognition is
  preserved. **Under the PSK default nothing changes** (byte-for-byte;
  `MYCELIUM_CUSTODY_DISABLE=1` forces the single-moderator path even under identity),
  and persistence structurally requires the identity provider/verifier pair anyway (a
  custodial session cannot run on PSK). Per-session MLS state persists in an encrypted
  SQLite store (passphrase = `HMAC(MYCELIUM_CUSTODY_STORE_SECRET, ws/room/handle)`);
  on restart `restore_sessions` revives every session with **no re-invite** (proven
  across a real two-process kill/restart). **Honest scope boundary (keep it honest in
  code + the user-facing guides):** custodial means the hub still holds
  every key + plaintext; this hardens the wire + attribution + access-by-membership,
  and it is **NOT** E2E-from-the-hub.
- **The UI is part of the stack, not an opt-in profile.** `mycelium-frontend` has
  no compose profile: `mycelium install` and `mycelium up` bring it up alongside
  the SLIM node and the backend, and `mycelium down` / `logs` / `status` see it
  without any profile plumbing. The app *is* the product surface — the CLI is the
  agent-facing protocol, the browser is where a human works — so there is no
  `--ui` / `--no-ui` flag and no install prompt to decline it. The collector
  (`profiles: [metrics]`) is still the one opt-in service.
- **Usage is counted by the task, recorded on the hub, and shared only with
  consent.** The product's KPIs come from usage events the hub records
  itself (`app/services/analytics.py`): `hub_started`, `task_filed` and
  `task_resolved` (off the board's notices, in `main.py`'s notice
  dispatcher), `flow_completed` (the conductor's close),
  `negotiation_completed` (the aligner) and `agent_joined` (an
  `agents/<handle>` manifest appearing). They follow the unit of work, not
  one engine, so every way the app starts work lands in them. They are
  appended to `usage/events.jsonl` in the data dir, always, and
  `GET /api/observability/usage` adds them up for the Metrics page. They
  carry kinds and outcomes, never a name, handle, room or text
  (`PROHIBITED_FIELDS`, and a room's own flow counts as `custom`).
  Sending them to `telemetry.analytics_destination` is
  `telemetry.send_product_analytics`, off by default, asked at
  `mycelium install` and on the desktop app's first screen, whose answer wins
  for the hub it starts (`desktop serve --share-usage`). This is separate
  from the OTel export (`telemetry.enabled`), which is the backend's own
  operational metrics.
- **Room folders are a person's, not a room's.** How someone files their
  rooms list is kept on the hub under their handle
  (`GET`/`PUT /api/users/{handle}/room-folders`,
  `app/services/room_folders.py`, one JSON file at
  `preferences/<handle>/room-folders.json`), so it follows them across
  browsers and into the app. It sits beside the user store rather than in it,
  because `contracts/user-store.json` freezes the record the CLI mirrors, and a
  preference is not identity. On a gated hub only your own is readable or
  writable. A room knows nothing about the folders it is in; a room sits in at
  most one of a person's folders; before a name is chosen, the layout lives in
  the browser (`useRoomFolders`, `lib/room-folders.ts`).
- **GUI server state is one SWR cache; client state stays local.** Every room
  read in the frontend goes through `mycelium-frontend/src/lib/room-data.ts` —
  typed SWR hooks keyed `["room", name, resource]`, so N panels reading the same
  resource make one request and share one cache entry (the composer's `@`
  popover and the Members rail are two views of one `useRoomRoster`). SWR owns
  polling, refocus and dedup, so components hold no `setInterval` and no
  fetched-data `useState`; a pushed SSE event calls `useRoomRevalidate(room)`
  rather than threading a refresh counter down as a prop. A store (Zustand) is
  reserved for genuine client state — which rail is open, what's selected — not
  for anything the hub owns.
- **The room's center is tabs and splits, one kind of tab for every view.**
  `components/room-dock.tsx` lays the center out with dockview: Channel, Stream,
  Board, Files, Schedules, each open memory and each open thread is a tab
  (`lib/room-dock.ts` names them: fixed ids for the room's views, `memory:<key>`
  and `thread:<episode>` for the rest, so opening one that is open shows it).
  A room opens on Channel, Stream, Board and Files; the others are added from the tab
  strip's +. Stream (`components/stream/room-stream.tsx`, modelled in
  `lib/room-stream.ts`) is everything that happens in the room, one line
  each: channel and thread messages, board and pull request changes, saved
  memories, with a colored lane per task thread. Files
  (`components/files/room-files.tsx`, `lib/room-files.ts`) is the room's
  uploads as a gallery by day: each upload's `posted_in` is the first message
  that links it (`app/services/upload_origins.py`, one pass over the history),
  and an image's `thumb_url` is a WebP the hub makes once per blob hash
  (`uploads.thumbnail`), so the grid never loads a full image. The room's connection details (its SLIM channel and A2A bridge)
  are on its settings page, not a tab. Tabs
  drag to reorder and onto an edge to split; a thread opens in a group beside
  the Channel's. The layout is this browser's, per room, and a saved one this
  version can't fully draw is dropped rather than half-restored. The Channel
  never closes and renders with `visibility: hidden` behind another tab, so it
  keeps its scroll and never reads as an empty viewport. Too narrow to split,
  every tab sits in one group. A new view is a new tab kind, not a new pane.
- **A2A bridge is proxied, not an MLS member (be honest).** The A2A bridge (epic
  #719) makes a room speak Agent2Agent both ways: `adapter: a2a` registers a
  remote endpoint as a room member (`a2a_bridge.py` answers its `@`-mentions by
  calling it — event-driven off the summon seam, chat-first, not negotiation-
  scoped), and `a2a_server.py` exposes a room *as* an A2A agent via the a2a-sdk
  server (card + JSON-RPC). **Honest boundary:** a bridged A2A agent is **not** a
  member of the room's MLS group — it's proxied by a backend seat that reads
  plaintext and calls the remote out-of-band. **The hop is plain HTTPS (decided
  default, #726):** `slima2a` (SLIMRPC transport) pins `a2a-sdk==1.1.0` exactly
  while the backend tracks `1.1.2`, so adopting it would pull SQLAlchemy,
  aiosqlite, and OpenTelemetry into the image — cost not justified yet. Reopen
  #726 when the pin relaxes and a live SLIMRPC round-trip is proven. Even
  SLIMRPC would be point-to-point RPC to a SLIM identity, still not room-group
  membership. Either way the hub sees plaintext, so it is **NOT** E2E-from-the-hub.
  Remote auth is a bearer token named by `a2a_auth_env` and resolved from the
  backend env — the secret never lands in room memory.
- **CI is fast on purpose; timing is reported, never enforced.** A PR run's
  critical path is ~100s, set by the frontend jobs (vitest runs in two
  halves on runners of its own, beside typecheck, lint and build), and that is a property worth defending — it erodes
  twenty seconds at a time, invisibly. It had drifted to ~175s by Oct 2026
  before anyone noticed. The `changes` job gates on what a PR touched: a
  docs-only PR skips every code job, and the frontend jobs and the backend
  jobs (unit tests, SLIM slices) each skip a PR that touched only the other
  side, the CLI, the desktop app or the tooling. Anything else, a contract,
  `openapi.json`, a workflow or a path nobody listed, runs both, and a push
  to main runs everything. The CLI job runs on every PR. The `timing` job reads the run's own job
  durations back from the Actions API, writes them into the run summary, and
  warns against the budgets in `.github/ci-budgets.json`. It cannot fail a run:
  a slow PR should merge and say it was slow. Raise a budget deliberately, in
  the PR that makes the job slower. `scripts/check_workflows.py` (the `hygiene`
  job) keeps the budgets naming real jobs, every `uses:` pinned to a commit SHA,
  and every workflow declaring its own `permissions:`.
- **CI is tiered, and the tier is the design decision.** Tier 0 is per-PR and
  must stay parallel and under ~60s (the contract tests below all live inside
  jobs that already run). Tier 1 is per-PR but path-filtered — the three image
  smoke builds, and the docs link check. Tier 2 is `nightly.yml`: the full
  install path and the live-LLM cognition slice, too slow and too paid for the
  PR path, where a failure opens one issue rather than blocking a person.
  Putting a good check in the wrong tier is how the ~100s baseline gets spent.
- **The CLI takes its shared flags one way, and two audits hold it there.**
  Agents type a CLI the way they type every other one, so `--room`, `--as`,
  `--json`, `--limit` and `--yes` are decorators in `mycelium/cli_options.py`
  (`in_room`, `acts_as`, `emits_json`, `paged`, `confirms`; text an agent writes
  is `text_input.takes_text`: the argument, `--body` or `--file`) that spell,
  default and resolve them the same everywhere. A new command uses them rather
  than declaring the flags itself. `mycelium.cli_audit` walks the built CLI and
  fails on drift (a read with no `--json`, a required `--as`, a `--room`
  without `-r`); `mycelium.cli_prose` parses every `mycelium …` command written
  in the docs, skills, prompts and frontend strings against the same CLI, so a
  renamed flag can't survive in what agents are told. Both run as tests; an
  exemption names its reason, and a stale one is a finding.
- **The checks are derivations, not lists.** Every gate added here recomputes
  something and fails on the drift, rather than asserting against a copy that
  has to be maintained: `openapi.json` vs the live app, `docs/*.html` vs its
  markdown, the frontend's `/api/*` fetches and its mock fixture *shapes* vs the
  spec, `compose.yml`'s services vs the container names the CLI addresses,
  `MyceliumConfig`'s fields vs what `generate_env_file` renders. Where a thing
  legitimately can't be derived, it is declared with its reason next to the code
  that owns it (`docker_utils.LOCAL_ONLY_FIELDS`, `ENVIRONMENT_SUPPLIED_VARS`)
  and the check asserts the declaration is still true — a stale exemption is a
  failure too.
- **A config field that reaches no container is a bug, not a feature.** `.env`
  is the only transport from `config.toml` into the stack, so a field nothing
  renders is a setting a user writes and watches do nothing. Both directions are
  gated (`tests/test_config_env_coverage.py`): every config leaf renders or is
  declared local-only, and every `${VAR}` compose substitutes has something that
  writes it — otherwise `config apply` silently drops a hand-set value.
- **Screenshots are captured and committed by hand.** `pnpm screenshots`
  (in `mycelium-frontend/`) shoots the mock app and writes the PNGs into
  `docs/` and a sibling splash checkout (`MYCELIUM_SPLASH_DIR`); a person
  looks at them and opens the PRs. There is no workflow that opens pull
  requests on its own.
- **The docs site is the latest stable release's, not main's.** Pages
  publishes `docs/` from a workflow (`pages.yml`) that `release.yml` calls
  once a stable release is out, at its tag, so the site never documents what
  no user can install yet (and `install.sh`, served from it, matches the
  release it installs). A docs change merged to main goes live with the next
  release; `gh workflow run pages.yml -f ref=<ref>` redeploys by hand for a
  fix that can't wait. Videos under `docs/` are Git LFS objects, which is
  why it is a workflow: Pages serves a branch's LFS pointers, not the files.

## Local development

> **This section is for contributors iterating on the backend source.** End users
> follow the normal install path:
> `curl -fsSL https://mycelium-io.github.io/mycelium/install.sh | bash`, which on
> an Apple silicon Mac installs and opens the desktop app (no Docker) and
> elsewhere installs the CLI for `mycelium install` (`--docker` picks that on a
> Mac, `--client-only` the CLI alone).

### Starting the stack

The `mycelium up` / `mycelium install` flow uses `compose.yml` with
`pull_policy: always` (released images), the correct Docker path for end users. For dev,
add `compose-dev.yml`, which builds `mycelium-backend` from local source and wires
`~/.mycelium/.env` into the containers. The stack is a SLIM node + the backend (+
the frontend, plus an optional collector), with **no database**. Always run
from the repo root.

```bash
docker compose \
  -f mycelium-cli/src/mycelium/docker/compose.yml \
  -f mycelium-cli/src/mycelium/docker/compose-dev.yml \
  up -d --build
```

On subsequent runs, drop `--build` unless you've changed backend code. The
frontend comes up with the stack; add `--profile metrics` for the collector.

### LLM config

All containers get their LLM settings from `~/.mycelium/.env`, generated from
`config.toml`. Set these once (the aligner's Pi session uses them):

```bash
mycelium config set llm.model "anthropic/claude-sonnet-4-6"
mycelium config set llm.api_key "<key>"
mycelium config set llm.base_url "<base-url>"
mycelium config apply
```

Then recreate the backend to pick up the new env:

```bash
docker compose -f mycelium-cli/src/mycelium/docker/compose.yml \
  -f mycelium-cli/src/mycelium/docker/compose-dev.yml \
  up -d --force-recreate mycelium-backend
```

**Important:** `mycelium config apply` regenerates `.env` from `config.toml`. If you
edit `.env` directly, those changes are overwritten. Always use `mycelium config set`.

### Running the backend outside Docker (hot-reload)

```bash
cd fastapi-backend
uv run uvicorn app.main:app --reload --port 8000
```

No DB is needed. The SLIM node still must be running (`mycelium hub host`, or the
`slim` compose service). The aligner's Pi mediator needs `pi` on PATH when the
backend runs on the host (the image ships it; `mycelium doctor` warns if it's
missing). Update `config.toml` if you change the backend port:

```bash
mycelium config set server.api_url "http://localhost:8000"
mycelium config apply
```

## Conventions

- Use `uv run` for all Python commands, never bare `python` or `pip install`
- Use `uv add` to manage dependencies, not manual pyproject.toml edits
- Ruff for linting and formatting (`select = ["ALL"]` with explicit ignores)
- Tests: unit tests run against local files + temp `.mycelium/` dirs (conftest.py
  sets `MYCELIUM_DATA_DIR`); the live-node SLIM slices are guarded by a reachable
  node (`MYCELIUM_SLIM_ENDPOINT`) and skip without one
- Live-LLM tests guarded by `MYCELIUM_LLM_TESTS=1` (costs tokens)
- Commit messages: imperative, concise, body for context if needed
- **PR titles and descriptions are written in plain English.** A title is
  one ordinary sentence saying what changes, in words someone outside the
  room already knows. It has no shorthand from the discussion, no slogans,
  and no fragments strung together with commas or colons. It becomes the
  commit on `main` and the changelog line. A description opens with what
  changes for the person using it, then why and how, then what was and
  wasn't tested. The rules and examples are in the `prose-edit` skill
  ("PR titles and descriptions"). Reviewers check the title against them,
  and a title that fails holds the PR until it's rewritten.
- **A PR an agent opens is signed in its description's footer.** It names
  the agent and the room it works in, in backticks and never with `@` (a
  room handle can be a real GitHub user, and GitHub would notify them):

  ```markdown
  ---
  <sub>🤖 Opened by the Mycelium agent `mycelium-coder` from the room `mycelium-general`.</sub>
  ```
- **No design or planning documents in git.** Plans, design briefs, RFCs,
  proposals, implementation write-ups and per-issue notes do not belong in this
  repo — not under `docs/`, not beside the code they describe. They are stale the
  week after they are written, and a reader who finds one cannot tell whether it
  describes the code, a path not taken, or a plan nobody executed. The code, its
  tests, and the PR that changed it are the record; anything that needs to argue
  for a decision belongs in the PR body or the issue, where it stays attached to
  the change it justifies. Don't open a PR that adds one, and don't create one as
  a step toward doing the work — do the work.

  What *does* belong here: current-state documentation (`mycelium-cli/src/mycelium/docs/`,
  the READMEs, this file), runbooks, and the contracts under `contracts/`.