Skip to content
letmoa.run

Configuration

Moa loads config from two levels, merged together:

  1. Global: ~/.config/moa/config.json
  2. 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.

{
"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": "..." }
}
}
}
FieldTypeDescription
permissions.modestringyolo, ask, auto (default yolo)
permissions.allow[]stringGlob patterns auto-approved in ask mode
permissions.deny[]stringGlob patterns always denied
permissions.modelstringModel for auto mode evaluator
permissions.rules[]stringNatural-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 like Bash(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. Use deny to reduce accidents, not to contain an adversarial command. For real containment use mode: ask/auto (a human or model approves each call) and the path sandbox (path_scope).

FieldTypeDescription
path_scopestringworkspace or unrestricted
allowed_paths[]stringExtra directories allowed outside workspace
disable_sandboxboolDeprecated — use path_scope: "unrestricted"
FieldTypeDescription
max_budgetfloatMax 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_turnsintMax agent turns per run (0 = unlimited)
max_tool_calls_per_turnintMax tool calls per turn (0 = unlimited)
max_run_durationstringGo duration, e.g. "30m" (empty = unlimited)
FieldTypeDefaultDescription
memory_enabledbooltrueCross-session project memory
auto_verifyboolfalseRun verification checks automatically after changes
brave_api_keystringEnables the web_search tool
push_summariesstring"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_urlstring"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_ttlstring"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_languagestring"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_modelstring"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_vocabularystring[][]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_shellbooltrueWhether bash persists working directory and exported env between calls in a session
compact_atint0Default 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_strategystringnotifyWhat 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_modelstringsessionModel 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_disabledboolfalseTurn 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_checkbooltrueCheck GitHub for a newer stable Moa release (six-hour ETag cache); set false to opt out
eventsobjectabsentWake-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
previewobjectabsentThe 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"] }
FieldTypeDefaultDescription
subagent_max_turnsint100Max agent turns per subagent run (0 = package default)
subagent_max_run_durationstring"10m"Max subagent wall-clock duration, Go duration (empty = package default)
subagent_max_concurrent_asyncint5Max 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.
FieldTypeDescription
pinned_models[]stringModels shown as shortcuts in the web selector. Pinning or unpinning a model there updates this list. Global-only.
auto_title_modelstringModel 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_modelstringModel 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.

FieldTypeDescription
mcp_serversmapMCP server definitions (see example above)
mcp_servers.<name>.lazyboolDelay connecting until the first tool call after tool metadata has been discovered (default false)
mcp_servers.<name>.idle_timeoutstringClose 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[]stringServer 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[]stringProject dirs whose .mcp.json is trusted. Global-only.
trusted_project_paths[]stringProject 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

A server entry declares exactly one transport:

  • command (+ optional args, env) — stdio: Moa spawns the server as a local subprocess.
  • url (+ optional headers) — streamable HTTP: Moa connects to a remote endpoint. Only http and https are 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-specific files live in <cwd>/.moa/:

PathPurpose
config.jsonProject config (merged with global)
verify.jsonVerification commands for the verify tool
tools/*.jsonCustom script tools
skills/<name>/SKILL.mdProject 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.

~/.config/moa/projects/<hash>/state.json holds what moa records about a project on your behalf, kept out of the repository:

FieldWritten when
permission_allowYou approve a tool call with “always allow”
disabled_mcp_serversYou switch a server off with the project scope
configNever — 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 project scope no longer requires trusting the project, because nothing is written there. Trust still governs whether the project’s own config, script tools and .mcp.json are loaded.
  • allow patterns already present in a .moa/config.json keep working, and so does a disabled_mcp_servers entry 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.

VariablePurpose
ANTHROPIC_API_KEY / OPENAI_API_KEY / XAI_API_KEY / META_API_KEYProvider 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_DIRMoves everything moa stores about itself (default ~/.config/moa): config.json, credentials, sessions, skills, memory, attachments. See Running two instances
MOA_SERVE_TOKENShared secret for moa serve opt-in authentication; equivalent to --token (see Web UI)
MOA_AUTOMATION_TOKENShared secret enabling the inbound Automation API; equivalent to --automation-token. Separate from MOA_SERVE_TOKEN
MOA_NO_UPDATE_CHECK=1Disables the best-effort GitHub release check for this process
MOA_ATTACHMENTS_DIRBase directory for moa serve attachment staging (default /tmp/moa-<uid>); detailed in Web UI
MOA_SERVE_STATIC_DIRServe the web UI from this directory instead of the build embedded in the binary; for frontend development (see Web UI)

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:

Terminal window
MOA_CONFIG_DIR=~/.config/moa-work moa serve --port 8081

Each 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:

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

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.

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.