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 --waitonly proves what has a healthcheck. In the development compose,api,engineandwebhad none, sodocker compose up --waitreported 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 (/healthfor api and engine, which touches the database;/for the Vite server). The container broker got its own in a separate change;postgresandneo4jalways had one. Nothing here is an environment variable — it is written down because--waitis 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 composedoes not forward the host environment into a container: a variable arrives if — and only if — it is written in that service'senvironment:(or anenv_file). This is not a detail; it is howANAMNESE_ENABLEDandPSYCHOLOGIST_ENABLEDstayed silently unreachable while three places in the code promised the 2026-08-10 pause was reversible withX=trueplus a restart. Measured inside the running engine container, they came back empty andruntime.exsfell 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, andscripts/ci/flags-do-engine-no-compose.spec.tsfails 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-meexist sopnpm devcomes 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 originalGIT_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:
| variable | what |
|---|---|
BRABO_API_IMAGE | full reference of the api image; ghcr.io/…@sha256:… when the source is GHCR, brabo-api:prod when built locally |
BRABO_ENGINE_IMAGE | same, for the engine |
BRABO_WEB_IMAGE | same, for the web |
BRABO_BACKUP_IMAGE | same, for the backup job (under the backup profile) |
BRABO_BROKER_IMAGE | same, 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:
| variable | what install.sh writes |
|---|---|
COMPOSE_PROFILES | container-broker — read by Compose from the --env-file, so up -d --wait brings the broker up with no flag |
BROKER_URL | http://broker:8090 — the api's side; without it the api never calls the broker |
DOCKER_GID | the 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:
| variable | what install.sh writes |
|---|---|
OLLAMA_PORT | 11434, 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_PROFILES | llm, 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
| variable | default | when it fails |
|---|---|---|
DATABASE_URL | postgres://brabo:brabo@localhost:5432/brabo | without it, nothing comes up |
PORT | 3000 | — |
API_JSON_BODY_LIMIT | 10mb | ceiling 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_URL | http://localhost:3000 | used in git OAuth callbacks; wrong = broken callback |
ENGINE_URL | http://localhost:4000 in code, http://engine:4000 in Compose | synchronous 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_URL | same as ENGINE_URL | used 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_VERSION | dev | becomes 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 🔒
| variable | default | when it fails |
|---|---|---|
CREDENTIALS_MASTER_KEY 🔒 | dev-master-key-change-me only outside production | wraps 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 production | signs 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_PORT | 5173 (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.
| variable | default | what it does |
|---|---|---|
AUTH_JWT_SECRET 🔒 | dev-auth-jwt-secret-change-me only outside production | passphrase 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 production | HMAC 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_MS | 900000 | 15 min |
AUTH_REFRESH_TOKEN_TTL_MS | 1209600000 | 14 days |
AUTH_REFRESH_ABSOLUTE_TTL_MS | 2592000000 | absolute ceiling of the family, counted from login — without it rotation grants an eternal session |
AUTH_REGISTRATION_ENABLED | true | any value other than "false" keeps registration open |
AUTH_LOCKOUT_ENABLED | true | same convention |
AUTH_LOCKOUT_WINDOW_MS | 900000 | sliding window of the count |
AUTH_LOCKOUT_THRESHOLDS | 5:30,8:300,12:900 | email bucket ladder, failures:seconds |
AUTH_LOCKOUT_IP_THRESHOLDS | 20:30,30:120 | IP bucket ladder, more permissive and with a short ceiling |
AUTH_EMAIL_TOKEN_TTL_MS | 172800000 | email verification, 48 h |
AUTH_RESET_TOKEN_TTL_MS | 3600000 | password reset, 1 h |
AUTH_SET_PASSWORD_TTL_MS | 604800000 | setting the first password (migrated user), 7 days — longer than the reset because the recipient didn't ask for it |
AUTH_IP_ATTEMPT_THRESHOLD | 60 | ceiling of attempts per IP on the auth routes |
AUTH_MAIL_LOG_TOKENS | false | dev 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_untilcolumn with an unlock queue. Don't touch one without the other.
Rotating
AUTH_TOKEN_PEPPERlogs everyone out and invalidates outstanding verification and reset tokens. Unlike the keys, the pepper does not have a_PREVIOUS. See the runbook.
Where the
_PREVIOUSvariables reach.AUTH_JWT_SECRET_PREVIOUS,CREDENTIALS_MASTER_KEY_PREVIOUSandBRABO_SERVICE_TOKEN_PREVIOUSare mapped in theenvironment:of all three composes (dev, production and install) as${X:-}— the api gets the three, the engine and the broker getBRABO_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.envand recreating the service is enough. Before RN-595 they were mapped nowhere anddocker composedoesn'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): theExternalSecretpulls the wholebraboobject withdataFrom.extract, so a_PREVIOUSpresent there lands inbrabo-secretsand 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.
| variable | default | what it does |
|---|---|---|
MAIL_TRANSPORT | log | log (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_PORT | 587 | provider port — 587 is STARTTLS, 465 is implicit TLS (SMTP_SECURE=true) |
SMTP_SECURE | false | true 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
MailSenderport doesn't carry structure for rich bodies, and a template engine would be an injection surface with no upside. The link usesWEB_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.
| variable | default | what it does |
|---|---|---|
BRABO_SEED_PASSWORD | brabo12345678 | password 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).
| variable | default | what it does |
|---|---|---|
RATE_LIMIT_ENABLED | true | any value other than "false" keeps it on |
RATE_LIMIT_WINDOW_MS | 60000 | window size |
RATE_LIMIT_USER | 300 | requests per user per window |
RATE_LIMIT_IP | 600 | requests 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).
| variable | default | what it does |
|---|---|---|
BRABO_SERVICE_TOKEN 🔒 | dev-service-token-change-me only outside production | goes 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
_PREVIOUSdance) doesn't break anyone's boot: the symptom is403on/internal/*and401on 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
| variable | default | note |
|---|---|---|
GIT_LOCAL_REPOS_ROOT | /tmp/brabo-git-repos | Local provider. In /tmp, repos disappear on reboot |
PROJECT_WORKSPACES_ROOT | /tmp/brabo-project-workspaces | project agent worktrees in Container mode. Needs to be the same path on the engine, and the same volume |
BRABO_PROJECTS_BASE | empty | the 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 / _SECRET | empty | empty = GitHub OAuth connection unavailable (PAT still works) |
GITLAB_OAUTH_CLIENT_ID / _SECRET | empty | same |
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
| variable | default | note |
|---|---|---|
OLLAMA_HOST | http://localhost:11434 | — |
OLLAMA_REQUEST_TIMEOUT_MS | 300000 | inactivity 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_MS | 300000 | the 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_URL | https://huggingface.co | base 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_MS | 15000 | inactivity 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 theapiservice'senvironment:, and the service has noenv_file, so setting them in the.envhas no effect under Compose — shouldHUGGINGFACE_API_TOKENbe forwarded (at least in production and installation)?
Knowledge graph (ADR 0099)
| variable | default | note |
|---|---|---|
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_USER | neo4j (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_MS | 2000 | period 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_MS | 2000 | period 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
| variable | default | note |
|---|---|---|
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_NAME | brabo-api | — |
OTEL_DIAG_LOG | — | 1 turns on OTel's own diagnostic log |
LOG_LEVEL | info in production, debug outside | also 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_MS | 15000 | collection period of the domain gauges |
engine
Essentials
| variable | default | when it fails |
|---|---|---|
DATABASE_URL | ecto://brabo:brabo@localhost:5432/brabo | note the ecto:// scheme, not postgres:// |
POSTGRES_HOST / _USER / _PASSWORD | localhost / brabo / brabo | used when DATABASE_URL isn't assembled |
POOL_SIZE | — | an exhausted pool jams Oban and the queue stops being consumed |
PORT | 4000 | — |
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_URL | http://localhost:3000 | the engine calls the api back through here |
ECTO_IPV6 | — | — |
SKIP_MIGRATIONS | — | used by the migration Job |
Cluster and shutdown
| variable | default | note |
|---|---|---|
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_MS | 45000 | preStop window. Goes up together with terminationGracePeriodSeconds, never alone |
SESSION_HEARTBEAT_TIMEOUT_MS | 30000 | how long the session waits for the tab's heartbeat before asking the api whether there is pending work |
SESSION_CONVERSATION_IDLE_TIMEOUT_MS | 28800000 (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
| variable | default | note |
|---|---|---|
TOOL_LOOP_MAX_ITERATIONS | 8 | ceiling of loop turns for the conversational agent. Once exhausted, the agent ends with a blocking artifact |
TOOL_LOOP_MAX_ITERATIONS_EXECUCAO | 60 | ceiling 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_GATE | 60 | ceiling for QA subagents, for the same reason |
DEFAULT_CONTEXT_WINDOW | 128000 | used when the model doesn't declare its window (the conversational agents never do); was 8192 until RN-702 |
CONTEXT_COMPACTION_THRESHOLD | 0.7 | fraction of the window that triggers compaction |
LLM_TURN_TIMEOUT_MS | 300000 | 5 min per turn |
TOOL_ROUTER_TIMEOUT_MS | 2000 | ceiling, 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_MS | 120000 | ceiling 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_BYTES | 32768 | BYTE 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_BYTES | 32768 | BYTE 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_BYTES | 32768 | BYTE 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_HITS | 500 | ceiling 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_MS | 180000 | 3 min for the SecOps scanner |
TRANSPORT_MAX_BODY_BYTES | 8388608 (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_ENABLED | false | turns 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
| variable | default | note |
|---|---|---|
PSYCHOLOGIST_ENABLED | false | GLOBAL 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_THRESHOLD | 20 | events in the session that separate a light analysis from a heavy one |
PSYCHOLOGIST_MAX_ITERATIONS_LEVE / _PESADA | 4 / 8 | — |
PSYCHOLOGIST_BUDGET_MICROS_LEVE / _PESADA | 50000 / 300000 | USD 0.05 and USD 0.30 per analysis |
PSYCHOLOGIST_MAX_PROMPT_EVENTS_LEVE / _PESADA | 50 / 400 | how many events go into the prompt |
PSYCHOLOGIST_MAX_PAYLOAD_CHARS | 600 | truncation of each event's payload |
PSYCHOLOGIST_RAG_TOP_K | 3 | how many relevant rag_search snippets go into the context, deducted from the recent-events ceiling above (RN-417) |
GRAPH_TEMPLATES_ENABLED | false | turns 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
| variable | default | note |
|---|---|---|
ANAMNESE_ENABLED | true | product 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_SECONDS | 900 | 15 min between runs |
ANAMNESE_MIN_EVENTS | 10 | below this it doesn't run — avoids profiling on noise |
ANAMNESE_INITIAL_WINDOW_DAYS | 30 | window of the first run |
ANAMNESE_MAX_ITERATIONS | 6 | — |
ANAMNESE_BUDGET_MICROS | 200000 | USD 0.20 per run |
ANAMNESE_MAX_PROMPT_EVENTS | 500 | — |
ANAMNESE_MAX_PAYLOAD_CHARS | 600 | — |
Load guards
| variable | default | note |
|---|---|---|
START_OUTBOX_DRAIN | true | boot 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_ANAMNESE | true | test/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_SYNC | true | periodic 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_SECONDS | 21600 (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_RESCUE | true | periodic 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_SECONDS | 300 (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_SECONDS | 900 (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
| variable | default |
|---|---|
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_PATHandMIX_TEST_PARTITIONare 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:
| source | when | how |
|---|---|---|
import.meta.env.VITE_* | build | baked into the bundle. Changing it requires a rebuild |
window.__BRABO_CONFIG__ | runtime | served 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.
| variable | serves for |
|---|---|
VITE_API_URL | api address |
VITE_ENGINE_URL | engine address (Phoenix channel) |
VITE_LOG_LEVEL | level 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_VERSION | version 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.
| variable | default | note |
|---|---|---|
BACKUP_DIR | — (S3) | destination directory. Set ⇒ disk; empty ⇒ S3. In the compose service it defaults to /backups |
BRABO_BACKUP_HOST_DIR | backup_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_DAILY | 7 | retention by count, not by age |
BACKUP_KEEP_WEEKLY | 4 | — |
GIT_REPOS_DIR | /data/git-repos | the 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_PREFIX | daily/ | weekly/ to restore from a weekly copy |
RESTORE_GIT_PREFIX | git-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.
| variable | why |
|---|---|
OLLAMA_CONTEXT_LENGTH | the default of 4096 silently truncates a prompt built for 128k |
OLLAMA_MAX_LOADED_MODELS | with a high OLLAMA_KEEP_ALIVE, models pile up until memory overflows |
OLLAMA_KEEP_ALIVE | how long the model stays resident |
DEMO_QA_MODEL | points the QA gate to an API model — the per-agent binding wins over the project's |
The
ollamaimage version is not a variable — it is a digest. Like every third-party image indocker/, it is pinned asollama/ollama:0.40.6@sha256:…(ADR 0159, tag inline since ADR 0178), and the same digest is used byci.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 twodocker compose pulls is no longer a possible cause: nothing moves unless someone edits the digest. Bumping it is a runbook procedure, andscripts/ci/imagens-pinadas.tsrefuses 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.
| variable | default | why |
|---|---|---|
GRAFANA_PORT | 3001 | same 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_PORT | 9090 | the port the runbook already uses in the cluster's kubectl port-forward |
LOKI_PORT | 3100 | only 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
| variable | default | when it fails |
|---|---|---|
BROKER_URL | http://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_PORT | 8090 | Port 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 development | The 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_URL | http://api:3000 | Where 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_GID | 999 (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.
| variable | default | why |
|---|---|---|
DEV_UID | 1000 | your 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_GID | 1000 | your 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.
| variable | default | what it changes |
|---|---|---|
DEMO_MODEL | qwen2.5-coder:7b | the model every demo script uses, unless one of the ones below overrides it for a specific role |
DEMO_QA_MODEL | DEMO_MODEL | only 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_MODEL | DEMO_MODEL | only the Infra agent in the GitHub-workflows demo |
DEMO_MODEL_LEVE | qwen2.5-coder:7b | the Psychologist's light model (the one the cheap pass uses) |
DEMO_MODEL_PESADO | qwen-psicologo-pesado:latest | the Psychologist's heavy model. It's a local Modelfile variant, not a published tag — hence the one below |
DEMO_OLLAMA_COPY_FROM | qwen2.5-coder:7b | the base the demo copies from when it has to create the heavy model on the local Ollama |
DEMO_TIMEOUT_MS | 1800000 (30 min); 900000 in the dev-agent demo | how long the script waits for the run to finish before giving up |
DEMO_ANAMNESE_TIMEOUT_MS | 900000 (15 min) | the same deadline, for the Anamnesis demo |
DEMO_PSICOLOGO_TIMEOUT_MS | 600000 (10 min) | the same deadline, for the Psychologist demo |
GOLDEN_SET_QA_MODEL | qwen2.5-coder:latest | the 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).
| variable | default | what it changes |
|---|---|---|
E2E_API_URL | http://localhost:3000 | where the suite's HTTP support hits the api |
E2E_WEB_URL | http://localhost:8088 | the baseURL Playwright opens, and the origin the CSRF assertion depends on |
E2E_USER | owner@brabo.dev | the account the suite logs in with |
E2E_PASSWORD | the seed password hardcoded in e2e/suporte/api.ts | that 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).
| variable | default | what it changes |
|---|---|---|
BRABO_CLUSTER_NAME | brabo | the name of the k3d/kind cluster the bootstrap creates or reuses |
BRABO_CLUSTER_TOOL | k3d | kind switches the bootstrap to kind — which ignores NetworkPolicy in silence, hence k3d as the default |
BRABO_KEEP_CLUSTER | unset | 1 reuses an existing cluster with the same name instead of deleting and recreating it |
BRABO_SKIP_BUILD | unset | 1 skips building the production images and uses the ones already in the Docker daemon |
BRABO_SKIP_OBSERVABILITY | unset | 1 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) |
TAG | unset | a 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_NAMESPACE | brabo | the namespace every proof script talks to |
API_URL / ENGINE_URL / WEB_URL | http://localhost:3000 / :4000 / :8088 | where 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_USER | owner@brabo.dev | the account the smoke and the proofs log in with |
BRABO_SMOKE_PASSWORD | the seed's local password, hardcoded in the scripts | that account's password — also what the bootstrap passes to the seed pod as BRABO_SEED_PASSWORD |
HPA_JOBS | 60 | how many probe jobs make hpa-test enqueues to make the engine scale |
HPA_TIMEOUT | 240 | seconds make hpa-test waits for the scale-up |
ROLLOUT_SESSIONS | 5 | how many sessions make rollout-test opens before the rollout |
ROLLOUT_CONVERGENCE_SECONDS | 120 | the 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_DIR | a mktemp -d | where make rollout-test writes the evidence (pod logs, events, replica samples); propriedades.yml sets it and uploads the folder |
EVIDENCIA_INTERVALO | 2 | seconds between the evidence collectors' samples; the spec shortens it (AT-174) |
RESTORE_MUTACAO | unset | tabela-faltando turns make test-restore into make test-restore-mutacao, the deliberate break it must catch (AT-126) |
RESTORE_KEEP_JOB | unset | 1 keeps the backup/restore Jobs after make test-restore, for inspection |
PROJECT_WORKSPACES_ROOT_NO_POD | /data/project-workspaces | where make test-reprojecao-artefatos-k8s looks for the artifact folder inside the api pod |
KUBE_VERSION | 1.31.0 | the 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/envalidin the api orNimbleOptionsin the engine). Today an invalid numeric value silently becomesNaNand 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.