Skip to content

Configuration reference

Configuration keys phpbotscout reads, with types, defaults, and the environment-variable mapping.

Every key is readable from a config file, an environment variable under the PHPBOTSCOUT prefix, or any other source the store is given. See Run the bot for a worked example.

Where configuration is read from

Layers, lowest precedence first. A later layer overrides an earlier one key by key; a layer that is absent is simply empty, not an error.

  1. The config files — by default /etc/phpbotscout/config.yaml then $HOME/.phpbotscout/config.yaml
  2. A project-local .phpbotscout.yaml, found by walking up from the working directory
  3. Environment variables under the PHPBOTSCOUT prefix
  4. CLI flags you actually changed

There is no shipped-defaults layer. The framework supports one, but phpbotscout embeds no assets/config.yaml, so a key you delete from your file falls back to the default compiled into the code — the value in the tables below — rather than to a shipped file. The config init writes is a starting point, not a floor beneath your edits.

--config replaces the default files rather than adding to them, and it also suppresses the project-local layer. Naming a config file means "use this one": a file the caller did not name, and may not know is there, must not override files they did name.

phpbotscout config path prints the files being read, with each one's role. Use it before believing anything else on this page about where a value came from.

The environment-variable mapping

Dots become underscores and the whole key is upper-cased, under the PHPBOTSCOUT prefix:

Key Variable
log.level PHPBOTSCOUT_LOG_LEVEL
ai.provider PHPBOTSCOUT_AI_PROVIDER
index.path PHPBOTSCOUT_INDEX_PATH

Credential values are the exception, and are never read this way. gitlab.auth.env and discord.guilds[].auth.env hold the name of a variable, and the secret is read from the variable that name points at.

Most commands refuse to run without a config file

With none of the candidate files present, any command that reads configuration exits with:

ERRO failed to load configuration: no config file found hints="Run 'phpbotscout init' to create a configuration."

phpbotscout init writes $HOME/.phpbotscout/config.yaml. A project-local .phpbotscout.yaml in the working directory satisfies the check on its own, which is the quickest way to run against a throwaway configuration.

discord

Key Type Default Meaning
discord.guilds list required Guilds to serve. Exactly one entry is accepted in v1; the list shape exists so multi-guild is additive later rather than a breaking migration.
discord.guilds[].space string required The guild (server) ID. Not a lookup — the gateway supplies the guilds the bot is in. It is an authorisation boundary, so a shared invite link cannot turn an unintended server into one the bot serves.
discord.guilds[].channels list of string required The channel allowlist. Covers each channel's threads too — see below. Fails closed: an empty list permits nothing and is rejected at startup.
discord.guilds[].auth.env string DISCORD_PHPBOTSCOUT_TOKEN Name of the environment variable holding the bot token. Recommended, and the only mode permitted under CI=true.
discord.guilds[].auth.keychain string OS keychain entry as service/account.
discord.guilds[].auth.value string The token itself. Refused under CI=true.

Exactly one auth mode may be set. Two is an error rather than a preference, because silently choosing one of them is the wrong behaviour where a credential is concerned.

Supervision

How the daemon restarts the Discord connection when it goes wrong. Every value is validated at startup, and the ones that would silently switch supervision off (a zero threshold, a zero restart maximum) are refused rather than accepted.

Key Type Default Meaning
discord.start_timeout duration 30s Budget for building a connection and connecting. A restart whose predecessor has not finished closing waits inside this, then fails and counts against the allowance.
discord.stop_timeout duration 5s Budget for stopping the connection, and also the daemon's shutdown timeout, so the two cannot disagree.
discord.post_timeout duration 3s Ceiling on one post through the connection. Must not exceed stop_timeout: a post holds the connection open, and a stop waits for it only until its own budget.
discord.swap_wait duration 15s How long a post waits for a fresh connection while one is being rebuilt. Covers the first restart; under backoff a post records as unposted instead.
discord.handler_timeout duration 5s Deadline on each message handed on by the reader. A handler still running past it fails readiness, never health: restarting the connection cannot cure it.
discord.health.interval duration 15s How often the connection is probed.
discord.health.threshold int 4 Consecutive failed probes before the connection is rebuilt. With the default interval that is 60s, chosen to exceed Discord's 41.25s heartbeat so the library's own recovery is not fought.
discord.restart.max int 10 Consecutive failures before the daemon gives up and exits non-zero.
discord.restart.initial_backoff duration 2s Wait before the first retry; doubles each time.
discord.restart.max_backoff duration 5m Cap on the backoff.
discord.restart.reset_interval duration 5m How long a connection must run before the failure count resets. Must exceed the longest possible failed run (start_timeout + health.interval × health.threshold + stop_timeout, 95s at the defaults), or a connection that dies on schedule never exhausts.

health

Key Type Default Meaning
health.host string 127.0.0.1 Interface the health listener binds. Wildcards (0.0.0.0, ::, empty) are rejected — binding every interface and relying on a firewall being correct is how private services become public ones.
health.port int 8081 Port for /healthz, /livez and /readyz.

index

Key Type Default Meaning
index.path string phpbotscout.db Where the SQLite index lives. Read by the index commands and by the daemon's refresher, so both always act on the same database.
index.refresh_interval duration 1h How often serve re-reads the corpus. Values below 1m are rejected at startup: every refresh clones the repositories that moved, and a shorter interval is a clone loop aimed at the project's own infrastructure.

An hour is the default because documentation changes on the timescale of a working day, not a minute. The refresh also runs once at startup, so a daemon restarted after downtime does not wait a full interval to catch up.

Written as a Go duration string (30m, 2h, 90s). It is parsed by phpbotscout rather than by the decoder, so a config file and the live store accept exactly the same values.

gitlab

Discovery and cloning both read this section. Keys follow the toolkit's forge convention, so they behave identically to every other tool in the estate.

Key Type Default Meaning
gitlab.auth.env string Name of the environment variable holding the token. Recommended, and the only mode permitted under CI=true.
gitlab.auth.keychain string OS keychain entry as service/account.
gitlab.auth.value string The token itself. Not refused under CI=true — see below.
gitlab.url.api string GitLab.com API endpoint, for a self-managed instance.

Resolution runs in that order and falls back to GITLAB_TOKEN when the section names nothing. Note that auth.env is the name of a variable, not its value.

A literal here is not refused under CI, unlike the Discord one

discord.guilds[].auth.value is refused when CI=true; gitlab.auth.value is not, and never has been. The two are resolved by different code — the Discord token by this project, the GitLab one by the toolkit's forge credential chain — and only the first applies the CI guard.

The guard exists to stop a setup flow writing a literal into a file that CI will archive. Nothing enforces it when reading one back, so a literal in a committed config.yaml will be used in CI. Prefer auth.env there, which is the recommended mode regardless.

The token needs the Maintainer role on the group: discovery reads each repository's Pages settings to locate its documentation site, and GitLab gates that endpoint behind it. A weaker token does not silently exclude repositories — they are reported as unresolved by index sources.

Cloning itself needs no token, since the corpus is public by construction.

What no token actually looks like

Running index sources with no credential at all against the phpboyscout group in August 2026 enumerated 92 public projects and qualified none of them. Every documented candidate was reported like this:

phpboyscout/go-tool-base   rejected  unresolved  could not read the documentation site: GET https://gitlab.com/api/v4/projects/phpboyscout%2Fgo-tool-base/pages: 403 {message: 403 Forbidden}

unresolved rather than a rejection on the merits, because a 403 on the Pages endpoint means "I could not find out", not "there is no site".

ai

Read by ask when it composes an answer. Retrieval never touches these keys, so ask --retrieval-only works with none of them set.

Key Type Default Meaning
ai.provider string claude-local Which chat provider composes the answer.
ai.model string Model name passed to the provider. Empty leaves the provider's own default.

Which providers actually work

Five provider names are compiled into the binary, and three of them cannot be used:

ai.provider Works Credential Notes
claude-local yes none Drives a locally installed, already-authenticated claude binary. The default
claude yes ANTHROPIC_API_KEY Anthropic's API
openai no Refused at construction
gemini no Refused at construction
openai-compatible no Needs a base URL, and there is no config key for one

openai and gemini fail before any request is made, because ask builds its client one-shot and those provider modules do not implement that contract:

ERRO ask: build openai client: provider openai does not support Config.Stateless

This is not a configuration mistake you can work around from the config file. One-shot is load-bearing: without it a long-running client re-sends every previous exchange, is unsafe for concurrent use, and — in a shared channel — makes one person's question part of the context for the next person's.

Naming a provider that does not exist at all fails differently:

ERRO ask: build ollama client: unsupported provider: ollama

Which model you get

With ai.provider: claude and ai.model unset, requests go to the chat module's default model, claude-opus-4-8 — not to the model the project selected on measurement, which is Claude Haiku 4.5 (claude-haiku-4-5). Nothing in this repository sets ai.model, so a deployment that wants Haiku has to say so:

ai:
  provider: claude
  model: claude-haiku-4-5

See choosing a provider for why that model, and what phpbotscout does not do for what changing it does not buy you.

The AI credential is not on the credentials chain

gitlab.auth and discord.guilds[].auth accept an env-var name, a keychain reference or a literal. The AI provider accepts none of them. ask builds its client without passing any credential configuration, so for ai.provider: claude the key is read from the well-known ANTHROPIC_API_KEY variable and from nowhere else. There is no ai.auth.env, no ai.auth.keychain, and no ai.token.

Without it:

ERRO ask: build claude client: Anthropic API key is required but not provided

log

Key Type Default Meaning
log.level string info One of debug, info, warn, error, fatal, case-insensitive.

--debug overrides it for one invocation. An unrecognised value is ignored silently and logging stays at infoconfig get log.level will happily read back bogus while the logger never used it, so do not take that read as confirmation the level applied.

What happens when a value is wrong

serve validates before anything binds or connects, so a misconfiguration is a startup failure with a named field rather than a confusing error three layers downstream. These are the messages, verbatim:

Configuration Message
No discord.guilds entry ingest: no guild configured
More than one entry 2 configured: ingest: more than one guild configured, which is not supported in v1
A guild with no space guilds[0]: ingest: guild space must not be empty
An empty channels list guilds[0] (123): ingest: channel allowlist must not be empty
Two auth modes set guilds[0] (123): ingest: exactly one of auth.env, auth.keychain or auth.value may be set
health.host a wildcard host "0.0.0.0": ingest: health host must be an explicit interface, not a wildcard
health.port outside 1–65535 port 70000: ingest: health port must be between 1 and 65535
discord.post_timeout above stop_timeout serve: discord: 6s > 5s: transport: post_timeout must not exceed stop_timeout
discord.restart.reset_interval too short serve: discord: 1m1s against 1m35s: transport: restart.reset_interval must exceed the longest failed run (...)
A zero or negative supervision value serve: discord: health.threshold -1: transport: value must be positive
index.refresh_interval below a minute serve: index.refresh_interval 30s is below the 1m0s minimum — every refresh clones the repositories that moved, and a shorter interval is a clone loop against the forge

An empty channels list is a failure, not a permission to read everything. A watchlist that silently meant "everywhere" would be the wrong default for reading people's messages.

Threads are covered by their channel

Listing a channel also watches the threads hanging off it. You do not list thread IDs, and there is no key for doing so.

Discord models a thread as a channel with its own ID, so an allowlist matched exactly would let the bot open a thread on a message and then never hear a single reply in it — it could start a conversation it could not follow. A thread is admitted on the strength of its parent instead.

This does not widen the watchlist. A thread cannot exist anywhere its parent does not, so admitting it reaches no channel you did not already name. Removing a channel removes its threads with it, and takes effect immediately like any other channels change — a revoked channel cannot keep leaking through a thread that outlived it.

A thread whose parent cannot be determined is not read. An unresolvable parent is not evidence of permission.

Two mistakes do not produce an error, and are worth knowing for that reason:

  • An unrecognised log.level is ignored, silently, and logging stays at info.
  • A missing index file is created rather than reported. ask opens the database at index.path and creates it when it is not there, so asking from the wrong working directory produces a new empty index and a decline, not an error. Only a path whose directory is missing fails, with ask: open index: store: open: unable to open database file (14).

Hot-reload

discord.guilds[].channels applies immediately. space and auth are read once at start and require a restart, logging a warning that names the field so an ignored change is never a silent one.

index.refresh_interval and index.path are read once at start. Changing either requires a restart.

Keys phpbotscout does not have

Asked for often enough to be worth stating plainly:

  • No key names a source to index. The corpus is enumerated from the forge and filtered by a predicate; configuration is a request to index and never an authority on visibility. See what the bot is allowed to read.
  • No key sets a retrieval score threshold. ask --limit bounds how many passages are retrieved, and that is the only retrieval knob exposed.
  • No key configures moderation, answering in Discord, or issue creation. None of that is built yet — see what phpbotscout does not do.
  • No key changes chunk size, overlap or ranking weights. Those are compile-time constants; the values are in the indexing reference.
  • No key for an AI credential. See the AI credential is not on the credentials chain.