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). Counts the run’s subagents too, including ones left running in the background: the run stops when the whole family has spent more than this |
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 | |
push_summaries | string | "passive" | How push notifications announce an owner digesting a report and wake-on-event deliveries. passive = no sound, one per project (per source for events) that replaces the previous one. active = as loud as a finished run. off = never. Questions and permissions are not affected: they are always urgent |
push_relay_url | string | "https://moa-push-relay.ealeixandre.workers.dev" (provisional; moves to https://push.letmoa.run) | Origin of the native push relay. Change it only if you build the iOS app with your own APNs key and run your own relay (relay/). HTTPS origin only (plain HTTP is accepted for a loopback relay under test). Read from the global config only |
cache_ttl | string | "1h" | Interactive prompt-cache TTL. Only "5m" changes behavior; any other value falls back to the 1h default. On Anthropic it also sets when the web UI warns that the cache has expired and offers Start fresh |
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. The same happens at run time: if that model fails with anything but an unrecoverable error (a temporary provider error, an overload, a usage limit), the summary is retried once with the session’s model, the transcript says Summarized with ‹model› — ‹compact model› was unavailable (‹reason›), and your setting is left as it is. Authentication and invalid-request errors, a context too long for the model, and a refusal are not retried Editable from the web Settings sheet |
trim_disabled | bool | false | Turn off the tool-result elision that runs before a compaction. On by default: when context crosses the threshold, older tool outputs are replaced by short placeholders that name what was elided, and the conversation continues without a summary; only when that no longer frees enough does the summarizer run. The transcript keeps the original outputs — the elision applies to what the model is sent. Set true to go straight to compaction as before |
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}, {owner:id|name}, 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 | The Live Preview proxy listener port is remembered after activation. The web UI derives the public address separately for each browser; old configs may still contain public_url as an API fallback. Running state is in memory, so restarting moa serve never reopens the port. 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. Pinning or unpinning a model there updates this list. 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.7 (also available as grok), grok-4.6, 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 models gpt-6.1-sol and gpt-6-luna are also available as sol and
luna (gpt-6-sol stays selectable by its ID). gpt-6-astra is available as
astra (or gpt-6). Astra and GPT-6.1 Sol map their five thinking
positions to low, medium, high, xhigh, and max, so the off
position selects the 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) |
mcp_servers.<name>.lazy | bool | Delay connecting until the first tool call after tool metadata has been discovered (default false) |
mcp_servers.<name>.idle_timeout | string | Close the server after this much time without tool calls; reconnect on the next call (Go duration, e.g. "15m"; default unset = never) |
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.
lazy and idle_timeout are available per local command-based server. They
are not supported for remote URL servers; an entry setting either key on a
remote server is rejected. For example, in ~/.config/moa/config.json:
{ "mcp_servers": { "playwright": { "command": "bun", "args": ["x", "@playwright/mcp@latest", "--headless", "--isolated"], "lazy": true, "idle_timeout": "15m" } }}Servers without these keys still start when a session opens and stay connected
until it closes. An idle_timeout alone retains eager initial discovery; lazy
alone stays connected once used. A lazy server needs its tool names, schemas and
descriptions before the model can call it. On a cache miss (for instance a new
server or a changed command), Moa connects once to discover its tools, then
closes it; subsequent sessions can advertise those tools without starting it.
The first call after a lazy start or idle close waits for the server to reconnect.
After an idle close, the first tool result notes that state held by the old
process (such as open browser pages or logins) is gone.
The metadata cache is in the OS user cache directory (moa/mcp-tools); it
contains tool definitions, not tool results or config credentials. Its key
includes the server name, working directory, command, arguments and
environment. Changing those invalidates its cached metadata.
If a server changes its tools without a config change (for example an @latest
package update), use Restart in the MCP panel to rediscover its tools; new
tools cannot be advertised from an old cached list before that. These keys work
in both config levels and in the global or trusted project .mcp.json (under
mcpServers there); same-name entries replace rather than extend one another.
{ "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 sent on every request to that endpoint, so they
are where static 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.
A remote server that answers 401 shows up in the MCP panel as needing sign-in,
with a Connect button (Reconnect once a previous sign-in has expired).
Moa supports servers that follow the MCP authorization spec: it discovers the
authorization server, registers itself with a loopback redirect
(http://127.0.0.1:<port>/callback) and uses PKCE. Sign in on the page that
opens; it ends on a 127.0.0.1 address that does not load — paste that address
into the panel. Tokens are kept in mcp-oauth.json in the config directory
(mode 0600), shared by every session, and renewed automatically. Once signed
in, the OAuth token replaces any static Authorization header for that server.
Servers that require a pre-registered OAuth client are not supported. To drop a
sign-in, use Sign out on the server in the MCP panel: the server goes back to
needing sign-in in every open session.
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.
In moa serve, the first prompt of a new session waits up to 16 seconds for
its MCP servers to finish starting, so their tools are there from the first
turn. A server that fails, or is still starting after that, does not hold the
turn: its tools join once it is ready.
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.