Skip to content

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.

~/.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 servers

The 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.

# The agent used when no --agent flag is passed
default_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 agent
Terminal window
muaz config show # print the current config.yaml
muaz config edit # edit config fields interactively

The tool-access model (levels, allow_when, resolution order, MCP servers) is explained on Tools & approval; provider binding on Providers & models.

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) | none

none 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 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 enabled

Brave 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.

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-work
Terminal window
muaz models list # profiles, the default, and per-agent bindings
muaz models set <name> --type <type> --model <model> [--base-url … --api-key …]
muaz models set-model <name> <model> # change just a profile's model
muaz models default <profile> # set the global default
muaz 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.

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"
}
]
Terminal window
muaz mcp install <spec> # register an npm package, uvx package, or HTTP URL
muaz mcp list # list registered servers
muaz mcp remove <name> # remove a server by name

Use /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 session

Per-plugin enabled/priority/trusted state. Managed by muaz plugins … — see Plugins.

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.

~/.muaz/plugins/local/commands/deploy.yaml
name: deploy
description: "Deploy to staging"
command: "cd ~/project && make deploy-staging"

The command runs via sh -c, with these environment variables available:

VariableValue
MUAZ_SESSION_IDCurrent session UUID
MUAZ_AGENT_NAMECurrent agent name
MUAZ_MODELCurrent model identifier

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.

VariableMeaning
MUAZ_STOREBackend: unset or fs (~/.muaz), fs:<dir>, an absolute path, memory, mongodb://…, dynamodb://<table>
MUAZ_SCOPETenant partition, default local. One process serves one scope
MUAZ_RUNTIME_DIRWhere 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.

Terminal window
MUAZ_STORE=fs:/srv/muaz MUAZ_SCOPE=user:alice muaz gateway --host 0.0.0.0

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).

Terminal window
MUAZ_STORE='mongodb://muaz:pw@db.internal:27017/muaz?tls=true' \
MUAZ_SCOPE=user:alice muaz gateway --host 0.0.0.0

DynamoDB. 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 —

Terminal window
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:

Terminal window
MUAZ_STORE=dynamodb://muaz MUAZ_SCOPE=user:alice muaz gateway --host 0.0.0.0

The 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.

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.

VariableMeaning
MUAZ_MASTER_KEYBase64-encoded 32-byte key that encrypts stored secrets. Set it to enable the database secret store; unset falls back to the local keychain
Terminal window
# 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.0

The 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.)

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:

Terminal window
muaz runtime install # both, or name one: node | uv
muaz runtime list # what is installed, and the pinned versions

They 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.

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:

Terminal window
MUAZ_STORE=dynamodb://muaz MUAZ_SCOPE=user:alice muaz purge-scope --yes

It 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.

Point MUAZ_CONFIG_FILE at a YAML file to pin settings the user cannot loosen:

/etc/muaz/instance.yaml
network: none
workspace_root: /workspace
tools:
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:

SettingHow it composes with the user’s
plugin_registriesReplaces it — adding a registry admits unsanctioned code
tools.blockedUnion — the user may block more, never fewer
web_tools.allowlist_onlyOR — the user may turn it on, never off
web_tools.allowed_urlsReplaces it — a user-added pattern widens egress
web_tools.denied_urlsUnion — a denial always tightens
workspace_rootContainment — the user may narrow to a subdirectory, never step outside
networkStrictest 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:

VariableMeaning
MUAZ_LOCAL_MODEL_ACCESSWhether 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.