Skip to main content
Translation from 2026-10-16; the original has changed since.

Configuration

All configuration is via environment variable. There is no application config file — what exists is permissions.json, which is project policy, not process configuration.

Two versioned files are read at runtime and are not configuration, although they're easy to confuse with it: permissions.json above, and docs/gates.yml (ADR 0054), the declarative gate registry. Neither has an environment variable to point to a path — the registry is found by walking up from __dirname — and neither changes behavior by being edited in production: the registry DESCRIBES the gates, it doesn't apply them. It travels inside the api image; see the runbook.

What travels is the registry and nothing it points at. The evidencia of a teste/ci gate names files under apps/api/test/, scripts/ci/ and .github/, and none of those are in the image. Checking that those targets exist is therefore a claim about the repository, enforced in CI, never by the process serving GET /gates — which is why the api reads the registry without looking for them. It used to look, and it answered 500 in every installation (RN-070).

up --wait only proves what has a healthcheck. In the development compose, api, engine and web had none, so docker compose up --wait reported them ready the moment the container started — before the process was listening. That is how a container could die seconds later with the command still reporting success. The three now carry the same check their production images already declare in the Dockerfile (/health for api and engine, which touches the database; / for the Vite server). The container broker got its own in a separate change; postgres and neo4j always had one. Nothing here is an environment variable — it is written down because --wait is the thing scripts trust, and a service without a healthcheck makes it lie.

The defaults below were extracted from the code, not from prior documentation. The when it fails column is the part that saves time: almost every variable has a default that works in development and a specific failure mode in production.

A variable only reaches the process if the compose maps it. docker compose does not forward the host environment into a container: a variable arrives if — and only if — it is written in that service's environment: (or an env_file). This is not a detail; it is how ANAMNESE_ENABLED and PSYCHOLOGIST_ENABLED stayed silently unreachable while three places in the code promised the 2026-08-10 pause was reversible with X=true plus a restart. Measured inside the running engine container, they came back empty and runtime.exs fell back to "false" — no error anywhere. Every boolean flag the engine reads is now mapped in all three compose files (dev, production and installation — the installation one only since AT-202), with the code's own default except for the two background-agent boot keys that production and installation turn off on purpose, and scripts/ci/flags-do-engine-no-compose.spec.ts fails the build for the next one that isn't (RN-540). Kubernetes is deliberately outside that rule: a Deployment/ConfigMap intercepts nothing, so there is no broken switch to fix there.

Development defaults are insecure on purpose. Values like dev-master-key-change-me exist so pnpm dev comes up without ceremony. In production they need to be changed — and for six of them the process refuses to boot without changing them (api or engine, marked with 🔒). Five follow the pattern of ADR 0059/RN-093: missing, set to the public example literal, or too short brings the boot down. See RN-114 for the four that joined the original GIT_OAUTH_STATE_SECRET.

Installation compose (docker-compose.install.yml)​

The compose that runs on an installed machine takes the five product images from mandatory variables, with no default — install.sh writes them into .env:

variablewhat
BRABO_API_IMAGEfull reference of the api image; ghcr.io/…@sha256:… when the source is GHCR, brabo-api:prod when built locally
BRABO_ENGINE_IMAGEsame, for the engine
BRABO_WEB_IMAGEsame, for the web
BRABO_BACKUP_IMAGEsame, for the backup job (under the backup profile)
BRABO_BROKER_IMAGEsame, for the container broker (ADR 0162). Written whether or not the broker is turned on: Compose interpolates the whole file before filtering by profile, so a ${VAR:?…} on a disabled service still refuses the file (measured)

They are ${VAR:?…} on purpose. With a default, a missing variable would bring half the stack up on an image nobody picked, and the mistake would show up as strange behaviour instead of a refusal.

These are not in the generated inventory below: the generator scans apps/, and these are read by Compose, not by product code (RN-527).

The broker is in this compose since ADR 0162, off, under the container-broker profile, and install.sh turns it on only when asked and answered "s" (RN-575). Then it writes four lines, together or not at all:

variablewhat install.sh writes
COMPOSE_PROFILEScontainer-broker — read by Compose from the --env-file, so up -d --wait brings the broker up with no flag
BROKER_URLhttp://broker:8090 — the api's side; without it the api never calls the broker
DOCKER_GIDthe socket's gid as seen from inside a container, measured with the broker image itself; failing to measure is a named refusal, never the 999 default
PROJECT_WORKSPACES_HOST_ROOT<DockerRootDir>/volumes/brabo_project_workspaces/_data, computed before startup and checked against the volume's real Mountpoint after it

BRABO_PROJECTS_HOST_BASE is not written: the compose derives it from BRABO_PROJECTS_BASE, as in the validation compose. Turning it on or off later is in the runbook.

The local models (Ollama, the llm profile) are asked about right after the broker (RN-691). Answering "s" measures a free host port first and writes:

variablewhat install.sh writes
OLLAMA_PORT11434, or the first free port up to 11443 when something (a native Ollama) already listens there; the whole range busy is a named refusal, nothing written
COMPOSE_PROFILESllm, joined with container-broker on ONE line when both were accepted (container-broker,llm) — two lines would not add up, the last one wins

Without them the semantic duplicate check is skipped. Turning them on later is in the runbook.

api​

Essentials​

variabledefaultwhen it fails
DATABASE_URLpostgres://brabo:brabo@localhost:5432/brabowithout it, nothing comes up
PORT3000—
API_JSON_BODY_LIMIT10mbceiling for the JSON body the api accepts, applied to the parser registered in main.ts before any route. It exists so that a change to the engine's transport ceiling (TRANSPORT_MAX_BODY_BYTES, 8 MiB) doesn't require a new deploy — the default keeps headroom over it. Too small, and a large context turn comes back as 413
NODE_ENV—production turns on strict CORS and key validation
API_PUBLIC_URLhttp://localhost:3000used in git OAuth callbacks; wrong = broken callback
ENGINE_URLhttp://localhost:4000 in code, http://engine:4000 in Composesynchronous api→engine commands fail. Leave it empty in .env: set there, it wins over the Compose default and the api tries to talk to localhost:4000 from inside its own container — every session activation dies with ECONNREFUSED and the frontend never moves. Each environment already has the right default without the line
ENGINE_PUBLIC_URLsame as ENGINE_URLused only to build the engineWsUrl (WebSocket) returned in POST .../runner-ticket/.../terminal-ticket — the runner and the web can be OUTSIDE the cluster, so the internal address (http://engine:4000) doesn't work; ENGINE_URL remains the one used for the usual synchronous api→engine calls (RN-419)
BRABO_VERSIONdevbecomes service.version in the OpenTelemetry resource — it's how you know which build generated a trace. The release image injects the tag via a docker-bake.hcl ARG; outside of a release it stays dev. It does not appear in /health, which deliberately doesn't return the version (see the route's description)
MIGRATIONS_FOLDER./src/db/migrations—

Security 🔒​

variabledefaultwhen it fails
CREDENTIALS_MASTER_KEY 🔒dev-master-key-change-me only outside productionwraps the DEKs. In production the api refuses to boot if it's missing, set to the default above (public — it's in .env.example), or shorter than 16 characters (RN-114). This is only the BOOT check: swapping it for a valid but different key without re-wrapping still makes every credential unreadable with no error at all — the failure shows up on first use. See rotation
CREDENTIALS_MASTER_KEY_PREVIOUS—only during rotation. Present = the api tries the previous key when the current one fails
GIT_OAUTH_STATE_SECRET 🔒dev-oauth-state-secret-change-me only outside productionsigns the OAuth state; weak = CSRF in the git connection flow. In production the api refuses to boot without it, with the default above (which is public — it's in .env.example), or with fewer than 16 characters. Generate with openssl rand -base64 32. See ADR 0059 and RN-093
WEB_ORIGIN 🔒http://localhost:${WEB_PORT}in production the api refuses to boot if it's missing or is *. CORS is strict per environment. The port is part of the value: the web on :5174 is a different origin and gets blocked — see ADR 0037. In the composes, the default derives from WEB_PORT, so changing the port carries CORS with it; setting WEB_ORIGIN by hand overrides the derivation and it becomes your responsibility to keep it consistent again
WEB_PORT5173 (dev) · 8088 (prod)published port of the web on the host. Not read by any service — it feeds the default of WEB_ORIGIN in the composes, and that's what keeps port and CORS from diverging

First-party auth​

Auth lives in the api's domain, and since the cutover it's also the sole issuer. Decisions in ADR 0031 and ADR 0032.

variabledefaultwhat it does
AUTH_JWT_SECRET 🔒dev-auth-jwt-secret-change-me only outside productionpassphrase the access token's Ed25519 pair is derived from via scrypt — no private key is committed. In production the api refuses to boot if it's missing, set to the default above (public — it's in .env.example), or shorter than 16 characters (RN-114)
AUTH_JWT_SECRET_PREVIOUS—accepted only for verification, during rotation; enters the JWKS and never signs
AUTH_TOKEN_PEPPER 🔒dev-auth-token-pepper-change-me only outside productionHMAC key for hashing opaque tokens (refresh, account links, PATs) and the lockout bucket key. Its own secret — it no longer falls back to AUTH_JWT_SECRET (RN-613). In production the api refuses to boot if it's missing, set to either example value from this repository, or shorter than 16 characters. An installation that ran without it must set it to the current AUTH_JWT_SECRET, or everyone is logged out — see the runbook
AUTH_ACCESS_TOKEN_TTL_MS90000015 min
AUTH_REFRESH_TOKEN_TTL_MS120960000014 days
AUTH_REFRESH_ABSOLUTE_TTL_MS2592000000absolute ceiling of the family, counted from login — without it rotation grants an eternal session
AUTH_REGISTRATION_ENABLEDtrueany value other than "false" keeps registration open
AUTH_LOCKOUT_ENABLEDtruesame convention
AUTH_LOCKOUT_WINDOW_MS900000sliding window of the count
AUTH_LOCKOUT_THRESHOLDS5:30,8:300,12:900email bucket ladder, failures:seconds
AUTH_LOCKOUT_IP_THRESHOLDS20:30,30:120IP bucket ladder, more permissive and with a short ceiling
AUTH_EMAIL_TOKEN_TTL_MS172800000email verification, 48 h
AUTH_RESET_TOKEN_TTL_MS3600000password reset, 1 h
AUTH_SET_PASSWORD_TTL_MS604800000setting the first password (migrated user), 7 days — longer than the reset because the recipient didn't ask for it
AUTH_IP_ATTEMPT_THRESHOLD60ceiling of attempts per IP on the auth routes
AUTH_MAIL_LOG_TOKENSfalsedev only: prints the verification/reset token to the log

The email ladder's ceiling equals the window on purpose. With a sliding window, whoever keeps insisting pushes the window along and stays blocked for as long as they keep it up; whoever stopped comes back with a clean window. A ceiling larger than the window would create a lockout the window can't represent, and would require a persistent locked_until column with an unlock queue. Don't touch one without the other.

Rotating AUTH_TOKEN_PEPPER logs everyone out and invalidates outstanding verification and reset tokens. Unlike the keys, the pepper does not have a _PREVIOUS. See the runbook.

Where the _PREVIOUS variables reach. AUTH_JWT_SECRET_PREVIOUS, CREDENTIALS_MASTER_KEY_PREVIOUS and BRABO_SERVICE_TOKEN_PREVIOUS are mapped in the environment: of all three composes (dev, production and install) as ${X:-} — the api gets the three, the engine and the broker get BRABO_SERVICE_TOKEN_PREVIOUS (RN-595). Empty is the same as absent in every reader, so outside a rotation nothing changes; during one, setting them in .env and recreating the service is enough. Before RN-595 they were mapped nowhere and docker compose doesn't forward the host environment, so a rotation silently became a hard swap. On Kubernetes they reach the Pods through the secret store (AT-220): the ExternalSecret pulls the whole brabo object with dataFrom.extract, so a _PREVIOUS present there lands in brabo-secrets and an absent one is simply not there. The price is that every key of that object becomes an environment variable of api, engine, migrations and backup — the store object must hold nothing else. The procedure is in the runbook.

Real SMTP (MailSender)​

MailSender sends real email only when MAIL_TRANSPORT=smtp — the default is log (link/token go to the api's log, AUTH_MAIL_LOG_TOKENS above), including in production: sending email is the operator's opt-in. Decision in ADR 0096.

variabledefaultwhat it does
MAIL_TRANSPORTloglog (default) or smtp. Any other value falls back to log
SMTP_HOST 🔒—SMTP provider host. Only when MAIL_TRANSPORT=smtp: in production the api refuses to boot if it's missing, whitespace-only, or set to the example value published in .env.example (RN-114)
SMTP_PORT587provider port — 587 is STARTTLS, 465 is implicit TLS (SMTP_SECURE=true)
SMTP_SECUREfalsetrue turns on implicit TLS on the connection (typically port 465)
SMTP_USER 🔒—SMTP auth username. Same requirement as SMTP_HOST in production
SMTP_PASSWORD 🔒—SMTP auth password/token. Same requirement as SMTP_HOST in production — never appears in logs
SMTP_FROM—sender, format "Name <email@domain>" (or just the email). Same requirement as SMTP_HOST in production, plus format validation

The email body is plain text, no HTML — the MailSender port doesn't carry structure for rich bodies, and a template engine would be an injection surface with no upside. The link uses WEB_ORIGIN (above).

Development seed​

Consumed by pnpm --filter api seed, not by the running api. Without an external IdP, this is where the credential to log into the local web and the smoke test comes from.

variabledefaultwhat it does
BRABO_SEED_PASSWORDbrabo12345678password of the seeded users (owner@brabo.dev, dev@brabo.dev), created with email already verified
BRABO_FORCE_SEED—unlocks the seed with NODE_ENV=production, where it refuses to run by default. Do not set it in a real environment: the account is born with a known, verified password

The seed is idempotent and doesn't touch the password of anyone who already exists. Running it again after someone has changed their own password doesn't revert it.

Rate limit​

Sliding window in Postgres — there's no Redis (ADR 0027).

variabledefaultwhat it does
RATE_LIMIT_ENABLEDtrueany value other than "false" keeps it on
RATE_LIMIT_WINDOW_MS60000window size
RATE_LIMIT_USER300requests per user per window
RATE_LIMIT_IP600requests per IP per window

If the rate limit table is unavailable, the request goes through. The guard protects against abuse, not unauthorized access — that's the job of the authentication guard, which runs first.

Internal traffic 🔒​

The shared secret that authenticates api ↔ engine. The same variable on both sides — each one sends the current value and accepts both, and that's what makes rotation possible without downtime (RN-035).

variabledefaultwhat it does
BRABO_SERVICE_TOKEN 🔒dev-service-token-change-me only outside productiongoes in the X-Brabo-Service-Token header and is what EngineServiceGuard compares in constant time. In production the api refuses to boot if it's missing, set to the default above (public — it's in .env.example), or shorter than 16 characters (RN-114)
BRABO_SERVICE_TOKEN_PREVIOUS—accepted only for verification, during rotation. When set, it goes through the same rule as the current token: surrounding whitespace is trimmed, and in production the api refuses to boot if it's the public default or shorter than 16 characters. Unlike the current token, it's never required (RN-598)

Setting only the NEW value on one side (without going through the _PREVIOUS dance) doesn't break anyone's boot: the symptom is 403 on /internal/* and 401 on api-to-engine calls. Procedure in the runbook. The BOOT check above (RN-114) is a different thing: it only fails on the public default or a missing/short variable, not on a mismatch between the two sides.

Git​

variabledefaultnote
GIT_LOCAL_REPOS_ROOT/tmp/brabo-git-reposLocal provider. In /tmp, repos disappear on reboot
PROJECT_WORKSPACES_ROOT/tmp/brabo-project-workspacesproject agent worktrees in Container mode. Needs to be the same path on the engine, and the same volume
BRABO_PROJECTS_BASEemptythe single host folder under which Mounted-mode projects live, mounted by identity into api and engine. Empty is a NORMAL state: the api reports projectsBase: null and the project wizard doesn't offer Mounted mode at all
GITHUB_OAUTH_CLIENT_ID / _SECRETemptyempty = GitHub OAuth connection unavailable (PAT still works)
GITLAB_OAUTH_CLIENT_ID / _SECRETemptysame

Project in Mounted mode: one base, mounted by identity​

Since ADR 0072, a project can be born in Mounted mode (the old local, renamed by ADR 0104) — the code lives in a user folder and PROJECT_WORKSPACES_ROOT doesn't participate in its root.

The path of a given project is a project-level datum (projects.workspace_path), chosen at creation and never an environment variable. What the ENVIRONMENT provides is the base those folders live under — BRABO_PROJECTS_BASE, one per installation (ADR 0141, RN-500):

# .env — ABSOLUTE. `~` is not expanded by Compose.
BRABO_PROJECTS_BASE=/home/voce/projetos-brabo

You don't have to write that line by hand. Since ADR 0146, pnpm bootstrap → Docker › Base de projetos runs scripts/dev/consentir-base.mjs, which proposes $HOME/projetos-brabo, refuses anything overlapping either the Brabo checkout or PROJECT_WORKSPACES_HOST_DIR (two roots with opposite owners — sharing a folder would make a Mounted project called loja land on top of a Container project whose workspace_dir_name is also loja), proves on macOS and Windows that Docker can actually see the folder by mounting it, and only then writes the variable.

The proof is a mount, never a read of Docker Desktop's settings.json: that file is undocumented, differs across versions and platforms, and describes what the user configured rather than what the daemon will do — the same rule the product already applies to LLM providers, where a capability is declared only when proved by execution. It has three outcomes and they don't collapse: proved, refused (the variable is not written), and couldn't prove — no local image to mount with — which writes the variable and says loudly that the proof did not run.

Run from the menu it only reports: every bootstrap menu item runs with stdin from /dev/null on purpose, so it cannot ask. To choose, run node scripts/dev/consentir-base.mjs in your own terminal.

# docker/docker-compose.yml — on BOTH `api` and `engine`, already written
volumes:
- ${BRABO_PROJECTS_BASE:-brabo_projects_base}:${BRABO_PROJECTS_BASE:-/data/brabo-projects-base}

The operator sets this once and restarts api + engine; nothing is edited per project, ever. Before this, every mounted project needed a hand-written bind-mount line in both services plus a restart — which kills every in-flight agent turn, terminal socket and LLM call in the installation.

The mount is by identity ($X:$X): the same absolute path on the host and inside both containers. That is what keeps honest the string the user types and the screen shows back, and it is why projectScopeRoot (api) and Engine.Actions.Workspace.workspace_dir/2 (engine) need no translation layer.

It is not PROJECT_WORKSPACES_HOST_DIR, and must never point at the same folder. A managed workspace is named by workspace_dir_name (UNIQUE) and a mounted project is named by the user, so <base>/loja and a container project whose folder name is loja would collide, with the repo bootstrap running git init inside someone else's project. The ADR has the other two reasons.

With the variable unset, Mounted mode isn't offered — never offer a mode the installation can't honor. And pnpm dev refuses to start when the base overlaps the Brabo checkout in either direction; that check lives in the preflight because it runs on the host, and the api can only compare against process.cwd() (/workspace inside its own container). See the runbook.

LLM​

variabledefaultnote
OLLAMA_HOSThttp://localhost:11434—
OLLAMA_REQUEST_TIMEOUT_MS300000inactivity ceiling of the Ollama socket, not total duration. A local model has a different order of magnitude of latency to the first token, hence its own env var; see inference environment
LLM_REQUEST_TIMEOUT_MS300000the same inactivity ceiling for the API providers (OpenAI and compatible, Anthropic). Applies to "didn't even send the headers" and to "stopped sending chunks mid-stream" — see LLM providers
HUGGINGFACE_API_TOKEN(unset)optional Hugging Face Hub token for the model search (GET /workspaces/:workspaceId/huggingface/models). Present, it goes as Authorization: Bearer … and raises the Hub's rate limit; absent, the header is simply not sent — the Hub accepts public search without a credential, and a missing token is never an error
HUGGINGFACE_HUB_URLhttps://huggingface.cobase URL of the Hub the search talks to. An override for tests (a fake HTTP server on an ephemeral port); in production it is left unset
HUGGINGFACE_REQUEST_TIMEOUT_MS15000inactivity ceiling of the Hub search request, same semantics as LLM_REQUEST_TIMEOUT_MS (the name is built in a constant, so the generated inventory below does not see it)

TODO(humano): no compose file maps the three HUGGINGFACE_* variables into the api service's environment:, and the service has no env_file, so setting them in the .env has no effect under Compose — should HUGGINGFACE_API_TOKEN be forwarded (at least in production and installation)?

Knowledge graph (ADR 0099)​

variabledefaultnote
NEO4J_URI—e.g. bolt://neo4j:7687. This one is the switch: empty = graph OFF, dependent routes degrade (GraphUnavailableError/503) — nobody needs a local Neo4j just to run the suite. In production, the absence of any of the three brings the boot down. Turning the graph on in development is this one line in .env and a restart of the api; until 2026-09-04 that did nothing, because the dev compose did not pass the three variables to the api service at all and the service has no env_file — docker-compose.prod.yml had supplied them since day one, which is why only development was affected
NEO4J_USERneo4j (dev)reaches the container with the same default the neo4j service uses for NEO4J_AUTH, so set it only to CHANGE the user — changing it there changes both sides at once. Two independent defaults would leave the api authenticating with credentials the server no longer has
NEO4J_PASSWORD 🔒dev-neo4j-password-change-me (dev only)same pairing rule as NEO4J_USER. No public default in production on purpose — there's no plausible "example value" for a database password, and docker-compose.prod.yml keeps NEO4J_AUTH empty so the official image's own entrypoint refuses to start rather than booting with a guessable one
GRAPH_PROJECTOR_INTERVAL_MS2000period of the poller that drains the outbox's graph_projection queue and writes handoffs/hypotheses/profiles/interactions to the graph (RN-416)
ARTIFACT_PROJECTOR_INTERVAL_MS2000period of the poller that drains the outbox's artifact_projection queue and writes each agent's artifacts as Markdown under the project's docs/ folder (RN-523). A DERIVED projection: the event log stays the source, and the folder can be deleted and rebuilt

Observability​

variabledefaultnote
OTEL_EXPORTER_OTLP_ENDPOINT—missing turns off EXPORT, not instrumentation (ADR 0035). The span is still created and trace_id still shows up in the log — that's what gives correlation in development, without a collector. Without it, the span is discarded at the end instead of leaving the process
OTEL_SERVICE_NAMEbrabo-api—
OTEL_DIAG_LOG—1 turns on OTel's own diagnostic log
LOG_LEVELinfo in production, debug outsidealso decides the FORMAT together with NODE_ENV: outside production the log comes out readable (pino-pretty in-process, with the layer tree); in production, one JSON line per event
METRICS_GAUGE_INTERVAL_MS15000collection period of the domain gauges

engine​

Essentials​

variabledefaultwhen it fails
DATABASE_URLecto://brabo:brabo@localhost:5432/brabonote the ecto:// scheme, not postgres://
POSTGRES_HOST / _USER / _PASSWORDlocalhost / brabo / braboused when DATABASE_URL isn't assembled
POOL_SIZE—an exhausted pool jams Oban and the queue stops being consumed
PORT4000—
PHX_HOST / PHX_SERVER—PHX_SERVER=true is what makes the release serve HTTP
SECRET_KEY_BASE 🔒—required in the release (runtime.exs, :prod block, Phoenix's default raise). Until RN-114, docker-compose.prod.yml supplied a public literal as a fallback and masked that raise — the variable always arrived SET. The raise itself didn't change
API_URLhttp://localhost:3000the engine calls the api back through here
ECTO_IPV6——
SKIP_MIGRATIONS—used by the migration Job

Cluster and shutdown​

variabledefaultnote
DNS_CLUSTER_QUERY—the headless Service that forms the Erlang cluster. Without it each replica is an island and every rollout drains everything
SHUTDOWN_DRAIN_TIMEOUT_MS45000preStop window. Goes up together with terminationGracePeriodSeconds, never alone
SESSION_HEARTBEAT_TIMEOUT_MS30000how long the session waits for the tab's heartbeat before asking the api whether there is pending work
SESSION_CONVERSATION_IDLE_TIMEOUT_MS28800000 (8h)ceiling of an idle conversation: a conversational agent waiting for the user holds the session past the heartbeat for this long, counted from the end of its turn; past it the session closes as closed with cause conversation_idle_timeout (RN-581). Mapped to the engine in all three composes (dev, production, installation) with this same default; not written in deploy/k8s/, on purpose — nothing there intercepts the environment, so absence is the default, and changing it is an env: item in the overlay's engine patch
RELEASE_NAME / RELEASE_NODE—node identity in the distribution

Harness​

variabledefaultnote
TOOL_LOOP_MAX_ITERATIONS8ceiling of loop turns for the conversational agent. Once exhausted, the agent ends with a blocking artifact
TOOL_LOOP_MAX_ITERATIONS_EXECUCAO60ceiling for dev agents. Higher because they explore the repository before writing — and because task_budget_micros keeps spend in check underneath
TOOL_LOOP_MAX_ITERATIONS_GATE60ceiling for QA subagents, for the same reason
DEFAULT_CONTEXT_WINDOW128000used when the model doesn't declare its window (the conversational agents never do); was 8192 until RN-702
CONTEXT_COMPACTION_THRESHOLD0.7fraction of the window that triggers compaction
LLM_TURN_TIMEOUT_MS3000005 min per turn
TOOL_ROUTER_TIMEOUT_MS2000ceiling, in ms, of ONE call to the Jev tool router (ADR 0179, RN-625). Read by the api, not the engine. Past it the step falls to the whole tool catalog with motivoDaQueda: timeout; the turn never fails because of the Jev. 0 of 328 measured requests went over 2 000 ms. The on/off switch is per workspace (PUT workspaces/:id/tool-router), not an environment variable
TERMINAL_ACTION_TIMEOUT_MS120000ceiling for a terminal command (2 min since RN-757: a plain npm install did not fit in the old 15 s). The engine waits this value + 90 s on container-exec and + 120 s on the propose_action of a terminal action; above ~180 s the outer wait passes the 300 s header limit of the Node fetch the api uses to call /internal/actions/execute
TERMINAL_OUTPUT_MAX_BYTES32768BYTE ceiling of a command's output (RN-074). The output stays in the loop's history and travels on every following turn; without a ceiling, a find over a large tree brings down the entire execution with a 413 from the provider
READ_FILE_MAX_BYTES32768BYTE ceiling of the content read by read_file (RN-141) — same class of overflow as RN-074, through the read_file door instead of the terminal; independent variable, same value by coincidence of context
SEARCH_WORKSPACE_MAX_BYTES32768BYTE ceiling of search_workspace's final text (RN-150) — same class of overflow as RN-074/RN-141, through the search door; independent variable
SEARCH_WORKSPACE_MAX_HITS500ceiling on the NUMBER of hits search_workspace collects before assembling the response (RN-150) — stops scanning/reading content as soon as it hits the ceiling, avoiding paying I/O for a tree with too many hits only to truncate by bytes afterward
SECOPS_SCAN_TIMEOUT_MS1800003 min for the SecOps scanner
TRANSPORT_MAX_BODY_BYTES8388608 (8 MiB)TRANSPORT ceiling that context compaction respects on top of the model's window (RN-412) — the effective window is min(context_window, this ceiling converted to tokens), so compaction fires BEFORE the body overflows the api's HTTP limit, not only when the model would "forget"
GRAPH_INSTRUCTION_TEMPLATES_ENABLEDfalseturns on the :graph source of InstructionFiles — today only the ux-designer identity resolves a template from the graph before the inline text (RN-413). Its OWN name, not GRAPH_TEMPLATES_ENABLED below — the two would collide with opposite defaults if they shared the key

Psychologist​

variabledefaultnote
PSYCHOLOGIST_ENABLEDfalseGLOBAL pause of NEW rounds (automatic and on-demand) — the user's product decision on 2026-08-10, not a bug, same flag shape as ANAMNESE_ENABLED below (which was turned back on in RN-680; this one was not). Doesn't erase anything that already exists. Turning it on requires restarting the engine (RN-117). No boot key pairs with it: the Psychologist's automatic trigger is session close, not a tick
PSYCHOLOGIST_TRIAGE_THRESHOLD20events in the session that separate a light analysis from a heavy one
PSYCHOLOGIST_MAX_ITERATIONS_LEVE / _PESADA4 / 8—
PSYCHOLOGIST_BUDGET_MICROS_LEVE / _PESADA50000 / 300000USD 0.05 and USD 0.30 per analysis
PSYCHOLOGIST_MAX_PROMPT_EVENTS_LEVE / _PESADA50 / 400how many events go into the prompt
PSYCHOLOGIST_MAX_PAYLOAD_CHARS600truncation of each event's payload
PSYCHOLOGIST_RAG_TOP_K3how many relevant rag_search snippets go into the context, deducted from the recent-events ceiling above (RN-417)
GRAPH_TEMPLATES_ENABLEDfalseturns on resolving psychologist-kickoff/anamnese-kickoff/context-manager-summarize as a graph template — key SHARED between Psychologist, Anamnese and the ContextManager's summarization prompt (RN-417), not to be confused with GRAPH_INSTRUCTION_TEMPLATES_ENABLED above. Off (the default) means the graph API is never even called; on, any failure falls back to the inline text with no error

Anamnese​

variabledefaultnote
ANAMNESE_ENABLEDtrueproduct flag: may a NEW round happen (periodic and on-demand). false is a GLOBAL pause that erases nothing (RN-115); changing it requires restarting the engine. It was false from 2026-08-10 (the user's pause) until the owner turned the Anamnesis back on with fixes on 2026-10-01 (RN-680): a round with no eligible subject — an effective project member with their own interaction in the window — makes no LLM call. On its own it is not enough for the PERIODIC round: START_ANAMNESE (boot key, below) also has to be true, and the two answer different questions
ANAMNESE_INTERVAL_SECONDS90015 min between runs
ANAMNESE_MIN_EVENTS10below this it doesn't run — avoids profiling on noise
ANAMNESE_INITIAL_WINDOW_DAYS30window of the first run
ANAMNESE_MAX_ITERATIONS6—
ANAMNESE_BUDGET_MICROS200000USD 0.20 per run
ANAMNESE_MAX_PROMPT_EVENTS500—
ANAMNESE_MAX_PAYLOAD_CHARS600—

Load guards​

variabledefaultnote
START_OUTBOX_DRAINtrueboot key of the outbox drain (Engine.Workers.OutboxDrainWorker, every 2s) — the ONLY consumer of the session/task/container events the api writes to the outbox — plus orphan-worktree pruning and orphan-session adoption. Off, nobody delivers those events: a session the api closed keeps its process and conversational agents running (RN-581), and a dev agent waiting for an action decision, a gate, its PR or the container reaching running is never woken. It does NOT turn the Psychologist on: the drain only enqueues it with PSYCHOLOGIST_ENABLED=true. false only in the production compose (docker-compose.prod.yml), on purpose; the install compose uses the code's true since AT-219
START_ANAMNESEtruetest/dev LOAD guard: prevents kickoff/0 from even being called on boot, but doesn't decide anything about product — not to be confused with ANAMNESE_ENABLED (product: GLOBAL pause, survives any value of this one). Turning it off prevents new enqueues, it doesn't clear the queue. Accumulated jobs run on the next boot — the queue needs to be purged. See inference environment
START_MODEL_SYNCtrueperiodic tick of the model catalog sync. Turning it off doesn't freeze anything: the "Update catalog" button on the settings screen calls the same use case (RN-043)
MODEL_SYNC_INTERVAL_SECONDS21600 (6h)a provider's catalog changes on a scale of days, and each round spends one API call per provider — hence the generous default
START_GATE_RESCUEtrueperiodic tick of the gate-cycle rescue (Engine.Gates.GateRescuer, RN-140). Turning it off doesn't change the boot: the rescue runs once there regardless
GATE_RESCUE_INTERVAL_SECONDS300 (5 min)a stuck gate blocks the user's entire PR — a much shorter interval than Anamnese/model sync, and each tick costs only a query that's almost always empty
GATE_RESCUE_STALE_AFTER_SECONDS900 (15 min)generous on purpose: a QA subagent's ToolLoop can legitimately run up to TOOL_LOOP_MAX_ITERATIONS_GATE (60) iterations, and a short threshold would rescue — and duplicate — a merely slow cycle

Internal traffic and observability​

variabledefault
BRABO_SERVICE_TOKEN 🔒dev-service-token-change-me only outside production — the same value as the api. Surrounding whitespace is trimmed, and a whitespace-only value counts as missing. In a :prod release the engine refuses to boot if it's missing, the public default above, or shorter than 16 characters, naming the variable — the same rule as the api (RN-601)
BRABO_SERVICE_TOKEN_PREVIOUS 🔒— accepted only for verification, during rotation. Trimmed; in a :prod release the engine refuses to boot if it's the public default or shorter than 16 characters. Never required, and equal to the current one means no rotation (RN-601)
OTEL_EXPORTER_OTLP_ENDPOINT— the Elixir exporter speaks HTTP/protobuf on 4318, not gRPC on 4317. Missing turns off only the export (traces_exporter: :none), not instrumentation — see ADR 0035
WEB_ORIGIN— the same variable as the api, and it feeds TWO things here: the Phoenix socket's check_origin (the live team panel) and the HTTP CORS of the health routes, which the browser needs to read /health (ADR 0037). Missing in production closes CORS and keeps check_origin at Phoenix's strict default — the engine still boots regardless, unlike the api
PROJECT_WORKSPACES_ROOT/tmp/brabo-project-workspaces — same as the api's, on the same volume

SOME_APP_SSL_CERT_PATH, SOME_APP_SSL_KEY_PATH and MIX_TEST_PARTITION are leftovers from the Phoenix scaffold and test configuration. Do not configure them.


web​

The web is static, served by nginx. It reads its configuration from two sources, and the distinction matters:

sourcewhenhow
import.meta.env.VITE_*buildbaked into the bundle. Changing it requires a rebuild
window.__BRABO_CONFIG__runtimeserved at /config.js, generated by the container's entrypoint

That's why the same image serves every environment: /config.js is rewritten at boot. The VITE_* vars are the development fallback.

variableserves for
VITE_API_URLapi address
VITE_ENGINE_URLengine address (Phoenix channel)
VITE_LOG_LEVELlevel of the browser's JSON logger (default info). In a cluster the WEB_LOG_LEVEL key of brabo-config is in charge, which the entrypoint writes to /config.js — VITE_* only counts in a local build
VITE_BRABO_VERSIONversion shown in the footer of the auth screens (default dev). The only one that's build-time by choice, not by limitation — see below

A blank page after deploy is almost always /config.js pointing to localhost — the deploy smoke test checks exactly that.

Why the version doesn't go through /config.js​

URLs belong to the environment: the same image needs to talk to the staging api and to the production api, and that's what /config.js exists for (ADR 0024). The version belongs to the artifact — the image brabo-web:1.1.2 shouldn't be able to report anything else. If it came from /config.js, the footer would become an editable field instead of an identity, and a wrong ConfigMap would make the screen lie about which build is running.

The full path, from commit to screen: release.yml computes versao=${TAG#v} → passes it as VERSION to docker buildx bake → the web target of docker-bake.hcl converts it into VITE_BRABO_VERSION → the ARG/ENV of docker/web/Dockerfile.prod exposes it to pnpm build → Vite inlines it into import.meta.env → runtime-config.ts reads it → AuthLayout shows it. The same VERSION feeds the api image's BRABO_VERSION (ADR 0036).


Backup​

Consumed by the backup image, not by the apps — as a CronJob on Kubernetes and as docker compose run --rm backup … on a compose install. Details in Restore; the reasoning is ADR 0152.

The destination is one of two, and the choice is BACKUP_DIR. Set it and the backup goes to disk; leave it empty and it goes to S3, which is what the Kubernetes CronJob does. The five BACKUP_S3_* variables stopped being unconditionally required: they are the configuration of one destination, and demanding a bucket before you can migrate demands infrastructure a single-machine install never asked for.

variabledefaultnote
BACKUP_DIR— (S3)destination directory. Set ⇒ disk; empty ⇒ S3. In the compose service it defaults to /backups
BRABO_BACKUP_HOST_DIRbackup_local (named volume)host path mounted at /backups. The named-volume default verifies a backup; it is the wrong destination to migrate with, because docker compose down -v deletes it along with what it was meant to save
BACKUP_S3_ENDPOINT / BACKUP_S3_BUCKET—S3-compatible destination; required only when BACKUP_DIR is empty
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY—bucket credential
BACKUP_KEEP_DAILY7retention by count, not by age
BACKUP_KEEP_WEEKLY4—
GIT_REPOS_DIR/data/git-reposthe bare repos of local-provider projects. Absent ⇒ the step is skipped and said so (the Kubernetes CronJob does not mount this volume)
RESTORE_DB—name of the restore's destination database
RESTORE_PREFIXdaily/weekly/ to restore from a weekly copy
RESTORE_GIT_PREFIXgit-daily/git-weekly/ for the weekly copy of the bare repos
RESTORE_GIT_FORCE—1 allows extracting over existing bare repos. Off by default: overlaying two repository states produces a mix that no git complains about
RESTORE_ADMIN_URL—connection with CREATEDB permission; in production it's separate from DATABASE_URL

Four prefixes, not two: daily/ and weekly/ hold the Postgres dumps, git-daily/ and git-weekly/ the bare-repo archives. They are kept apart because retention is by count — two kinds of file under one prefix would make "keep 7" mean three and a half backups.

Local inference (containers)​

These are not read by our code — they belong to the ollama container, and they're here because they're the most frequent cause of an agent behaving strangely. The symptom table is in inference environment.

variablewhy
OLLAMA_CONTEXT_LENGTHthe default of 4096 silently truncates a prompt built for 128k
OLLAMA_MAX_LOADED_MODELSwith a high OLLAMA_KEEP_ALIVE, models pile up until memory overflows
OLLAMA_KEEP_ALIVEhow long the model stays resident
DEMO_QA_MODELpoints the QA gate to an API model — the per-agent binding wins over the project's

The ollama image version is not a variable — it is a digest. Like every third-party image in docker/, it is pinned as ollama/ollama:0.40.6@sha256:… (ADR 0159, tag inline since ADR 0178), and the same digest is used by ci.yml/golden-set-rag.yml, because the RAG golden-set floor is keyed by model rather than by environment (ADR 0138). So an inference behaviour that changed between two docker compose pulls is no longer a possible cause: nothing moves unless someone edits the digest. Bumping it is a runbook procedure, and scripts/ci/imagens-pinadas.ts refuses a bump that reaches only some of the places the tag appears.


Local observability (containers)​

Also not read by our code: these are the ports of the docker/docker-compose.observability.yml overlay, which brings up Prometheus, Loki and Grafana alongside the development stack (pnpm dev:obs). The mechanism is in local observability.

variabledefaultwhy
GRAFANA_PORT3001same port the cluster's Grafana uses; the two don't coexist, for the same reason pnpm dev and make deploy-local don't coexist
PROMETHEUS_PORT9090the port the runbook already uses in the cluster's kubectl port-forward
LOKI_PORT3100only for querying directly; the normal path is through Grafana

The overlay does not define OTEL_EXPORTER_OTLP_ENDPOINT: without a Collector in between, pointing the apps at an address that doesn't exist just produces an export error on every turn (ADR 0035 separated instrumenting from exporting exactly for this reason). Metrics and logs work without it; traces still require the cluster.


Container broker​

The broker (ADR 0130) is the only process in the product that talks to a Docker daemon on the server, and the only service with /var/run/docker.sock mounted. Locally it comes up with pnpm dev; in production it stays under profiles: ["container-broker"]. The two compose files differ on purpose (ADR 0146, point 3) — do not uniformize them.

It used to be off by default in both, on the grounds that nothing called it yet. That premise died: it has four real callers, and ADR 0144 made Mounted mode bring its container up through the broker — so with the profile off, the default local mode ended in BrokerIndisponivelError. What sustains the asymmetry is what the socket means on each side: whoever runs pnpm dev already holds it (they are running docker compose), while in production it is a real privilege boundary and the operator is not the developer.

This changes when the broker runs, never what it accepts: the five containment layers are untouched, and it still receives a projectId plus one of five operations, with no parameter anywhere to write privileged or a free -v.

Coming up by default is what exposed a defect that had never mattered: the local image installed its dependencies on START, and the broker's only network is internal: true, so there is no registry to reach — the container died with getaddrinfo EAI_AGAIN registry.npmjs.org, and with no healthcheck up --wait still printed Healthy. Dependencies now install at BUILD time and the service has a healthcheck; the network was not touched, and must not be. The operational detail lives in the runbook.

In production, bring it up explicitly:

docker compose -f docker/docker-compose.prod.yml \
--profile container-broker up -d broker
variabledefaultwhen it fails
BROKER_URLhttp://broker:8090 in the dev compose; empty in production and installation (read by the api)Empty is a NORMAL state: whoever reads a container's observed state then says "not observed" instead of inheriting the recorded one (RN-486), and the api answers brokerConfigurado: false, so project creation only offers runner (RN-573). Locally the broker is up by default, so the dev compose defaults it to the service it brings up (RN-599) — a value in .env still wins. The production and installation composes keep no default on purpose: there the broker is under a profile, and whoever turns it on writes the URL
BROKER_PORT8090Port the broker listens on. It publishes NOTHING to the host — only the api reaches it, through the internal: true compose network
BRABO_SERVICE_TOKEN 🔒dev-service-token-change-me in developmentThe SAME secret as api ↔ engine, in the same header. With NODE_ENV=production the broker refuses to boot when it is empty, when it is the repository's public literal, or under 16 characters — the RN-114 rule, which here guards a process that talks to the host's Docker. BRABO_SERVICE_TOKEN_PREVIOUS, when set, goes through the same rule except the requirement: trimmed, and in production the broker refuses to boot if it's the public literal or under 16 characters (RN-601)
API_URLhttp://api:3000Where the broker READS the Architect's decision. It does not receive a container spec; it comes and gets one (RN-485)
PROJECT_WORKSPACES_HOST_ROOT—The project folders' root on the HOST, not inside any container. Without it, start refuses naming this variable and the other four operations keep working. Do not confuse it with PROJECT_WORKSPACES_ROOT, which is the path inside the containers: -v is resolved by the DAEMON against the host filesystem, and a path from inside the api would make it create and mount an EMPTY folder
DOCKER_GID999 (compose)The gid of the host's docker group (getent group docker | cut -d: -f3). The socket is root:docker and the broker runs non-root, so compose uses group_add. The default is the most common one and is wrong on several distributions. Since ADR 0146 this matters on EVERY development machine, not only where someone turned the profile on — getting it wrong does not break the boot, it breaks the use: every operation dies with "permission denied" on the socket, surfacing only when someone proposes container_start. pnpm dev reports the state of this variable on every run (RN-512), and says "does not apply" on a machine with no docker group rather than accusing it

PROJECT_WORKSPACES_HOST_ROOT cannot be derived from a managed Docker volume. In the dev compose it derives from PROJECT_WORKSPACES_HOST_DIR (above) — so leaving that one unset, the state of a freshly copied .env.example, leaves the broker with no root and container mode with no container: the stack comes up healthy and container_start ends refused. pnpm dev reports that on every run, in the same mould as DOCKER_GID, and never refuses to start (RN-599). It also reports the ~ case: Compose expands ~ in the bind-mount source that api/engine use, but NOT in the variable the broker receives (measured with docker compose config), so write the path ALREADY EXPANDED. And an explicit PROJECT_WORKSPACES_HOST_ROOT that differs from the folder api/engine mount is reported as divergent — the container would get a folder the agents never wrote to. Switching from the managed volume to a host folder does not migrate what is already in the project_workspaces volume: the data stays there, it just stops being what api and engine see.

BRABO_PROJECTS_HOST_BASE is the broker's SECOND root — the base of Mounted projects, also on the host (ADR 0141, ADR 0144). Unlike the one above it does not need to be filled in: the compose derives it from BRABO_PROJECTS_BASE, which is already a host path by definition (it is what api and engine mount by identity). Set it explicitly only if the Docker daemon reaches that folder by a different path.

It is what makes a Mounted project able to have a container at all (RN-503): the api sends a discriminated locator (localizacao.tipo — gerenciada or montada) plus the relative segment, and the broker resolves it against the matching root. The two roots never stand in for each other. Missing this one makes start of a mounted project refuse with 503, NAMING the variable, without touching a container — falling back to PROJECT_WORKSPACES_HOST_ROOT would mount another project's folder, because the managed root is named by workspace_dir_name and the base is named by the user.


Dev container user (build-time)​

Not read by our code either, and not even a runtime environment variable: these are Docker build.args for the three dev images (docker/api/Dockerfile, docker/web/Dockerfile, docker/engine/Dockerfile), consulted once at docker compose build and baked into the USER each container runs as. They exist so the containers run with the same UID/GID as your host user instead of root — everything written to a bind mount (apps/api/dist, a project workspace in mounted execution mode) is then already yours, no sudo chown needed afterward.

variabledefaultwhy
DEV_UID1000your id -u. NOT read from the shell's ${UID} — it's read-only in bash and isn't exported to the environment by default, so the compose file would always see it empty
DEV_GID1000your id -g, same reasoning

1000/1000 matches both the most common single-developer Linux setup and the node user that node:26-alpine (the api/web base image) already ships — when your pair matches, the Dockerfiles reuse that built-in user instead of creating a new one. An environment that already has containers running as root from before this change needs a one-time chown of the node_modules/_build/deps/.mix/.hex named volumes (or docker compose down -v to let them be recreated) — see the note in Getting started.

The production and installation composes are different: the engine runs from its production image as the fixed engine user (uid/gid 1000, no DEV_UID), with read_only: true and a tmpfs at /home/engine. That tmpfs is declared uid=1000,gid=1000,mode=0700 — not a variable — because semgrep (the SecOps gate) writes ~/.semgrep there; created as root, the scan returns nothing and the gate approves without static analysis (RN-707, runbook The SecOps gate is stuck on "SAST não rodou").

Both engine images pin semgrep with the build argument SEMGREP_VERSION (1.183.0, the same in docker/engine/Dockerfile and Dockerfile.prod, held equal by scripts/ci/semgrep-do-engine.spec.ts). It is a build argument, not a runtime variable — nothing goes in the .env. Unpinned, pip took 1.179.0, which publishes no musllinux wheel: on the Alpine image the package came from the sdist without semgrep-core and every scan failed. Raising it means picking a release with a musllinux wheel on PyPI and changing both Dockerfiles in the same PR.

The engine images run Elixir 1.20.4 / OTP 29.1.1 on Alpine 3.24 (builder hexpm/elixir:1.20.4-erlang-29.1.1-alpine-3.24.2, runtime alpine:3.24.3, both by digest — ADR 0209). OTP below 29.1 aborts at boot under Alpine 3.24's musl 1.2.6 on hosts whose CPU has AMX (sys_sigaltstack(): Internal error); do not lower the OTP without lowering the musl. Nothing goes in the .env.

None of the six Dockerfiles uses corepack (Node 25+ no longer ships it, AT-360): the build installs pnpm from the registry tarball, checked against a sha512 written in the Dockerfile itself — the build arguments PNPM_VERSION (11.3.0, the same as packageManager in package.json, so pnpm never tries to download another version at runtime) and PNPM_SHA512. They are build arguments, not runtime variables, and there is nothing to set in the .env: bumping pnpm means changing packageManager, the PNPM_VERSION of the workflows and both arguments in the six Dockerfiles in the same PR — see the runbook section on the broker image.


Tooling variables (not product)​

Everything above is product: what whoever operates an installation sets in the deployment .env. What follows is tooling — read only by CI and by whoever develops Brabo. None of it belongs in an operator's .env, and that distinction is the reason the inventory below marks each source.

Demo and seed scripts — apps/api/scripts/​

They drive real agents against a local Ollama, so what they take is a model name and a deadline. Every one of them has a default and none is required.

variabledefaultwhat it changes
DEMO_MODELqwen2.5-coder:7bthe model every demo script uses, unless one of the ones below overrides it for a specific role
DEMO_QA_MODELDEMO_MODELonly the QA agent, in the PR-gates demos — so you can judge with a model other than the one that wrote the code
DEMO_WORKFLOWS_MODELDEMO_MODELonly the Infra agent in the GitHub-workflows demo
DEMO_MODEL_LEVEqwen2.5-coder:7bthe Psychologist's light model (the one the cheap pass uses)
DEMO_MODEL_PESADOqwen-psicologo-pesado:latestthe Psychologist's heavy model. It's a local Modelfile variant, not a published tag — hence the one below
DEMO_OLLAMA_COPY_FROMqwen2.5-coder:7bthe base the demo copies from when it has to create the heavy model on the local Ollama
DEMO_TIMEOUT_MS1800000 (30 min); 900000 in the dev-agent demohow long the script waits for the run to finish before giving up
DEMO_ANAMNESE_TIMEOUT_MS900000 (15 min)the same deadline, for the Anamnesis demo
DEMO_PSICOLOGO_TIMEOUT_MS600000 (10 min)the same deadline, for the Psychologist demo
GOLDEN_SET_QA_MODELqwen2.5-coder:latestthe model that seed-golden-set-qa.ts records as the judge of the QA golden set (ADR 0123)

NOME, which the inventory below lists under this source, is not a variable: it's the literal process.env.NOME inside a prompt string in demo-pr-gates.ts, teaching the agent how to replace a hardcoded secret. The inventory extracts by pattern and has no way to tell a mention from a read — it's listed here instead of being filtered at the source, because a name-by-name ignore list is a second place to keep in sync.

Browser E2E — e2e/​

e2e/ isn't a workspace member (ADR 0120) and runs against the production compose, which is why its addresses are the published ports and not the dev ones. The credentials are the seed account's — the same ones docker/smoke.sh creates. The inventory scans every .ts under e2e/, the Playwright specs included — there the spec is the code that reads the environment, not a unit test beside it (AT-124).

variabledefaultwhat it changes
E2E_API_URLhttp://localhost:3000where the suite's HTTP support hits the api
E2E_WEB_URLhttp://localhost:8088the baseURL Playwright opens, and the origin the CSRF assertion depends on
E2E_USERowner@brabo.devthe account the suite logs in with
E2E_PASSWORDthe seed password hardcoded in e2e/suporte/api.tsthat account's password. It's the password of a local seed, never a real one — it isn't repeated here so that the only copy stays the one in the code

Local cluster and scheduled proofs — deploy/k8s/​

The bootstrap (make deploy-local) and the property proofs that .github/workflows/propriedades.yml runs weekly read these from the environment. All have defaults; none belongs in an installation's .env. The inventory scans every deploy/k8s/*.sh and matches only the expansion with a default (${X:-…}), which is how a shell script reads a variable from outside (AT-212).

variabledefaultwhat it changes
BRABO_CLUSTER_NAMEbrabothe name of the k3d/kind cluster the bootstrap creates or reuses
BRABO_CLUSTER_TOOLk3dkind switches the bootstrap to kind — which ignores NetworkPolicy in silence, hence k3d as the default
BRABO_KEEP_CLUSTERunset1 reuses an existing cluster with the same name instead of deleting and recreating it
BRABO_SKIP_BUILDunset1 skips building the production images and uses the ones already in the Docker daemon
BRABO_SKIP_OBSERVABILITYunset1 skips Tempo, Loki, Collector, Alloy and Grafana (Prometheus and the adapter stay: the engine's HPA reads them). Only propriedades.yml sets it (AT-177)
TAGunseta release tag (vX.Y.Z-qa.N) for the bootstrap to check out and deploy instead of the working tree; requires a clean tree
BRABO_NAMESPACEbrabothe namespace every proof script talks to
API_URL / ENGINE_URL / WEB_URLhttp://localhost:3000 / :4000 / :8088where the proof scripts reach the cluster's services — the ports the k3d load balancer publishes. Same names as the product variables above, a different reader: here they are only the scripts' target
SMOKE_USERowner@brabo.devthe account the smoke and the proofs log in with
BRABO_SMOKE_PASSWORDthe seed's local password, hardcoded in the scriptsthat account's password — also what the bootstrap passes to the seed pod as BRABO_SEED_PASSWORD
HPA_JOBS60how many probe jobs make hpa-test enqueues to make the engine scale
HPA_TIMEOUT240seconds make hpa-test waits for the scale-up
ROLLOUT_SESSIONS5how many sessions make rollout-test opens before the rollout
ROLLOUT_CONVERGENCE_SECONDS120the ceiling for every session to be adopted or drained after the rollout. Do not raise it to turn a red run green (see the orphan-session gap in CLAUDE.md)
ROLLOUT_EVIDENCE_DIRa mktemp -dwhere make rollout-test writes the evidence (pod logs, events, replica samples); propriedades.yml sets it and uploads the folder
EVIDENCIA_INTERVALO2seconds between the evidence collectors' samples; the spec shortens it (AT-174)
RESTORE_MUTACAOunsettabela-faltando turns make test-restore into make test-restore-mutacao, the deliberate break it must catch (AT-126)
RESTORE_KEEP_JOBunset1 keeps the backup/restore Jobs after make test-restore, for inspection
PROJECT_WORKSPACES_ROOT_NO_POD/data/project-workspaceswhere make test-reprojecao-artefatos-k8s looks for the artifact folder inside the api pod
KUBE_VERSION1.31.0the Kubernetes version make k8s-validate validates the rendered overlays against

Full inventory​

The tables above explain what each variable does. This section is the inventory: extracted from the code on every pnpm docs:generate, it exists so that a new variable doesn't go undocumented anywhere without anyone noticing.

⚠️ Block generated by pnpm docs:generate. Do not edit by hand — the next build overwrites it.

Inventory extracted from the code: 182 variables read at runtime. All have a description in the tables above.

Each source is marked product — what whoever operates the installation sets in the deployment .env — or tooling, read only by CI and by whoever develops the product. A tooling variable never belongs in an operator's .env.

api — 67 variables · product

  • API_JSON_BODY_LIMIT (apps/api/src/main.ts)
  • API_PUBLIC_URL (apps/api/src/application/use-cases/auth/start-social-login.use-case.ts)
  • ARTIFACT_PROJECTOR_INTERVAL_MS (apps/api/src/application/artifact-projection/artifact-projector.ts)
  • AUTH_ACCESS_TOKEN_TTL_MS (apps/api/src/infrastructure/security/ed25519-access-token-issuer.ts)
  • AUTH_EMAIL_TOKEN_TTL_MS (apps/api/src/application/use-cases/auth/auth-config.ts)
  • AUTH_IP_ATTEMPT_THRESHOLD (apps/api/src/application/use-cases/auth/auth-config.ts)
  • AUTH_JWT_SECRET (apps/api/src/infrastructure/security/auth-key-material.ts)
  • AUTH_JWT_SECRET_PREVIOUS (apps/api/src/infrastructure/security/auth-key-material.ts)
  • AUTH_LOCKOUT_ENABLED (apps/api/src/infrastructure/persistence/drizzle/drizzle-login-throttle.ts)
  • AUTH_LOCKOUT_IP_THRESHOLDS (apps/api/src/infrastructure/persistence/drizzle/drizzle-login-throttle.ts)
  • AUTH_LOCKOUT_THRESHOLDS (apps/api/src/infrastructure/persistence/drizzle/drizzle-login-throttle.ts)
  • AUTH_LOCKOUT_WINDOW_MS (apps/api/src/infrastructure/persistence/drizzle/drizzle-login-throttle.ts)
  • AUTH_MAIL_LOG_TOKENS (apps/api/src/infrastructure/mail/log-mail-sender.ts)
  • AUTH_REFRESH_ABSOLUTE_TTL_MS (apps/api/src/application/use-cases/auth/auth-config.ts)
  • AUTH_REFRESH_TOKEN_TTL_MS (apps/api/src/application/use-cases/auth/auth-config.ts)
  • AUTH_REGISTRATION_ENABLED (apps/api/src/application/use-cases/auth/auth-config.ts)
  • AUTH_RESET_TOKEN_TTL_MS (apps/api/src/application/use-cases/auth/auth-config.ts)
  • AUTH_SET_PASSWORD_TTL_MS (apps/api/src/application/use-cases/auth/auth-config.ts)
  • AUTH_TOKEN_PEPPER (apps/api/src/infrastructure/security/auth-key-material.ts)
  • BRABO_FORCE_SEED (apps/api/src/scripts/provisionar-usuario.ts)
  • BRABO_PROJECTS_BASE (apps/api/src/infrastructure/filesystem/project-workspaces-root.ts)
  • BRABO_SEED_PASSWORD (apps/api/src/db/seed.ts)
  • BRABO_SERVICE_TOKEN (apps/api/src/infrastructure/security/service-token.ts)
  • BRABO_SERVICE_TOKEN_PREVIOUS (apps/api/src/infrastructure/security/service-token.ts)
  • BRABO_VERSION (apps/api/src/tracing.ts)
  • BROKER_URL (apps/api/src/infrastructure/http-clients/container-broker.client.ts)
  • CREDENTIALS_MASTER_KEY (apps/api/src/infrastructure/security/envelope-encryption.service.ts)
  • CREDENTIALS_MASTER_KEY_PREVIOUS (apps/api/src/infrastructure/security/envelope-encryption.service.ts)
  • CUSTO_RECONCILIACAO_INTERVAL_MS (apps/api/src/application/use-cases/llm/reconciliar-custo-estimado.use-case.ts)
  • DATABASE_URL (apps/api/src/db/migrate.ts)
  • ENGINE_PUBLIC_URL (apps/api/src/application/use-cases/runner/request-runner-ticket.use-case.ts)
  • ENGINE_URL (apps/api/src/application/use-cases/runner/request-runner-ticket.use-case.ts)
  • GIT_LOCAL_REPOS_ROOT (apps/api/src/infrastructure/git/local-git-provider.ts)
  • GIT_OAUTH_STATE_SECRET (apps/api/src/infrastructure/security/oauth-state-secret.ts)
  • GITHUB_OAUTH_CLIENT_ID (apps/api/src/infrastructure/git/github-oauth-client.ts)
  • GITHUB_OAUTH_CLIENT_SECRET (apps/api/src/infrastructure/git/github-oauth-client.ts)
  • GITLAB_OAUTH_CLIENT_ID (apps/api/src/infrastructure/git/gitlab-oauth-client.ts)
  • GITLAB_OAUTH_CLIENT_SECRET (apps/api/src/infrastructure/git/gitlab-oauth-client.ts)
  • GRAPH_PROJECTOR_INTERVAL_MS (apps/api/src/application/graph-projection/graph-projector.ts)
  • HUGGINGFACE_API_TOKEN (apps/api/src/infrastructure/huggingface/huggingface-client.ts)
  • HUGGINGFACE_HUB_URL (apps/api/src/infrastructure/huggingface/huggingface-client.ts)
  • LOG_LEVEL (apps/api/src/infrastructure/observability/logger.config.ts)
  • MAIL_TRANSPORT (apps/api/src/infrastructure/mail/smtp-config.ts)
  • METRICS_GAUGE_INTERVAL_MS (apps/api/src/infrastructure/observability/domain-gauges.collector.ts)
  • MIGRATIONS_FOLDER (apps/api/src/db/migrate.ts)
  • NEO4J_PASSWORD (apps/api/src/infrastructure/graph/neo4j-config.ts)
  • NEO4J_URI (apps/api/src/infrastructure/graph/neo4j-config.ts)
  • NEO4J_USER (apps/api/src/infrastructure/graph/neo4j-config.ts)
  • NINE_ROUTER_BASE_URL (apps/api/src/infrastructure/llm/nine-router-provider.ts)
  • NODE_ENV (apps/api/src/infrastructure/graph/neo4j-config.ts)
  • OLLAMA_HOST (apps/api/src/infrastructure/llm/ollama-provider.ts)
  • OTEL_DIAG_LOG (apps/api/src/tracing.ts)
  • OTEL_EXPORTER_OTLP_ENDPOINT (apps/api/src/tracing.ts)
  • OTEL_SERVICE_NAME (apps/api/src/tracing.ts)
  • PORT (apps/api/src/main.ts)
  • PROJECT_WORKSPACES_ROOT (apps/api/src/infrastructure/filesystem/project-workspaces-root.ts)
  • RATE_LIMIT_ENABLED (apps/api/src/interfaces/http/shared/rate-limit.guard.ts)
  • RATE_LIMIT_IP (apps/api/src/interfaces/http/shared/rate-limit.guard.ts)
  • RATE_LIMIT_USER (apps/api/src/interfaces/http/shared/rate-limit.guard.ts)
  • RATE_LIMIT_WINDOW_MS (apps/api/src/infrastructure/observability/domain-gauges.collector.ts)
  • SMTP_FROM (apps/api/src/infrastructure/mail/smtp-config.ts)
  • SMTP_HOST (apps/api/src/infrastructure/mail/smtp-config.ts)
  • SMTP_PASSWORD (apps/api/src/infrastructure/mail/smtp-config.ts)
  • SMTP_PORT (apps/api/src/infrastructure/mail/smtp-config.ts)
  • SMTP_SECURE (apps/api/src/infrastructure/mail/smtp-config.ts)
  • SMTP_USER (apps/api/src/infrastructure/mail/smtp-config.ts)
  • WEB_ORIGIN (apps/api/src/infrastructure/mail/smtp-mail-sender.ts)

engine — 63 variables · product

  • ANAMNESE_BUDGET_MICROS (apps/engine/config/runtime.exs)
  • ANAMNESE_ENABLED (apps/engine/config/runtime.exs)
  • ANAMNESE_INITIAL_WINDOW_DAYS (apps/engine/config/runtime.exs)
  • ANAMNESE_INTERVAL_SECONDS (apps/engine/config/runtime.exs)
  • ANAMNESE_MAX_ITERATIONS (apps/engine/config/runtime.exs)
  • ANAMNESE_MAX_PAYLOAD_CHARS (apps/engine/config/runtime.exs)
  • ANAMNESE_MAX_PROMPT_EVENTS (apps/engine/config/runtime.exs)
  • ANAMNESE_MIN_EVENTS (apps/engine/config/runtime.exs)
  • API_URL (apps/engine/config/runtime.exs)
  • BRABO_SERVICE_TOKEN (apps/engine/config/runtime.exs)
  • BRABO_SERVICE_TOKEN_PREVIOUS (apps/engine/config/runtime.exs)
  • CONTEXT_COMPACTION_THRESHOLD (apps/engine/config/runtime.exs)
  • DATABASE_URL (apps/engine/config/dev.exs)
  • DEFAULT_CONTEXT_WINDOW (apps/engine/config/runtime.exs)
  • DNS_CLUSTER_QUERY (apps/engine/config/runtime.exs)
  • ECTO_IPV6 (apps/engine/config/runtime.exs)
  • GATE_RESCUE_INTERVAL_SECONDS (apps/engine/config/runtime.exs)
  • GATE_RESCUE_STALE_AFTER_SECONDS (apps/engine/config/runtime.exs)
  • GRAPH_INSTRUCTION_TEMPLATES_ENABLED (apps/engine/config/runtime.exs)
  • GRAPH_TEMPLATES_ENABLED (apps/engine/config/runtime.exs)
  • LLM_TURN_TIMEOUT_MS (apps/engine/config/runtime.exs)
  • MIX_TEST_PARTITION (apps/engine/config/test.exs)
  • MODEL_SYNC_INTERVAL_SECONDS (apps/engine/config/runtime.exs)
  • OTEL_EXPORTER_OTLP_ENDPOINT (apps/engine/config/runtime.exs)
  • PHX_HOST (apps/engine/config/runtime.exs)
  • PHX_SERVER (apps/engine/config/runtime.exs)
  • POOL_SIZE (apps/engine/config/runtime.exs)
  • PORT (apps/engine/config/runtime.exs)
  • POSTGRES_HOST (apps/engine/config/test.exs)
  • POSTGRES_PASSWORD (apps/engine/config/test.exs)
  • POSTGRES_USER (apps/engine/config/test.exs)
  • PROJECT_WORKSPACES_ROOT (apps/engine/config/runtime.exs)
  • PSYCHOLOGIST_BUDGET_MICROS_LEVE (apps/engine/config/runtime.exs)
  • PSYCHOLOGIST_BUDGET_MICROS_PESADA (apps/engine/config/runtime.exs)
  • PSYCHOLOGIST_ENABLED (apps/engine/config/runtime.exs)
  • PSYCHOLOGIST_MAX_ITERATIONS_LEVE (apps/engine/config/runtime.exs)
  • PSYCHOLOGIST_MAX_ITERATIONS_PESADA (apps/engine/config/runtime.exs)
  • PSYCHOLOGIST_MAX_PAYLOAD_CHARS (apps/engine/config/runtime.exs)
  • PSYCHOLOGIST_MAX_PROMPT_EVENTS_LEVE (apps/engine/config/runtime.exs)
  • PSYCHOLOGIST_MAX_PROMPT_EVENTS_PESADA (apps/engine/config/runtime.exs)
  • PSYCHOLOGIST_RAG_TOP_K (apps/engine/config/runtime.exs)
  • PSYCHOLOGIST_TRIAGE_THRESHOLD (apps/engine/config/runtime.exs)
  • READ_FILE_MAX_BYTES (apps/engine/config/runtime.exs)
  • SEARCH_WORKSPACE_MAX_BYTES (apps/engine/config/runtime.exs)
  • SEARCH_WORKSPACE_MAX_HITS (apps/engine/config/runtime.exs)
  • SECOPS_SCAN_TIMEOUT_MS (apps/engine/config/runtime.exs)
  • SECRET_KEY_BASE (apps/engine/config/runtime.exs)
  • SESSION_CONVERSATION_IDLE_TIMEOUT_MS (apps/engine/config/runtime.exs)
  • SESSION_HEARTBEAT_TIMEOUT_MS (apps/engine/config/runtime.exs)
  • SHUTDOWN_DRAIN_TIMEOUT_MS (apps/engine/config/runtime.exs)
  • SOME_APP_SSL_CERT_PATH (apps/engine/config/runtime.exs)
  • SOME_APP_SSL_KEY_PATH (apps/engine/config/runtime.exs)
  • START_ANAMNESE (apps/engine/config/runtime.exs)
  • START_GATE_RESCUE (apps/engine/config/runtime.exs)
  • START_MODEL_SYNC (apps/engine/config/runtime.exs)
  • START_OUTBOX_DRAIN (apps/engine/config/runtime.exs)
  • TERMINAL_ACTION_TIMEOUT_MS (apps/engine/config/runtime.exs)
  • TERMINAL_OUTPUT_MAX_BYTES (apps/engine/config/runtime.exs)
  • TOOL_LOOP_MAX_ITERATIONS (apps/engine/config/runtime.exs)
  • TOOL_LOOP_MAX_ITERATIONS_EXECUCAO (apps/engine/config/runtime.exs)
  • TOOL_LOOP_MAX_ITERATIONS_GATE (apps/engine/config/runtime.exs)
  • TRANSPORT_MAX_BODY_BYTES (apps/engine/config/runtime.exs)
  • WEB_ORIGIN (apps/engine/config/runtime.exs)

web — 4 variables · product

  • VITE_API_URL (apps/web/src/lib/runtime-config.ts)
  • VITE_BRABO_VERSION (apps/web/src/lib/runtime-config.ts)
  • VITE_ENGINE_URL (apps/web/src/lib/runtime-config.ts)
  • VITE_LOG_LEVEL (apps/web/src/lib/runtime-config.ts)

broker — 7 variables · product

  • API_URL (apps/broker/src/config.ts)
  • BRABO_PROJECTS_HOST_BASE (apps/broker/src/config.ts)
  • BRABO_SERVICE_TOKEN (apps/broker/src/config.ts)
  • BRABO_SERVICE_TOKEN_PREVIOUS (apps/broker/src/config.ts)
  • BROKER_PORT (apps/broker/src/config.ts)
  • NODE_ENV (apps/broker/src/config.ts)
  • PROJECT_WORKSPACES_HOST_ROOT (apps/broker/src/config.ts)

api/scripts — 15 variables · tooling

  • DATABASE_URL (apps/api/scripts/demo-repo-bootstrap.ts)
  • DEMO_ANAMNESE_TIMEOUT_MS (apps/api/scripts/demo-anamnese.ts)
  • DEMO_MODEL (apps/api/scripts/demo-anamnese.ts)
  • DEMO_MODEL_LEVE (apps/api/scripts/demo-psicologo.ts)
  • DEMO_MODEL_PESADO (apps/api/scripts/demo-psicologo.ts)
  • DEMO_OLLAMA_COPY_FROM (apps/api/scripts/demo-psicologo.ts)
  • DEMO_PSICOLOGO_TIMEOUT_MS (apps/api/scripts/demo-psicologo.ts)
  • DEMO_QA_MODEL (apps/api/scripts/demo-pr-gates-area-qa.ts)
  • DEMO_TIMEOUT_MS (apps/api/scripts/demo-dev-agent-real.ts)
  • DEMO_WORKFLOWS_MODEL (apps/api/scripts/demo-infra-workflows-github.ts)
  • GIT_LOCAL_REPOS_ROOT (apps/api/scripts/demo-repo-bootstrap.ts)
  • GOLDEN_SET_QA_MODEL (apps/api/scripts/seed-golden-set-qa.ts)
  • NOME (apps/api/scripts/demo-pr-gates.ts)
  • OLLAMA_HOST (apps/api/scripts/demo-psicologo.ts)
  • RATE_LIMIT_WINDOW_MS (apps/api/scripts/relatorio-seguranca-runtime.ts)

e2e — 4 variables · tooling

  • E2E_API_URL (e2e/suporte/api.ts)
  • E2E_PASSWORD (e2e/suporte/api.ts)
  • E2E_USER (e2e/suporte/api.ts)
  • E2E_WEB_URL (e2e/playwright.config.ts)

deploy/k8s — 22 variables · tooling

  • API_URL (deploy/k8s/rollout-test.sh)
  • BRABO_CLUSTER_NAME (deploy/k8s/bootstrap.sh)
  • BRABO_CLUSTER_TOOL (deploy/k8s/bootstrap.sh)
  • BRABO_KEEP_CLUSTER (deploy/k8s/bootstrap.sh)
  • BRABO_NAMESPACE (deploy/k8s/hpa-test.sh)
  • BRABO_SKIP_BUILD (deploy/k8s/bootstrap.sh)
  • BRABO_SKIP_OBSERVABILITY (deploy/k8s/bootstrap.sh)
  • BRABO_SMOKE_PASSWORD (deploy/k8s/bootstrap.sh)
  • ENGINE_URL (deploy/k8s/smoke.sh)
  • EVIDENCIA_INTERVALO (deploy/k8s/rollout-evidencia.sh)
  • HPA_JOBS (deploy/k8s/hpa-test.sh)
  • HPA_TIMEOUT (deploy/k8s/hpa-test.sh)
  • KUBE_VERSION (deploy/k8s/validate.sh)
  • PROJECT_WORKSPACES_ROOT_NO_POD (deploy/k8s/test-reprojecao-artefatos.sh)
  • RESTORE_KEEP_JOB (deploy/k8s/test-restore.sh)
  • RESTORE_MUTACAO (deploy/k8s/test-restore.sh)
  • ROLLOUT_CONVERGENCE_SECONDS (deploy/k8s/rollout-test.sh)
  • ROLLOUT_EVIDENCE_DIR (deploy/k8s/rollout-test.sh)
  • ROLLOUT_SESSIONS (deploy/k8s/rollout-test.sh)
  • SMOKE_USER (deploy/k8s/bootstrap.sh)
  • TAG (deploy/k8s/bootstrap.sh)
  • WEB_URL (deploy/k8s/smoke.sh)

TODO(humano): there is no schema validation of the variables at boot (something like zod/envalid in the api or NimbleOptions in the engine). Today an invalid numeric value silently becomes NaN and a typo in a variable name falls back to the default without warning. The only exceptions are the ones marked 🔒, which fail explicitly in production.