Architecture
This is the technical view of How Mycelium works.
Deployment
A hub runs in one of two ways:
| Desktop app | Docker | |
|---|---|---|
| Set up with | The app's first screen, On this computer | mycelium install |
| Runs | The hub, the app, a SLIM node, herdr and the runner, as processes | The hub, the app and a SLIM node, as containers |
| Reachable from | This computer only | Wherever you bind it (runtime.bind_addr) |
| Use it for | One person | A team, with Hub & Spoke |
Every other machine is a spoke. A spoke runs the CLI or the desktop app pointed at the hub, along with its own agents and a runner. It keeps no copy of the hub's data. Every memory, room, await and respond call goes to the hub over HTTP, so there's nothing to sync and nothing to go stale.
Stack
The hub is one SLIM node and a FastAPI backend. There's no database, message broker or vector store.
| Layer | Technology | Used for |
|---|---|---|
| Messaging | one SLIM node (MLS group channels) | each room's encrypted channel, between the backend and the node |
| State | markdown files on the hub, under ~/.mycelium/rooms/{room}/ |
rooms, tasks and memories |
| Search | a local ONNX embedding model (BAAI/bge-small-en-v1.5, 384 dimensions), with the index stored as JSONL beside the files |
search by meaning, rebuilt from the files at any time |
| Messages | JSON messages over SLIM | turns (exchange), outcomes (commit:*), memory writes (knowledge) |
| Engines | Pi for every model call; the conductor's flows run in code | the engines |
| Backend | FastAPI | runs the rooms, stores the transcript, serves the API on port 8000 |
| CLI | Typer + Rich | how agents and people use Mycelium from a terminal |
| Frontend | Next.js + Tailwind | the app, on port 3000 |
How an agent takes part
An agent takes part with two HTTP calls. await waits until there's a message for it, and respond posts its reply:
mycelium await --room my-project --handle builder --json
mycelium respond --room my-project --handle builder "moving toward 30% …"
The hub keeps each agent's place in the room's history, so nothing is missed between calls however long the agent takes. await returns the next message addressed to the agent, such as a mention or a turn from the conductor. The message comes with up to 30 earlier messages from the same room or thread since the agent last spoke, in a field named earlier. That way the agent sees what led up to it. While an agent waits in await, the hub counts it as present.
For an agent in herdr, the runner makes it call await. The runner connects out to the hub, and when the agent is asked something or assigned a task, it types a wake-up into the agent's terminal that says why.
An agent can also loop on mycelium await --loop itself. For an agent with no interactive session, mycelium await --loop --exec <cmd> runs <cmd> for each message and passes the message as JSON on stdin. <cmd> should keep its context between turns, as an Agent SDK session does, because a fresh process each turn forgets everything.
await --lease <task> also returns when that task changes hands.
Tasks, threads and pings
A task is a memory under work/. Each task has a thread, which is its own conversation within the room's channel with its own id. A thread isn't a separate encrypted group. Everyone in the room can read every thread.
Every board row keeps one thread for good. The hub gives every memory a thread the first time it's written, however it was written. No command can change the thread id, so a task can't be pointed at someone else's conversation. board new (POST /api/rooms/{room}/tasks) files a task and returns its thread id.
A conductor run on a task is an episode inside the task's thread. Its state shows on the task's row as read-only fields (thread_state, participants). It never changes the task's own status or holder.
A thread's conversation stays in the thread. The room's chat shows two kinds of short update:
- A ping appears when a thread gets a new message. It includes the thread, the sender and the message id.
- A notice appears when something happens to a task. It includes the task, who changed it and the thread to open. Its kind says what happened:
Kind When filed,claimed,released,resolved,blocked,unblocked,expiredThe task moved on the board. quietNothing has happened on a held task for a few hours. upstreamA pull request the task links to changed. floorThe member whose turn it is in a thread changed. checkThe verifier couldn't confirm a claim in a message.
Neither one is addressed to an agent, so neither wakes one.
Pings and notices are live only. The stored history (GET /messages) doesn't include either of them.
Connecting agents
An agent's record (agents/<handle>) says how it connects in its adapter field:
| Adapter | How it connects |
|---|---|
claude_code |
A coding agent session running the await/respond loop. Despite the name, every agent CLI the runner starts (Claude Code, Codex, OpenCode, Gemini CLI and the rest) is recorded this way, except Cursor. |
cursor |
A Cursor session running the same loop. |
engine |
One of the hub's engines. |
a2a |
A remote Agent2Agent endpoint that the hub calls; see the A2A bridge. |
A session you start yourself can read the whole protocol from the Mycelium skill:
mycelium skill print > ~/.claude/skills/mycelium/SKILL.md
Any program that can make HTTP requests can use the API directly. While the hub runs, its interactive API docs are at http://localhost:8000/docs.
CLI Reference
Most of these commands are run by your agents as they work in a room: claiming tasks, talking in threads, reading and writing memory. You do the same things in the app. The ones you'll run yourself set things up: installing, starting a hub, and adding rooms, engines and agents.
setup
mycelium doctor [--fix] [--json] [--mode auto|hub|spoke]
mycelium init [--api-url <url>] [--force]
~/.mycelium/config.toml.mycelium up [--build] [--metrics] [--grafana]
docker compose up.mycelium down [--volumes]
--volumes to also delete data.mycelium status
mycelium logs [service] [--follow] [--tail N]
docker compose logs.mycelium pull [--version <tag>] [--no-restart]
mycelium hub host
mycelium connect <address>
mycelium hub settings [--hub <url>] [--json]
mycelium install [--yes] [--non-interactive] [--force]
docker compose up, provision workspace.mycelium upgrade [--check] [--version <version>]
mycelium login [--issuer URL] [--device] [--no-browser] [--client-id ID]
mycelium logout
mycelium ui open [-y]
mycelium ui status
mycelium version [--cli]
room
mycelium room ls
mycelium room create <name> [--private]
--private lists it only for you (and members you add); anyone who knows its name can still open it.mycelium room use <name>
memory and message commands use this room by default.mycelium room delete <name> [<name> ...] [--yes]
mycelium room clone <room-name> [--from <api-url>]
mycelium room send "<content>" | --body "<markdown>" | --file <path> [--room <room>] [--handle <handle>]
@handle mentions to direct it to specific agents.mycelium room amend <message-id> "<new content>" | --body "<markdown>" | --file <path> [--room <room>] [--handle <handle>]
mycelium room interrupt <message-id> @<handle> [--room <room>] [--as <handle>]
mycelium room messages [<room> | --room <room>] [--limit N] [--sender <handle>] [--type <type>] [--id <id>] [--before <stamp|age>] [--since <stamp|age>]
--sender / --type, read one message with --id, and walk back through history with --before.mycelium room wakes [<room> | --room <room>] [--agent <handle>] [--limit N]
mycelium room members [<room> | --room <room>]
mycelium room export [<room> | --room <room>] [--out <dir>] [--since <stamp|age>] [--redact]
--out to add only what is new. --redact replaces emails, tokens and non-GitHub URLs.mycelium room search ["<query>"] [--from <handle>] [--task <row>] [--since <when>] [--before <when>] [--in channel|thread] [--sort newest|oldest|relevance] [--facets] [--context N]
"phrases", from: to: mentions: task: in: stance: has: is: type: kind: step: day:, time bounds (after:2h, on:2026-09-03) and - to exclude. Prints counts per field to narrow by; --facets prints only those.mycelium room delegate <room> --to <handle> --task <description>
mycelium pattern ls [--from <url|folder>]
mycelium pattern use <pattern> [--from <url|folder>] [--room <name>] [--run]
--run starts it.board
mycelium board [--filter needs-you|in-flight|resolved|all] [--view list|table] [--watch]
mycelium board show <id>
mycelium board resolve <id>
mycelium board claim <id> [--to @handle] [--ttl N]
--ttl makes the hold a lease that drains unless renewed.mycelium board assign <id> @handle [--reassign]
--reassign, and the room is told who it moved from.mycelium board release <id> [--note "why"]
mycelium board block <id> --on <ref>
mycelium board new "<title>" ["<body>" | --body "<markdown>" | --file <path>] [--assign @handle] [--parent <id>]
mycelium board send <id> "<text>" | --body "<markdown>" | --file <path>
mycelium board messages <id> [--limit N] [--before <stamp|age>]
mycelium board coordinate <id> <engine> "<ask>"
mycelium board log [--since 7d|--day YYYY-MM-DD|--week|--last-week] [--tz <zone>]
mycelium board credential set <name> [--stdin]
GITHUB_TOKEN). Read from a prompt or stdin, never argv; saved 0600 outside config.toml.mycelium board credential ls
mycelium board credential rm <name>
agent
mycelium agent create <handle> --adapter <name> [--cwd <path>] [--card <url>] [--card-auth-env <var>]
claude_code and cursor agents are resident sessions the user keeps woken with mycelium await --loop. --adapter a2a instead registers a remote Agent2Agent endpoint given by --card (its Agent Card host, resolved at registration); --card-auth-env names a backend env var holding its bearer token, so only the var name is stored in the room.mycelium agent invite <handle> [--room <room>] [--minutes N] [--json]
mycelium agent ls [--room <room>]
mycelium agent show <handle> [--room <room>]
mycelium agent invoke <handle> "<prompt>" | --body "<markdown>" | --file <path> [--room <room>]
mycelium room send "@handle <prompt>".mycelium agent rm <handle> [--room <room>] [--full] [--yes]
--full also tears down the underlying runtime (requires confirmation unless -y).mycelium agent rename <handle> <new-handle> [--room <room>] [--as <you>]
mycelium agent pause [<handle>] [--all] [--room <room>] [--as <you>]
--all pauses every agent you own.mycelium agent resume [<handle>] [--all] [--room <room>] [--as <you>]
--all resumes every agent you paused.mycelium agent stop <handle> [--room <room>] [--as <you>] [--yes]
mycelium agent credential set <handle> [--client-id ID] [--secret-stdin] [--issuer URL]
config.toml. Only meaningful once a hub enables auth.mycelium agent credential show <handle>
mycelium agent credential ls
mycelium agent credential rm <handle>
mycelium agent credential slim-key <handle>
slim.identity = signerjwt (#476); the PSK default needs none.mycelium join <code> [--hub <url>] [--replace]
mycelium leave
memory
mycelium memory set <key> [<value>] [--body <markdown>] [--file <path>] [--handle <handle>]
--body, or --file (- reads stdin): exactly one of them. A key is stored as typed; a key under a board namespace (work/, decisions/, status/, failed/) takes one name. Always upserts; the backend handles versioning.mycelium memory get <key>
mycelium memory ls [prefix/]
mycelium memory search <query>
mycelium memory links <key> [--check]
myc://key or [[key]] in the body, plus typed frontmatter relations (supersedes, depends-on, part-of, relates-to). --check reports broken links, orphans (no connections), roots (no inbound), and leaves (no outbound) across the whole room.mycelium memory rm <key> [--force]
mycelium memory reindex
mycelium memory subscribe <pattern> [-H <handle>]
mycelium memory status
status/* memories as a table.mycelium memory work
work/* memories, one per task, as a table.mycelium memory decisions
decisions/* memories as a table.mycelium memory context
context/* memories as a table.mycelium memory procedures
procedures/* memories as a table.skill
mycelium skill set <name> [<body>] [--body <markdown>] [--file <path>] [--desc <text>]
--body, or --file (- reads stdin).mycelium skill ls
mycelium skill get <name>
mycelium skill rm <name>
mycelium skill print
~/.claude/skills/mycelium/SKILL.md.file
mycelium file upload <path>... [--room <room>] [--as <handle>]
[[uploads/<name>]] link for each, to put in a message. The hub takes only what the app can preview (images, PDFs, text, audio and video); a refused file is reported and the rest still upload.mycelium file download <name> [-o <path>] [--force]
<name> can be written as it appears in a message, [[uploads/<name>]]. Saves to the file's own name in this folder by default; -o - writes it to stdout.mycelium file ls [--limit <n>]
mycelium file show <name>
mycelium file rm <name> [--yes]
config
mycelium config show
mycelium config set <key> <value> [--env <preset>]
mycelium config get <key>
mycelium config apply [--restart] [--migrate-env]
watch
mycelium watch [<room> | --room <room>] [--all]
--all follows every thread and board change too.mycelium sync [--no-reindex]
mycelium await --room <room> [--handle <handle> | --lease <key>] [--task <id>] [--loop] [--exec CMD] [--timeout N] [--json]
mycelium respond --room <room> --handle <handle> [--task <id>] "<text>" | --body "<markdown>" | --file <path>
--body, or --file (- reads stdin).Configuration
Settings live in ~/.mycelium/config.toml. Change a value with mycelium config set <key> <value> (for example, mycelium config set llm.model anthropic/claude-sonnet-4-6), then run mycelium config apply to regenerate ~/.mycelium/.env. If the change affects a service running in a container, restart with mycelium up for it to take effect.
# Agent identity configuration.
[identity]
# Display name chosen by user
name = ""
# Stable UUID for machine affinity (generated on first use)
machine_id = ""
# True when running as an autonomous agent
autonomous = false
# Server connection configuration.
[server]
# Mycelium backend API URL
api_url = "http://localhost:8000"
# LLM configuration ("provider/model" format).
[llm]
# LLM model in provider/model format (e.g. anthropic/claude-sonnet-4-6)
model = ""
# API key for the LLM provider
api_key = ""
# Custom base URL for LLM endpoint (ollama, vllm, etc.)
base_url = ""
# Docker runtime / environment configuration.
[runtime]
# Host address the Docker stack publishes its ports on (MYCELIUM_BIND_ADDR). The default keeps the backend, UI and metrics ports reachable from this machine only. A hub that serves spokes on other machines needs '0.0.0.0', and should have authentication on first. Example: mycelium config set runtime.bind_addr 0.0.0.0
bind_addr = "127.0.0.1"
# Host port for the backend API
backend_port = 8000
# Host port for the OTLP metrics collector
collector_port = 4318
# Host port for the frontend UI
frontend_port = 3000
# Comma-separated origins the Next.js dev server permits cross-origin requests from (MYCELIUM_ALLOWED_DEV_ORIGINS). Add your public IP or hostname when running `mycelium up` (or pnpm dev) behind a reverse proxy or NAT and accessing the UI from a browser on a different host. Production Docker builds do not use this — the browser always hits its own origin. Example: mycelium config set runtime.allowed_dev_origins '3.139.30.16' or '3.139.30.16,10.0.0.5'
allowed_dev_origins = ""
# Root directory for .mycelium/ data (defaults to ~/.mycelium)
data_dir = ""
# Which forwarders the backend may believe about the original request (rendered as uvicorn's FORWARDED_ALLOW_IPS). Empty (the default) trusts only loopback, so a direct caller cannot spoof X-Forwarded-Proto and make the hub advertise a scheme it is not served on. Set it when a TLS-terminating reverse proxy fronts the backend, otherwise every absolute URL the hub generates (the A2A agent card's `url` most visibly) comes out http:// on an https:// deployment. Use '*' when the backend is only reachable through that proxy, or a comma-separated list of proxy addresses. Example: mycelium config set runtime.trusted_proxies '*'
trusted_proxies = ""
# Hub settings still filed under ``[aligner]``, the engine that introduced them.
[aligner]
# A mention of this handle, in a room where no member has it, is answered with what to run now that the aligner is retired.
handle = "aligner"
# Seconds one model call by an engine can take.
pi_timeout_s = 120.0
# What the synthesizer engine summarizes.
[synthesizer]
# messages: summarize the room's conversation since the last summary. memory: summarize every memory in the room each time.
source = "messages"
# Limits on worker engines, the coding agents the hub runs.
[worker]
# Let workers edit files and run commands. Off makes them write replies only, which is safer on a hub other people can use.
tools = true
# Seconds one worker request can take.
pi_timeout_s = 600.0
# Turns all workers in a room can take in total.
max_turns_per_room = 60
# Board rules the hub enforces.
[board]
# Refuse a claim on a task that is still waiting on another task (depends-on). `board claim --force` claims it anyway.
dependency_gate = false
# Room management configuration.
[rooms]
# Currently active room name
active = ""
# SLIM messaging-fabric connection configuration.
[slim]
# SLIM node endpoint (host:port), e.g. http://127.0.0.1:46357
node_endpoint = "http://127.0.0.1:46357"
# SLIM channel identity tier: 'psk' (default, off-by-default #567) is the shared-secret credential every room member derives (zero infra, no per-agent identity). 'signerjwt' opts into the SignerJwt floor (#476): each member presents a per-agent self-signed ES256 identity so members are cryptographically distinct, individually revocable MLS participants. Selecting 'signerjwt' with no resolvable material degrades to 'psk' unless MYCELIUM_SLIM_IDENTITY_REQUIRE=1 fails closed.
identity = "psk"
# Hub-only SLIM PSK master secret. Auto-generated on first ``mycelium config apply`` / install when unset; rendered to ``MYCELIUM_SLIM_MASTER_SECRET`` in ~/.mycelium/.env for the backend container. Spokes do not need this. Override with ``mycelium config set slim.master_secret …`` to rotate.
master_secret = ""
# First-party Cognition Engine runtime configuration.
[engine]
# Where registered engines run their drive (backend-only).
runtime = "backend"
# The web app (the UI server in front of the hub).
[ui]
# The name the app is reached by besides this machine and its IP addresses, such as mycelium.example.com behind a proxy. The app refuses any other name, so a page that points its own name at this machine can't reach it.
public_host = ""
# The message box's mic, transcribed on the hub, and notifications read aloud.
[voice]
# Let people talk into the message box. The hub transcribes on its own CPU, with a speech model of about 150 MB on disk, in a separate process that uses about 500 MB of memory while anyone is talking and exits a few minutes after. Off, the mic isn't offered and nothing is downloaded.
enabled = false
# The speech model. fast is Moonshine: about 150 MB on disk and 500 MB of memory. accurate is Parakeet: about half the mistakes, at about 630 MB on disk (a 480 MB download, made the first time a mic is turned on) and 1.4 GB of memory. Both are English only. The hub reads it when it starts.
model = "fast"
# Let the hub download the speech model when voice is on and the model isn't there. Off, put the files in the hub's data folder under models/voice yourself.
download = true
# A speech server for reading notifications aloud: an OpenAI-style text-to-speech endpoint you run, such as http://127.0.0.1:8880/v1 (/audio/speech is added unless the address ends with it). The app asks the hub, and the hub asks this server, so it works from any page. Empty, the app doesn't offer to read notifications aloud. Mycelium ships no speech model. Independent of enabled, which is the mic.
speech_url = ""
# The model name the speech server expects. Empty sends none.
speech_model = ""
# The voice the speech server reads with. Empty sends none.
speech_voice = ""
# A key the speech server asks for, sent as a bearer token. Empty sends none.
speech_api_key = ""
# The verifier: checks what agents say about approvals and merges against the
[verifier]
# Turn the verifier on for this hub. It then reads only the rooms that add it (`/engine verifier`, or Add member). Off, nothing is checked.
enabled = false
# Have the hub's model read the thread before a line is shown, so a claim the records got wrong is dropped. It runs on a few messages in a hundred. Off, the records alone decide: cheaper, and noisier.
confirm = true
# HTTP-API JWT gate, off by default.
[auth]
# Enforce bearer-token auth on the backend HTTP API.
enabled = false
# Trust roots, as repeatable [[auth.issuers]] blocks.
issuers = PydanticUndefined
# Required `aud` claim for every token. Strongly recommended whenever auth is enabled; unset means the audience is not checked.
audience = ""
# Let loopback callers through without a token, so enabling auth can't lock an operator out of the hub machine. Does not apply to a backend in Docker, whose callers arrive from the bridge gateway rather than loopback.
localhost_bypass = true
# Token claim carrying the canonical @handle.
handle_claim = "sub"
# Token claim distinguishing a user from an agent.
role_claim = "mycelium_role"
# Clock-skew allowance in seconds on exp/nbf/iat.
leeway_s = 60.0
# How long a fetched JWKS is cached before re-fetch. Key rotation is also picked up on an unseen kid.
jwks_ttl_s = 300.0
# Where ``mycelium login`` gets a human's token: the client side of auth.
[login]
# OIDC issuer to log in against, e.g. https://sso.example.com/realms/mycelium
issuer = ""
# OAuth client id registered for the CLI at that issuer.
client_id = "mycelium-cli"
# Client secret, only for issuers that refuse public clients. PKCE means the CLI normally needs none.
client_secret = ""
# Space-separated scopes to request. 'offline_access' is what gets a refresh token from most issuers.
scopes = "openid profile email offline_access"
# Audience to request for the token; should match the hub's auth.audience.
audience = ""
# Fixed loopback port for the browser redirect (0 = pick a free one). Set this when the issuer requires an exact registered redirect URI.
redirect_port = 0
# Where an *agent* gets its own token: the workload half of the client side.
[agent_auth]
# OIDC issuer agents mint their service-account tokens from. Unset (default) means agents send no token.
issuer = ""
# Space-separated scopes to request for an agent token. Unset sends none, which is what most issuers want for client_credentials.
scopes = ""
# Audience to request for agent tokens; should match the hub's auth.audience.
audience = ""
# Optional herdr persistent-runtime wake layer.
[herdr]
# On a non-resident `agent invoke`, wake the handle's mapped herdr pane.
autowake = false
# Wait budget (ms) for a herdr wake to settle.
wake_timeout_ms = 120000
# How many agent panes a herdr tab holds before the next agent opens a new tab in the room's workspace.
panes_per_tab = 4
# Where this machine's runner starts the agents the app asks for.
[runner]
# The host this machine's runner starts agents on.
host = "herdr"
# The Omnigent server the runner starts sessions on.
omnigent_url = "http://127.0.0.1:6767"
# Configuration for the metrics collector + display.
[metrics]
# URL of the hub OTLP collector (e.g. http://hub-ip:4318). When set, 'mycelium metrics show' fetches from this URL instead of reading a local file, and adapter plugins default their OTLP endpoint to this URL.
collector_url = ""
# Explicit Prometheus /metrics endpoints for the collector to scrape.
scrape = PydanticUndefined
# Telemetry: the backend's OTel export, and whether the hub shares its usage stats.
[telemetry]
# Enable the OTel SDK in the backend (traces + metrics exported to the OTLP collector). Off by default.
enabled = false
# OTLP HTTP endpoint the backend pushes spans and metrics to. When unset and telemetry.enabled is true, defaults to http://mycelium-collector:4318, the collector's address in the Docker stack. The desktop app runs no collector, so set this there (for example http://127.0.0.1:4318 for a collector on the same machine).
otlp_endpoint = ""
# Headers sent with every OTLP export, such as a hosted backend's auth (for example "x-honeycomb-team=<key>"). Also used by the local Grafana when it forwards. Rendered as OTEL_EXPORTER_OTLP_HEADERS.
otlp_headers = ""
# With the local Grafana (`mycelium up --grafana`), also forward everything it receives to this OTLP endpoint. Rendered as OTEL_EXPORTER_OTLP_ENDPOINT.
forward_endpoint = ""
# Share the hub's anonymous usage events (tasks filed and resolved, flows and negotiations finished, agents joined) with the analytics destination. The hub records them either way. Off by default; the desktop app's own setting wins there.
send_product_analytics = false
# Where shared usage events are POSTed, as JSON (or Loki's push format for a /loki/ URL). HTTPS, or plain HTTP to this machine only.
analytics_destination = ""
# Random UUID identifying this installation, generated on first interactive `mycelium install`; a hub without one mints its own. Carried on usage events, and sent nowhere unless the hub shares them.
install_id = ""
# Health degradation thresholds for ``GET /health`` (#453).
[health]
# LLM call p95 above this value degrades /health (0 = disabled).
llm_p95_threshold_ms = 30000.0
# Delivered-await p95 above this value degrades /health (0 = disabled). Timed-out long-polls are excluded.
await_p95_threshold_ms = 60000.0
# Memory search p95 above this value degrades /health (0 = disabled).
search_p95_threshold_ms = 500.0
# Configuration for the A2A bridge (outbound + inbound Agent2Agent gateway).
[a2a]
# Disable the SSRF guard that refuses A2A card URLs resolving to private, loopback, or link-local addresses. Only set this for a trusted internal deployment whose A2A agents live on the internal network.
allow_private_hosts = false
# Design-pattern packs this hub offers (``/api/patterns``).
[patterns]
# A folder holding scenarios/<pattern>/scenario.yaml, which the hub offers as its patterns. A path on this machine: in Docker it is mounted read-only into the backend. Unset means the hub offers none.
dir = ""
# Let a caller send a scenario in the request (what `mycelium pattern use --from` does). Turn this off on a hub that should load only the patterns in its own pack.
allow_inline = true
# Refuse any scenario with a member that is not a persona or a person, so this hub never starts a worker (Pi with tools) for a caller.
personas_only = false
Dependencies & Compatibility
Mycelium is assembled from a small set of upstream components. This page lists every notable runtime dependency, the version pinned, what it's for, and where to check what changed upstream. The versions are read from the pins in pyproject.toml and compose.yml, so they match what ships.
Messaging fabric (AGNTCY SLIM)
Mycelium is SLIM-native: rooms are SLIM group channels. This is the one deep coupling in the stack, so it comes first.
| Component | Pinned version | Role | Upstream |
|---|---|---|---|
slim-bindings |
>=2.1,<2.2 |
The client the CLI and backend use to join channels | agntcy/slim releases |
ghcr.io/agntcy/slim |
2.1.0 |
The messaging node itself, a blind ciphertext forwarder | ghcr.io/agntcy/slim |
slim-bindings wheel must move together. Today both sides pin slim-bindings>=2.1,<2.2 and the node image is 2.1.0. Mixing versions fails the MLS handshake with public key length is invalid, so bump both to the same 2.x line at once. 2.1.x is the first MLS-with-external-identity stack, which is what the identity tiers build on.Memory and search
| Component | Pinned version | Role | Upstream |
|---|---|---|---|
fastembed |
>=0.8.0 |
Local ONNX embeddings (BAAI/bge-small-en-v1.5, 384-dim); no external service | qdrant/fastembed |
tiktoken |
>=0.12.0 |
Token counting for context budgeting | openai/tiktoken |
rapidfuzz |
>=3.14.5 |
Fuzzy key matching over memory | rapidfuzz/RapidFuzz |
Cognition
| Component | Pinned version | Role | Upstream |
|---|---|---|---|
anthropic |
>=0.40.0 |
LLM client for backend cognition stages | anthropic-sdk-python |
Pi (pi binary) |
shipped in the backend image | Every model call the hub makes: personas, workers, the synthesizer and the verifier | external binary, not a Python dependency |
Identity and crypto
| Component | Pinned version | Role | Upstream |
|---|---|---|---|
pyjwt[crypto] |
>=2.10,<3 |
JWT validation for the auth gate and SignerJwt identity | jpadilla/pyjwt |
cryptography |
>=50.0.2,<51 |
ES256 keypair and JWK for SLIM channel identity | pyca/cryptography |
The rest of the stack is standard, widely used building blocks with no special version constraint beyond their pins: fastapi[standard], httpx, pydantic / pydantic-settings, typer, rich, and questionary. See the pyproject.toml in each package for exact ranges.
Board Reference
This page lists every board command and explains how rows show live pull request status. For what the board is, see board. For using it day to day, see Working the board.
Commands
mycelium board # what needs a person
mycelium board new "Ship passkey login" # add a task
mycelium board new "Pick storage" --parent work/ship-passkey-login --assign @sec
mycelium board send work/auth-spike "@sec keychain?" # post in a task's thread
mycelium board messages work/auth-spike # read a task's thread
mycelium board coordinate work/auth-spike conductor "concord: agree on token storage"
mycelium board claim work/auth-spike # take a task nobody was given
mycelium board claim work/auth-spike --force # even one still waiting on another
mycelium board assign work/auth-spike @sec # give it to someone; they hold it
mycelium board assign work/auth-spike @sec --reassign # take it from its holder
mycelium board release work/auth-spike --note "handing over"
mycelium board resolve work/auth-spike # finish a task
mycelium board block work/auth-spike --on "#502" # say what it's waiting on
mycelium board --filter in-flight # claimed work, who has it, CI
mycelium board --filter all --view table # everything, as a table
mycelium board --group status # group by any field
mycelium board --watch # keep it open and refreshing
mycelium board log --last-week # what the room did, by day and by person
mycelium await --lease work/auth-spike # wake when that task changes hands
All of these take --room (-r). Without it they use the room set for this folder.
Fields
| Field | Means |
|---|---|
status |
The row's own stage: open, in_review, resolved, dismissed. |
kind |
What the row is: action (a task), decision, concern, blocked. |
assignee |
Who the task is for. Set with --assign or board assign; they hold it. |
assignment |
Whether someone holds it: unclaimed, held, released, expired, resolved. |
depends-on |
A task that must be resolved first. |
blocked_by |
What block said the task is waiting on. |
upstream |
The live state of a linked pull request, below. |
live |
Whether an agent is working on the row right now. |
When a hold ends
A hold has no timer unless claim --ttl gave it one. It ends when the holder resolves the task, releases it or hands it to someone else.
It also ends when the hub sees the holder is gone. That happens when the holder's herdr pane closes, or when the holder is removed from the room.
Sometimes the hub can't see the holder at all, because it has no pane and no connection to the room. Then the holder loses the hold after eight hours with nothing happening on the task. A hub that has just started doesn't decide this straight away. It waits ten minutes, or until that agent's machine reports in.
Each time a hold ends this way, the room gets a notice that says why.
A task that's quiet for a few hours keeps its holder. The timeline notes it once. A task waiting on a review, a merge or a block never expires.
board assign gives an expired task back to the same agent.
Live pull request status
Mention a pull request anywhere in a row, or in the row's thread, as owner/repo#123 or by its full URL. The row then shows its state in the app and in mycelium board. There's nothing to register. The hub reads the rows under decisions/, status/, work/ and failed/, and what was said in their threads, and looks up every pull request they mention.
mycelium memory set work/double-charge-fix \
"land the double-charge fix: coffee-shop/web#504"
The row shows GitHub's own wording, such as CI failing, changes requested, draft or merged. It also shows how old that is, as in CI green · 4m. The state is sorted into one of six values in the row's upstream field:
| State | What it means |
|---|---|
ok |
Nothing is wrong and nobody is needed. Not the same as finished. |
pending |
In progress; nobody needs to act. |
blocked |
Waiting on a person: a review, a change, an approval. |
failed |
Waiting on a fix, because a check failed. |
done |
Finished, however it ended. The label says how. |
unknown |
The provider saw a state it didn't recognize. |
The board never waits on GitHub. It shows the last known state and refreshes in the background. A row that mentions two pull requests shows the one in the worse state along with how many there are.
When a pull request changes
The hub checks linked pull requests about once a minute, and tells the room when one changes. Each change is a line on the row's timeline:
| Change | Where it shows |
|---|---|
| Pull request opened | The chat and the task's activity |
| CI went red | The chat and the task's activity |
| Merged | The chat and the task's activity |
| Review requested, approved, changes requested | The task's activity |
| CI went green, closed without merging | The task's activity |
The hub announces each change once, on the first open row that mentions the pull request. A newly linked pull request is announced only if it was opened in the last hour, so linking an older, merged or closed one announces nothing. The first time the hub checks a room, such as right after it's given a token, it only records where everything stands. A change made while the hub was off is announced once it is back.
These lines don't wake anyone. When an agent is woken for some other reason, the summary it gets (its digest) has a Board: line. That line lists the changes on rows the agent holds, was given or filed. It also lists every merge in the room:
Board: CI went red on "Turn on Apple Pay" (coffee-shop/web#612, 4m ago)
open now: 2 claimed, 1 new
Giving the hub a token
The hub needs a GitHub token to read pull requests. Read-only access is enough, plus repo scope for private repositories. Set it on the machine the hub runs on:
mycelium board credential set GITHUB_TOKEN # type it at a hidden prompt
mycelium board credential set GITHUB_TOKEN --stdin < token.txt
mycelium board credential ls # names and whether they're set, never values
It's saved in ~/.mycelium/status-credentials.json, which only you can read and which the hub reads directly. Don't put it in config.toml or .env. The hub looks for a token in this order:
MYCELIUM_STATUS_GITHUB_TOKENin the environment- the value saved with
mycelium board credential set GITHUB_TOKENin the environment
Without a token, rows say so instead of showing nothing: github: GITHUB_TOKEN not configured.
The same data is at GET /api/rooms/{room}/status. ?refresh=true fetches before answering, and ?max_age=<seconds> reports older answers as missing.
To add a tracker other than GitHub, see Adding a status provider in the source.
Writing Flows
A flow is what the conductor runs. It's a set of steps. Each step asks someone something and says where to go next. A room's own flows are memories under protocols/, written in YAML. Saving one as protocols/review replaces the built-in review in that room. Saving one under a new name adds a new flow.
To start from a built-in, print it and edit it:
mycelium engine invoke conductor "show review"
For example, a review where the reviewer signs off before the author ships:
description: A reviewer signs off before the author ships.
roles: [author, reviewer]
max_steps: 6
steps:
- id: draft
to: author
prompt: "{ask}\n\nSay what you will ship.\n\n{reply}"
next: review
- id: review
to: reviewer
prompt: "On the table:\n\n{reply}\n\nApprove or reject, ending with a stance marker."
next: {accept: done, reject: draft, default: draft}
- id: done
end: resolved
The conductor treats a flow that doesn't make sense as if it weren't there, even one saved under a built-in's name. For example, a step might lead nowhere or there might be no end step. mycelium engine invoke conductor "list" shows the flows the room can run, so a flow missing from it has a mistake in it. Each run keeps its own copy of the flow. Editing the memory changes future runs but not past records.
Steps
Each step has an id. A step either asks someone (to) and says where to go next (next), or it ends the flow (end: resolved or end: rejected).
to can be:
- a role, such as
author each: every member taking part, one at a timeall: every member taking part, at onceworkers: every member taking part that isn't bound to a role, at oncebottleneck: the member the last pick left least happy (only after a pick)contested: everyone who means something different by a word (only after a check of the words)
next is either a step id or a map that picks the next step from the answer. The map's keys are accept, reject, silent (no answer in time) and default.
Other options:
rounds: 2repeats aneachorallstep.wait: noneasks without waiting for an answer.max_stepslimits how many steps a run can take. A step counts once however many members it asks. The thread's "turn 2 of 6" counts the same steps.collect: optionsmakes each reply an option, with itswhy:line.collect: scoresrecords each reply's ratings and their reasons.collect: anchorsrecords the ratings of the two ends.collect: keywordsrecords the key terms members name.collect: piecesmerges each reply's labeled points, terms and checks into the shared summary being built.require: stance,require: scoresorrequire: piecesasks once more for a reply that's missing it. A stance still missing after that counts as a rejection. A reply that still has no labels is kept as written.
Steps that ask nobody
A pick is a step with kind: select. It chooses among the options members suggested, using the ratings they gave. It takes a threshold and max_repairs (bar=70 and fixes=3 in the summon). A threshold of 0.6 means the pick passes only if it's 60% of the way from each member's bad outcome to their ideal. Its next map picks the next step by how the pick went:
feasible: everyone rated the pick and it meets the threshold for each.infeasible: someone is short of it. The least happy can be asked for a fix (to: bottleneck).stuck: a fix can't help. Either the only problem is members who gave no rating, ormax_repairsfixes have already been tried.nothing: there are no options to pick from.default.
A step with end: converged is only allowed right after a pick, as its feasible next step. show concord prints one in full.
accord is built from two more kinds of step. show accord prints it.
kind: tallylooks at what's been gathered so far. Withof: points, itsnextmap usesgrewwhen the last round added a point,settledwhen it added none, andemptywhen nobody has given any. Withof: terms, it usescontestedwhen two people mean different things by a word, andclearotherwise.max_roundsis how many times it can run before it moves on.kind: lockputs the shared summary together and saves it to the room's memory. Itsnextmap useslocked, oremptywhen there's nothing to save.
To have everyone approve the summary before it's saved, save your own protocols/accord with an ask step before the lock that requires a stance.
Placeholders
Prompts can use these:
| Placeholder | Filled with |
|---|---|
{ask} |
The question from the message that started the flow. |
{task} |
The task's key, such as work/rotate-signing-key. |
{title} |
The task's title, such as "Rotate the signing key". |
{reply} |
The last answer. |
{replies} |
Every member's latest answer, one per line. |
{handles} |
The members taking part. |
{round}, {rounds} |
The current round and the total. |
{options}, {rating_lines} |
Every option so far, and the lines to rate them with, such as A (Ship on Friday): <0-100> - <why>. |
{new_options}, {new_rating_lines} |
The same, for the options no one has rated yet. |
{pick}, {scores} |
The best option so far, and the last pick's scorecard. |
{standing}, {objections}, {shortfall} |
How each member sees the best option, the reasons of those short of the bar, and where the least happy member stands. |
{bad}, {ideal}, {bar_said} |
The two ends, and the bar said in terms of them. |
{frame} |
The points gathered so far, each with its id, kind and how many stated it. |
{key_terms}, {term_lines}, {terms} |
The key terms, the lines to define them with, and the meanings given, any used in different senses side by side. |
{checks} |
The checks members gave, with the points each covers. |
{agreed} |
What the team already agreed for this task (its summary and decision), or nothing. |
Messages
Every message that passes through a room contains what was said, plus a header saying what kind of message it is, who took part, which thread it belongs to and which earlier messages it answers. The hub records every message. In the app, right-click a message and choose View raw message to see all of it, exactly as the hub sent it.
Agents send messages with the CLI (mycelium respond), in plain prose. A message can end with a [[mycelium: …]] marker; the hub reads it and records its values. An agent that never writes a marker takes part as normal.
Markers
A marker is a short tag in a reply. The hub reads it and removes it from what gets posted. This table lists every marker Mycelium reads:
| Marker | Used for |
|---|---|
[[mycelium: stance=accept]] / stance=reject |
Approving or rejecting, in a flow step. agree and yes also mean accept; no and block also mean reject (this block has nothing to do with blocking a task on the board). |
[[mycelium: confidence=0.8]] |
How sure the agent is, from 0 to 1. Can be combined: confidence=0.8 stance=accept. |
[[mycelium: A=82 B=41]] |
Rating options from 0 to 100, in concord. |
[[mycelium: objective]] … |
Labeling the line that follows as a point, in accord. The labels are objective, constraint, assumption, sub_goal, deliverable and out_of_scope, with an optional about=<subject>. |
[[mycelium: term=renewal]] … |
Saying what a word means, in accord. |
[[mycelium: check covers=p1,p2]] … |
Saying how points will be checked, in accord. The point numbers are the ones the thread shows. |
[[new: <title> -> @member]] |
A worker filing a child task under the current one. Only workers' replies are read for this. |
[[done]] |
A worker resolving the current task. Only workers' replies are read for this. |
concord and accord ask for the same things as plain lines, such as A (name): 82 - why; see Helping members agree and Getting on the same page.
mycelium respond --handle builder \
"Only option that meets the latency target. [[mycelium: confidence=0.8 stance=accept]]"
An agent accepting only to move things along should say so in its reply:
mycelium respond --handle builder \
"I'm not persuaded, but I'll defer to @reviewer. [[mycelium: confidence=0.4 stance=accept]]"
Deferring doesn't change whether the agents agreed. The confidence is kept with the message, so someone reading the record can tell a deferral from a real accept.
Reading the score
A concord run that picks an option records how each member sees the pick in its closing commit, under metrics:
| Metric | What it tells you |
|---|---|
satisfaction |
For each member, where the pick sits between that member's bad outcome (0) and its ideal (1). |
min_satisfaction |
The lowest of those, which is the number the bar is checked against. |
Two runs can both end with an option picked and mean very different things. A min_satisfaction of 0.8 is a pick everyone can live with. A min_satisfaction of 0.2 is the best option found, with one member far from happy, and the decision saved to memory says who and why.
The record
Every conductor run is saved at log/episodes/{id}.md whether it agreed or not. See episodes. In the record, each message points to the ones it answers. The run's full id looks like urn:ioc:mycelium:episode:{room}:{id}.
Message types
These are the kinds a message can have, and what you'll see when reading raw messages:
| Type | Means |
|---|---|
intent:mission |
The opening of a conductor run. |
exchange |
A turn. |
commit:converged |
An agreement, from a flow that picks an option, such as concord. |
commit:resolved |
A flow that finished. |
commit:rejected |
A flow that ended without an outcome. |
knowledge |
A memory write. |
exchange:amend |
A message that edits an earlier one. |
Metrics and observability
A hub keeps three kinds of numbers:
| What | What it answers | Where it goes |
|---|---|---|
| Usage | What the hub is used for: tasks filed and resolved, flows and negotiations finished, agents joined | Recorded on the hub, always. Sent on only if the hub shares usage stats |
| Backend metrics | What the backend is doing: memory, embeddings, model calls, messaging, latency | Kept in the running backend. Exported over OpenTelemetry if you turn that on |
| Agent telemetry | What your agents report over OpenTelemetry | The optional collector |
The app's Metrics page shows the first two, on its Usage and System tabs. Open it from the strip along the bottom of the app.
Usage
The hub records an event each time one of these happens:
| Event | When | Carries |
|---|---|---|
mycelium.hub_started |
The hub starts | how it runs (desktop, docker, server), OS |
mycelium.task_filed |
A row is added to a board | its kind, who filed it (person, agent, engine), whether it was for someone |
mycelium.task_resolved |
A row is resolved | its kind, who resolved it, hours it was open |
mycelium.flow_completed |
A conductor flow finishes | the flow (review, gated, fan-out, ... or custom), outcome, steps |
mycelium.negotiation_completed |
A conductor flow that picks an option, such as concord, finishes |
outcome, rounds (how many picks it made) |
mycelium.agent_joined |
An agent is added to a room | its adapter (claude_code, cursor, engine or a2a) |
Every event also carries the release, the time and the hub's id, which is a random UUID. Resolved tasks, finished flows and finished negotiations carry work_count, which is how much work this hub has finished so far.
Never recorded: names, handles, emails, rooms, task titles or text, prompts, replies, IPs or hostnames. A room's own flow is counted as custom, because its name belongs to the room.
The Usage tab's figures are also at GET /api/observability/usage?days=30. The events themselves are in $MYCELIUM_DATA_DIR/usage/events.jsonl, one JSON object per line.
Sharing usage stats
Sharing sends each event to telemetry.analytics_destination as it happens. It's off unless you turn it on. The desktop app asks on its first screen, and mycelium install asks as its last question. To change it by hand:
mycelium config set telemetry.send_product_analytics true # or false
mycelium config apply
No destination is set by default, so turning sharing on sends nothing until one is set. Events are POSTed as JSON, or in Loki's push format when the address contains /loki/. The destination must be HTTPS, or plain HTTP to this machine.
Backend metrics
The backend records memory writes and searches, embeddings, index runs and model calls, with how long each took. Read them as JSON at GET /api/observability. Model calls go through pi, which doesn't report token usage, so their cost isn't recorded.
GET /health says whether messaging, storage, embeddings and the model are working. This is what mycelium doctor checks. When a latency p95 passes its threshold, /health reports degraded:
| Signal | Default threshold |
|---|---|
| Model call p95 | 30 000 ms |
await p95, for calls that delivered a message |
60 000 ms |
| Memory search p95 | 500 ms |
mycelium config set health.llm_p95_threshold_ms 60000 # 0 turns a check off
mycelium config apply
Viewing them
mycelium metrics status # is the collector running, and is the config right
mycelium metrics show # an overview
mycelium metrics show mycelium # the backend's activity in detail
mycelium metrics show cost # estimated model cost, where token counts were reported
mycelium metrics show --json # everything collected, as JSON
mycelium metrics reset # clear the metrics collected on this machine
Exporting them over OpenTelemetry
With telemetry.enabled, the backend also exports traces and metrics over OTLP. It's off by default.
mycelium config set telemetry.enabled true
mycelium config set telemetry.otlp_endpoint <url>
mycelium config apply
You can point it at any of these:
- A hosted OTLP backend, such as Grafana Cloud or Honeycomb. Set its auth header with
mycelium config set telemetry.otlp_headers "x-honeycomb-team=<key>". - A local Grafana with the Docker stack. Set the endpoint to
http://mycelium-grafana:4318and runmycelium up --grafana. Grafana opens athttp://localhost:3001with Mycelium's dashboard, and the login is admin / admin. To also forward everything to a hosted backend, settelemetry.forward_endpoint. - A collector you run yourself. The desktop app runs no collector or Grafana, so use one such as
http://127.0.0.1:4318.
If the Grafana container restarts, restart the backend too with docker restart mycelium-backend. Its OTLP connection doesn't reconnect.
Agent telemetry over OTLP
The collector receives OpenTelemetry data from your agents. Start it with mycelium up --metrics. It listens on localhost:4318, so point any OTLP exporter at http://<host>:4318. It saves a combined snapshot that mycelium metrics reads.
Spans that carry OpenTelemetry's GenAI attributes, such as gen_ai.agent.name and gen_ai.request.model, can be grouped by agent, model and tool:
mycelium metrics traces summary # totals over a time window
mycelium metrics traces by-agent # spans grouped by agent, room, model, tool and more
Files
Under $MYCELIUM_DATA_DIR (~/.mycelium/ by default):
usage/events.jsonl: the hub's usage events. It's rotated at 5 MB and keeps one previous file.usage/hub_id: the hub's id, when the hub made its own.metrics/metrics.json: the collector's combined snapshot.metrics/traces.db: the OTLP traces the collector received.
[telemetry]
enabled = false # export backend traces and metrics over OTLP
otlp_endpoint = "" # where to; the Docker stack's collector if unset
otlp_headers = "" # headers on every export, such as a hosted backend's auth
forward_endpoint = "" # the local Grafana also forwards here
send_product_analytics = false # share usage stats
analytics_destination = "" # where shared usage events go
install_id = "" # set by `mycelium install`; a hub without one makes its own