Install
Run your own instance. One Go binary, one data directory.
Requirements
- Go 1.25+ — golang.org/dl
- Linux/macOS — Windows via WSL2
- A server with a public IP, if you want inbound mail
Quick Start
# Clone the repository
git clone https://github.com/micro/mu.git
cd mu
# Build and run the server
go build -o mu .
./mu --serve
--serve is the switch between the two things the binary is: with it you get
the server, without it the same binary is the CLI (mu news_list, mu agent
"..." — see Help). Forget it and you get --serve not set.
Mu runs on port 8080 by default. Visit http://localhost:8080, create the
first account — it becomes admin — and pick an AI provider.
Configuration
Nothing is required to start. Each key below switches on the feature next to it,
and every one of them can also be set at /admin/config in the browser once you are
admin, so the environment is for the things you want fixed at deploy time.
# An AI provider — one of these, for the agent, chat and summaries
export ANTHROPIC_API_KEY="your-key" # Claude, from console.anthropic.com
# export ATLAS_API_KEY="your-key" # Atlas Cloud (DeepSeek, Qwen), also images
# export OPENROUTER_API_KEY="your-key" # OpenRouter (one key, many models)
# export OPENAI_BASE_URL="http://localhost:11434/v1" # Ollama or any compatible endpoint
# Video
export YOUTUBE_API_KEY="your-key" # Google Cloud Console
# Places — falls back to OpenStreetMap without it
export GOOGLE_API_KEY="your-key" # enable Places API (New) and the Routes API
# Web search
export BRAVE_API_KEY="your-key"
# Card top-ups for credits
# export STRIPE_SECRET_KEY="sk_live_..."
# export STRIPE_PUBLISHABLE_KEY="pk_live_..."
# export STRIPE_WEBHOOK_SECRET="whsec_..."
Mu also reads a dotenv file at startup: $MU_ENV_FILE, then ~/.env, then
~/.mu/.env — the first that exists wins.
Every setting the code reads is listed under Configuration reference below.
Production Deployment
Using systemd
Create /etc/systemd/system/mu.service:
[Unit]
Description=Mu Personal AI Platform
After=network.target
[Service]
Type=simple
User=mu
WorkingDirectory=/home/mu
ExecStart=/home/mu/mu --serve
Restart=always
RestartSec=5
EnvironmentFile=/home/mu/.env
[Install]
WantedBy=multi-user.target
Then:
sudo systemctl daemon-reload
sudo systemctl enable mu
sudo systemctl start mu
Using Docker
The repository ships a Dockerfile and a docker-compose.yml, so there is
nothing to write:
git clone https://github.com/micro/mu && cd mu
docker compose up
The compose file mounts a named volume at /data and sets HOME=/data, which
is where everything under ~/.mu lands — keep that volume and you keep your
instance. Uncomment the provider you want in docker-compose.yml, or pass keys
with --env-file.
By hand, without compose:
docker build -t mu .
docker run -p 8080:8080 -v mu-data:/data --env-file .env mu
Reverse Proxy (nginx)
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Use Let’s Encrypt for free SSL certificates with Certbot.
To send and receive as your own domain:
- MX record pointing at your server.
- Port 25 inbound — or
MAIL_PORT=2525for testing. - DKIM keys, so your mail is signed and not treated as spam:
./scripts/generate-dkim-keys.sh
That prints a DKIM_PRIVATE_KEY for your environment and a TXT record to add at
<selector>._domainkey.<your-domain>, where the selector is MAIL_SELECTOR
(default default).
- SPF — a TXT record at your domain authorising your server to send.
Set MAIL_DOMAIN to the domain and restart. mail_send is account-only: an
unauthenticated caller can never send, so a paying agent cannot spend your
domain’s reputation.
Reading your mail in a mail client
Mu speaks IMAP, so the mail this instance receives can be read in whatever client is already open — Mail.app, Thunderbird, your phone — and the agent’s replies appear in the thread there.
| Server | your domain |
| Incoming (IMAP) | IMAP_PORT, 1143 by default; set it to 143 in production |
| Outgoing (SMTP) | SUBMISSION_PORT, 1587 by default; set it to 587 in production |
| Username | your Mu username, or your full address |
| Password | an access token from /token |
Mu has no password — sign-in is a passkey or a link — so an access token is what goes in the password field. That is the app-password pattern, and it has the property that matters: a client is revoked on its own without touching how you sign in. The same token is both halves; a client asks twice.
Outgoing is a separate listener from the MTA. MAIL_PORT is the server that
receives mail from the internet and authenticates nobody, which is what port 25
is for. SUBMISSION_PORT is where you send from, and it authenticates
everybody: nothing happens on it before AUTH, and the address in From must be
one your account owns, so a token is not a way to send as somebody else.
What goes out through it is the same mail the compose form sends — same
allowance, same price, same rules about who you may write to. See
service/mail/outbound.go, which is the only way mail leaves an instance.
Folders are your addresses. The inbox holds everything. Each plus-address
tag you have received mail at is a folder of its own — mail to you+research@
appears in the folder INBOX/research — so an agent’s mail can be subscribed to
on its own. Junk is what the spam filter caught, where you can see it and
disagree with it.
TLS is the proxy’s job. Nothing in Mu terminates TLS; the web server runs behind something that does, and IMAP is the same. Bind the listener to loopback so only the proxy can reach it, and never expose the plaintext port — a token would cross it in the clear.
IMAP_PORT=127.0.0.1:1143
nginx does this with the stream module, not the mail one. ngx_mail speaks
IMAP itself and wants an auth_http endpoint to tell it which backend to use
for each user; Mu authenticates its own sessions, so there is nothing for that
endpoint to decide. ngx_stream is a TCP proxy with TLS on the front, which is
exactly the missing piece.
# At the TOP LEVEL of nginx.conf — a sibling of http {}, not inside it.
# conf.d/*.conf and sites-enabled/* are both included from within http {},
# so a stream block dropped there fails to load.
stream {
upstream mu_imap {
server 127.0.0.1:1143;
}
upstream mu_submission {
server 127.0.0.1:1587;
}
server {
# Both, on a host with an AAAA record. A stream block takes no IPv6
# listener by default, so `listen 993 ssl` alone binds 0.0.0.0 and a
# client that resolves AAAA finds nothing. The web server does not
# show this because the packaged default site carries a listen [::]
# line of its own.
listen 993 ssl;
listen [::]:993 ssl;
# fullchain.pem, not cert.pem. A browser will fetch a missing
# intermediate and a mail client will not, so half a chain is a site
# that works in Firefox and fails in Gmail with the same message.
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
proxy_pass mu_imap;
# Longer than the server's own 30-minute idle timeout. nginx defaults
# to 10 minutes here, which silently drops every client sitting on
# IDLE — the mail arrives and nobody is told.
proxy_timeout 35m;
}
# Outgoing, so the client can reply. 465 is implicit TLS, the same as 993:
# this listener offers no STARTTLS, so a client told to use it on 587 would
# send the token in the clear believing otherwise.
server {
listen 465 ssl;
listen [::]:465 ssl;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
proxy_pass mu_submission;
proxy_timeout 5m;
}
}
The same certificate as the web server. On Debian and Ubuntu the module is a
separate package (apt install libnginx-mod-stream); elsewhere nginx needs
--with-stream --with-stream_ssl_module, which nginx -V will tell you.
This is 993, implicit TLS — the port every client offers first. There is no STARTTLS on 143: the server does not advertise it, so a client asked to use it there would be sending a token in the clear believing otherwise.
Check it from somewhere else. Every test run on the server itself passes
while the port is unreachable from the internet, which is how an afternoon
goes. ss -lntp showing 0.0.0.0:993 means nginx is bound, and nothing more.
What the failure looks like tells you where it is. Connection refused, at
once, means the packets arrived and nothing was listening — nginx is not up, or
not on that port. Nothing at all, until it times out, means they were dropped
before they got there: a firewall. A mail client reports both as the same
unhelpful sentence, so the distinction has to come from openssl.
Cloud firewalls are the usual culprit, because they are default-deny and
typically opened for 80, 443 and 22 alone — DigitalOcean’s Cloud Firewalls
drop rather than reject, so they produce the timeout above. Check the
provider’s rules and the host’s own (ufw status, iptables -L INPUT -n):
having both, with only one of them open, is easy to do.
To check the whole path:
printf 'a1 LOGIN you TOKEN\r\na2 LOGOUT\r\n' | openssl s_client -quiet -crlf -connect your-domain.com:993
stunnel and Traefik do the same job if nginx is not what is in front.
Folders cannot be created, renamed or deleted from the client, and a client cannot upload mail into one. Folders here follow your addresses and your mail, so there is nothing for those commands to do that would still be true a minute later.
To check the listener before pointing a real client at it,
examples/imap-client signs in, lists the folders and
prints the newest messages. It is written against
emersion/go-imap rather than anything in
this repo, so it fails the way a real client would.
Outbound deliverability
By default Mu delivers its own mail: it looks up the recipient’s MX and speaks SMTP to it. That is correct and it is not the hard part. The hard part is the reputation of the IP the packets came from — a new address with no history, no feedback loop and no bounce processing gets filed as spam by the large providers however carefully the message is signed, and nothing in the protocol fixes it from this end.
So outbound can go through a submission server instead:
export SMTP_RELAY_HOST="smtp.provider.example" # :587 assumed
export SMTP_RELAY_USER="apikey"
export SMTP_RELAY_PASS="..."
Anything that speaks submission works — this is named for the protocol, not for a provider. The message is still built here and still signed with your own DKIM key; the relay is one hop, not a rewrite. STARTTLS is required, because the credential crosses that connection.
Inbound is unchanged either way: Mu runs its own SMTP server and owns the mailbox, which is the half that matters.
Who is allowed to send you mail
This instance does not accept mail from strangers. A message gets in if any one of these is true:
| Rule | |
|---|---|
| 1 | It is a reply to something you sent — In-Reply-To or References matches a Message-ID this server generated. |
| 2 | You have written to that address before. Recorded automatically on the way out. |
| 3 | The sender’s domain is whitelisted — see below. |
| 4 | The sender’s address is verified on an account here. Somebody who proved they own a mailbox is not a stranger, whatever their domain. |
Anything else is refused with a 550, so the sender’s own mail server tells
them rather than the message disappearing.
Building your own whitelist. Set MAIL_WHITELIST to a comma-separated list
of domains:
MAIL_WHITELIST=acme.com, partner.co.uk, supplier.example
It is live — change it at /admin/config and the next message is judged by the new
list, no restart. There is also a built-in list of common company and
infrastructure domains. Consumer domains (gmail.com, outlook.com,
hotmail.com) are deliberately not on it: they are where unsolicited mail
comes from, and rule 4 already covers the case that matters — your own users
writing in from a personal address.
There used to be a fifth rule: mail addressed to support@ and nothing else got
through whatever the sender’s domain, because the point of a support address is
hearing from people you have never heard of. That also made it the one address
here that spam could reach, and a per-sender cap does nothing about a thousand
senders. The address, the page and the rule are gone.
Taking payments
Callers pay in credits, prepaid against an account. Set the STRIPE_* keys to
let people buy them by card; without those keys your instance runs with no
metering, which is usually what you want for one you run for yourself.
Costs are per operation and are set in code — see the cost block in
internal/quota/quota.go for what is charged and why.
Federation (optional)
Set MU_DOMAIN to your public domain and blog posts federate over ActivityPub —
remote servers resolve your users at /.well-known/webfinger and actor URLs
under that domain. It must match the domain you actually serve on.
Tor Hidden Service (Optional)
Mu can be accessed as a Tor hidden service (.onion) for anonymous access.
1. Install Tor
sudo apt install tor
2. Configure the hidden service
Add to /etc/tor/torrc:
HiddenServiceDir /var/lib/tor/mu/
HiddenServicePort 80 127.0.0.1:8080
Restart Tor and get your .onion address:
sudo systemctl restart tor
sudo cat /var/lib/tor/mu/hostname
3. Configure passkeys for .onion access
If you use passkeys, add the .onion origin so WebAuthn works on both domains:
export PASSKEY_EXTRA_ORIGINS="http://your-onion-address.onion"
Note: Passkeys registered on your-instance won’t work on the .onion address (WebAuthn spec limitation). Users can register separate passkeys for each origin, or use password login over Tor.
4. Nginx for .onion (optional)
If using nginx, add a server block for the .onion address:
server {
listen 80;
server_name your-onion-address.onion;
location / {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
No TLS needed — Tor provides end-to-end encryption for .onion addresses.
Data Storage
Everything is under ~/.mu/:
~/.mu/
├── data/ # accounts, sessions, posts, feeds, the search index,
│ │ # settings.json, cached cards — one file per thing
│ └── files/ # bytes stored by the files service
├── store/ # internal service state
├── keys/ # encryption key, DKIM key, the CLI's wallet seed
└── .env # optional dotenv, read at startup
Back up that directory and you have backed up the instance. It is plain JSON on
disk, so it is greppable and diffable; MU_USE_SQLITE=1 moves just the search
index into ~/.mu/data/index.db, and setting S3_* moves stored file bytes to
an object store (see Configuration reference below).
In Docker, HOME is /data, so this tree is /data/.mu on the mounted volume.
Updating
cd mu
git pull origin main
go build -o mu .
sudo systemctl restart mu
Troubleshooting
Port already in use:
# Find what's using port 8080
lsof -i :8080
Check logs:
journalctl -u mu -f
Run without building:
go run . --serve
Configuration reference
Every variable below is one the code actually reads — TestEveryConfigVarIsDocumented
checks this page against every settings.Get and os.Getenv in the source, in both
directions, so it can neither fall behind nor accumulate settings that no longer
exist. Any of them can also be set at /admin/config in the browser.
Core
| Variable | Default | What it does |
|---|---|---|
ADMIN / MU_ADMIN |
first account | Who is admin — comma-separated ids, usernames or emails |
MU_DOMAIN |
localhost |
Public domain. Used for the OAuth issuer an MCP client discovers, Stripe returns, ActivityPub actor URLs and mail. Set this if you run behind a proxy |
MU_ENV_FILE |
~/.env, then ~/.mu/.env |
A dotenv file read at startup; the first that exists wins. Settings saved at /admin/config go to ~/.mu/data/settings.json instead |
MCP_REGISTRY_PROOF |
— | Domain-ownership proof served at /.well-known/mcp-registry-auth when publishing to the MCP registry — see the MCP registry listing notes in the repository |
MU_ENCRYPTION_KEY |
— | Encrypts stored settings at rest |
INVITE_ONLY |
off | Require an invite code to sign up |
CAPTCHA_SECRET |
— | Signing key for the signup captcha |
AI provider
One of these is needed for the agent — mu setup will prompt for it. Without
one, the agent, chat and AI summaries are off and everything else works.
| Variable | What it does |
|---|---|
ANTHROPIC_API_KEY |
Claude |
ANTHROPIC_MODEL |
Override the default model |
ATLAS_API_KEY |
Atlas Cloud (DeepSeek, Qwen) — also image generation |
ATLAS_MODEL |
Override the Atlas model used when the caller did not name one (default deepseek-ai/deepseek-v4-pro) |
OPENROUTER_API_KEY |
OpenRouter — one key for Claude, GPT, Gemini and the rest of their catalogue |
OPENROUTER_MODEL |
Override the OpenRouter slug (default openai/gpt-4o-mini) |
IMAGE_MODEL |
Override the image model |
OPENAI_BASE_URL · OPENAI_API_KEY |
Any OpenAI-compatible endpoint — Ollama, vLLM, llama.cpp |
OPENAI_MODEL |
Which model to ask for on that endpoint (default gpt-4o-mini). A local server usually names its own — llama3.2, qwen2.5 — and the default will 404 there |
X402_BAZAAR |
true to advertise your priced tools to the x402 Bazaar index. Each 402 then carries a listing — tool name, description, arguments — and the facilitator catalogues it. Off by default: listing tells a third party this instance exists and what it sells, which is not a decision to inherit from an upgrade |
AGENT_NATIVE |
off falls back to the hand-rolled planner |
AGENT_NATIVE_STREAM |
off forces the streaming UI onto the planner |
Service keys
Each switches on one tool. Without the key that tool is unavailable; the rest still work.
| Variable | Tool |
|---|---|
BRAVE_API_KEY |
web_search |
YOUTUBE_API_KEY |
video_list, video_search |
GOOGLE_API_KEY |
places_search, places_nearby, places_eta — open-data fallback without it. places_eta also needs the Routes API enabled on the key, not just Places |
Texts
An SMS number, from Twilio. Without these the sms_* tools refuse and /sms
says so; nothing else is affected.
| Variable | Default | What it does |
|---|---|---|
TWILIO_ACCOUNT_SID |
— | The account SID, which starts with A-C. An API key SID (S-K…) is a credential, not an account: Twilio accepts one for sending, so a key in this slot works and looks configured, and then inbound is refused forever because a webhook signature can only be checked against the account’s own auth token |
TWILIO_AUTH_TOKEN |
— | The account’s auth token. Used to send when there is no API key, and always used to verify inbound webhooks. An API key secret will not do |
TWILIO_API_KEY · TWILIO_API_SECRET |
— | An API key to send with, so the account auth token is not spent on outbound calls. Optional, and it does not replace TWILIO_AUTH_TOKEN — signatures still need that |
TWILIO_FROM |
— | The numbers texts are sent from and received on, in E.164 (+447700900123), comma-separated. One per country you serve. The sender is chosen to match the destination — a US long code texting a UK handset is filtered by UK carriers, and a UK number texting a US handset is blocked outright, so a country with no number of its own is refused rather than sent from the wrong one |
TWILIO_MESSAGING_SERVICE_SID |
— | A Twilio Messaging Service to send through instead of picking a number here. With Geomatch enabled it chooses the sender whose country matches the handset, which is the same rule applied by the party that knows which of your numbers are registered for what. Set TWILIO_FROM as well so the page can say what a reply will come from |
SMS_COUNTRIES |
1,44,353,33,49,34,39,31 |
Country codes this instance will text, comma-separated. An allowlist rather than a blocklist: a text to a premium range can cost fifty times what one to a mobile does, and those ranges are where revenue-share fraud lives |
SMS_DAILY_LIMIT |
5 |
Messages one account may send in a day, on top of the per-message price. It is limit_env on sms_send in quota.json, where the number lives. Set it to 0 to stop sending entirely — that is the kill switch, and it is the same setting rather than a second one because an operator reaching for it is in a hurry |
SMS_NEW_ACCOUNT_LIMIT |
3 |
The same cap for an account less than a day old. Signing up is free and takes a minute, so this is the only thing between a script and the full allowance |
SMS_KNOWN_ONLY |
off | Restrict sending to numbers the caller already knows — someone in their contacts, a number they verified as their own, or one that texted them first. Off, because contacts_add takes any number and defeats it in one call, and because it stopped an agent doing the ordinary thing. On, it is a real brake for an instance that wants one |
SMS_VERIFY_INBOUND |
on | Require an arriving message to carry a valid Twilio signature. Turn it off if this instance authenticates with an API key, because then there is no account auth token and nothing a signature can be checked against — the cost is that anybody who knows the webhook URL can write into somebody’s message history and opt numbers out |
SMS_DEFAULT_COUNTRY |
— | Country code assumed for a number written without one. Unset, a number with no + is refused rather than guessed |
Senders have to be registered before they will deliver. In the US, an
unregistered long code is blocked by every major carrier: either a toll-free
number with toll-free verification (free, reviewed in days, two-way, the
shortest path for low volume) or a 10DLC long code with a brand and campaign
registered through The Campaign Registry. In the UK, use a virtual mobile
number (+447…) rather than an alphanumeric sender ID — an alphanumeric sender
cannot receive, which means no replies and no way for anyone to text STOP, and
US carriers reject alphanumeric senders outright.
| TWILIO_WEBHOOK_URL | — | The inbound webhook address exactly as configured on the number. Only needed if the signature check is failing: it covers the URL Twilio called, which behind a proxy is not the URL this process sees, and a mismatch drops every inbound message while Twilio reports it as 11200 |
Point each number’s inbound webhook at https://<your domain>/sms/webhook. The
request is verified against TWILIO_AUTH_TOKEN, so nothing else needs opening
up, and MU_DOMAIN has to match what Twilio calls or the signature will not
check out.
File storage
Uploaded files and archived images go to the local disk by default, under
~/.mu/data. On a hosted instance that is usually the wrong place: the volume
is small, is not replicated, and goes when the machine does. Set these and they
go to any S3-compatible bucket instead — DigitalOcean Spaces, Cloudflare R2,
Backblaze B2, MinIO, S3.
| Variable | Default | What it does |
|---|---|---|
S3_ENDPOINT |
— | Bucket endpoint, e.g. https://lon1.digitaloceanspaces.com. Unset means the local disk |
S3_BUCKET |
— | Bucket name |
S3_ACCESS_KEY · S3_SECRET_KEY |
— | Credentials |
S3_REGION |
us-east-1 |
Region for the signature. DigitalOcean uses the datacentre slug, e.g. lon1 |
S3_ENDPOINT and S3_BUCKET must both be set, with both credentials. Anything
less is a misconfiguration: it is logged and the instance keeps using the disk
rather than failing.
Switching an instance that already holds files is safe. New writes go to the bucket, and a read that misses there falls back to the disk, so files stored before the change keep working with no migration. Copy them across at your leisure; the fallback stops mattering once you have.
Keep the bucket private. Files are served through Mu, which checks who is asking — a public bucket would let anyone holding an object URL route around that.
| Variable | Default | What it does |
|---|---|---|
MAIL_DOMAIN |
— | The domain you send and receive as |
MAIL_PORT |
2525 |
SMTP listener — 25 in production, off to have none |
IMAP_PORT |
1143 |
IMAP listener — 143 in production, off to have none. See Reading your mail in a mail client |
SUBMISSION_PORT |
1587 |
SMTP submission, so a mail client can send — 587 in production, off to have none |
MAIL_SELECTOR |
default |
DKIM selector, the <selector>._domainkey DNS record |
DKIM_PRIVATE_KEY |
— | DKIM signing key |
SMTP_RELAY_HOST |
— | Hand outbound mail to a submission server instead of delivering it to the recipient’s MX. host or host:port, 587 assumed. See Outbound deliverability |
SMTP_RELAY_USER |
— | Username for the relay. No username means no AUTH |
SMTP_RELAY_PASS |
— | Password for the relay |
MAIL_WHITELIST |
— | Domains you accept mail from, comma separated: acme.com, partner.co.uk. Merged with a built-in list of company and infrastructure domains; consumer domains are deliberately absent. Live — no restart |
Notifications
Mail, briefings and answers can turn up on a phone with the page closed. Nothing to configure: the first time somebody turns it on, this instance mints its own signing key and keeps it.
| Variable | Default | What it does |
|---|---|---|
VAPID_PRIVATE_KEY |
minted on first use | The key that signs push requests, base64url. Set it only to move an instance without invalidating what people have already subscribed — a browser binds its subscription to the public half, so a new key silently stops every existing device receiving anything |
The payload is encrypted end to end (RFC 8291): the push service — Google’s, Apple’s, Mozilla’s — forwards bytes it cannot read. It does learn that a notification went to a device, and when.
Turning it on is a button on /account, per device, and the browser asks before
anything is stored. It needs HTTPS: a service worker will not register over
plain HTTP, except on localhost.
The daily briefing
| Variable | Default | What it does |
|---|
DNS records are above, and Who is allowed to send you mail is the whole inbound rule.
Social
| Variable | Default | What it does |
|---|---|---|
SOCIAL_ATPROTO |
off | true to watch the open social network — Bluesky’s public firehose — for posts worth surfacing on /social |
Off unless you turn it on. Everything else in Mu works with no configuration; this one does not, because pulling strangers’ posts into your instance is a decision about what you are willing to publish, and it is yours to make.
No key and no account: the firehose is public JSON over a websocket. What arrives is about three million posts a day, so almost all of the work is refusing them — English, not a reply, long enough to stand alone, pointing at something, in one of the categories the news is already sorted by, and not an advert or a repost bot. What survives is scored, cut to one per category and one per author, and then read by your model, which picks at most three. It is allowed to pick none.
It does not hold the connection open. Ninety seconds every fifteen minutes is enough to find far more than three worth publishing, and holding it open the rest of the time costs 2.6 GB a day to fill a buffer that gets thrown away. Four fifths of what does arrive is refused on the raw bytes, before it reaches a JSON parser. Budget roughly 150 MB a day and one model call every fifteen minutes.
Without a model configured the shortlist is published in score order, which works but is noticeably worse — the arithmetic cannot tell a news story from a press release, and both look identical to it.
Channels
| Variable | What it does |
|---|
Sign-in
| Variable | What it does |
|---|---|
GOOGLE_CLIENT_ID · GOOGLE_CLIENT_SECRET |
Google sign-in, and the calendar connection below |
GOOGLE_REDIRECT_URI |
Defaults to <your-origin>/oauth2/callback |
The same credentials let a signed-in person attach their Google Calendar, so
events_free counts the week they actually have rather than only what Mu
scheduled. Nobody is asked at signup — the ask appears on /events, and in the
agent’s reply when it had to answer from one calendar.
Two things must be true in the Google Cloud project for it to work: the
Google Calendar API enabled, and .../auth/calendar.readonly listed on the
OAuth consent screen. That scope is sensitive, so a public app needs Google’s
verification before anyone outside your test users can grant it. Read-only is
deliberate — Mu never writes to a calendar it does not own.
| PASSKEY_ORIGIN · PASSKEY_RP_ID · PASSKEY_EXTRA_ORIGINS | WebAuthn — derived from the request when unset |
Payments
Callers pay in credits. STRIPE_* is the one that matters: set those keys and
people can buy credits by card. The X402_* and chain variables configure
stablecoin settlement, which funds credits — the way in is still MCP with a
token.
| Variable | What it does |
|---|---|
X402_PAY_TO |
Your wallet address — receives x402 payments |
X402_NETWORK · X402_VERSION |
The advertised pair. Default eip155:8453 + 2. CDP settles base+1 too, and that pair works — but the discovery index carries only v2 entries, so a v1 server is payable and unfindable. Both are live: change them at /admin/config and the next request uses them |
X402_ASSETS |
Accepted tokens (default USDC) |
TFL_APP_KEY |
Optional. Transit works with no key at all — this only raises TfL’s rate limit, and one is free to register at api-portal.tfl.gov.uk |
TRANSIT_FEEDS |
Optional. Published timetables to load, comma separated, named by agency or place: reading buses, bart, vbb. Matched against the Mobility Database catalogue, which lists about 1,160 keyless feeds. Nothing is downloaded that is not named here — a feed is tens of megabytes. Each is checked once a day and only re-fetched when it has actually changed, and a feed that fails to download or build leaves the previous one serving |
BODS_API_KEY |
Optional. Bus Open Data Service key, free at data.bus-data.dft.gov.uk. Live bus positions across England, which is what transit_buses answers from. Without it transit still has stops and timetables; it just cannot say where anything is |
LDBWS_TOKEN |
Optional. National Rail Live Departure Boards token, free at realtime.nationalrail.co.uk. Powers transit_trains — the board at any British station. Not the Darwin real-time feed, which is a Kafka consumer group and a different kind of program: this is request in, board out, and a restart loses nothing |
OS_MAPS_KEY |
Optional. Ordnance Survey Data Hub key, for /tiles — the basemap under anything spatial. Free tier at osdatahub.os.uk. Britain only. Without it the service still serves every tile this instance has already fetched, so a lapsed key degrades to the region you have already used rather than to nothing. Tiles are free to callers; what bounds them is TILE_FETCH_PER_HOUR |
TILE_FETCH_PER_HOUR |
Optional, default 2000. How many tiles one account may make this instance fetch from Ordnance Survey in an hour. Tiles already held are served without limit and without a session, because serving one again costs nothing — this bounds only what is spent upstream. Raise it to seed a region on purpose |
X402_SERVERS |
Other MCP servers this instance may pay, as name=url — read by the outbound client, which no tool currently exposes |
CDP_API_KEY_ID · CDP_API_KEY_SECRET |
Coinbase facilitator credentials |
STRIPE_SECRET_KEY · STRIPE_PUBLISHABLE_KEY · STRIPE_WEBHOOK_SECRET |
Card top-ups for credits. Point the endpoint at https://<your domain>/stripe/webhook and subscribe it to checkout.session.completed. It is belt and braces rather than the only route: the return from Stripe settles a purchase too, so a webhook that is missing, misconfigured or signed with the wrong secret no longer means the card is charged and nothing happens |
BASE_RPC_URL · TRADE_CHAIN · TRADE_RPC_URL |
On-chain reads |
The webhook used to be at /wallet/stripe/webhook, and that path still answers
so an instance upgrading does not lose a top-up between the deploy and the
dashboard edit. Move it when convenient; the old one goes away once nothing is
arriving there. It is named for Stripe rather than for whichever page shows a
balance because a webhook URL is a contract with somebody outside this process:
it is configured once, possibly by somebody who has since left, and it should
not need changing because we rearranged our own routes.
Prices and limits
Prices are data, not code. They live in quota.json at the top of the repo,
embedded into the binary by main.go: one entry per operation, with its cost in
credits, the label the cost tables show, and the environment variable that
overrides it.
Three ways to change one, in increasing order of precedence:
- Edit
quota.jsonand rebuild. - Drop a
quota.jsonin the data directory (~/.mu/data/quota.json). It is merged entry by entry, so a file naming one operation changes that one and leaves the rest alone — no restart needed if you call the reload. - Set the variable named on the entry —
CREDIT_COST_SEARCH=2,CREDIT_COST_IMAGE=20. This is the container-friendly one.
An override of 0 is ignored, because an unset variable and one set to "0"
look the same to a container and a price silently dropping to free is the wrong
way to fail. Make something free in the file.
The full list is on /tools, which renders from that same file, so this page does not repeat twenty-six rows.
| Variable | Default | What it does |
|---|---|---|
POST_LIMIT_PER_HOUR · NEW_POST_LIMIT_PER_HOUR |
— | Posting rate limit, and the tighter one for new accounts |
VIDEO_SEARCH_PER_HOUR |
20 | YouTube searches one account may run per hour |
VIDEO_SEARCH_PER_DAY |
80 | YouTube searches this instance may run per day, kept under the API’s own quota |
SIGNUP_MAX_PER_IP · SIGNUP_WINDOW_HOURS |
— | Signups allowed per IP, and the window |
GUEST_MAX_PER_IP · GUEST_WINDOW_MINUTES |
120 · 60 | Free tool calls an unauthenticated caller may make per IP. Credits price what a call costs; this is what stops a loop, since a free call is charged nothing |
FREE_TURNS |
10 |
Exchanges somebody gets by email before they are asked to sign up. Writing to agent@ from an address this instance has never seen opens an unclaimed account — no password, holding the conversation — and this is what it may spend. When it runs out the answer still goes, followed by one mail with a sign-up link that claims the account and keeps the conversation. Nothing user-facing quotes the number, so it stays yours to change |
TRIAL_DAILY_TOTAL |
500 |
Free email exchanges this instance will give away in a day, across everybody. An allowance per sender is unbounded in aggregate, so this is the ceiling that makes it a budget rather than an open tab. Set it to 0 on an instance you run for yourself |
X402_FACILITATOR_URL |
Coinbase | x402 facilitator to settle through |
Runtime
| Variable | Default | What it does |
|---|---|---|
MU_REGISTRY |
in-process | mdns puts services on the local network — note it announces every service this process hosts |
MU_ADVERTISE |
loopback | Address to advertise when the registry is networked |
MU_USE_SQLITE |
— | SQLite with FTS5 for the search index, instead of the file store |
MU_SOURCE_DIR |
— | Source tree, for the admin source viewer |
MCP_GATEWAY_ADDR |
— | Run go-micro’s MCP gateway on its own port |
PUBLIC_URL · APP_URL |
— | Public origin, when it can’t be derived |
TOR_ONION |
— | Onion address, shown in the footer |
NOTES |
on | Mu posts its own story to its own blog on a low cadence; off disables |
CLI
| Variable | What it does |
|---|---|
MU_TOKEN |
Personal Access Token |
MU_URL |
Instance to talk to — defaults to the hosted one |
MU_NO_COLOR |
Disable colour output |
Object storage and generation policy
| Variable | Default | What it does |
|---|---|---|
S3_BUCKET |
— | Bucket for off-box backups. A snapshot on the same disk survives a bad write and not the disk; this is the copy that survives losing the machine. Later it is also where files and generated images belong, which is why these are named for the storage rather than for the backup |
S3_REGION |
us-east-1 |
Region of the bucket |
S3_ENDPOINT |
— | For anything that is not AWS — R2, Backblaze, MinIO. Leave empty for AWS |
S3_ACCESS_KEY_ID |
— | Access key. Give it write access to this bucket and nothing else: it is on a machine that runs model output |
S3_SECRET_ACCESS_KEY |
— | Secret key |
S3_PREFIX |
— | Optional path inside the bucket, so one bucket can hold several instances |
BACKUP_S3 |
false |
Whether backups are pushed to the bucket above |
GENERATE_ADULT |
false |
Whether this instance will generate explicit sexual content. Off by default. It has no effect on sexual content involving children, which is refused always and is not a setting — see internal/safety |


