Pular para o conteúdo principal

Configuration Reference

Complete reference for GarraIA configuration options.

Configuration File Location​

GarraIA looks for config.yml (fallback config.toml) in its config directory, resolved as:

  1. $GARRAIA_CONFIG_DIR (when set)
  2. ~/.config/garraia (XDG; the default on new installs)
  3. ~/.garraia (legacy — only when the XDG directory does not exist)

Run garraia config check to see which directory and file are active.

Full Configuration Example​

# Gateway settings
gateway:
host: "127.0.0.1" # Bind address
port: 3888 # HTTP/WebSocket port
# #1513 — MCP Streamable HTTP bridge at POST /mcp, for an external
# orchestrator. Off by default; requires gateway.api_key to mount.
# See docs/gateway-mcp-http.md.
mcp_http:
enabled: false
allow_send: false # unlocks garra_send_message
max_history_messages: 50
allow_ask: false # #1612: unlocks garra_ask (spends LLM tokens)
ask_allowed_models: [] # empty = only the project default model
ask_budget_per_minute: 10 # garra_ask calls per minute, whole bridge
allow_agent: false # #1615: unlocks garra_agent, which runs tools on this machine
agent_budget_per_minute: 2 # garra_agent runs per minute, whole bridge
agent_max_seconds: 300 # wall clock for one garra_agent run, 5-600
redact_patterns: [] # #1626: extra regexes redacted on the bridge (opt-in)

# LLM Providers
llm:
# Anthropic Claude
claude:
provider: anthropic
model: claude-sonnet-4-5-20250929
api_key: "sk-ant-..."

# OpenAI
openai:
provider: openai
model: gpt-4o
api_key: "sk-..."
base_url: "https://api.openai.com/v1" # Optional custom endpoint

# Azure OpenAI
azure:
provider: openai
model: gpt-4o
api_key: "your-azure-key"
base_url: "https://your-resource.openai.azure.com/"

# Ollama (local)
ollama:
provider: ollama
model: qwen3.8:latest
base_url: "http://localhost:11434"

# OpenRouter
openrouter:
provider: openrouter
model: openai/gpt-4o
api_key: "sk-or-..."

# Other OpenAI-compatible providers
deepseek:
provider: openai
model: deepseek-chat
base_url: "https://api.deepseek.com/v1"
api_key: "your-key"

mistral:
provider: openai
model: mistral-large-latest
base_url: "https://api.mistral.ai/v1"
api_key: "your-key"

# Channel Configuration
# Tokens should be provided via environment variables (TELEGRAM_BOT_TOKEN,
# DISCORD_BOT_TOKEN, etc.) rather than stored in this file.
# See: docs/src/guides/connect-telegram.md for the full precedence chain.
channels:
telegram:
type: telegram
enabled: true
# bot_token resolved from: vault → config → TELEGRAM_BOT_TOKEN env var

discord:
type: discord
enabled: false
# bot_token resolved from: vault → config → DISCORD_BOT_TOKEN env var
# application_id: "123456789"

slack:
enabled: false
bot_token: "xoxb-..."
app_token: "xapp-..."

whatsapp:
enabled: false
phone_number_id: "123456789"
access_token: "YOUR_ACCESS_TOKEN"
verify_token: "YOUR_VERIFY_TOKEN"
webhook_verify: true

# Agent Configuration
agent:
system_prompt: |
You are GarraIA, a helpful AI assistant.
You are running locally and respect user privacy.
max_tokens: 4096
max_context_tokens: 100000
temperature: 0.7
tools:
- bash
- file_read
- file_write
- web_fetch
- web_search
# Backend of `web_search` (#1034). Omit the section to keep the old rule:
# Brave when a key resolves, otherwise no search tool. `searxng` needs no
# key — point it at a self-hosted instance with `json` in search.formats.
web_search:
backend: searxng # brave | searxng
searxng_url: http://127.0.0.1:8081 # or GARRAIA_SEARXNG_URL

# Multi-agent Configuration
# Named agents are resolved via `agent_router` (explicit agent_id > channel
# setting > "default" agent). Each entry maps to `NamedAgentConfig`:
# provider, model, system_prompt, max_tokens, max_context_tokens, tools
agents:
assistant:
provider: openai
system_prompt: "You are a helpful general assistant."
max_tokens: 4096

coder:
provider: openai
system_prompt: "You are an expert programmer."
tools: [bash, file_read, file_write]

# Exemplo: agente nomeado "hera" com persona própria (docs/operacao/hera-persona.md).
# A persona é o system_prompt; a memória usa o tenant padrão até existir
# tenant por agente (issue #965 — conversa entre agentes via A2A).
hera:
provider: ollama-local
system_prompt: |
Você é a Hera: a assistente pessoal desta instalação do Garra.
Português do Brasil. Frases curtas. Responda primeiro, explique depois.
Você não tem internet, calendário, email nem ferramentas.
Não invente fato pessoal. "Não sei" é resposta completa.

# Memory Configuration
memory:
enabled: true
embedding_provider: ollama-embed # key of the `embeddings` entry below
# shared_continuity: false # optional; fact extraction runs on every turn (no auto_extract knob)

# Embeddings Configuration (named map — pick one entry via memory.embedding_provider)
embeddings:
ollama-embed:
provider: ollama
model: nomic-embed-text
base_url: "http://localhost:11434"
dimensions: 768

# Voice Configuration
voice:
enabled: false
tts_endpoint: "http://127.0.0.1:7860"
stt_provider: whisper
language: "pt"

# MCP Servers
mcp:
filesystem:
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem@2.0.0", "/tmp"]

github:
command: npx
args: ["-y", "@modelcontextprotocol/server-github"]
env:
GITHUB_TOKEN: "your-token"

# Timeouts (in seconds) — gateway/server only. The interactive REPL has its
# own, separate knob: see "Turn timeout in `garra chat`" under CLI Configuration.
timeouts:
llm:
default_secs: 120
tts:
default_secs: 120
mcp:
default_secs: 60
health:
default_secs: 5

# Execution profile (ADR 0024, #1329) — see docs/execution-profiles.md.
# `standard` (default, section absent) is today's posture: ToolGate floor by
# mode, file-tool jail, `search` floor on personal WhatsApp. `isolated-pod`
# declares that THIS process runs in a disposable pod: full power inside the
# pod for the WhatsApp owner in 1:1 chats, MCP filesystem rooted at pod_root.
# GARRAIA_EXECUTION_PROFILE overrides the file; an invalid value refuses boot.
execution:
profile: standard # standard | isolated-pod
# pod_root: /workspace # optional, absolute; only meaningful in isolated-pod

# Security
security:
vault_password: "your-vault-password" # Or set GARRAIA_VAULT_PASSWORD env
pairing_code_length: 8
rate_limit:
enabled: true
requests_per_minute: 60

# Logging
logging:
level: "info" # debug, info, warn, error
format: "json" # text, json

Environment Variables​

You can use environment variables for sensitive values:

llm:
openai:
provider: openai
model: gpt-4o
# Resolved in order: config > env var
api_key: "${OPENAI_API_KEY}"

Supported env var resolution:

  • ${VAR_NAME} - Uses environment variable
  • Leave empty - Uses GARRAIA_{PROVIDER}_API_KEY

Runtime overrides read directly by the loader (not secrets):

VariableOverridesNotes
GARRAIA_CONFIG_DIRconfig directorySee "Configuration File Location" above.
GARRAIA_EXECUTION_PROFILEexecution.profilestandard | isolated-pod. Wins over the file, resolved once at load; config check and /api/diagnostics report the source (default | file | env). Any other value is a load error: the gateway refuses to boot and config check reports Error (exit 2). Never persisted back to the file by a save. execution-profiles.md.
GARRAIA_FILE_ROOTSadds to agent.file_rootsPATH-style list; extra roots for the native file-tool jail (#1244). When neither this nor agent.file_roots resolves to a root, the jail falls back to the profile's default workspace (<data_dir>/workspace, #1378) — never / and never $HOME.
GARRAIA_ALLOW_INVALID_CONFIGthe boot gate (#1247)Exactly 1 lets garraia start/restart boot despite a blocking config Error (exit 78 otherwise); any other value (true, 0, 1, empty) counts as unset. The blocking findings are still logged at error level, naming this variable.
GARRAIA_GATEWAY_API_KEYgateway.api_keySecret. A non-blank value wins over the file (#1261); blank counts as unset. Applied at load into a field that is never serialized, so a save never writes it to disk. config check reports presence only, and warns when it differs from the file key. Required (one or the other) for a non-loopback bind: without a credential garraia start refuses to boot (exit 78).
HOST / PORTthe listener bindRead by garraia start and garraia restart (flag > env > 127.0.0.1:3888). gateway.host / gateway.port in the file are deprecated and never read.

Provider / model resolution precedence​

GAR-576 — clarifies how garra chat picks a provider and a model when multiple signals compete (CLI flags, config blocks, environment variables, .env).

Provider resolution​

When garra chat runs, the provider is chosen in this strict order:

  1. --provider <X> CLI flag — absolute precedence. <X> is a kind (ollama, llamacpp, anthropic, openai, openrouter) or the name of any llm: entry (an alias such as lmstudio with provider: openai). See "Endpoint and credential" below for which entry supplies the URL and the key.
  2. config.agent.default_provider — read as a lookup key into config.llm[...]. If the matching block has a usable credential (api_key in config; the matching *_API_KEY env var, but only when the block talks to the kind's default host — see "Endpoint and credential" below; or, for OpenAI-compatible local backends, a base_url of their own), this provider wins.
  3. Autodetect (no --provider, no usable default_provider) — cloud first, since issue #1180: the cloud providers you hold a credential for (api_key in config or the matching *_API_KEY env var, under the same default-host rule) are tried in the order Anthropic → OpenAI → OpenRouter, a provider with no credential being skipped; then Ollama, if its health check passes; and, as the last resort, an offline Ollama handle so the REPL still opens and tells you what is missing.

Before #1180 this chain probed Ollama first, so a stray ollama serve silently beat an exported OPENROUTER_API_KEY. If you want the local daemon to win over a cloud credential, say so explicitly — that is the escape hatch, not autodetect:

agent:
default_provider: ollama

Model resolution (per provider kind)​

Inside the chosen provider, the model is resolved in this order:

  1. --model <X> CLI flag — absolute precedence.
  2. The model of the entry the provider was bound to (llm.<X> for --provider <X>, llm.<default_provider> for the default). With default_provider: lmstudio next to an llm.openai, the LM Studio endpoint gets llm.lmstudio.model, never llm.openai.model.
  3. config.llm[<key>].model with <key> matching the provider kind (key-match).
  4. First config.llm[*] whose provider: field equals the chosen kind and supplies a non-empty model (provider-field match — lets operators give blocks arbitrary names like my-router).
  5. Hardcoded last-resort default per kind: qwen3.8:latest, claude-sonnet-4-5-20250929, gpt-4o, z-ai/glm-5.3-flash. The source of truth for the two project defaults (DEFAULT_CLOUD_MODEL, DEFAULT_LOCAL_MODEL and their provider keys) is crates/garraia-config/src/defaults.rs (issue #1180) — the CLI and the gateway read that file, so garra chat, the wizard, garra mcp-server, the gateway boot path and POST /api/providers cannot disagree. crates/garraia-cli/src/defaults.rs only re-exports those constants and holds the test that locks the Desktop's config.default.yml to the same values. The per-kind table above is chat.rs::hardcoded_default_model, which reads the constants for its two #1180 rows.

Endpoint and credential (always the same entry)​

A provider's base_url and its api_key always come from the same llm: entry. The CLI never pairs the key of one entry with the address of another, nor the key of an entry with the provider's default host when that entry points somewhere else (crates/garraia-cli/src/provider_binding.rs).

How the provider was chosenEntry that supplies URL + key
--provider <X> / MCP provider: <X>llm.<X>; if absent and <X> is a kind, llm.main when its provider: is <X>; otherwise none
agent.default_provider: <K>llm.<K>
autodetect (Anthropic, OpenAI, OpenRouter)same rule as --provider <kind>; an llm.<kind> that declares another provider: is used whole only when it carries its own api_key, otherwise the candidate falls back to "no entry"

Inside that entry the entry's own api_key wins over the kind's environment variable (OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY). The variable only ever reaches the kind's default host: it fills an entry that has no key only when that entry has no base_url, or a base_url that is the default host (https://api.openai.com[/v1], https://api.anthropic.com, https://openrouter.ai/api/v1 — the last one is what garraia init writes, with the key left in the environment). An entry pointing anywhere else without its own api_key gets no credential: an OpenAI-compatible one sends the keyless placeholder not-needed (what LM Studio / vLLM expect), anthropic and openrouter refuse with an error. So an OPENAI_API_KEY sitting in a .env of the current directory (loaded by dotenvy) is never shipped to a proxy or to a third-party LM Studio just because its entry has no api_key. This is stricter than the gateway, which resolves config > env without looking at the base_url.

When no entry applies, the endpoint is the kind's default host and the key can only come from that environment variable — the key of some other entry of the same kind is never borrowed.

The MCP garra_agent looks the built provider up by the id it registered under (llama-cpp for llamacpp, the kind for an alias of anthropic/ollama/llamacpp), so every alias accepted by -p also works there; the envelope still reports the name that was passed.

--url <address> (an ad-hoc OpenAI-compatible endpoint) takes its key from LLM_API_KEY, or from the api_key of an llm: entry whose base_url is that same address; otherwise it sends none. It does not read OPENAI_API_KEY or GARRAIA_EMBEDDING_API_KEY (it did up to v0.4.4): those are credentials of other endpoints. --url is honoured together with --provider only for the keyless llamacpp.

OpenRouter cost policy​

z-ai/glm-5.3-flash is the project's official default model (issue #1180): a flash-tier model cheap enough to be the unattended default while being good enough for real work. Everything else is an explicit choice:

ModelWhen to use
z-ai/glm-5.3-flashThe default. Every surface (garra, garra ask, garra mcp-server, the wizard, Desktop) resolves to it when nothing is chosen.
openrouter/autoReal tasks / complex reasoning, at a price. Only via an explicit --model openrouter/auto.
openrouter/freeZero-cost smoke tests. No longer a default anywhere — pass --model openrouter/free to get it.

Recommended baseline config.yml:

llm:
openrouter:
provider: openrouter
model: z-ai/glm-5.3-flash # the official default
base_url: "https://openrouter.ai/api/v1"
ollama:
provider: ollama
model: qwen3.8:latest # second option / fallback
base_url: "http://localhost:11434"

agent:
default_provider: openrouter # honored by `garra chat` autodetect
fallback_providers: ["ollama"] # local is always the SECOND option

To run a heavier task, pass the model explicitly:

garra chat --provider openrouter --model openrouter/auto

There is no automatic upgrade to a pricier model — paid traffic above the default always requires an explicit --model flag.

Presets​

An OpenRouter preset is an account resource addressed as @preset/<slug>, in two shapes: the pure form @preset/<slug> and the based form <author>/<model>@preset/<slug>. Presets do not appear in GET /models, so /model validates them differently from a catalog model (issue #1752):

  • In config.yml the value must be quoted — model: "@preset/z-ai-glm-5-3-flash". A bare leading @ starts a YAML tag and the parser (serde_norway) fails to load the file without the quotes.
  • The transactional /model validates a preset with one real minimal call (POST /chat/completions, max_tokens: 1), since the preset is not in the catalog. That call carries a small charge and is subject to rate limits; a 429/5xx/network error keeps the current model ("could not validate"). With a based preset the base <author>/<model> is checked against the catalog first (free), so a base that is absent is refused without spending the call.
  • The preset string is sent to OpenRouter intact — it is the identifier, and OpenRouter resolves which model it points to. /model reports the resolved model as a note, but the active model stays the preset.
  • garra ask --model @preset/<slug> routes to OpenRouter without the validation call (it is one-shot). During streaming, /stats shows the requested model (the preset), not a resolved name.
  • GARRAIA_MCP_MODEL_ALLOWLIST compares the whole string: to allow a preset over MCP, list the literal preset (e.g. z-ai/glm-5.3-flash@preset/x) — the base alone does not cover it, because a preset can change both model and cost.

Routing variants​

OpenRouter also accepts routing suffixes on a catalog model — <author>/<model>:<variant> — that steer which provider serves the request: :floor (cheapest provider), :nitro (highest throughput), :online (web search) and :exacto (exact provider). Like presets, they do not appear in GET /models, so /model validates them separately (issue #1764):

  • The list is closed — only :floor, :nitro, :online, :exacto. A suffix outside it is refused with a hint. :free, :thinking and :extended are not routing variants: they are catalog ids, validated by plain equality against GET /models (no extra call).
  • /model checks the base <author>/<model> against the catalog first (free): a base that is absent is refused without any POST. A present base is validated with one real minimal call (POST /chat/completions, max_tokens: 1) of the whole string, exactly like a preset — one probe per /model, never per turn.
  • Cost: the probe carries a small charge and is subject to rate limits; a 429/5xx/network error keeps the current model. :online in particular bills a web search on that probe, so validating <model>:online costs a search even though only one token is requested.
  • garra ask --model <model>:floor routes to OpenRouter without the validation call (it is one-shot) — the base already has a single /, so the router treats it as an ordinary OpenRouter id.
  • GARRAIA_MCP_MODEL_ALLOWLIST compares the whole string: to allow a variant over MCP, list the literal <model>:<variant> — the base alone does not cover it, because the suffix changes routing (and, for :online, cost).

GAR-579 — esta mesma precedência é consumida por garra ask, o comando não-interativo. Tanto garra chat quanto garra ask compartilham chat::select_explicit_provider / chat::detect_provider, então o comportamento é byte-equivalente.

Hot Reload​

Configuration changes in config.yml are applied automatically:

  1. Edit <config-dir>/config.yml (~/.config/garraia/config.yml by default)
  2. Changes detected within seconds
  3. No restart required

CLI Configuration​

Turn timeout in garra chat​

garra chat --timeout-secs <N> sets an optional wall-clock ceiling over the whole turn (the LLM call plus every tool it runs), range [1, 600] seconds. It is opt-in and has no default (#1751): without the flag the turn runs until the model finishes. Ctrl+C still cancels the turn, and the 30s per-tool limit (GARRA_TOOL_TIMEOUT_SECS) and the providers' connect timeout stay in force regardless.

When the ceiling fires — or on Ctrl+C — the text already streamed and the tool evidence are kept in the conversation history (in memory, and in sessions.db with --persist) marked as interrupted, so the next turn and --resume see how far the answer got instead of losing it.

This is a different knob from the gateway's timeouts.llm.default_secs (120s, server only) above. garra ask --timeout-secs (default 60s, exit 124) and the garra_agent MCP timeout (300s) are unchanged.

Validate config​

garraia config check

Options:

  • --json — machine-readable JSON output
  • --strict — treat warnings as errors (useful for CI)

The same check runs at boot (#1247)​

Every garraia start, garraia restart and garraia start -d runs the same check once, on the loaded config, before anything else happens (fork, PID file, stopping the running daemon, bind):

  • each Error is logged once at error level and each Warning once at warn level, followed by a summary line pointing to garraia config check. In start -d the same lines go to the terminal's stderr before the fork, because afterwards the log file is the only output;
  • the boot is refused (exit 78, EX_CONFIG) only for an Error on a short, closed list of fields that would fail open with nothing downstream to catch them. In v0.4.5 that list is the half-configured TLS pair (gateway.tls_cert_path / gateway.tls_key_path): with only one of them set the gateway used to serve plain HTTP in silence;
  • every other Error (for example an llm entry without a resolvable key) is reported but does not block the boot, so configs that work today keep working after an update;
  • the gateway.host / gateway.port findings are not repeated at boot: the bind is judged on the real address by the #1261 refusal (auth-config.md §5.1).

An invalid execution.profile is still a load error and never reaches this check. Out-of-range memory.retention.interval_hours / max_age_days do not block the boot either: the retention worker does not start (and deletes nothing), with an error in the log.

Advanced Options​

Custom Channels​

channels:
custom:
type: http
endpoint: "http://localhost:8080/webhook"
auth_header: "X-Custom-Auth"

Scheduling​

scheduler:
enabled: true
timezone: "America/Sao_Paulo"

Observability​

observability:
tracing:
enabled: true
endpoint: "http://localhost:4318/v1/traces"
metrics:
enabled: true
port: 9090

Tool sandbox (agent.sandbox)​

Runs the agent's process-spawning tools inside a throwaway container instead of on the host. Default mode: off changes nothing.

agent:
sandbox:
mode: all # off (default) | all | allowlist
backend: docker # docker | podman | ssh (ssh is remote execution, not isolation)
image: debian:bookworm-slim # must contain the programs the tools call
elevated: [] # tools that stay on the host even with mode: all
sandboxed_tools: [] # used by mode: allowlist
mount_workdir: true # mount only the (canonical) working dir, read-write
network_disabled: true

Covered tools: bash (shell line) and, since #1225 S2, run_tests, git_diff, code_review and repo_search (argv, no shell). Every container runs with --cap-drop ALL, --pids-limit 512, --security-opt no-new-privileges and your own uid (--user <uid>:<gid>, or --userns=keep-id on podman). Nothing falls back to the host: a missing backend, a stopped daemon, a relative or missing working dir, or ssh for a working-dir tool is a refused call.

Migration for mode: all. Those four tools now run in the container, so the image needs their programs (git, rg or grep, cargo/npm/python). The default debian:bookworm-slim has only grep: run_tests and git_diff answer "the program does not exist in the sandbox image". Either point image at one with your toolchain, or list the tool in elevated to keep it on the host. With network_disabled: true, cargo cannot download crates.

In execution.profile: standard, garraia mcp-server and the gateway only register bash when this section puts it in a working docker/podman container (#1272); see execution-profiles.md.

A remote container is backend: docker with a docker context pointing at ssh://host — note that the mount then refers to paths on the REMOTE host. There is no ssh + container backend (#1225 S5, won't-do: see security/threat-model.md §5.13).

Runs ledger (runs)​

The gateway records every scheduled run (mode: heartbeat) in the agent_runs table of sessions.db. garraia runs list reads it from the terminal, and GET /api/runs reads it over HTTP (#1227).

Retention​

runs:
retention_days: 0 # default: never delete
  • 0 (the default) keeps every row. An upgrade never deletes history. While retention is off the gateway logs once at boot how many runs the ledger holds and how to turn retention on.
  • 1..=3650 deletes terminal runs (done, error, cancelled, interrupted) whose end time (or start time, when no end was recorded) is older than that many days. The sweep runs at boot and then every 24 hours. Its log carries only the count, never run content.
  • A running row is never deleted, whatever its age. A run left running by a crash becomes interrupted at the next boot, and only then ages like any other terminal run.
  • garraia config check rejects values above 3650.

The key is top-level on purpose: agents: is a map of named agents, so agents.runs_retention_days would be read as an agent called runs_retention_days.

GET /api/runs​

Read-only. Query: status (running, done, error, cancelled, interrupted; anything else is 400) and limit (default 20, clamped to [1, 200]). The response is {"runs": [...]} with id, session_id, mode, status, started_at, finished_at (UTC ISO 8601 with Z) and goal_preview / result_preview / error_preview: at most 120 characters, with known secret formats redacted and control characters replaced. The full 500-character snippets stored in the ledger are not exposed over HTTP.

Access is stricter than the rest of /api/*:

  • With gateway.api_key set, a valid Authorization: Bearer is required. This is how a phone or another machine on the LAN reads the ledger.
  • Without gateway.api_key, only the local machine can read it: the peer must be loopback and the Host header must be a loopback address (127.0.0.1, [::1], any 127.x.y.z) or localhost. A LAN peer gets 503 runs: auth not configured; a loopback peer behind another Host name (DNS rebinding) gets 403.
  • Behind a reverse proxy, set gateway.api_key. Without it, a request that carries X-Forwarded-For, X-Real-IP, Forwarded or X-Forwarded-Host gets 503 runs: auth not configured even from a loopback peer: the peer is the proxy, not the owner's own client, and a proxy that rewrites Host to its upstream would otherwise pass the loopback checks.

Why there is no runs resume​

A scheduled run marked interrupted is already retried automatically: at boot the scheduler puts a task whose lease expired back to pending (recover_expired_leases), and the next tick executes it again, which records a new run in the ledger. A manual resume of the old row would execute the task twice (duplicate system message and duplicate channel delivery). The ledger also cannot replay a run faithfully: goal is truncated to 500 characters and no column links a run back to its task. Resuming is left out until a real consumer of the sub-agent coordinator exists; when it does, it must require explicit confirmation and create a new run that references the original.