Skip to content

Configuration

The portal is where WhyGraph is configured: configuration is stored per project in the portal's database and edited in the UI. The Settings page holds defaults for every project, and a project's own settings override them. API keys and GitHub tokens are stored encrypted there.

whygraph.toml at a repo root is not read at scan time. It is a file the portal offers to import once when you add the repo, in the same shape as the tree below; after that, the portal's settings are the only configuration. Every field has a built-in default, so a project with no file behaves exactly as if an unedited one were present.

Nothing writes whygraph.toml for you (whygraph init was removed). Create one by hand only if you want to seed a project's settings on import.

whygraph.toml is gitignored - never commit a token

A whygraph.toml can hold API keys, so keep it out of git. The portal adds it to .gitignore when it initializes a project, and moves any keys in it into its encrypted store when it imports the file. That includes [scan].token, which is imported once into the project's encrypted GitHub token.

In the portal

For each project the portal builds its configuration from two layers, the project layer winning:

  1. the defaults from Settings in the sidebar - the portal defaults locally, the organization's settings in production (models, provider endpoints; provider keys are inherited too);
  2. the project's own settings.

Both pages are split into sections (General, Models and keys, GitHub, ...) with a section list beside them; ?section=<id> opens a page at one section. An empty project field shows the value it inherits and where from (Inherited: ... from portal defaults, or from the organization); a field the project sets is marked Overridden here with Reset. Each section has its own Save and Discard, so saving one section never sends another section's unsaved edits; a key is saved or removed on its own, at once, from its key card. People who may only read the settings see them disabled. See Settings for every section.

Two things differ from a whygraph.toml:

  • Keys and tokens are entered in the UI, not read from your environment. The portal and its scans are started with none of your shell's variables - ANTHROPIC_API_KEY, GH_TOKEN and the rest do not reach them. A project's own key for a provider wins over the inherited one, and an inherited key is never sent to a project that overrides that provider's endpoint (its key card says so).
  • Only some keys can be set. The WhyGraph database is always in the portal's data directory (projects/<org>/<slug>/whygraph.db) and the CodeGraph index always at <repo>/.codegraph/codegraph.db. Importing a repository's whygraph.toml takes models, the [analyze] / [rationale] / [chat] tuning keys, the [areas] table, and [scan].forge, remote, default_branch, hooks and serve_codegraph. It never takes endpoints, database paths or the log file, drops any setting for the removed claude-cli provider, and reports what it dropped. Endpoints are set in the UI.

To reach an Ollama daemon or a gateway on your machine from the portal's container, use host.docker.internal - see Adding projects.

The full tree

The values shown are the defaults. This is the tree a whygraph.toml uses; the portal stores the same tree per layer.

[scan]
forge = "off"                 # source-control forge for the PR/issue crawl:
                              #   "off"    - skip the remote crawl (default)
                              #   "github" - pull PRs/issues from the GitHub remote
                              #   "auto"   - detect from the remote URL (github only, for now)
remote = "origin"             # git remote whose URL is inspected for forge github/auto
                              # (a plain name: letters, digits, ".", "_", "/", "-")
# token = "ghp_..."           # GitHub token. Imported once into the project's encrypted
                              # GitHub token; never read at scan time.
hooks = true                  # auto-rescan git hooks installed by the portal:
                              #   true    - all four (post-commit, post-merge,
                              #             post-rewrite, post-checkout)
                              #   false   - none (the portal removes any already installed)
                              #   [list]  - only these, e.g. ["post-commit", "post-merge"]
# default_branch = "main"     # the branch treated as "shipped history". Default:
                              # resolved from origin/HEAD, else origin/main, else
                              # origin/master. Set for repos on develop / trunk.
                              # Must not start with "-".
serve_codegraph = true        # serve CodeGraph's tools to agents at /mcp/<slug>/code.
                              # false answers every call there with a "turned off" error.
                              # Local and linked projects; not available in production.

[llm]
# model = "anthropic/claude-opus-4-7"  # default model for every role, "provider/model"

[analyze]
# provider = "anthropic"      # which [llm.*] adapter writes per-commit descriptions
# model = "claude-haiku-4-5"  # override the model for analysis only
max_workers = 4               # parallel LLM calls in the diff-analyzer crawler; also bounds a card's inline describes
# max_diff_chars = 50000      # diff truncated past this length before prompting
# large_commit_file_count = 30  # commits (merges: vs their first parent) touching more files are described per-file on demand
# pr_origin_min_commits = 5   # recover a squash-merged PR's original commits past this size
# agent_descriptions_per_hour = 600  # org setting only, see "Agent limits" below
# agent_descriptions_per_member_per_hour = 150  # org setting only, one member's share

[rationale]
# provider = "anthropic"      # which [llm.*] adapter writes the rationale card
# model = "claude-haiku-4-5"  # override the model for rationale only
# pr_roster_max_commits = 30      # squashed-commit headlines shown per PR in the prompt
# pr_discussion_max_comments = 20 # PR comments shown per PR in the prompt
# pr_comment_max_chars = 500      # each PR comment clipped to this length
# agent_generations_per_hour = 120  # org setting only, see "Agent limits" below
# agent_generations_per_member_per_hour = 30  # org setting only, one member's share

[chat]
# The chat assistant. Provider/model are DEFAULTS for new sessions only -
# each session stores the pair it was started with.
# provider = "anthropic"      # anthropic | openai | deepseek | openrouter
# model = "claude-opus-4-7"   # default: [llm].model, then the provider's default
# max_tool_rounds = 8         # hard bound on tool rounds per user turn
# max_rationale_generations = 2  # uncached rationale cards one turn may generate
                              # (0 = cache-only). Bounds nested LLM spend.
# context_token_budget = 60000   # history sent to the model, ~chars/4 estimate
# stream_timeout_sec = 300    # how long a reply may stay silent (30-1800 s)
# max_tokens = 8192           # output cap per model call (2048-128000); unset = 32000 on
                              # Anthropic, the provider's own default elsewhere
# max_continuations = 1       # automatic continuations of a reply cut off by the output
                              # limit (0-3)
# effort = "medium"          # default effort for chats with no level of their own:
                              # low | medium | high | xhigh | max (unset = the provider's default)
# mode = "engineer"          # project only: the mode a new chat starts in when you have no
                              # earlier chat here. engineer | lead

# Named parts of the codebase, used by the chat assistant (see "Areas" below).
# Up to 50 tables; each name is a lowercase slug.
# [areas.billing]
# description = "Invoices and payments"   # one line, at most 200 characters; optional
# paths = ["src/billing/", "tests/billing"]  # required: 1-20 repo-relative posix paths
# labels = ["billing", "payments"]         # optional: up to 20 issue / PR labels

# Per-provider connection settings - key, endpoint, timeout. No model here:
# pick models with [llm].model or a role's own model.
[llm.anthropic]
# api_key = "sk-ant-..."        # moved into the encrypted store on import
timeout_sec = 60

[llm.openai]
# api_key = "sk-..."            # moved into the encrypted store on import
# base_url = "..."              # default: https://api.openai.com/v1
timeout_sec = 60

[llm.deepseek]
# api_key = "sk-..."            # moved into the encrypted store on import
timeout_sec = 60

[llm.openrouter]
# api_key = "sk-or-..."         # moved into the encrypted store on import
timeout_sec = 60

[llm.ollama]
# host = "http://localhost:11434"
timeout_sec = 120

Section by section

Section What it controls
[scan] The crawl: which source-control forge to pull PRs and issues from, the git remote name, a GitHub token (imported once into the encrypted store), which auto-rescan hooks to install, which branch counts as shipped history, and serve_codegraph, whether the portal serves CodeGraph's tools to agents (default true).
[llm] model - the default "provider/model" for every role. See Choosing models.
[analyze] The per-commit LLM diff descriptions written during a scan - provider, model, parallelism (max_workers), the truncation / per-file thresholds, and an org-only agent limit.
[rationale] The whygraph_rationale_brief card - provider, model, how much of a squash-merged PR is rendered into the prompt, and an org-only agent limit.
[chat] The chat assistant - default provider and model for new sessions, plus the per-turn tool, generation, and context budgets, the output limit, continuations, the default effort, and (per project) the default mode.
[areas.<name>] Named product areas: a set of paths and issue / PR labels the chat assistant can filter history by. Empty by default.
[llm.*] Per-provider connection settings - key, timeout, and base_url / host where relevant. Five adapters; only four can drive chat. See LLM providers.

Areas

An area names part of your codebase so a question such as "what changed in billing?" resolves to the right code. Each [areas.<name>] table holds a description, a list of paths and a list of labels. The chat assistant sees the areas in its project overview, can pass area to find_changes, get_work_in_flight and get_area_owners, and the portal's Overview lists them. Areas are optional; with none configured those tools work on paths alone. In the portal you edit them under Settings > Areas, where Suggest areas proposes some from where the last year's commits landed (nothing is saved until you save the section). Areas are set per project, never in the portal defaults, and a linked project has no Areas section (the platform owns its settings).

[areas.billing]
description = "Invoices and payments"
paths = ["src/billing/", "tests/billing"]
labels = ["billing", "payments"]

What is accepted (anything else is refused when you save in the portal, and dropped with a warning when read from a whygraph.toml):

  • At most 50 areas. The name is a lowercase slug (a-z, 0-9, -), at most 40 characters, and token and api_key are reserved.
  • Only the keys description, paths and labels. An unknown key is an error.
  • description is one line of at most 200 characters.
  • paths is required: 1 to 20 entries of at most 256 characters, relative posix paths (no leading /, no \, no ..). An entry ending in / is a prefix; otherwise it matches that exact path or anything under it.
  • labels is optional: up to 20 entries of at most 50 characters, matched case-insensitively against issue and PR labels.
  • Entries are non-empty strings without control characters.

Paths are only compared with the file paths recorded in history; nothing opens them.

Directory renames are not followed

History is matched by the paths you list. If a directory was moved, add its old location to paths to keep the earlier history in the area.

Ownership, not performance

get_area_owners describes who knows the code under an area. It is not a ranking of people, and the assistant is told not to present it as one.

Agent limits (organization only)

Four keys bound the LLM spend that agents on connected machines can cause on a production portal: an organization-wide limit and a per-member limit for each of the two kinds of work. They are different from every other key:

Key Default Range Bounds
[rationale].agent_generations_per_hour 120 0 to 10000 Uncached rationale cards generated for agents, per hour, across the organization
[analyze].agent_descriptions_per_hour 600 0 to 10000 Commits described by the lazy backfill for agents' evidence, per hour, across the organization
[rationale].agent_generations_per_member_per_hour 30 0 to 10000 The same cards, per hour, for one member's agents
[analyze].agent_descriptions_per_member_per_hour 150 0 to 10000 The same descriptions, per hour, for one member's agents

Both limits of a kind apply to every call: the organization key is the ceiling for everyone together, the per-member key stops one person using the whole allowance. A member value above the organization value is harmless, because both are enforced. The defaults are a quarter of the organization's.

0 gives agents only what exists: cached cards, and descriptions already written. Cached cards are always served. A refusal says which limit it was (scope is org or member). The limits are about agents only; Explorer and Chat spend is bounded by budgets.

  • Organization defaults only. They are set by an owner, in the organization's Settings under Agent limits (or PUT /api/portal/defaults). An admin cannot set them.
  • Never per project. A project's settings refuse them, and the platform reads them from the organization's defaults row, never from a project's merged configuration.
  • Never imported from a repository. A whygraph.toml that sets them has them dropped and reported on import, because a repository is untrusted input and must not raise its own organization's spend.
  • Production only. Local mode has no connected machines, so they have no effect there.

Choosing models

Set one default for everything with [llm].model, then override a single role where it pays off:

[llm]
model = "anthropic/claude-opus-4-7"   # every role, unless it says otherwise

[analyze]
model = "claude-haiku-4-5"            # a cheaper model for per-commit descriptions

[llm].model is split on the first /: the part before it is the provider, the rest is the model. So "openrouter/openrouter/auto" means OpenRouter's openrouter/auto model.

For each role WhyGraph resolves one (provider, model) pair, highest precedence first:

# Source Note
1 The role's own model ([analyze], [rationale], [chat]) Written as "provider/model" it switches provider too - but only when the table has no provider key and the prefix is a known provider, so provider = "openrouter" + model = "openai/gpt-4o" stays one OpenRouter model.
2 [llm].model Used when its provider is the role's provider.
3 [llm.<provider>].model Deprecated - see below.
4 The provider's built-in default claude-opus-4-7, gpt-4o, deepseek-flash, openrouter/auto, llama3.

The provider comes from the role's provider, else the prefix of its model, else the provider of [llm].model, else anthropic.

Chat needs a provider that can stream tool calls. If [llm].model names ollama, chat falls back to anthropic; naming it in [chat] itself is an error.

Rationale cards follow the model

Cached rationale cards are keyed on the provider and the pinned model. Changing [llm].model or [rationale].model therefore regenerates cards rather than serving ones written by another model. With nothing pinned the key is the same one 1.x used, so an upgraded project keeps its cache.

Deprecated keys

These 1.x keys still work through 2.x - each logs one deprecation warning per process - and are removed in 3.0. When a table sets both the old and the new spelling, the new one wins.

1.x key Use instead
[scan].provider [scan].forge
[scan].max_workers [analyze].max_workers
[llm.<provider>].model [llm].model = "<provider>/<model>", or a role's own model
[analyze].timeout_sec, [rationale].timeout_sec [llm.<provider>].timeout_sec - timeouts belong to the provider

A 1.x file resolves to exactly the same providers, models, and timeouts it did before.

An unknown key is a warning, not an error

A typo'd or stale key is logged and ignored rather than aborting the command, so an old config keeps working across upgrades. Check the log if a setting doesn't seem to take effect.

Environment variables

The portal and its scans read none of your shell's credential variables: its container is started with none of your environment, so provider keys and GitHub tokens are entered under Settings instead, and an ambient GH_TOKEN / GITHUB_TOKEN or *_API_KEY no longer applies to anything. A portal started outside Docker removes ANTHROPIC_API_KEY, OPENAI_API_KEY, DEEPSEEK_API_KEY, OPENROUTER_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL and OPENAI_BASE_URL from its environment at start and logs their names: only keys and endpoints stored under Settings are used. The variables below are the ones the shim and the portal read.

One variable is internal:

Variable Used for
WHYGRAPH_CONFIG_JSON What the portal hands its scan child: the project's whole merged config tree, as a JSON object shaped like whygraph.toml, so no file is written into the checkout. You do not set it yourself; the scan child refuses to start without it.

These are read by the Docker shim and the portal rather than by a scan:

Variable Used for
WHYGRAPH_IMAGE Override the image a single shim invocation runs, including whygraph up.
WHYGRAPH_PORT The portal's port (default 8765) when whygraph up --port has not set one. It is also what Claude Code's agent entry reads, ${WHYGRAPH_PORT:-8765} - see Connecting agents.
WHYGRAPH_DATA The portal's data directory (default ~/.local/share/whygraph).
WHYGRAPH_VERSION Pin the version at install time. See Installation.
WHYGRAPH_POSTGRES_IMAGE Shim only. Override the pinned postgres image of the database container, for a registry mirror. It must be the same Postgres major version as the pin; a different image recreates the database container (after a dump).
WHYGRAPH_SKIP_BACKUP Shim only. Set to 1 to let whygraph up recreate a running database container without dumping it first, when that dump fails and you accept the risk. See Backup and restore.

The portal reads its database from two variables. The shim sets both for its own database container, so you only set them to run whygraph portal yourself:

Variable Used for
WHYGRAPH_DATABASE_URL Required by whygraph portal: the Postgres database the portal keeps its own data in, such as postgresql+psycopg://whygraph@whygraph-portal-postgres:5432/whygraph. postgresql:// and postgres:// are accepted too; any other database is refused. Without it, whygraph portal exits with status 2.
WHYGRAPH_DATABASE_PASSWORD_FILE Optional. A file whose contents (surrounding whitespace and newlines stripped) are the database password; it wins over a password in the URL. The shim uses /data/postgres.password, so the password never appears in docker inspect, a process list or the environment of what the portal starts.

Neither is a whygraph.toml key: the portal database is host configuration, not project configuration.

These select and shape the portal's production mode, and are read once at its start. None is a whygraph.toml key either.

Variable Used for
WHYGRAPH_MODE local (the default) or production. It is fixed at the portal's first start and stored in its database; a different value later is refused.
WHYGRAPH_BASE_URL Required in production. The public address, such as https://whygraph.example.com or http://whygraph.localhost:8765: scheme and host (and a non-default port), no path. Organizations live on <org>.<host>. http is accepted only for hosts ending in .localhost; an IP address or a single-label host is refused. A default port (:443, :80) is dropped. An invalid value, or WHYGRAPH_SHARED_FOLDERS set in production, stops the first start with exit code 2.
WHYGRAPH_GITHUB_OAUTH_CLIENT_ID Required in production. The client ID of the GitHub OAuth App that signs people in; its callback URL is <WHYGRAPH_BASE_URL>/auth/github. See GitHub sign-in.
WHYGRAPH_GITHUB_OAUTH_CLIENT_SECRET_FILE Required in production. A file holding that app's client secret (trailing whitespace stripped). The secret is never an environment value. A missing variable, or an unreadable or empty file, stops the start with exit code 2.
WHYGRAPH_GITHUB_APP_SLUG Required in production. The URL name of the GitHub App that projects are imported through (github.com/apps/<slug>). See The GitHub App.
WHYGRAPH_GITHUB_APP_CLIENT_ID Required in production. That app's client ID (not its numeric app ID).
WHYGRAPH_GITHUB_APP_CLIENT_SECRET_FILE Required in production. A file holding the app's client secret.
WHYGRAPH_GITHUB_APP_PRIVATE_KEY_FILE Required in production. The app's private key, an unencrypted RSA .pem file. It never leaves the portal process.
WHYGRAPH_GITHUB_APP_WEBHOOK_SECRET_FILE Required in production. A file holding the app's webhook secret, at least 32 characters. A missing app variable, an unreadable or empty file, a key that is not RSA or a short webhook secret stops the start with exit code 2, naming the variable.
WHYGRAPH_GITHUB_URL The GitHub web address, default https://github.com; set it for GitHub Enterprise Server. https only, except http for localhost and loopback addresses. Git clones and fetches production projects from it too; the pull request and issue crawl works only for github.com.
WHYGRAPH_GITHUB_API_URL The GitHub API address, default https://api.github.com; on Enterprise Server <WHYGRAPH_GITHUB_URL>/api/v3. Same scheme rule.
WHYGRAPH_UPDATE_CHECK Set to 0 to turn off the portal's daily release check. Read only by a portal that is started with it - the production compose bundle, or whygraph portal run directly - not by one started with whygraph up, which passes none of your environment; there the switch is the Settings toggle.
DO_NOT_TRACK 1, true, yes or on (any case) turns the release check off, ahead of WHYGRAPH_UPDATE_CHECK and the Settings toggle; other values are ignored. The same reach as WHYGRAPH_UPDATE_CHECK: a portal started with it, not one started by whygraph up.
WHYGRAPH_TRUSTED_PROXIES Comma-separated IPs or CIDRs of the reverse proxies whose X-Forwarded-For the portal believes (it is uvicorn's forwarded_allow_ips). Unset trusts none. A bad entry exits with status 2.

Provider keys degrade gracefully - for scan and rationale

Missing a key for the analysis or rationale phase isn't fatal - that phase skips, and the rest of the scan still runs. Descriptions and cards backfill once a credential is available.

Chat is the exception. There's nothing useful to return without a model, so a turn with no usable credential fails immediately with an error naming the variable it needs.