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.
- The config files — by default
/etc/phpbotscout/config.yamlthen$HOME/.phpbotscout/config.yaml - A project-local
.phpbotscout.yaml, found by walking up from the working directory - Environment variables under the
PHPBOTSCOUTprefix - 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:
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:
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:
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:
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 info — config 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.levelis ignored, silently, and logging stays atinfo. - A missing index file is created rather than reported.
askopens the database atindex.pathand 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, withask: 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 --limitbounds 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.