Configuration
Moa loads config from two levels, merged together:
- Global:
~/.config/moa/config.json - Project:
<cwd>/.moa/config.json
CLI flags override both at runtime. Project config extends global config; some fields are global-only (noted below).
Both files describe what you configured. What moa decides on its own while you work — the approvals you granted with “always allow”, the MCP servers you switched off for a project — is kept separately, in your project state, and never written into the project.
Example
Section titled “Example”{ "permissions": { "mode": "ask", "allow": ["Bash(git:*)", "read"], "deny": ["Bash(curl:*)", "Read(**/.env)"], "model": "haiku", "rules": ["Deny writes outside repository"] }, "pinned_models": ["claude-sonnet-5", "gpt-5.3-codex"], "max_budget": 2.00, "max_turns": 100, "brave_api_key": "...", "mcp_servers": { "docs": { "command": "uvx", "args": ["my-mcp-server"], "env": { "API_KEY": "..." } } }}Config fields
Section titled “Config fields”Permissions
Section titled “Permissions”| Field | Type | Description |
|---|---|---|
permissions.mode | string | yolo, ask, auto (default yolo) |
permissions.allow | []string | Glob patterns auto-approved in ask mode |
permissions.deny | []string | Glob patterns always denied |
permissions.model | string | Model for auto mode evaluator |
permissions.rules | []string | Natural-language rules for the evaluator |
Pattern format: Tool(argPattern) — e.g. Bash(npm:*), Write(*.go), Edit(pkg/*). Case-insensitive tool names, glob-like arguments. Arg scoping now applies to grep/find/ls/multiedit (matched on their path), fetch_content (on its url), and apply_patch (matched against every file the patch touches).
Bash deny is not a security boundary.
Bash(...)rules match the literal command string by prefix/glob. A rule likeBash(rm -rf:*)does not reliably block recursive deletes — it is trivially evaded by flag reordering (rm -fr,rm -r -f), absolute paths (/bin/rm -rf), a leading space, or shell aliases. Usedenyto reduce accidents, not to contain an adversarial command. For real containment usemode: ask/auto(a human or model approves each call) and the path sandbox (path_scope).
Paths & sandbox
Section titled “Paths & sandbox”| Field | Type | Description |
|---|---|---|
path_scope | string | workspace or unrestricted |
allowed_paths | []string | Extra directories allowed outside workspace |
disable_sandbox | bool | Deprecated — use path_scope: "unrestricted" |
Limits
Section titled “Limits”| Field | Type | Description |
|---|---|---|
max_budget | float | Max USD per run (0 = unlimited) |
max_turns | int | Max agent turns per run (0 = unlimited) |
max_tool_calls_per_turn | int | Max tool calls per turn (0 = unlimited) |
max_run_duration | string | Go duration, e.g. "30m" (empty = unlimited) |
Features
Section titled “Features”| Field | Type | Default | Description |
|---|---|---|---|
memory_enabled | bool | true | Cross-session project memory |
auto_verify | bool | false | Run verification checks automatically after changes |
brave_api_key | string | Enables the web_search tool | |
cache_ttl | string | "1h" | Interactive prompt-cache TTL. Only "5m" changes behavior; any other value falls back to the 1h default |
stt_language | string | "en" | Speech-to-text language hint (ISO-639-1, e.g. "es", "en"). Avoids mis-detection on short clips. Use "auto" to let the model detect |
stt_model | string | "gpt-transcribe" | Speech-to-text model. "gpt-4o-mini-transcribe" costs half as much per minute; "whisper-1" is the older, slower model it replaced |
stt_vocabulary | string[] | [] | Words the transcriber keeps getting wrong (names, jargon, product names). Accumulates across scopes: a project adds its terms to your global ones. Keep it short — long lists make transcription worse (max 50 terms) |
persistent_shell | bool | true | Whether bash persists working directory and exported env between calls in a session |
compact_at | int | 0 | Default auto-compaction threshold in tokens for sessions that set none of their own. 0 = automatic: compaction waits for the model’s window. A session’s own limit always wins, and a subagent inherits its parent’s, falling back to this. Values under the engine’s floor (reserve + kept context + margin, ≈41k) are raised, since a lower threshold would compact on every turn. Editable from the web Settings sheet |
compact_strategy | string | notify | What the agent gets before an automatic compaction, which otherwise arrives mid-task with no warning: anything it had worked out but not written down is replaced by the summary. plain = no warning. notify = a reminder as the threshold nears, so it can persist unfinished work first; costs nothing, since the notice rides on the next request. prepare = a full turn to write things down before summarizing; more thorough, but costs an extra request. Subagents are never warned under any setting: they have no memory or checkpoint to write to, and their findings already travel back in their report. Editable from the web Settings sheet |
compact_model | string | session | Model that writes compaction summaries. session (or unset) = the session’s own model, which is how compaction has always behaved. Any model spec (terra, sonnet…) makes that model summarize instead. The summarizer request shares no cached prefix with the conversation. The chosen model needs its provider’s credentials; when they are missing the session’s model summarizes instead and the transcript says so. Editable from the web Settings sheet |
update_check | bool | true | Check GitHub for a newer stable Moa release (six-hour ETag cache); set false to opt out |
events | object | absent | Wake-on-event sources (events.sources.<name>). Each source is a webhook with its own secret, a target ({project:dir}, {session:id}, or "inbox"), when_none (inbox/create), when_many (inbox/latest), create (model, thinking, yolo, title), autorun (default false; set true to start a turn), and rate (auto-deliveries and session creations per rolling hour, default 10). Global-only — secrets live in ~/.config/moa/config.json. See Event hooks and moa hooks |
preview | object | absent | Where the browser reaches the Live Preview proxy: public_url and port. Written by the web UI when you confirm the address the first time, so it is never asked again — you rarely edit it by hand. Only the address is stored: whether the proxy is running is decided per use, in memory, so restarting moa serve never reopens the port on its own. Global-only — a repository must not be able to tell Moa where to publish a proxy |
Start stt_vocabulary empty and add words only once you catch the transcriber
getting them wrong. It is a hint, not a substitution: listing a word biases
spelling toward it, so a long list starts forcing your terms onto words that
merely sound similar. Because it accumulates, your own name belongs in the
global config and a project’s jargon in its .moa/config.json:
{ "stt_vocabulary": ["goreleaser", "Preact", "esbuild"] }Subagents
Section titled “Subagents”| Field | Type | Default | Description |
|---|---|---|---|
subagent_max_turns | int | 100 | Max agent turns per subagent run (0 = package default) |
subagent_max_run_duration | string | "10m" | Max subagent wall-clock duration, Go duration (empty = package default) |
subagent_max_concurrent_async | int | 5 | Max concurrent async subagents (0 = package default) |
subagent_allowed_models | []string | [] | Model IDs a subagent may run under. Empty = no restriction. Excluded models are hidden from the agent and refused if requested; a change applies to sessions already open, without a restart. The list governs the model a delegation explicitly asks for: a child that inherits the parent’s model still runs when that model is not listed. Editable from the web Settings sheet. Global-only. |
Models
Section titled “Models”| Field | Type | Description |
|---|---|---|
pinned_models | []string | Models shown as shortcuts in the web selector. Global-only. |
auto_title_model | string | Model for automatic session titles: auto (default), off, or a valid model spec/alias. After a new conversation accepts its first prompt, Auto sends that prompt to the selected available auxiliary provider; it does not retitle conversations restored with history. If the process stops while generation is in flight, the provisional title remains after restart. OpenAI Luna is selected when normal OpenAI completion credentials exist, otherwise Anthropic Haiku. Thus an Anthropic/xAI session can be sent to OpenAI, or an OpenAI/xAI session to Anthropic when Haiku is the fallback. Privacy-sensitive users should choose an explicit same-provider model or off. |
session_brief_model | string | Model for web/Pulse session status briefs: auto (default), off, or a valid model spec/alias. Auto has the same cross-provider behavior as titles: a snippet of any session transcript can be sent to Luna, or to Haiku when it is the available fallback. Choose an explicit same-provider model or off when that is not acceptable. |
The xAI models are grok-4.6 (also available as grok) and grok-4.5. They
always reason: see Thinking levels. A
provider-qualified custom model such as xai/<model-id> is accepted, but Moa
has no context-window or pricing metadata for it unless it is in the built-in
model registry.
The Meta models are muse-spark-1.3 (also available as muse) and
muse-spark-1.3-contributor. The contributor variant is much cheaper because
Meta uses its prompts to improve their products. Muse Spark always reasons, so
off resolves to its lowest effort.
The OpenAI model gpt-6-astra is also available as astra (or gpt-6). Its
five thinking positions map to low, medium, high, xhigh, and max, so
the off position selects Astra’s lowest reasoning effort rather than disabling
reasoning.
MCP servers
Section titled “MCP servers”| Field | Type | Description |
|---|---|---|
mcp_servers | map | MCP server definitions (see example above) |
disabled_mcp_servers | []string | Server names vetoed at this config level: the server stays configured but is never started. In a project file this is legacy — moa now records your vetoes in your project state |
trusted_mcp_paths | []string | Project dirs whose .mcp.json is trusted. Global-only. |
trusted_project_paths | []string | Project dirs whose .moa/config.json and .moa/tools/* are auto-loaded without a trust prompt. Global-only. |
Moa also loads .mcp.json files (Claude Code-compatible format):
~/.config/moa/.mcp.json— always loaded<cwd>/.mcp.json— loaded only when the path is trusted
Transports: local command or remote URL
Section titled “Transports: local command or remote URL”A server entry declares exactly one transport:
command(+ optionalargs,env) — stdio: Moa spawns the server as a local subprocess.url(+ optionalheaders) — streamable HTTP: Moa connects to a remote endpoint. Onlyhttpandhttpsare accepted.
{ "mcpServers": { "local": { "command": "uvx", "args": ["my-mcp-server"] }, "relay": { "url": "https://relay.example.com/mcp", "headers": { "Authorization": "Bearer ..." } } }}Setting both command and url — or neither — is a configuration error and the
file is rejected. headers are the only supported authentication mechanism
for a remote server: they are sent on every request to that endpoint, so it is
where credentials go; they are stored in plain text in the config file like any
other key there. Credentials embedded in the URL itself
(https://user:pass@host/mcp) are rejected — the URL is shown in the MCP panel
and written to logs, so it is not a place for secrets.
Redirects are never followed: headers would be re-sent to whatever origin a
30x points at, so a redirecting endpoint fails the request instead.
A remote server has no process to supervise: it shows up in the MCP panel like
any other server, and enable/disable/restart just drop and re-dial the
connection. If the connection is lost the server is reported as exited and can be
restarted. The endpoint is an outbound connection to an address you
configured — Moa applies no network policy beyond the scheme check, exactly as
with automation callback_urls.
Project directory: .moa/
Section titled “Project directory: .moa/”Project-specific files live in <cwd>/.moa/:
| Path | Purpose |
|---|---|
config.json | Project config (merged with global) |
verify.json | Verification commands for the verify tool |
tools/*.json | Custom script tools |
skills/<name>/SKILL.md | Project skills |
config.json and tools/*.json are only loaded for directories listed in the
global trusted_project_paths; .mcp.json needs trusted_mcp_paths. Until a
path is trusted, moa uses the global config alone. In the web UI, the MCP banner
offers Trust & Load to add the current directory to trusted_mcp_paths;
trusted_project_paths is edited by hand in ~/.config/moa/config.json.
Your project state
Section titled “Your project state”~/.config/moa/projects/<hash>/state.json holds what moa records about a
project on your behalf, kept out of the repository:
| Field | Written when |
|---|---|
permission_allow | You approve a tool call with “always allow” |
disabled_mcp_servers | You switch a server off with the project scope |
config | Never — this one is yours to edit (see below) |
The split follows who the decision belongs to. .moa/config.json describes the
project — which MCP servers it uses, what its limits are — and is meant to be
committed and shared. Approving a command on your machine is not that: it used
to be appended to the project’s config file, so a click produced a diff nobody
wanted to commit, and it followed the repository to everyone who cloned it.
This also keeps moa from writing into the checkout at all during a normal session, which is what previously made a shared checkout unusable: the file was created with private permissions, so whoever approved something first owned it.
Consequences worth knowing:
- A
project-scoped MCP veto is now yours alone; it no longer travels with the repository. To keep a server out of a project for everybody, don’t declare it in.moa/config.json. - The
projectscope no longer requires trusting the project, because nothing is written there. Trust still governs whether the project’s own config, script tools and.mcp.jsonare loaded. allowpatterns already present in a.moa/config.jsonkeep working, and so does adisabled_mcp_serversentry left there by an older moa. Moa cannot tell a deliberate team policy from a leftover click, so nothing is migrated or removed — move them yourself if you want them gone.- The state is per workspace path, hashed the same way memory scopes its project facts. Moving a checkout starts a fresh state.
Settings for one project, without touching the repository
Section titled “Settings for one project, without touching the repository”The config block takes the same fields as config.json, for settings you
want in this project but not in its repository — for example, a turn limit you
prefer here:
{ "config": { "max_turns": 40 }}Moa never writes this block; it is yours to edit. It is applied after global
config and after the project’s own, so it wins over both — but through the same
merge rules, so max_turns, max_tool_calls_per_turn and max_run_duration
can only be tightened: if your global config caps runs at 50 turns, asking
for 500 here still gives you 50. max_budget is the exception and takes any
explicit value, matching how the project’s own config has always behaved. No
trust prompt is involved, because the file is yours rather than the checkout’s.
It takes the same fields as config.json, which includes the ones that widen
what moa may do — permissions, path_scope, allowed_paths, mcp_servers.
That is intended, since the file is your own and no repository can write to it,
but it does mean a stray "path_scope": "unrestricted" here applies with no
prompt. It cannot grant trust: trusted_project_paths and trusted_mcp_paths
are global-only and ignored at this level, so a project cannot come to trust
itself through it.
Use it when the setting is about how you work here, and .moa/config.json
when it is about the project itself and should reach everyone who clones it.
Environment variables
Section titled “Environment variables”| Variable | Purpose |
|---|---|
ANTHROPIC_API_KEY / OPENAI_API_KEY / XAI_API_KEY / META_API_KEY | Provider credentials (see Quickstart). An environment key takes precedence over the stored credential for that provider; in particular, XAI_API_KEY selects the metered api.x.ai API-key route and eclipses a stored xAI OAuth login, and META_API_KEY eclipses a stored Muse subscription login. |
MOA_CONFIG_DIR | Moves everything moa stores about itself (default ~/.config/moa): config.json, credentials, sessions, skills, memory, attachments. See Running two instances |
MOA_SERVE_TOKEN | Shared secret for moa serve opt-in authentication; equivalent to --token (see Web UI) |
MOA_AUTOMATION_TOKEN | Shared secret enabling the inbound Automation API; equivalent to --automation-token. Separate from MOA_SERVE_TOKEN |
MOA_NO_UPDATE_CHECK=1 | Disables the best-effort GitHub release check for this process |
MOA_ATTACHMENTS_DIR | Base directory for moa serve attachment staging (default /tmp/moa-<uid>); detailed in Web UI |
MOA_SERVE_STATIC_DIR | Serve the web UI from this directory instead of the build embedded in the binary; for frontend development (see Web UI) |
Running two instances
Section titled “Running two instances”Everything moa keeps about itself lives in one directory, ~/.config/moa by
default. MOA_CONFIG_DIR moves all of it — config, credentials, sessions,
skills, memory, attachments — so two instances can run on one machine
without sharing anything:
MOA_CONFIG_DIR=~/.config/moa-work moa serve --port 8081Each instance keeps its own API keys, model defaults, MCP servers and history.
The one thing outside it is the temporary staging area for web-UI attachments,
shared per user account and overridable with MOA_ATTACHMENTS_DIR.
The variable points moa at a different directory; it does not move what is
already in the old one. A fresh MOA_CONFIG_DIR therefore starts empty — no
credentials, no history, no skills — and the previous state stays untouched in
~/.config/moa. Copy across whatever you want to keep:
cp ~/.config/moa/auth.json ~/.config/moa-work/If you were already setting MOA_CONFIG_DIR before moa 0.24, note that it used
to move only part of the state: credentials and attachments followed it, while
config.json, sessions and skills stayed in the home directory. Now
that all of it moves together, that leftover state is no longer read — move the
files you still want into the override directory.
Two people on one machine
Section titled “Two people on one machine”Give each person a Unix account instead. Separate accounts already separate everything above, and the isolation is enforced by the operating system rather than by moa: one account cannot read the other’s credentials even if moa has a bug.
Give each account its own checkout as well. A single checkout shared through
group permissions does not currently work: moa writes <project>/.moa/ with
private permissions, so whoever triggers the first write owns it and the other
account can no longer read the project’s verify.json, skills or script tools.
Moa is a single-user tool by design. It has no notion of who is asking: no per-user permissions, no ownership on sessions, no way to scope what one person’s agent may touch. Sharing one moa between two people means sharing one identity and one set of credentials, so keep the accounts separate rather than expecting moa to tell them apart.
AGENTS.md
Section titled “AGENTS.md”Moa discovers AGENTS.md files from the working directory upward and from ~/.config/moa/. Their content is injected into the system prompt as project instructions. This is the main way to give the agent persistent rules, preferences, conventions and prohibitions. Reusable workflows belong in skills and executable procedures in scripts or registered tools; do not put either category in memory.