Configuration
muaz keeps everything under ~/.muaz/. Authored content (agents, skills,
pipelines, prompts, commands) lives inside plugins —
your own in the local workspace and shared ones under plugins/. Read-only
defaults ship inside the binary itself. The root holds only config files and
runtime state.
Directory layout
Section titled “Directory layout”~/.muaz/├── config.yaml # global settings (default agent, tool access, web tools, telemetry)├── models.yaml # provider profiles + global default + per-agent bindings (one file)├── plugins.yaml # per-plugin state (enabled / priority / trusted / source / signature pin)├── mcp.json # global MCP server registry├── secrets.json # chmod-600 secret store (only when the OS keychain is unavailable)├── registry-state.json # chmod-600 newest plugin-index serial seen per registry (anti-rollback)├── gateway-token # chmod-600 persisted gateway token├── plugins/│ ├── local/ # YOUR workspace: agents/ skills/ pipelines/ prompts/ commands/│ │ └── agents/│ └── <installed>/ # plugins you install (e.g. auto-sde, the `coding` agent)│ └── config.yaml # chmod-600 values for that plugin's settings (secrets excluded)├── sessions/ # saved sessions + index.json (chmod 600)├── memory/ # cross-session notebooks: global.md + agents/ + projects/├── audit/ # append-only security audit log (audit.jsonl)├── cache/ # content-addressed mirrors of shipped built-ins└── runtimes/ # on-demand Node.js / uv runtimes for MCP serversThe preset built-ins (the starter and codebase agents, the code-review
step agents, and the pipeline itself) are compiled into the binary rather than
stored here. They
are laid down under cache/ once per version, addressed by content, so an
upgrade produces a fresh copy and a restart writes nothing at all.
Because those copies are addressed by content, an upgrade abandons the previous
one. muaz sweeps cache/ at startup, collecting mirrors nothing references any
more and that are at least a day old — old enough that another muaz process
cannot still be running out of one. muaz doctor reports what the cache
currently holds.
Everything above except secrets.json, audit/, gateway-token and
runtimes/ is addressed as a document rather than a path, which is what lets
a hosted deployment keep it in a shared database instead of on a disk. The
exceptions are deliberate: credentials never enter shared storage, downloaded
runtimes are a machine-specific cache, and the gateway token is a local
convenience a server passes as MUAZ_GATEWAY_TOKEN instead.
MCP OAuth tokens used to live in ~/.muaz/mcp_auth.json. They are
credentials, so they moved into the secret store — the OS keychain where there
is one. There is no migration: run muaz mcp connect <server> once to
re-authorize.
Global config (~/.muaz/config.yaml)
Section titled “Global config (~/.muaz/config.yaml)”# The agent used when no --agent flag is passeddefault_agent: starter
# Provider/model selection lives in models.yaml (see below), not here.
# Plugin registries, consulted in order (first hit wins). Omit for the official# muaz registry. Each carries its OWN signing keys, so an internal company# registry is trusted only for its own plugins. See Concepts → Plugins.# plugin_registries:# - name: acme-internal# index_url: "https://plugins.acme.internal/index.json"# trusted_keys: ["RW<your minisign public key>"]# require_signed: true # refuse unsigned plugins (default)# allow_plaintext: false # true only for an http:// intranet host
# Global tool access — one level per tool (off | ask | auto). Per-agent `tools:`# blocks override these; `blocked` forces a tool Off everywhere and cannot be# re-enabled by an agent. A tool value is a bare level, or `{ level, allow_when }`# where allow_when auto-approves specific calls: "program=NAME" (first shell# token), "dir=PATH" (path containment), or a glob over the primary argument.tools: default: ask # fallback for any tool without a more specific setting built_in: read_file: auto # read-only tools are workspace-confined, so auto list_dir: auto search: auto edit_file: ask write_file: ask patch_file: ask shell: ask fetch_webpage: ask web_search: ask blocked: [] # e.g. [shell] to hard-disable shell for every agentmuaz config show # print the current config.yamlmuaz config edit # edit config fields interactivelyThe tool-access model (levels, allow_when, resolution order, MCP servers) is
explained on Tools & approval;
provider binding on Providers & models.
Web tool guardrails
Section titled “Web tool guardrails”web_tools bounds the network tools (fetch_webpage, web_search). It applies
globally; a per-agent web_tools block merges field by field over it (fields the
agent sets win, omitted fields inherit the global value). All fields are optional
and shown here with their defaults.
web_tools: allow_private_hosts: false # reach localhost/private addresses (off blocks SSRF) https_only: true # reject plain http:// URLs (set false to allow http) allowed_urls: [] # glob allowlist, e.g. "https://*.example.com/*" denied_urls: [] # glob denylist (wins over the allowlist) max_response_bytes: 2097152 # download cap before truncation (2 MiB) max_content_chars: 50000 # character cap on returned content default_format: markdown # fetch_webpage default output: markdown | text user_agent: "..." # override the User-Agent (defaults to a desktop Chrome string)The stronger lock is network, set at the top level of config.yaml:
network: none # full (default) | nonenone strips the web tools from every agent and refuses HTTP MCP servers
install-wide. It is a floor, not a default: an agent’s own network: none
tightens it for that agent, but no agent can re-open what the install closed.
Web search backend
Section titled “Web search backend”web_search queries DuckDuckGo by default (a keyless HTML scrape — nothing to
configure). To use a different backend, set web_search in config.yaml. This is
install-wide (endpoint + credentials), not per-agent; the web_tools guardrails
above still apply to the backend request. Configurable in the browser UI’s
Settings → Web search section.
web_search: provider: brave # duckduckgo | brave | searxng api_key: ${BRAVE_SEARCH_API_KEY} # Brave: reference a secret (never inline the token)web_search: provider: searxng # a self-hosted SearXNG instance base_url: https://searx.example.org # must have the JSON format enabledBrave needs a subscription token: store its value as a secret (Settings → Web
search, or PUT /api/secrets) and reference it with ${VAR} — the value never
lands in config.yaml. SearXNG needs its instance to expose the JSON output
format; if the instance is on a private address, also set
web_tools.allow_private_hosts: true.
Models (~/.muaz/models.yaml)
Section titled “Models (~/.muaz/models.yaml)”Everything about model routing lives in this one file: named connection
profiles, the global default, and per-agent agents overrides. Agents are
provider-agnostic, so this is the single place models are chosen. It is re-read on
every resolution, so edits take effect without a restart. Edit directly or use the
browser UI’s Providers + Agents pages.
profiles: anthropic-main: type: anthropic # native Anthropic provider (prompt caching on) default_model: claude-sonnet-5 api_key: ${ANTHROPIC_API_KEY} bedrock-work: type: bedrock region: us-east-1 default_model: anthropic.claude-3-5-sonnet-20241022-v2:0 profile: work
# Global default: every agent that isn't bound below uses this profile.default: anthropic-main
# Per-agent overrides (agent name, or @plugin/agent), with an optional model swap.agents: coding: profile: bedrock-workmuaz models list # profiles, the default, and per-agent bindingsmuaz models set <name> --type <type> --model <model> [--base-url … --api-key …]muaz models set-model <name> <model> # change just a profile's modelmuaz models default <profile> # set the global defaultmuaz models bind <agent> --profile <p> [--model <m>]muaz models unbind <agent>muaz models remove <name>Keep the api_key: ${ANTHROPIC_API_KEY} reference here and store the actual key
with muaz (OS keychain, chmod 600 secrets.json fallback) via muaz auth
(or /auth in the REPL) or the UI’s Providers page — ${VAR} resolves from
the store first, then the environment. See
API keys & authentication
and Providers & models for the full reference.
MCP registry (~/.muaz/mcp.json)
Section titled “MCP registry (~/.muaz/mcp.json)”Global MCP server definitions. Plugins may also declare their own MCP servers in a
plugin-level mcp.json.
[ { "transport": "stdio", "name": "filesystem", "command": ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"] }, { "transport": "http", "name": "my-api", "url": "http://localhost:3000/mcp" }]muaz mcp install <spec> # register an npm package, uvx package, or HTTP URLmuaz mcp list # list registered serversmuaz mcp remove <name> # remove a server by nameUse /mcp inside the REPL to see live MCP connection status.
A stdio server starts on its first tool call, not at session open, and is closed
again once it has gone unused — see
When a connector actually starts.
The window is set in config.yaml:
mcp_idle_timeout_secs: 600 # default; 0 keeps servers running for the sessionPlugins (~/.muaz/plugins.yaml)
Section titled “Plugins (~/.muaz/plugins.yaml)”Per-plugin enabled/priority/trusted state. Managed by muaz plugins … — see
Plugins.
Custom slash commands
Section titled “Custom slash commands”Drop a YAML file in your local workspace’s commands/ directory
(~/.muaz/plugins/local/commands/) to add a custom / command. It appears in
/help alongside the built-ins.
name: deploydescription: "Deploy to staging"command: "cd ~/project && make deploy-staging"The command runs via sh -c, with these environment variables available:
| Variable | Value |
|---|---|
MUAZ_SESSION_ID | Current session UUID |
MUAZ_AGENT_NAME | Current agent name |
MUAZ_MODEL | Current model identifier |
Deployment settings
Section titled “Deployment settings”Everything above describes a personal install, where you own config.yaml and
nothing overrides you. A shared muaz — a team server, a container, one
sandbox per user — needs two more things: somewhere other than ~/.muaz to keep
state, and a way to fix settings the person using it must not be able to relax.
Both come from the process environment, read once at startup. With none of them set, muaz is exactly the local install described above.
Where state lives
Section titled “Where state lives”| Variable | Meaning |
|---|---|
MUAZ_STORE | Backend: unset or fs (~/.muaz), fs:<dir>, an absolute path, memory, mongodb://…, dynamodb://<table> |
MUAZ_SCOPE | Tenant partition, default local. One process serves one scope |
MUAZ_RUNTIME_DIR | Where the managed MCP runtimes (Node.js, uv) live. Default <state>/runtimes; point every scope at one shared copy, e.g. a runtime baked into an image |
memory keeps nothing past the process — the right choice for a throwaway
sandbox that pulls its work from elsewhere.
Scope is the tenancy boundary, and it is part of the key at rest rather than a
filter applied afterwards — so one shared store behind many single-user
processes cannot leak between them. Under a filesystem store, scope local maps
to the directory itself (which is what keeps your laptop’s ~/.muaz exactly
where it is) and any other scope gets a sibling directory of its own, along with
its own secrets and audit log.
MUAZ_STORE=fs:/srv/muaz MUAZ_SCOPE=user:alice muaz gateway --host 0.0.0.0Database backends
Section titled “Database backends”MongoDB and DynamoDB are opt-in, and the binaries on the releases page
deliberately carry neither driver — a personal install has no use for them, and
leaving them out keeps it small and dependency-free. Naming a backend the binary
was not built with tells you exactly that, rather than failing vaguely; run
muaz doctor to see which stores yours supports. Database-enabled builds are
produced for server deployments.
With one of them, sessions and the audit log stop being files: each backend
brings a native session repository and audit sink alongside the object store.
muaz doctor reports all three, which is worth checking on a container — it is
how you find out whether a transcript will survive a restart.
MongoDB. The database comes from the connection string, defaulting to
muaz. muaz creates the indexes it queries through, and refuses an
unencrypted connection to a remote host — transcripts and agent configuration
would otherwise cross the network in the clear. Add tls=true, or set
MUAZ_STORE_ALLOW_PLAINTEXT=1 if the link is already private. Encryption at
rest is the cluster’s job (Atlas and Enterprise provide it; a bare community
mongod does not).
MUAZ_STORE='mongodb://muaz:pw@db.internal:27017/muaz?tls=true' \MUAZ_SCOPE=user:alice muaz gateway --host 0.0.0.0DynamoDB. One table, single-table design. Region, credentials and any
endpoint override come from the standard AWS chain (AWS_REGION,
AWS_ENDPOINT_URL_DYNAMODB, the credential provider chain) — the same chain the
Bedrock provider uses. Tables are encrypted at rest by the service.
muaz does not create the table: a table with a secondary index is infrastructure, and an application that provisions one is an application that provisions one in the wrong account. Create it once —
aws dynamodb create-table --table-name muaz \ --billing-mode PAY_PER_REQUEST \ --attribute-definitions \ AttributeName=PK,AttributeType=S AttributeName=SK,AttributeType=S \ AttributeName=GSI1PK,AttributeType=S AttributeName=GSI1SK,AttributeType=S \ --key-schema AttributeName=PK,KeyType=HASH AttributeName=SK,KeyType=RANGE \ --global-secondary-indexes '[{ "IndexName": "gsi1", "KeySchema": [{"AttributeName":"GSI1PK","KeyType":"HASH"}, {"AttributeName":"GSI1SK","KeyType":"RANGE"}], "Projection": {"ProjectionType":"ALL"}}]'— then point muaz at it:
MUAZ_STORE=dynamodb://muaz MUAZ_SCOPE=user:alice muaz gateway --host 0.0.0.0The partition key is the scope, so a query for one tenant cannot reach another’s
items. The sparse gsi1 index carries session metadata only, which is what makes
listing sessions by recency a query rather than a maintained index document.
One limit worth knowing: DynamoDB caps an item at 400 KB, and muaz stores each object as one item, so a plugin file larger than ~380 KB is refused with a message saying so. Conversation messages are exempt — a tool can return a megabyte of build output and nobody chose that size, so a large message is split across items and rejoined on load.
Secrets on a database backend
Section titled “Secrets on a database backend”A microVM or container has no OS keychain, so provider API keys need somewhere to live. muaz keeps them in the same database — but never in the object store, and never as plaintext. Set a master key and each secret is stored as AES-256-GCM ciphertext under it, so a database dump yields ciphertext and turning it back into a key needs the master key, not merely read access to the table.
| Variable | Meaning |
|---|---|
MUAZ_MASTER_KEY | Base64-encoded 32-byte key that encrypts stored secrets. Set it to enable the database secret store; unset falls back to the local keychain |
# Generate one, then inject it the same way you inject MUAZ_STORE.openssl rand -base64 32
MUAZ_STORE=dynamodb://muaz MUAZ_SCOPE=user:alice \MUAZ_MASTER_KEY='…base64…' muaz gateway --host 0.0.0.0The key is the deployment’s to hold and rotate, and it must live somewhere other than the database it protects — an injected environment variable or a mounted secret, not a row in the same table. There is no per-secret cost: one master key protects every user’s keys, which is what makes this the right shape at scale. (Rotation to a new master key, and a KMS-backed key that never leaves the KMS, are planned; the stored format already carries the scheme and key fingerprint to support them.)
Pre-installing the MCP runtimes
Section titled “Pre-installing the MCP runtimes”muaz downloads its own pinned Node.js and uv the first time an MCP server needs them. On a long-lived install that happens once. On a scale-to-zero deployment — a fresh instance per request, thrown away when idle — that first-connect download becomes a cold-start cost paid on every wake.
Fetch them ahead of time instead:
muaz runtime install # both, or name one: node | uvmuaz runtime list # what is installed, and the pinned versionsThey land in <state>/runtimes by default. Point MUAZ_RUNTIME_DIR at a
read-only path outside per-user state to have every scope share one copy — the
runtimes are ~250 MB (Node about 200, uv about 50), so sharing them matters once
there is more than one scope on a machine. muaz doctor then reports the
runtimes as already installed and no download happens on the request path.
One caveat for remote (Streamable HTTP) MCP servers that require interactive
OAuth: the login redirect returns to the container’s own 127.0.0.1, which your
browser cannot reach, so that flow does not complete in a headless deployment
yet. stdio MCP servers (the common case) are unaffected, and a remote server
authenticated with a pre-provisioned token works. Routing the redirect back
through the gateway’s own origin is the planned fix.
Deleting a tenant’s data
Section titled “Deleting a tenant’s data”muaz purge-scope permanently erases everything stored for the current scope —
every object, session, audit record, and secret. It is the right-to-be-forgotten
operation a hosted deployment runs when a user is removed:
MUAZ_STORE=dynamodb://muaz MUAZ_SCOPE=user:alice muaz purge-scope --yesIt is irreversible, so without --yes it asks you to retype the scope name, and
it refuses to run non-interactively unless --yes is passed. There is no web-UI
equivalent by design.
Settings a deployment fixes
Section titled “Settings a deployment fixes”Point MUAZ_CONFIG_FILE at a YAML file to pin settings the user cannot loosen:
network: noneworkspace_root: /workspacetools: blocked: [shell]web_tools: allowlist_only: true allowed_urls: ["https://*.acme.internal/*"]plugin_registries: - name: acme index_url: "https://plugins.acme.internal/index.json" trusted_keys: ["RW<your minisign public key>"]The file accepts these settings and nothing else — anything else is an error, not a line silently ignored:
| Setting | How it composes with the user’s |
|---|---|
plugin_registries | Replaces it — adding a registry admits unsanctioned code |
tools.blocked | Union — the user may block more, never fewer |
web_tools.allowlist_only | OR — the user may turn it on, never off |
web_tools.allowed_urls | Replaces it — a user-added pattern widens egress |
web_tools.denied_urls | Union — a denial always tightens |
workspace_root | Containment — the user may narrow to a subdirectory, never step outside |
network | Strictest wins — none cannot be re-opened |
The rule throughout is that the user can always make things stricter and
never looser. Their own config.yaml still owns everything else.
For the common container knobs there are equivalents that need no file:
MUAZ_NETWORK, MUAZ_ALLOWLIST_ONLY, MUAZ_ALLOWED_URLS, MUAZ_DENIED_URLS,
MUAZ_BLOCKED_TOOLS (comma-separated lists; an empty value is an empty list,
not “unset”) and MUAZ_WORKSPACE_ROOT. These apply on top of
MUAZ_CONFIG_FILE.
Pinning workspace_root is what stops a browser session pointing itself
anywhere in the container: sessions resolve to the pin, a directory inside it is
still the user’s to choose, and a working_dir outside it is a 400 naming the
pin. Both container images ship a /workspace for this, and hosted sandboxes
pin it. Treat it as scratch unless you mount a volume there — see
Working directory.
One more environment variable is a declaration rather than a pin, because nothing can detect it:
| Variable | Meaning |
|---|---|
MUAZ_LOCAL_MODEL_ACCESS | Whether this process can reach a model server on the user’s own machine: direct (default), host_gateway (loopback is the container; the host is host.docker.internal — set in both images), or none (unreachable — set for hosted sandboxes) |
It changes nothing about what runs. It decides which
provider presets are
offered and what muaz doctor says about a loopback endpoint that failed.
Pinning allowlist_only: true without also pinning allowed_urls is refused at
startup: it would leave the user writing the allowlist, so the lock would be
decorative. Pin allowed_urls: [] to block all egress.
Pins are enforced when config is loaded, not when it is saved. A pinned
value is re-applied on every load, so editing config.yaml past one — by hand,
by CLI, or through the API — simply has no effect on what runs. Saving a
loosened value is additionally refused, with a message naming the setting, so
you find out at the point you act. muaz config show, muaz doctor and the
browser UI’s Settings page all show which settings are fixed and where they came
from.