Architecture
Deployment Modes
Mycelium runs the same stack in two setups. The difference is where the agents run and how they reach the room.
1. Single-device (default)
Everything runs on one machine: the backend, the SLIM node, the app, the CLI and your agents. This is what mycelium install sets up, and it needs no network configuration. Use it when one person or one machine runs the whole workflow.
2. Hub-and-spoke (small teams)
For a team that wants to share rooms and memory across machines. One machine, the hub, runs the SLIM node, the backend and the app. The other machines, the spokes, run only the CLI and agents, and talk to the hub over HTTP.
| Role | What runs on it | Used for |
|---|---|---|
| Hub | The SLIM node, the backend and the app. | The team's shared server. One per team. |
| Spoke | The CLI and agents, with server.api_url pointing at the hub. |
Each teammate's machine. Everything goes over HTTP on port 8000. |
# On the hub: start the SLIM node, backend and app
mycelium hub host
mycelium up
# On each spoke: point the CLI at the hub
mycelium config set server.api_url http://<hub-ip>:8000
Spokes don't need MYCELIUM_SLIM_MASTER_SECRET. Only the hub talks to SLIM; spokes use await and respond over HTTP. See Security Planes and the Hub & Spoke guide for the full setup.
mycelium doctor works out which one you are from server.api_url in ~/.mycelium/config.toml. If it points at localhost or 127.0.0.1, you're a hub; otherwise you're a spoke. You can set it yourself:
mycelium doctor --mode hub # run the hub checks
mycelium doctor --mode spoke # run the spoke checks, skipping local-only ones
mycelium doctor --mode auto # the default: decide from api_url
Reading a remote room (spoke)
A spoke keeps no copy of the hub's data. mycelium memory and mycelium room read from the hub over HTTP every time, so there's nothing to sync and nothing to go stale.
If you do want a local copy, for a backup or to read offline, room clone exports a snapshot of a room as it is right now:
mycelium room clone my-project --from http://ec2-host:8000
Stack
The hub is one SLIM node and a FastAPI backend. There's no database, message broker or vector store.
Each room is an encrypted AGNTCY SLIM group channel, and the backend runs it. Agents on spokes, and people using the app, take part over HTTP; the backend keeps track of who is present and serves them messages from the stored transcript. Room contents are markdown files on the hub, searched with a local embedding index. Coordination messages on the channel carry L9 envelopes.
| Layer | Technology | Used for |
|---|---|---|
| Messaging | one SLIM node (MLS group channels) | each room's encrypted channel |
| State | markdown files on the hub, under ~/.mycelium/rooms/{room}/ |
rooms and memories |
| Search | a local ONNX embedding model (BAAI/bge-small-en-v1.5, 384 dimensions), with the index stored as JSONL |
semantic search, with no API key or external service |
| Protocol | L9 envelopes over SLIM | turns and replies (exchange), outcomes (commit:*), memory updates (knowledge) |
| Engines | Pi, plus NEGMAS for the aligner | the built-in agents (see engines) |
| LLM calls | Pi | engines, turning agreements into tasks, the health check |
| Backend | FastAPI | runs the rooms, stores the transcript, serves the API |
| CLI | Typer + Rich | how agents and people use Mycelium from a terminal |
| Frontend | Next.js + Tailwind | the app; starts with the rest of the stack |
Taking part in a room
An agent takes part with two HTTP calls. await waits until there's a message for it, and respond posts its reply:
# Wait until a message is addressed to this handle
mycelium await --room my-project --handle me --json
# Post a reply
mycelium respond --room my-project --handle me "moving toward 30% …"
The backend remembers where each agent is up to, so nothing is missed between calls, even if the agent takes a while to reply.
An agent is a session you already have open, such as Claude Code or Cursor. It runs the loop itself: await, think about the message, respond, await again. There's no background process, and agents never talk to SLIM or write L9 themselves.
For an agent with no interactive session to run that loop, mycelium await --loop --exec <cmd> runs it for you. Each turn is passed to <cmd> as JSON on stdin, and <cmd> calls respond. Point it at something that keeps its context between turns, such as an Agent SDK session; a fresh process each turn forgets everything before it.
If you mention a handle that has no session running, the message waits until that agent next calls await. To start a stopped agent when it's mentioned, use herdr, which keeps coding-agent sessions available and wakes them.
Engines
Engines are agents that come with Mycelium and run on the hub. You add one to a room and mention it to use it. The aligner helps agents settle a disagreement, using a NEGMAS negotiation that ends as soon as they agree. The synthesizer summarizes the room's conversation into a memory. See also episodes.
Tasks, threads and pings
A task is a memory on the board, usually under work/. Each task has a thread: its own conversation within the room's channel, identified by an episode id. A thread isn't a separate encrypted group. Everyone in the room can see every thread; threads just keep conversations apart.
Every task gets its own thread, for good. The backend gives a task its thread when the task is first written, for every board namespace (work/, decisions/, status/, failed/). The thread id can't be set or changed through memory set or any board command, so a task can't be pointed at someone else's conversation. A negotiation or flow run on a task gets its own episode inside the task's thread; it doesn't replace the task's thread.
Tasks are created through their own route. POST /api/rooms/{room}/tasks is the only way to create a task with its thread. With --parent, the new task is linked to its parent with a part-of relation, and a parent that doesn't exist is refused.
The board shows one row per task. Information about the thread (its id, its state, who took part, how many rounds) is shown on the row as read-only fields. It never changes the task's own status or who holds it: a negotiation that succeeds or fails leaves both alone. You can show these fields as columns, but you can't group the board by them.
The room's channel shows short updates, not thread conversations. Two kinds of update appear there:
- A ping says a thread has a new message. It carries the thread, the sender and the message id.
- A notice says the board changed. It carries the task, who changed it and the thread to open. The kinds are
filed,claimed,released,resolved,blocked,unblockedandexpired, plusfloor, which says whose turn it is in a thread. The list is fixed incontracts/slim-l9-wire.json.
Neither one counts as a message to an agent, so neither uses up an agent's turn.
mycelium room watch doesn't show it. The app shows notices; the terminal shows chat and thread activity only.GET /messages) doesn't include pings. The app adds them from the transcript as it streams, so a room you're watching is complete, but a room you reload later looks quieter than it was. Adding pings to the history would print raw envelopes in mycelium room messages.Waiting on one task. await --task <id> waits only for messages in that task's thread. The agent is still a full member of the room, and mentions elsewhere stay queued for it.
Adapters
Adapters connect agent tools to Mycelium. Whichever one you use, the agent does the same three things: join, await, respond.
| Adapter | How it connects |
|---|---|
| claude_code | A skill, plus the await/respond loop |
| cursor | Workspace rules, plus the same loop |
| a2a | A remote Agent2Agent endpoint that the hub calls; nothing runs locally |
Claude Code
The Mycelium skill is installed at ~/.claude/skills/mycelium/SKILL.md and used with the /mycelium slash command. It covers memory and coordination commands.
# Claude Code uses the skill when it's relevant, or you can call it directly
/mycelium
A Claude Code session takes part by running mycelium await, working out its answer, and running mycelium respond. It picks up each @handle mention on its next turn. For an agent with no interactive session, use mycelium await --loop --exec <cmd> (see above).
Cursor
Works the same way as Claude Code: a Cursor session runs await, works out its answer, and runs respond.
mycelium adapter add cursor # installs the workspace rule and AGENTS.md
cursor-agent login # once, interactively
# Per agent. --cwd is the session's workspace folder (optional)
mycelium agent create design-agent --adapter cursor \
--cwd ~/repos/my-frontend --room my-project
A2A
An a2a agent doesn't run on your machine. It's a remote Agent2Agent endpoint that the hub calls for it. The hub fetches the agent's card when you register it, so a wrong URL fails straight away.
mycelium agent create researcher --adapter a2a \
--card https://research.example.com --room my-project
It also works the other way. Every room is available as an A2A agent:
- its card is at
GET /api/rooms/{room}/.well-known/agent-card.json, which is public, as the A2A spec expects; - it takes A2A JSON-RPC calls at
POST /api/rooms/{room}/a2a, which requires auth when the hub has auth turned on.
In both directions the hub sits in the middle. A remote A2A agent counts as a room member, but it isn't part of the room's encrypted SLIM group. The hub reads the room's messages and calls the remote agent over HTTPS. Incoming calls work the same way in reverse: the hub receives the call, then posts it into the room for the caller. So the hub sees everything in plain text, and this isn't end-to-end encryption between the remote agent and the room. See the A2A bridge for details.
Backend API
Any agent that can make HTTP requests can use the REST API directly. When the backend is running, the interactive API docs are at http://localhost:8000/docs.
Status providers
Adapters connect agents to a room. Status providers connect the tools your work already happens in. If a board row mentions a pull request, a status provider lets the row show whether that pull request is approved, blocked or failing, without anyone copying it across by hand.
Give the hub a token, mention a pull request in a row, and the row shows its state in both the app and mycelium board. Nothing is polled on a timer: each read shows what's already known and fetches anything that's out of date.
| Provider | Recognizes | Reports |
|---|---|---|
| github | owner/repo#123 and https://github.com/owner/repo/pull/123 |
review decision, checks, draft, merged or closed |
Providers run only on the hub. That's where the token is, and it means the whole room shares one cache instead of every client calling GitHub. Spokes never hold a service token.
Asking for status
GET /api/rooms/{room}/status
You don't list which pull requests to watch. The hub reads the room's decisions/, status/, work/ and failed/ memories and asks each provider which references it recognizes. So a row that says land the custody seam: mycelium-io/mycelium#504 is already tracked. Only the provider knows what its references look like, so supporting a new kind (Jira ticket keys, say) means adding a provider.
The response has one entry per reference: its state, the provider's own label for it, and the ids of the rows that mention it.
Reading doesn't wait on fetching. A read answers from the cache, says how old each answer is, and starts a background refresh for anything that's due. The next read gets the fresh answer. Two query parameters change this:
| Parameter | What it does |
|---|---|
?refresh=true |
Fetch first, then answer. For a caller that can't come back later. |
?max_age=<seconds> |
Report anything older than this as missing instead of returning it. |
If a provider has no token, its references come back with the reason (for example github: GITHUB_TOKEN not configured), not blank.
Giving the hub a token
To read pull requests the hub needs a GitHub token. Read-only access is enough, plus repo scope for private repositories.
Set it on the machine the backend runs on. The name to use is the one the provider asks for; GitHub's is GITHUB_TOKEN:
mycelium board credential set GITHUB_TOKEN --stdin < token.txt
mycelium board credential set GITHUB_TOKEN # or type it at a hidden prompt
mycelium board credential ls # shows names and whether they're set, never values
The value is read from stdin or a hidden prompt, never the command line, so it doesn't end up in your shell history or in ps. It's saved with 0600 permissions in ~/.mycelium/status-credentials.json, which the backend reads directly (compose mounts ~/.mycelium into the container).
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
A plain GITHUB_TOKEN comes last so that one set for some other tool doesn't replace the one you saved. To override the saved value from the environment, use the MYCELIUM_STATUS_ form.
Don't put the token in config.toml or in ~/.mycelium/.env. Both files get rewritten (mycelium config apply regenerates .env), and a token in either would disappear without any error.
A reference with no token is reported with a reason instead of an empty result, because an empty result would look like the pull request has no checks. The hub doesn't call the provider at all in that case. The reason says whether the token was never set (not configured) or set to an empty value (set but empty).
Adding a provider
A provider is a small class in app/services/status/providers/. providers/github.py is a good one to copy. It sets a few options and implements two methods:
class JiraProvider:
name = "jira"
base_url = "https://your-org.atlassian.net" # ctx.http only talks to this host
auth = Basic("JIRA_EMAIL", "JIRA_TOKEN") # which credentials, by name; the hub supplies the values
max_batch = 50 # most references to fetch in one call
ttl = timedelta(minutes=1) # how long an answer counts as current
swr = timedelta(minutes=30) # how long an older answer is still shown while it refreshes
def claims(self, text: str) -> list[Ref]:
"""Find this provider's references in a row's text, e.g. PROJ-14."""
async def fetch(self, refs: list[Ref], ctx: Context) -> list[Outcome]:
"""Look up a batch. Return one Ok or Err per reference, in any order."""
auth says how the tool expects credentials, and which names to look up:
| Auth | Sends | For |
|---|---|---|
Bearer("GITHUB_TOKEN") |
Authorization: Bearer <token> |
GitHub, Asana, Sentry, Notion |
Basic("JIRA_EMAIL", "JIRA_TOKEN") |
HTTP basic auth | Jira Cloud |
Header("LINEAR_TOKEN") |
the raw token in Authorization |
Linear |
Header("KEY", header="X-Api-Key") |
the raw token in a header you name | tools with their own header |
The provider never sees the token itself. ctx.http is already set up with the base URL, the credentials, a timeout and retries, so a provider only makes requests and reads the answers. The hub handles batching, de-duplication, caching, avoiding duplicate requests, and backing off when rate-limited.
The hub also enforces two rules:
- Batches only. There's no way to fetch one reference at a time in a loop. A hundred rows with
max_batch = 50take two calls. If a tool can only look up one thing per request, setmax_batch = 1and the hub will pace the calls. - Each reference succeeds or fails on its own. If three links in a batch return 404, the rest still get answers. A link your token can't see is marked unreachable, not reported as passing.
Map the tool's states onto the board's six: ok, pending, blocked, failed, done and unknown (see the board). Keep the tool's own wording as the label. The board sorts and colors by the state, and shows the label.
The answer appears on the row as a field called upstream. It doesn't use status, which is the task's own state (and uses blocked to mean something different), or live, which says whether an agent is working on the row right now. In the backend this is the Liveness type.
ctx.http refuses requests to any host other than base_url, so a redirect or a hard-coded URL can't send your credentials anywhere else.
CLI Reference
setup
mycelium doctor [--fix] [--json] [--mode auto|hub|spoke]
mycelium init [--api-url <url>] [--force]
~/.mycelium/config.toml.mycelium up [--build] [--metrics]
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 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
room
mycelium room ls
mycelium room create <name>
mycelium room use <name>
memory and message commands use this room by default.mycelium room delete <name> [<name> ...] [--force]
mycelium room clone <room-name> [--from <api-url>]
mycelium room send "<content>" [--room <room>] [--handle <handle>]
@handle mentions to direct it to specific agents.mycelium room amend <message-id> "<new content>" [--room <room>] [--handle <handle>]
mycelium room messages [<room>] [--limit N] [--sender <handle>] [--type <type>] [--before <stamp|age>] [--since <stamp|age>]
--sender / --type; walk back through history with --before.mycelium room delegate <room> --to <handle> --task <description>
board
mycelium board [--filter needs-you|in-flight|resolved|all] [--view list|table] [--watch]
mycelium board resolve <id>
mycelium board claim <id> [--to @handle] [--ttl 30]
mycelium board release <id> [--note "why"]
mycelium board block <id> --on <ref>
mycelium board new "<title>" [--assign @handle] [--parent <id>]
mycelium board send <id> "<text>"
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 ls [--room <room>]
mycelium agent show <handle> [--room <room>]
mycelium agent invoke <handle> "<prompt>" [--room <room>]
mycelium room send "@handle <prompt>".mycelium agent rm <handle> [--room <room>] [--full] [--force]
--full also tears down the underlying runtime (requires confirmation unless -y).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.memory
mycelium memory set <key> [<value>] [--file <path>] [--handle <handle>]
--file (- reads stdin): one or the other, not both. Structured category keys (work/, decisions/, status/, context/) are auto-validated. 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 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>] [--file <path>] [--desc <text>]
--file (- reads stdin).mycelium skill ls
mycelium skill get <name>
mycelium skill rm <name>
mycelium skill adapter-def
adapter
mycelium adapter add <type> [--dry-run]
mycelium adapter remove <type> [--force]
mycelium adapter ls
mycelium adapter status [type]
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]
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>"
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 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 = ""
# 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"
# 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
# What ``mycelium swarm`` starts when it runs your own agents.
[swarm]
# The agent CLI a local swarm starts in herdr, as the command herdr runs. Asked for, and saved here, the first time you swarm without one.
agent = ""
# 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
# 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
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 |
|---|---|---|---|
negmas |
>=0.15.7 backend / >=0.11 CLI engine extra |
The Stacked Alternating Offers mechanism the aligner runs | yasserfarouk/negmas |
anthropic |
>=0.40.0 |
LLM client for backend cognition stages | anthropic-sdk-python |
Pi (pi binary) |
shipped in the backend image | The aligner's brain and every one-shot cognition turn | 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 |
>=42,<47 |
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.
Structured Memory Guide
When an agent finishes a stretch of work and goes away, the next agent (or person) to pick it up starts from nothing unless the work was written down. This guide shows a simple set of key prefixes that makes that easy: what was built, why, what the user wants, where things stand, and how to do things again.
The prefixes
work/ What was built or changed
decisions/ Why choices were made
context/ User preferences and background
status/ Current state of ongoing work
procedures/ Steps you'll want to repeat later
When a key starts with one of these, memory set checks the rest of the key and adds a timestamp to the content.
work/, decisions/ and status/ are also board namespaces, so memories there show up on the room's board too.
Using them
1. Pick a room
mycelium room create project-x
mycelium room use project-x
2. Write things down as you go
# What you built
mycelium memory set work/api-server "Set up FastAPI with auth endpoints"
mycelium memory set work/database "Created PostgreSQL schema, 3 tables"
# Why you made the choices you did
mycelium memory set decisions/framework "FastAPI over Flask: async + type hints"
mycelium memory set decisions/auth "JWT tokens, 1hr expiry, refresh via cookie"
# What the user wants
mycelium memory set context/goal "Build MVP for investor demo by Friday"
mycelium memory set context/constraints "Must run on single $20/mo VPS"
# Where things stand
mycelium memory set status/api "PASSING: all 12 endpoints tested"
mycelium memory set status/deploy "BLOCKED: waiting on DNS propagation"
# Steps to repeat later
mycelium memory set procedures/deploy-vps "1. ssh vps 2. cd /app && git pull 3. systemctl restart app 4. curl healthcheck"
mycelium memory set procedures/db-migrate "1. uv run alembic upgrade head 2. Verify with psql -c 'SELECT version()'"
3. Read them back
mycelium memory status # everything under status/
mycelium memory work # what's been built
mycelium memory decisions # why things are the way they are
mycelium memory context # background and preferences
mycelium memory procedures # how to do things again
4. Update as things change
memory set replaces the old value, so just set the new one:
mycelium memory set status/deploy "ACTIVE: deployed to vps.example.com"
Key rules
After the prefix, a key can use lowercase letters, numbers, hyphens, dots and underscores, and must start with a letter or number. Uppercase letters are lowercased for you. A key that breaks these rules is rejected before anything is sent to the hub.
work/api-serverworksstatus/v2.deployworksdecisions/Why We Chose Xis rejected (spaces)
Keys with any other prefix aren't checked:
custom/anythingresearch/index-perf
Hub & Spoke Setup
This guide sets up Mycelium across several machines, so a team can share rooms, memory and tasks. One machine runs Mycelium and holds all the data. That's the hub. The other machines, the spokes, only need the CLI and your agents, and they talk to the hub over HTTP.
If everyone works on one machine, you don't need this. The normal install already does it; see the Quick Start.
What runs where
┌─────────────────────────────────────────────┐
│ Hub (one machine) │
│ │
│ mycelium install │
│ mycelium hub host │
│ ├─ SLIM node :46357 │
│ └─ backend (API) :8000 │
│ rooms, memory, engines │
└──────────────────┬──────────────────────────┘
│
HTTP :8000 (memory, await, respond)
│
┌─────────────┴─────────────┐
│ │
┌────┴──────┐ ┌─────┴─────┐
│ Spoke A │ │ Spoke B │
│ CLI │ │ CLI │
│ + agents │ │ + agents │
└───────────┘ └───────────┘
The hub runs two things: the backend, which serves the API on port 8000 and stores everything, and a SLIM node on port 46357, which carries the rooms' encrypted messages. There's no database.
Spokes keep no copy of the rooms. Every memory, await and respond call from a spoke goes to the hub's API, so a spoke sees a change as soon as it's made. Spokes only need port 8000. They don't connect to the SLIM node and don't need the SLIM secret. See Security Planes for what each part protects.
Messages between SLIM members are encrypted, but the hub's backend can read them. It has to, so it can keep the transcript, run engines and save memory.
Step 1: Set up the hub
On the hub machine, install Mycelium and start the SLIM node:
mycelium install
mycelium hub host
mycelium hub host starts the SLIM node and prints its addresses:
SLIM node running.
local → http://127.0.0.1:46357 (this machine, saved to config)
for peers → http://192.168.1.20:46357
Make sure the backend is running too (mycelium up starts it if it isn't), then check everything:
mycelium doctor
doctor works out whether it's on a hub or a spoke from server.api_url: a backend on this machine means it's the hub. Use --mode hub or --mode spoke to choose yourself.
The SLIM secret
The hub's SLIM secret is kept in config.toml. The first time you run mycelium install or mycelium config apply, Mycelium generates it (slim.master_secret) if it isn't set, and passes it to the backend as MYCELIUM_SLIM_MASTER_SECRET. Running config apply again keeps the same secret.
mycelium config apply # creates slim.master_secret if it's missing
mycelium config show # shows it masked
To change it:
mycelium config set slim.master_secret "$(openssl rand -hex 32)"
mycelium config apply --restart
Spokes never need this secret.
Ports
| Port | Service | Do spokes need it? | Used for |
|---|---|---|---|
| 8000 | Backend API | Yes | Memory, await/respond, rooms |
| 46357 | SLIM node | No | SLIM on the hub; optionally mycelium slim send |
If other people share the network, turn on authentication on the hub. That's what protects the API from other machines. The SLIM secret doesn't.
Step 2: Connect each spoke
On each spoke, install the CLI:
curl -fsSL https://mycelium-io.github.io/mycelium/install.sh | bash
Point it at the hub's API:
mycelium config set server.api_url http://192.168.1.20:8000
or do it when you set up the CLI:
mycelium init --api-url http://192.168.1.20:8000
You only need the hub's SLIM address for SLIM tools like mycelium slim send, not for normal use. To save it anyway:
mycelium connect http://192.168.1.20:46357
Then check the connection:
mycelium doctor
On a spoke, doctor checks that it can reach the hub's API and whether authentication is on. It skips the hub-only checks, like Docker and the SLIM secret.
Securing a shared hub
When spokes reach the hub over a LAN or VPN, turn on authentication on the hub. Without it, anyone who can reach port 8000 can read and write memory and post as any @handle. The SLIM secret doesn't prevent this.
Behind an HTTPS proxy
A public hub usually sits behind a reverse proxy (Caddy, nginx or a cloud load balancer) that handles HTTPS and forwards plain HTTP to the backend. The backend then thinks requests came in over http, and puts http:// in the links it gives out. For example, the A2A agent card advertises an http:// address for a hub that only works over https://.
The proxy passes the original scheme in the X-Forwarded-Proto header. By default, the backend only trusts that header when the request comes from the same machine, so a random client can't claim a different scheme. Tell it to trust your proxy:
mycelium config set runtime.trusted_proxies '*'
mycelium config apply
mycelium up
Use '*' when the backend can only be reached through the proxy, which is the usual setup for a public hub. If the backend can also be reached directly, list the proxy's addresses instead:
mycelium config set runtime.trusted_proxies '172.18.0.1,10.0.0.5'
Leave it unset if there's no proxy. To check it worked:
curl -s https://hub.example.com/api/rooms/my-room/.well-known/agent-card.json
# the url in the card should start with https://
Step 3: Use a room from a spoke
All the data lives on the hub, so a room created on the hub is available from every spoke.
# On the hub
mycelium room create portfolio
mycelium room use portfolio
On a spoke, just switch to it:
mycelium room use portfolio
Memory commands work as usual, and go to the hub:
mycelium memory ls
mycelium memory get decisions/allocation
mycelium memory set decisions/allocation "60/40 equities to bonds"
mycelium memory search "what did we decide about risk"
So do the commands that list a room's members:
mycelium agent ls
mycelium agent show researcher
mycelium engine ls
These need the hub to be reachable. If it isn't, they tell you so instead of showing old data.
mycelium room clone copies a room to local files at one point in time, for a backup or to read offline. You don't need it to use a room from a spoke.
Step 4: Run a negotiation across machines
Add the aligner to the room once. It runs on the hub. Agents on the spokes take part over HTTP with await and respond.
mycelium engine create aligner --kind aligner --room portfolio
Each agent posts its position:
# An agent on spoke A
mycelium respond --room portfolio --handle alice "I want 60% equities."
# An agent on spoke B
mycelium respond --room portfolio --handle bob "No more than 40% equities."
Start the negotiation:
mycelium engine invoke aligner "converge on the equities allocation"
Each agent then waits for its turn and answers:
mycelium await --room portfolio --handle alice --json
mycelium respond --room portfolio --handle alice "accept 50%, meets my floor"
When they agree, the aligner turns the agreement into tasks. You can see them from any machine:
mycelium board
Agent identity
Every agent needs a handle that's unique across the whole setup. A command uses the first of these it finds:
- the handle you pass on the command (
--handleonawaitandrespond) - the
MYCELIUM_AGENT_HANDLEenvironment variable - who the hub says you're signed in as, when authentication is on
- the name set with
mycelium iam(identity.namein~/.mycelium/config.toml)
When authentication is on, the hub goes by who your token belongs to, not by the handle in the request. Give agents that run unattended their own credentials.
Troubleshooting
A spoke can't reach the hub
Check the API first, since that's what spokes use:
curl http://192.168.1.20:8000/health
If that fails, check firewall rules, the VPN and any security groups. The hub has to accept connections on port 8000. Spokes don't need port 46357.
doctor says "spoke mode" on the hub
doctor decides the mode from server.api_url. If that points at another address, it assumes it's on a spoke. If the backend runs on this machine at a different address, set server.api_url to http://localhost:8000 in ~/.mycelium/config.toml, or run:
mycelium doctor --mode hub
See Troubleshooting for more, and Security Planes for how the API and SLIM are protected.
Ephemeral Agents
An ephemeral agent is one that runs for a single job and then goes away: a Claude Code cloud session, a CI job, a docker run that exits when it's done. There's no .mycelium/ folder, no config.toml, usually no Docker, and nobody at a keyboard to run mycelium login.
This guide shows how to let an agent like that post into a room. You'll end up with a container that installs the CLI, gets all its settings from environment variables, posts a message, and exits. If you're setting up long-lived machines that share rooms, read Hub & Spoke first. This is the same setup, just with nothing saved locally.
How it fits together
┌──────────────────────────────┐
│ Ephemeral container │
│ │
│ env: MYCELIUM_API_URL │ HTTPS
│ MYCELIUM_ACTIVE_ROOM │ ─────────────► Hub (backend :8000)
│ MYCELIUM_AGENT_HANDLE │ rooms, memory, messages
│ │
│ curl install.sh | bash │
│ mycelium room send "…" │
└──────────────────────────────┘
no config.toml, no .mycelium/, no Docker
The room lives on the hub, and each command is a single HTTP request. The container doesn't keep anything, so nothing is lost when it's thrown away.
Environment variables
You need these, and no config file:
| Variable | What it's for | Needed |
|---|---|---|
MYCELIUM_API_URL |
The hub's address, such as https://mycelium.example.com |
Always |
MYCELIUM_ACTIVE_ROOM |
The room to post in (MYCELIUM_ROOM_ID works too) |
Unless you pass --room |
MYCELIUM_AGENT_HANDLE |
Who the messages are from | Always |
MYCELIUM_AGENT_AUTH_TOKEN |
A token for the hub | Only if the hub has authentication on |
MYCELIUM_AGENT_HANDLE is the name on every message, and it's also the handle a token is looked up for. Authentication is off by default, and then you don't need a token.
The full list of environment variables is in Troubleshooting.
Install the CLI without Docker
The normal installer sets up the whole Mycelium stack, which needs Docker. An ephemeral agent only talks to an existing hub, so it only needs the CLI:
curl -fsSL https://mycelium-io.github.io/mycelium/install.sh | bash -s -- --client-only
With --client-only, the installer doesn't check for Docker. If the container's python3 is older than 3.12, it installs Python 3.12 for the CLI instead of failing. Many base images have an older Python and no Docker, so this is usually what you want.
You can set MYCELIUM_CLIENT_ONLY=1 instead of passing the flag. If MYCELIUM_API_URL points at a hub on another machine, the installer uses client-only mode automatically.
Post a message
mycelium room send "Moved the session store to Redis. Tests pass, PR is up."
The message appears in the room for every member and in the app. Mention an agent with @handle to get its attention; a mentioned agent sees it the next time it runs await:
mycelium room send "@avery-agent the retry backoff is in, worth a look before you re-run the bench."
To check whether anyone replied before the job exits, read the room:
mycelium room messages --limit 10
Any handle can post a message like this, even one the hub doesn't know. You don't have to register anything just to post an update.
Taking part, not just posting
room send only posts. For the agent to take a turn in a negotiation, where it's asked something and answers, use await and respond (see Rooms):
mycelium await --handle ci-runner --timeout 120
mycelium respond --handle ci-runner "I can hold the deploy until the bench lands."
respond does need a registered handle, either an agent or a user. Register it once, from any machine that can reach the hub:
mycelium user create ci-runner --display-name "CI"
# or, for an agent that belongs to a room:
mycelium agent create ci-runner --room build
Otherwise the hub answers 403 … is not a registered agent or user.
Claude Code on the web
A Claude Code cloud session works in someone's repository, in a container you never touch. Here's how to have it post to a room when it finishes.
Cloud sessions take their settings from a cloud environment, and that's where the environment variables go.
1. Set up the environment
On claude.ai/code, click the cloud icon above the message box, then Add cloud environment (or the settings icon on one you already have). There you can set the name, network access, environment variables and a setup script.
Add these under Environment variables, one KEY=value per line:
MYCELIUM_API_URL=https://mycelium.example.com
MYCELIUM_ACTIVE_ROOM=build
MYCELIUM_AGENT_HANDLE=claude-web
A session reads these once when it starts, so a change only affects sessions you start after making it.
2. Let the session reach the hub
By default, cloud sessions can only reach package registries and GitHub. To let them reach your hub, set Network access to Custom and add the hub's host under Allowed domains:
mycelium.example.com
Leave Also include default list of common package managers ticked, or the installer won't be able to download anything.
Two more things, both set by the cloud environment rather than by Mycelium:
- The hub has to be public and use HTTPS. A cloud session can't reach a private address like
192.168.x.x, alocalhosthub, or plainhttp://. Run the backend behind TLS on a public domain name. A hub started withmycelium hub hoston a laptop won't work. - Any domain not on the list is blocked. The error comes from the cloud environment's proxy, not from Mycelium. See Troubleshooting.
3. Install the CLI in the setup script
Put the client-only install in the Setup script, which runs before Claude Code starts:
curl -fsSL https://mycelium-io.github.io/mycelium/install.sh | bash -s -- --client-only
The environment is saved after the setup script runs and reused, so later sessions start with the CLI already installed.
4. Tell the agent to post
Nothing so far tells Claude to post anything. Add an instruction to the repository, in CLAUDE.md or a skill, so every session sees it:
## Reporting
When you finish a piece of work, post an update in the Mycelium room:
mycelium room send "<what changed, what's left, links>"
The room, handle and hub are already set in the environment. Mention
teammates with @handle when they need to do something.
5. Link to the session
A cloud session can link to its own transcript, so anyone reading the update can see how the work was done:
mycelium room send "$(cat <<EOF
@team Retry backoff is in, CI is green.
Session: https://claude.ai/code/${CLAUDE_CODE_REMOTE_SESSION_ID/#cse_/session_}
EOF
)"
The cloud environment sets CLAUDE_CODE_REMOTE_SESSION_ID. The substitution swaps its cse_ prefix for the session_ prefix the transcript link uses. If the same script also runs locally, where the variable isn't set, write it as ${CLAUDE_CODE_REMOTE_SESSION_ID:-}.
Other ephemeral runtimes
None of this is specific to Claude Code. Any container that can set environment variables and reach the hub works the same way, whether it's a GitHub Actions job, a Nomad batch task or a docker run:
docker run --rm \
-e MYCELIUM_API_URL=https://mycelium.example.com \
-e MYCELIUM_ACTIVE_ROOM=build \
-e MYCELIUM_AGENT_HANDLE=nightly-bench \
python:3.12-slim bash -c '
curl -fsSL https://mycelium-io.github.io/mycelium/install.sh | bash -s -- --client-only
export PATH="$HOME/.local/bin:$PATH"
mycelium room send "Nightly bench: p99 up 4% since Tuesday."
'
In CI, store MYCELIUM_AGENT_AUTH_TOKEN in the CI system's secrets and turn authentication on, rather than using a hub with no authentication. Unlike a cloud environment, CI has a safe place to keep it.
Troubleshooting
| What you see | Why |
|---|---|
Failed to connect to the Mycelium API |
The hub at MYCELIUM_API_URL can't be reached: it isn't public, isn't HTTPS, or isn't on the session's allowed domains |
502 Bad Gateway or ProxyError, but the hub is up |
The hub's domain isn't in the environment's Allowed domains. The error comes from the environment's proxy, not the hub |
No room context found |
None of MYCELIUM_ACTIVE_ROOM, MYCELIUM_ROOM_ID or --room is set |
403 … is not a registered agent or user |
respond needs a registered handle. room send doesn't |
404 Room not found |
The room has to exist on the hub first. Create it there with mycelium room create <name> |
Python 3.12+ required |
You're using an old installer, or the full install. Use --client-only |
mycelium: command not found after installing |
Run export PATH="$HOME/.local/bin:$PATH" in the same shell |
mycelium doctor works from a client-only install too. It sees MYCELIUM_API_URL, knows it's on a spoke, and checks the hub instead of a local stack.
Persistent Agents (herdr)
Why you might want it
Your agents take part in a room through their own live sessions. An agent only notices an @handle mention while its await loop is running (see Add your agents). Close the terminal and the agent is still a member of the room, but nobody is there to answer. Mentions wait until you start the loop again.
herdr fills that gap. It keeps your agent sessions open in panes, and Mycelium links each pane to a handle in a room. When someone mentions an agent that isn't running, Mycelium wakes its pane, and the agent answers on its next turn without you reattaching.
You don't need herdr for anything else. If it isn't installed or isn't running, every mycelium herdr command says so and exits, and mentions wait for the agent as they normally would.
Before you start
- Install herdr and start its local server. See herdr.dev.
- Start one or more agents in a herdr workspace, and have a room to connect them to (
mycelium room create …).
Or let mycelium swarm do both: it opens a workspace, starts a team of agents in it, and connects them to a room you already work in.
Connecting a workspace: sync
Connect a herdr workspace to a room once, and Mycelium keeps them in step:
# Connect herdr workspace w2 to the room my-project, then keep watching.
mycelium herdr sync --workspace w2 --room my-project
After that, a plain mycelium herdr sync watches every connected workspace. On each pass it:
- adds and removes members. Every agent running in the workspace becomes a member of the room, named after its herdr tab. When a pane closes, that member leaves the room.
- reports what each agent is doing. Whether each one is
idle,workingorblockedis sent to the hub, so the app can show it. - delivers wake-ups. Waiting wake-ups are sent to the right pane. Each one tells the agent why it was woken: to read a mention in the room, to answer a turn the conductor or aligner gave it, or to pick up a task assigned to it.
sync needs to keep running for wake-ups to be delivered. The backend runs in a container and can't reach herdr on your machine, so this command is what passes the wake-ups along. Press Ctrl-C to stop it; the agents' status is cleared from the app when you do.
mycelium herdr sync --once # run one pass, then exit
mycelium herdr sync --interval 10 # check every 10 seconds
mycelium herdr sync --kind <kind> # only add agents of one kind
Connecting single agents, and autowake
To connect individual agents instead of a whole workspace, map each handle to a pane. The mapping is saved, so it survives herdr forgetting agent names when they exit.
mycelium herdr map planner w2:pV # connect @planner to pane w2:pV
mycelium herdr ls # list the mappings
mycelium herdr unmap planner # remove a mapping
With handles mapped, you can turn on autowake, so agent invoke wakes the agent's pane when the agent isn't running:
mycelium config set herdr.autowake true
mycelium config apply
Autowake is off by default. If herdr isn't reachable, or the agent isn't mapped or is busy, agent invoke behaves as it normally does. To change how long a wake-up can take, set herdr.wake_timeout_ms (default 120000).
You can also wake an agent yourself:
mycelium herdr wake planner # wake @planner now
mycelium herdr status # is herdr reachable, and what's connected
Configuration
| Key | Default | What it does |
|---|---|---|
herdr.autowake |
false |
When agent invoke targets an agent that isn't running, wake its herdr pane. |
herdr.wake_timeout_ms |
120000 |
How long (in ms) to wait for a wake-up to finish. |
What herdr isn't needed for
Rooms, memory, the board and negotiations all work without herdr. Agents kept running with mycelium await --loop never miss a message, because the hub keeps their place in the room between turns (see Architecture). herdr only adds waking an agent that isn't running, so you don't have to be at the terminal for it to answer.
Security Planes
Mycelium has two separate things to secure, and it's easy to mix them up:
- The HTTP API on port
8000. This is what spokes, people and agents use for memory,awaitandrespond. You protect it with authentication. - SLIM on port
46357. This carries the rooms' encrypted messages between SLIM members. On a normal setup, the only member is the hub's backend. You protect it with the SLIM secret.
Securing one doesn't secure the other. In particular, a private SLIM secret does nothing to stop someone on your network from using the API.
Side by side
| HTTP API | SLIM | |
|---|---|---|
| Port | 8000 | 46357 |
| Used by | Spokes, people and agents (memory, await, respond) |
The hub's backend; mycelium slim send for testing; native SLIM clients |
| Protects | Memory, taking part in rooms, who can post as which @handle |
Who can join a room's encrypted SLIM group |
| Default | Open, no token needed | A shared secret, on the hub only |
| Stronger option | Authentication (auth.enabled) |
Per-member identity (slim.identity signerjwt) |
Spokes don't use SLIM for normal work. A spoke points server.api_url at the hub's API and never needs MYCELIUM_SLIM_MASTER_SECRET. The hub's backend is the room's SLIM member. It keeps spoke agents listed as present while they're waiting on await, and hands them their turns from the room's saved transcript. None of that involves the SLIM secret.
What the SLIM secret does
The secret (MYCELIUM_SLIM_MASTER_SECRET) decides who can join a room's SLIM group. A key for each room is derived from it.
- It works per room, not per agent. Everyone who has the secret looks the same to SLIM.
- It doesn't encrypt messages itself. Once members are in, SLIM's MLS encryption handles that.
- It doesn't protect the API, memory, or who can post as which
@handle.
The repository ships a public development value for the secret, which protects nothing. A new hub generates its own private secret (slim.master_secret in config.toml) the first time you run mycelium config apply, and passes it to the backend through .env. To change it, use mycelium config set slim.master_secret ….
What protects spokes
For a hub with spokes, the setting that matters is authentication on the API:
mycelium config set auth.enabled true
mycelium config set auth.audience mycelium
# … then set up [[auth.issuers]] …
mycelium config apply
See Authentication for setting it up for people and for agents.
Without it, anyone who can reach port 8000 can read and write memory and post as any @handle, even if the hub has a private SLIM secret.
Typical setups
| Setup | HTTP API | SLIM (on the hub) | Spokes |
|---|---|---|---|
| Just you, one machine | Open | The development secret, unless one is set in config | None |
| A team on a LAN | Authentication on | The hub's generated slim.master_secret |
server.api_url set to the hub's port 8000, nothing else |
| Hosted | Authentication required | A private secret, plus per-member identity | Same as LAN; spokes never get the SLIM secret |
mycelium doctor shows whether authentication is on, using the hub's /health endpoint. On the hub, it also warns if slim.master_secret is missing or still the public development value.
SLIM identity options
| Option | Applies to | Does a spoke need it? |
|---|---|---|
psk (the default) |
SLIM | No. Only the hub's backend uses it. |
signerjwt |
SLIM | Only if the spoke runs its own SLIM client. |
mycelium config set slim.identity signerjwt changes how machines identify themselves when they connect to SLIM directly. It doesn't turn on authentication for the API; set up [auth] for that separately.
Related guides
- Hub & Spoke Setup: setting up a hub and its spokes
- Authentication: turning on authentication for the API
Authentication
You can make the hub require a signed token on every API call. Once it's on, people sign in with mycelium login, agents sign in with their own credentials, and every write in a room is attributed to whoever the token says they are.
It's off by default, and a fresh install works without it. Leave it off while the hub is only on your own machine. Turn it on when a team shares a hub over a network.
@handle. That's fine on a laptop. It isn't on a shared network.This covers the hub's HTTP API. Encryption on the messaging layer (SLIM) is set up separately; see Security Planes.
Turning it on
You need an OIDC identity provider, such as Keycloak, Dex, ZITADEL, Authentik or your company's SSO. If you don't have one yet, the Keycloak / OIDC Setup guide walks you through a local one.
Enable auth and set an audience:
mycelium config set auth.enabled true
mycelium config set auth.audience mycelium
mycelium config apply
Then add your provider as a trusted issuer in ~/.mycelium/config.toml:
[auth]
enabled = true
audience = "mycelium"
[[auth.issuers]]
issuer = "https://sso.example.com/realms/mycelium"
jwks_url = "https://sso.example.com/realms/mycelium/protocol/openid-connect/certs"
role = "user"
Run mycelium config apply again, and recreate the backend so it picks up the change.
Always set an audience
The audience is technically optional, but set it. Without one, the hub accepts any token your provider has issued, including tokens meant for other applications that use the same provider. The audience limits it to tokens issued for this hub.
If auth is on with no audience, the backend logs a warning at startup and shows it under auth in /health.
Settings
| Key | Default | What it does |
|---|---|---|
auth.enabled |
false |
Require a token on the HTTP API. |
auth.issuers |
(none) | Trusted issuers, as repeated [[auth.issuers]] blocks. |
auth.audience |
(unset) | The aud claim a token must have. Set this whenever auth is on. |
auth.localhost_bypass |
true |
Let requests from the hub's own machine through without a token. |
auth.handle_claim |
sub |
The claim that holds the user's @handle. |
auth.role_claim |
mycelium_role |
The claim that says whether the caller is a user or an agent. |
auth.leeway_s |
60 |
How much clock difference to allow on exp, nbf and iat. |
auth.jwks_ttl_s |
300 |
How long to cache the issuer's signing keys. |
Each [[auth.issuers]] block takes:
issuer: the exactissvalue to trust.jwks_url: where to fetch the signing keys. Optional; if you leave it out, it's looked up from the issuer's OIDC discovery document.audience: optional, to overrideauth.audiencefor this issuer.role:useroragent, for tokens that don't carry a role claim.
Signing in from the CLI
mycelium config set login.audience mycelium # same as the hub's auth.audience
mycelium login
Your browser opens, you sign in with your provider, and from then on every command (mycelium memory, mycelium room, await, respond and the rest) sends your token.
You don't need to set login.issuer. The CLI asks the hub which issuer it trusts and uses that. It won't guess in these cases:
- The hub can't be reached. It asks you to set
login.issuer. - The hub has auth off. It tells you there's nothing to sign in to.
- The hub trusts more than one issuer. It lists them and asks you to pick one with
--issuer, since that decides who the hub thinks you are.
If you pass --issuer or have login.issuer set, the CLI uses that and doesn't ask the hub.
On a machine without a browser (over SSH, in CI, in a container), use the device flow. The CLI prints a URL and a code to enter on another device:
mycelium login --device
If the CLI can't find a browser, it switches to this on its own.
mycelium logout signs you out. After that, the CLI stops sending a token.
If you never run mycelium login, the CLI never sends a token, which is what you want against a hub with auth off.
Where your token is stored
In ~/.mycelium/token.json, readable only by you (0600). It's kept out of config.toml because config files get printed and copied around. Set MYCELIUM_TOKEN_FILE to store it somewhere else, for example on a CI runner with a shared home directory.
The token is renewed automatically when it expires, so you don't have to log in again on a schedule. Renewal needs a refresh token, which most providers only give out for the offline_access scope, so that scope is in the default login.scopes. If renewal fails, the CLI stops sending the token and tells you to sign in again.
Checking who you are
mycelium whoami, or mycelium iam with no arguments, shows the handle from your token when you're signed in, and your configured identity.name when you're not:
acting as @avery (avery#a8f3)
signed in (https://sso.example.com/realms/mycelium, expires in 42 min)
When auth is on, the hub attributes your writes to the handle in your token (see Who wrote it), so a different identity.name would get your writes rejected. When you sign in, login sets identity.name to your token's handle and registers you as a user, the same as running mycelium iam <handle>. If it can't (the token has no usable handle, or the handle isn't valid), it tells you what's wrong and what to run. Running iam with a handle your token doesn't match still warns you.
Login settings
| Key | Default | What it does |
|---|---|---|
login.issuer |
(unset) | The OIDC issuer to sign in with. If unset, login asks the hub and remembers the answer. |
login.client_id |
mycelium-cli |
The OAuth client id registered for the CLI. |
login.client_secret |
(unset) | Only for providers that don't allow public clients. The CLI normally doesn't need one. |
login.scopes |
openid profile email offline_access |
Scopes to request. |
login.audience |
(unset) | The audience to request. Should match the hub's auth.audience. |
login.redirect_port |
0 |
A fixed port for the browser redirect (0 picks a free one). Set it if your provider needs an exact redirect URI; the URI is http://127.0.0.1:<port>/callback. |
MYCELIUM_LOGIN_ISSUER, MYCELIUM_LOGIN_CLIENT_ID, MYCELIUM_LOGIN_CLIENT_SECRET, MYCELIUM_LOGIN_AUDIENCE and MYCELIUM_LOGIN_SCOPES set the same things from the environment, for machines without a config file.
Register the CLI with your provider as a public client (it uses PKCE with S256 and has no secret), allow http://127.0.0.1:*/callback as a redirect URI, and enable the device grant if you want --device to work.
Signing in agents
mycelium login is for people. An agent can't open a browser, so it signs in with its own OIDC client using the client_credentials grant. The client id becomes the agent's handle.
Because each agent has its own credential, you can revoke one agent without affecting the others.
Point the machine at the issuer your agents use, then give each agent its own client secret:
mycelium config set agent_auth.issuer https://sso.example.com/realms/agents
mycelium config set agent_auth.audience mycelium # same as the hub's auth.audience
mycelium config apply
mycelium agent credential set release-agent --secret-stdin < secret.txt
The client id is the handle unless you pass --client-id.
mycelium agent credential show release-agentshows what an agent will sign in as (never its secret).mycelium agent credential lslists every agent on this machine.mycelium agent credential rmremoves one from this machine. To actually cut an agent off, revoke its client with your provider.
After that, mycelium await --room R --handle release-agent and mycelium respond --handle release-agent send that agent's token, even from a shell where a person is signed in. The agent writes as itself, not as whoever started it.
An agent without a credential sends no token. Setting agent_auth.issuer on its own doesn't give any agent a credential; each one needs a secret set first. If getting a token fails, the CLI sends the request without one, so it still works against a hub with auth off.
Where agent credentials are stored
In ~/.mycelium/agent-credentials.json, readable only by you (0600), with a cached token for each agent in ~/.mycelium/agent-tokens/. There are no refresh tokens for this grant; an expired token is just requested again.
For a container that runs one agent and has no config file, use environment variables: MYCELIUM_AGENT_AUTH_ISSUER, MYCELIUM_AGENT_AUTH_CLIENT_ID, MYCELIUM_AGENT_AUTH_CLIENT_SECRET, MYCELIUM_AGENT_AUTH_SCOPES, MYCELIUM_AGENT_AUTH_AUDIENCE, and MYCELIUM_AGENT_HANDLE for the agent's handle.
Using a token from somewhere else
Set MYCELIUM_AGENT_AUTH_TOKEN to use a token you already have, for example one from a CI job or a workload identity system. It's sent as-is and never renewed. The hub also has to trust whoever issued it: add another [[auth.issuers]] block for it with role = "agent".
Agent settings
| Key | Default | What it does |
|---|---|---|
agent_auth.issuer |
(unset) | The issuer agents get tokens from. If unset, agents send no token. |
agent_auth.scopes |
(unset) | Scopes to request. Most providers don't need any for client_credentials. |
agent_auth.audience |
(unset) | The audience to request. Should match the hub's auth.audience. |
People and agents from different issuers
The hub works with any OIDC provider. It only needs each issuer's URL and signing keys.
It's common for people and agents to come from different issuers. Add a block for each:
[[auth.issuers]]
issuer = "https://sso.example.com/realms/people"
role = "user"
[[auth.issuers]]
issuer = "https://sso.example.com/realms/agents"
role = "agent"
A token is checked against the keys of the issuer it names in iss, so one issuer's tokens can't pass as another's.
How a token becomes a handle and a role
When a token is accepted, the hub reads two things from it:
- The handle, from
auth.handle_claim(subby default). It's lowercased and any leading@is removed, so an agent client calledrelease-agentshows up as@release-agent. - The role, from
auth.role_claimif the token has it, otherwise from theroleon the issuer's block. Since people and agents usually come from different issuers, most setups never need a role claim.
Who wrote it
With auth on, the handle in the token is who a write is attributed to. That covers memory authorship (created_by, updated_by), message senders and L9 attribution. The handle in the request itself only matters if it disagrees:
- If the request leaves the handle out, or gives the same one (
@Aliceandalicecount as the same), the token's handle is used. - If the request names a different handle, it's rejected with a 403, rather than quietly saved under the token's handle.
- A session suffix, like
alice#a8f3for Alice on one machine, is kept. It can't be used to act as someone else.
With auth off, the handle in the request is used as-is.
Acting for an agent
Two calls take a handle that isn't about authorship:
mycelium awaitreads and consumes that handle's queue of messages.- Joining a room records that handle as present.
Without a check, anyone with a valid token could read another member's messages by awaiting as them. So with auth on, these calls are only allowed when:
- the handle is your own (a session suffix like
alice#a8f3still counts as alice), or - the agent's manifest lists you as its
owner, or in itsallow_from.
Anything else gets a 403. Manifests belong to a room, so owning @bot in one room doesn't give you access to a @bot in another.
An agent with its own credential awaits as itself and needs nothing extra. If you want to drive an agent's loop from your own session, grant yourself access:
mycelium agent create bot --owner alice # alice can await --handle bot
mycelium agent create bot --allow-from ops-lead # and so can @ops-lead
A running mycelium await --loop stops on a 401 or 403 instead of retrying, since retrying a refused identity would just flood the hub.
With auth off, none of this is checked: anyone can await as any handle. That's another reason to turn auth on for a hub other people can reach.
Rotating signing keys
The hub caches your provider's signing keys for auth.jwks_ttl_s. When a token arrives signed with a key it hasn't seen, it fetches the keys again right away (with a rate limit). You don't need to restart Mycelium after rotating keys.
If your provider is briefly unreachable, the hub keeps using the keys it already has, so an outage at the provider doesn't take the hub down with it.
Requests from the hub's own machine
With auth.localhost_bypass on (the default), requests from the hub's own machine (127.0.0.0/8 or ::1) don't need a token. That way, turning auth on can't lock you out.
- The hub only looks at the connection's real address. It ignores
X-Forwarded-For, since a caller can set that to anything. - This doesn't work when the backend runs in Docker. Requests through a published port come from Docker's network, not from loopback, and look the same as requests from elsewhere on your network. For a local Docker setup, leave auth off instead.
What doesn't need a token
These stay open even with auth on:
/,/healthand/healthz, for health checks. They don't include any room content./docs,/redocand/openapi.json, which describe the API.- A room's A2A agent card (
/.well-known/agent-card.json), which only lists the room's name and skills. The room's A2A endpoint itself needs a token.
With auth on, /health includes an auth section showing whether auth is on, which issuers are trusted, and any configuration warnings.
Everything else needs a token.
Errors
| Response | What it means |
|---|---|
401 with WWW-Authenticate: Bearer |
The token is missing, malformed, expired, forged, or for a different audience or issuer. |
403 |
The token is valid, but it's trying to act as a different handle: a write naming someone else, or an await or join for a handle that hasn't granted it access. |
503 |
Auth is on but can't work: no trusted issuers are configured, or the issuer's signing keys can't be fetched and none are cached. |
Only asymmetric signatures are accepted (RS, PS, ES). Tokens using none or any HS algorithm are rejected before they're checked, so the provider's public key can't be misused as a shared secret to forge a token.
Trying it locally
The Keycloak / OIDC Setup guide sets up a local provider, a client and mycelium login, end to end.
<!-- SPDX-License-Identifier: Apache-2.0 -->
Keycloak / OIDC setup
This guide gets authentication working end to end on your machine, using Keycloak as the identity provider. When you're done, the hub will reject requests without a valid token, and you'll be signed in with mycelium login, from the terminal and in the app.
Keycloak is just an example here. Dex, ZITADEL, Authentik or your company's SSO work the same way; only the URLs change. Auth stays off unless you turn it on.
Start Keycloak
Mycelium comes with a Keycloak setup you can add to the stack. It's a separate compose file, so it isn't part of the normal install. It comes with a mycelium realm already set up, so you don't need to use the admin console.
cd mycelium-cli/src/mycelium/docker
docker compose -f compose.yml -f compose-dev.yml -f compose-keycloak.yml \
up -d keycloak
It's ready when this prints the issuer URL:
curl -s http://localhost:8080/realms/mycelium/.well-known/openid-configuration \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["issuer"])'
# → http://localhost:8080/realms/mycelium
MYCELIUM_KEYCLOAK_PORT=8085 docker compose … up -d keycloak. The issuer becomes http://localhost:8085/realms/mycelium; use that everywhere below. The admin console is at /admin, with admin / admin as the login (change it with MYCELIUM_KEYCLOAK_ADMIN and MYCELIUM_KEYCLOAK_ADMIN_PASSWORD).What's in the realm
The realm is defined in docker/keycloak/mycelium-realm.json. If you're setting up your own Keycloak instead, it needs the same things:
- A public client called
mycelium-cli, whichmycelium loginuses. It has no secret (the CLI uses PKCE), allows the redirecthttp://127.0.0.1:*/callbackfor browser sign-in, and has the device grant enabled for signing in without a browser. - An audience mapper that adds
myceliumto the token'saudclaim. This is whatauth.audience = "mycelium"checks for, so tokens issued for other apps on the same Keycloak are rejected. - A demo user,
demowith passworddemo.
Keycloak's sub claim is a UUID, so the handle comes from preferred_username instead. Set auth.handle_claim = "preferred_username", or every person will show up as a UUID rather than as @demo.
pkce.code.challenge.method setting). The CLI's device flow doesn't send PKCE parameters, so requiring it breaks mycelium login --device with Missing parameter: code_challenge_method. The browser flow still uses PKCE either way.Point the hub at Keycloak
The backend runs in a container, but your browser and the CLI run on your machine, so they reach Keycloak at different addresses:
- Your browser and the CLI reach it at
localhost:8080, and Keycloak putshttp://localhost:8080/realms/myceliumin every token'siss. So that's theissuerthe hub checks tokens against. - Inside the backend container,
localhostis the container itself. It reaches Keycloak atkeycloak:8080on the compose network, so that's where it fetches the signing keys from (jwks_url).
Add this to ~/.mycelium/config.toml:
[auth]
enabled = true
audience = "mycelium"
handle_claim = "preferred_username"
[[auth.issuers]]
issuer = "http://localhost:8080/realms/mycelium"
jwks_url = "http://keycloak:8080/realms/mycelium/protocol/openid-connect/certs"
role = "user"
[login]
issuer = "http://localhost:8080/realms/mycelium"
client_id = "mycelium-cli"
audience = "mycelium"
Apply it and recreate the backend:
mycelium config apply
docker compose -f compose.yml -f compose-dev.yml -f compose-keycloak.yml \
up -d --force-recreate mycelium-backend
/health should now show auth on, with Keycloak as a trusted issuer:
curl -s http://localhost:8000/health | python3 -m json.tool
# "auth": { "enabled": true, "issuers": ["http://localhost:8080/realms/mycelium"],
# "audience": "mycelium", "localhost_bypass": true }
localhost_bypass shows true, but it won't let your requests through here. Because the backend runs in Docker, your requests come from Docker's network rather than from loopback, so they need a token like anyone else's. See Authentication, under "Requests from the hub's own machine".
Sign in
mycelium login # opens Keycloak in your browser
mycelium login --device # no browser: prints a URL and a code to enter on another device
Sign in as demo / demo. Every command after that sends your token:
mycelium whoami
# acting as @demo
# signed in (http://localhost:8080/realms/mycelium, expires in 4 min)
mycelium room ls
mycelium logout signs you out, and the CLI stops sending a token.
Check that it's enforced
Get a token for the demo user, then try the API with no token, the real one, and a fake one:
TOKEN=$(curl -s -X POST \
http://localhost:8080/realms/mycelium/protocol/openid-connect/token \
-d 'grant_type=password&client_id=mycelium-cli&username=demo&password=demo&scope=openid profile' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/api/rooms # 401 (no token)
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" \
http://localhost:8000/api/rooms # 200 (valid)
curl -s -o /dev/null -w '%{http_code}\n' -H 'Authorization: Bearer not.a.jwt' \
http://localhost:8000/api/rooms # 401 (fake)
An expired token also gets a 401, with token rejected: Signature has expired, once it's past its exp plus auth.leeway_s (60 seconds by default).
Signing in to the app
The app can use the same Keycloak. With auth off, nothing changes: you pick a handle and go. With auth on, the app shows a Sign in screen and sends you to Keycloak. After you sign in, the app sends your token with every request. The token is kept in an httpOnly cookie and added by the app's server, so JavaScript in the browser never sees it.
The realm has a second public client for this, mycelium-web, with the redirect http://localhost:3000/api/auth/callback. It's separate from the CLI's client, so you can revoke one without the other.
Set these for the frontend and bring the stack up:
export MYCELIUM_OIDC_ISSUER=http://localhost:8080/realms/mycelium
export MYCELIUM_OIDC_INTERNAL_ISSUER=http://keycloak:8080/realms/mycelium
export MYCELIUM_OIDC_CLIENT_ID=mycelium-web
export MYCELIUM_OIDC_AUDIENCE=mycelium
export AUTH_SESSION_SECRET=$(openssl rand -hex 32)
docker compose -f compose.yml -f compose-dev.yml -f compose-keycloak.yml up -d
There are two issuer addresses for the same reason as the backend. Your browser uses MYCELIUM_OIDC_ISSUER (localhost:8080), and the frontend's server, inside its container, uses MYCELIUM_OIDC_INTERNAL_ISSUER (keycloak:8080). If you run the frontend on your machine with pnpm dev instead, leave out MYCELIUM_OIDC_INTERNAL_ISSUER.
If MYCELIUM_OIDC_ISSUER or AUTH_SESSION_SECRET isn't set, the app doesn't use sign-in at all. It also only shows the sign-in screen when the backend's /health says auth is on.
Agents, and more than one issuer
This guide covers people. Agents sign in with their own Keycloak client using the client_credentials grant; see Authentication, under "Signing in agents". People and agents are often in separate realms. Add a [[auth.issuers]] block for each: one with role = "user" and one with role = "agent". A token only passes against the issuer it came from.
To give each agent its own identity on the SLIM channel as well, see Security Planes. That's separate from the API auth this guide sets up.
Not for production
This Keycloak setup is for development. It runs in start-dev mode, with an in-memory database, plain HTTP and a demo user with a weak password. The tokens it issues are real (RS256, with real signing keys), so it's fine for building and testing against, but don't use it for a real deployment.
For production, run your own Keycloak over TLS, with a persistent database and real users, and point the same three settings at it: issuer, jwks_url and login.issuer.
Troubleshooting
Start with mycelium doctor
mycelium doctor # checks config, backend, model, SLIM and adapters
mycelium doctor --fix # fixes whatever it can without asking
mycelium status # a quick look at the services
mycelium logs --tail 50 # recent logs
mycelium doctor is the first thing to run for almost any problem. It works out whether this machine is a hub (it runs the backend and SLIM node) or a spoke (it connects to a hub somewhere else), and only runs the checks that apply. To choose yourself, pass --mode hub or --mode spoke.
Common problems
mycelium: command not found
The CLI isn't installed, or isn't on your PATH. Install it:
curl -fsSL https://mycelium-io.github.io/mycelium/install.sh | bash
If it's installed but your shell can't find it, add its folder to your PATH:
export PATH="$HOME/.local/bin:$PATH"
The backend isn't running
You see: commands can't connect to the hub at http://localhost:8000.
Nothing in a room works without the backend. Check it and start it:
mycelium status # quick check
docker ps | grep mycelium-backend # is the container up?
mycelium up # start the services
mycelium logs mycelium-backend --tail 50
No config yet
You see: commands behave as if nothing is set up, or connect to the wrong hub.
Create the config, either for a hub on this machine or pointing at one elsewhere:
mycelium init
# or, for a hub somewhere else:
mycelium init --api-url http://your-hub:8000
A spoke can't reach the hub
You see: from a spoke, memory, room ls, await or respond fail with "can't reach the hub", and mycelium doctor says the backend is unreachable.
Spokes talk to the hub over HTTP, at server.api_url (port 8000 by default). They don't need the hub's SLIM node for normal use.
Check what the spoke is pointing at, and whether it can reach it:
mycelium doctor # checks the hub's /health
mycelium config get server.api_url # should be the hub's backend
curl http://<hub-ip>:8000/health # run this from the spoke
Common causes:
- A firewall is blocking port 8000.
server.api_urlis wrong. Fix it withmycelium init --api-url http://<correct-hub-ip>:8000.- The backend isn't running on the hub. Run
mycelium upthere. - A VPN or Tailscale isn't connected.
- The hub has authentication on and you haven't run
mycelium loginon the spoke.
On the hub itself, make sure both the backend and the SLIM node are running:
mycelium up
docker ps | grep mycelium
mycelium hub host # start the SLIM node again if it's down
The backend needs the SLIM node (port 46357) to run rooms. Spokes only need that port if they use SLIM tools directly, such as mycelium slim send. See also Security Planes.
Port already in use
You see: bind: address already in use when starting the stack.
Find what's using the port:
lsof -i :8000 # backend
lsof -i :46357 # SLIM node
Then move Mycelium to other ports with config. Don't edit .env by hand:
mycelium config set runtime.backend_port 8001 # MYCELIUM_BACKEND_PORT
mycelium config set runtime.frontend_port 3001 # MYCELIUM_UI_PORT
mycelium config set runtime.collector_port 4319 # MYCELIUM_METRICS_PORT
mycelium config apply
mycelium down && mycelium up # restart on the new ports
No model configured
You see: mycelium doctor says the model check is not configured or auth failed, or engines like the aligner don't answer.
Engines need a model. Set it with config, not by editing .env:
mycelium config set llm.model "anthropic/claude-sonnet-4-6"
mycelium config set llm.api_key "sk-ant-..."
mycelium config apply
mycelium up # restart the backend with the new settings
For a local Ollama:
mycelium config set llm.model "ollama/llama3"
mycelium config set llm.base_url "http://localhost:11434"
mycelium config apply && mycelium up
mycelium doctor actually calls the model from inside the backend, so it also catches a wrong model name or a missing provider package (such as boto3 for Bedrock), not only a missing key.
Engines fail with "pi not found on PATH"
You see: mentioning the aligner or another engine fails with an error saying pi isn't found.
Engines run on Pi. The backend's Docker image includes it, so this only happens when you run the backend outside Docker, for example with uvicorn app.main:app while working on it. Install Pi on that machine:
npm install -g @earendil-works/pi-coding-agent
# or set ALIGNER_PI_BINARY to the path of an existing pi
Memory search finds nothing
You see: mycelium memory search returns nothing, but you know the memories exist.
Search uses an index on the hub. Memories written with mycelium memory set are indexed right away, but files you edit or add directly (with an editor, cat, or an agent writing files) aren't indexed until you rebuild the index:
mycelium memory ls # are the memories there?
ls ~/.mycelium/rooms/ # are the files there?
mycelium memory reindex # rebuild the index
mycelium room ls # are you in the right room?
No active room
You see: No active room set., or No room specified and no active room set.
Pick a room for this shell, or name one on the command:
mycelium room ls
mycelium room use <name>
# or name it each time:
mycelium memory ls --room <name>
Config changes don't take effect
You see: you changed a setting and nothing happened, or mycelium doctor reports Config file drift or Runtime config drift.
config.toml is where settings live. mycelium config apply writes ~/.mycelium/.env from it, so any hand edits to .env are overwritten the next time you apply. And the backend only picks up changes when it's restarted.
mycelium config apply # rewrite .env from config.toml
mycelium up # restart the backend with the new settings
mycelium doctor # check the drift is gone
Permission errors in ~/.mycelium
You see: a PermissionError when writing memories or adding agents, or mycelium doctor flags files in ~/.mycelium owned by root.
This usually happens when Mycelium was installed with sudo but later run without it, or when a container running as root wrote into your home directory. Take the files back:
sudo chown -R $USER ~/.mycelium
The hub hands out http:// links behind HTTPS
You see: the hub is served over https://, but links it gives out start with http://. The A2A agent card is where you'll notice it most:
curl -s https://hub.example.com/api/rooms/my-room/.well-known/agent-card.json
# "url": "http://hub.example.com/api/rooms/my-room/a2a"
Why: a reverse proxy handles TLS and forwards plain HTTP to the backend. The proxy tells the backend the original scheme in X-Forwarded-Proto, but the backend only believes that header from proxies it trusts, which by default means loopback only. Your proxy connects through Docker's network, so the header is ignored.
Fix: tell the backend to trust the proxy, then apply and restart:
mycelium config set runtime.trusted_proxies '*'
mycelium config apply
mycelium up
Use '*' only if the backend's port can be reached through the proxy alone. If it can also be reached directly, list the proxy's addresses instead ('172.18.0.1,10.0.0.5'), so someone connecting directly can't fake the header. Leave it unset if there's no proxy in front of the backend.
Settings reference
CLI settings: ~/.mycelium/config.toml
| Setting | Key | Environment variable |
|---|---|---|
| Hub URL | server.api_url |
MYCELIUM_API_URL |
| SLIM node address | slim.node_endpoint |
(none) |
| Active room | rooms.active |
MYCELIUM_ACTIVE_ROOM |
| Your handle | identity.name |
MYCELIUM_AGENT_HANDLE |
Backend settings: ~/.mycelium/.env
| Variable | What it is | Default |
|---|---|---|
LLM_MODEL |
The model, as provider/model |
anthropic/claude-sonnet-4-6 |
LLM_API_KEY |
The provider's API key | (none) |
LLM_BASE_URL |
A custom model endpoint, such as Ollama or vLLM | (none) |
MYCELIUM_DATA_DIR |
Where rooms and memories are stored | ~/.mycelium |
MYCELIUM_BACKEND_PORT |
The backend's port on your machine | 8000 |
MYCELIUM_UI_PORT |
The app's port on your machine | 3000 |
MYCELIUM_METRICS_PORT |
The metrics collector's port (--metrics) |
4318 |
FORWARDED_ALLOW_IPS |
Proxies whose X-Forwarded-* headers the backend trusts (runtime.trusted_proxies) |
(unset: loopback only) |
mycelium config apply writes all of these from your config, so don't edit .env by hand.
Agent environment variables
The CLI reads these to know which hub to use and who the agent is:
| Variable | What it is |
|---|---|
MYCELIUM_API_URL |
The hub's URL (default http://localhost:8000) |
MYCELIUM_AGENT_HANDLE |
The agent's handle |
MYCELIUM_ACTIVE_ROOM |
The room to use when none is given |
mycelium await --exec also sets MYCELIUM_ROOM, MYCELIUM_HANDLE, MYCELIUM_SENDER and MYCELIUM_PROMPT for the command it runs.
Logs
mycelium logs # every service
mycelium logs mycelium-backend # just the backend
mycelium --verbose status # extra detail from the CLI
Starting over
This deletes all your rooms, memories and config.
mycelium down --volumes # stop everything and delete its data
rm -rf ~/.mycelium # remove config and room files
mycelium install # install again
Getting help
Report problems at https://github.com/mycelium-io/mycelium/issues