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.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.auth.env |
string | DISCORD_PHPBOTSCOUT_TOKEN |
Name of the environment variable holding the bot token. Recommended. |
discord.auth.keychain |
string | — | OS keychain entry as service/account. |
discord.auth.value |
string | — | The token itself. Refused under CI=true. |
discord.guilds |
list | required | The servers to serve. Several are accepted, each space once. |
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[].exclude_channels |
list of string | [] |
Channel IDs the bot must never read, with their threads. The bot reads every public channel of the server except these (see below). Applies immediately on change. |
discord.guilds[].post_answers |
bool | false |
Let the bot post its answers in this server, in a thread with its sources. Off, the server's scope is built read-only and the bot cannot post there at all (shadow mode). Read at start: a change needs a restart. |
discord.guilds[].answer_budget |
int | respond.answer_budget |
This server's own hourly answer ceiling, in place of respond.answer_budget. 0 is no ceiling. Negative values are rejected. Unset, the server shares the process-wide budget. |
discord.guilds[].ai.provider |
string | ai.provider |
The chat provider that composes this server's answers. |
discord.guilds[].ai.model |
string | ai.model |
The model passed to this server's provider. |
discord.guilds[].corpus |
list of string | [] |
GitLab path prefixes, such as phpboyscout/scoutdm, that narrow what this server answers from. A prefix covers that source and every source under prefix/. Empty is the whole discovered corpus. An empty entry is rejected. |
discord.guilds[].forum_scopes |
map of channel ID to list of string | {} |
For each support forum, the GitLab path prefixes it is about, such as "1519629970455134300": [phpboyscout/keryx]. A question asked in that forum, or in a thread under it, fills most of its retrieved passages from those sources unless it names a module itself. Cut to the server's corpus. A forum with no prefix, or an empty one, is rejected. |
The bot is one Discord application with one token, so the credential is
discord.auth, set once for every server. 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.
The per-server keys (answer_budget, ai.*, corpus and forum_scopes) are read at start.
A change needs a restart, and is not warned about.
A corpus prefix narrows what discovery found and can never add to it. A
prefix that matches no indexed source is not refused, because the index is
empty on a fresh install until its first refresh. It is logged at startup
instead:
WARN a server's corpus matches no indexed source; it will decline everything until one does space=... corpus=[...]
Several servers¶
One serve process can serve several servers, such as a stage server that
rehearses a setting and the production server that gets it once it has earned
it. One application is invited to each, with one token.
Each server's data is kept apart in the one database. Questions, answers and
topics record their server, and a topic never spans two. The documentation
index is shared. The user-hash salt is one per deployment, so a person is the
same pseudonym in every server, and forget finds everything in one
pass.
A server with its own corpus or ai.* is answered by its own answering
service. A server with its own answer_budget spends it alone, so stage using
up its hour never blocks production. review works
on one server at a time. See
spec 0029.
Rows recorded before servers were recorded have no server. On the first start with exactly one server configured, they are adopted into it, and the count is logged. With several configured they stay unassigned, because nothing says where they came from, and the log says so:
WARN questions recorded before servers were assigned belong to no server questions=12 detail="start once with only their server configured to adopt them"
Supervision¶
How the daemon restarts the Discord connection when it goes wrong. Every value is validated at startup. A value left at zero takes its default, and a negative one, which would silently switch supervision off, is refused.
| 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 such as 0.0.0.0 and :: are rejected; empty takes the default — 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.clones |
string | clones beside index.path |
Where the kept clones live, one directory per source. Created mode 0700. After each completed refresh, the clone of a source that no longer qualifies is deleted. Only clones the indexer made are touched: a git repository named for a source path, such as phpboyscout%2Fphpbotscout. Other files and directories, the index among them, are left alone. A directory of its own is still the tidy choice. |
index.ssh.key |
string | — | Path of the private key the indexer clones and fetches with. Set, every source is fetched over SSH with that key alone, and there is no HTTPS fallback. Unset, sources are fetched over HTTPS. |
index.ssh.known_hosts |
string | — | Path of the file the forge's host keys are pinned in. Required with index.ssh.key. The host key is checked against this file and nothing else. ~/.ssh/known_hosts and SSH_KNOWN_HOSTS are never read, and nothing is added to the file. |
index.feed.url |
string | https://phpboyscout.uk/projects.json |
Where the estate feed is read from, once per discovery run. Must be HTTPS; anything else is refused at startup. The feed is canon for which sources may be indexed: a source it does not list is rejected with the clause feed. The last good copy is kept in the index database and used when a fetch fails. See index sources. |
index.refresh_interval |
duration | 1h |
How often serve re-reads the corpus, and the unit index status measures staleness in. Values below 1m are rejected at startup: every refresh fetches each source that may have moved, and a shorter interval is a polling loop aimed at the project's own infrastructure. |
Fetching over SSH¶
Over HTTPS every fetch counts against the forge's web rate limit, which an hourly refresh of the whole corpus can exhaust. Over SSH it does not, so a deployment should set a key. Use a key that does nothing else, registered on the bot's own forge account, and keep both files readable by the bot alone:
index:
ssh:
key: /home/phpbotscout/.config/phpbotscout/git_ed25519
known_hosts: /home/phpbotscout/.config/phpbotscout/known_hosts
Build the known-hosts file once, and compare its fingerprints with the ones the forge publishes
before trusting it. ssh-keyscan asks the network, which is what the file pins against:
The SSH URL each source is fetched from is the one the forge reports, and it must name the host in
gitlab.url.api (GitLab.com when unset). A source whose SSH URL is missing or names
another host fails on its own, with the reason recorded. A clone made over HTTPS is switched to SSH
on its next fetch, without cloning again.
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.
respond¶
| Key | Type | Default | Meaning |
|---|---|---|---|
respond.thread_window |
duration | 24h |
How long a thread the bot opened stays its thread after the last message in it. A follow-up inside the window is answered without a mention; outside it the thread is treated as archived, which is what Discord does after the same period of inactivity. Must be positive. |
respond.answer_budget |
int | 0 |
How many answers the bot will give per hour, across everyone. A server with its own discord.guilds[].answer_budget uses that instead. 0 is no ceiling. Once reached, the bot says so in the channel rather than going quiet, and answers again as the hour slides on. Negative values are rejected. |
The window counts from the last activity, not from when the thread was opened, so a conversation that is still going after two days is still answered. Threads are restored from the answer records at startup, so a restart does not forget them.
The budget exists because anyone on a public server can mention the bot and each answer is a model call somebody pays for. It is a single ceiling for the whole bot rather than one per person: one ceiling needs nothing stored and bounds every runaway you have not thought of, at the cost that one person hammering the bot takes it away from everybody for the rest of the hour. Pick a number with that in mind, and expect to raise it once you have traffic to look at.
answer¶
| Key | Type | Default | Meaning |
|---|---|---|---|
answer.upstreams |
list | none | Upstream projects to signpost. When the bot declines a question, or answers it only in part, and the question belongs to one of these upstreams, it adds a line pointing at the upstream's own documentation. See what phpbotscout does not do. |
Each entry:
| Field | Meaning |
|---|---|
upstream |
The upstream's name, as the line prints it and as a question might name it. |
aliases |
Other names a question might use for the upstream. Optional. |
wrapper |
The wrapping project's source path, such as phpboyscout/ffmpeg-wasi. |
names |
What a question calls the wrapper. The first is printed in the line. At least one. |
terms |
Words that make a question naming the wrapper an upstream one, such as bitrate or codec. |
docs |
The upstream's documentation URL. HTTPS only, with no credentials and no placeholder host. |
version |
Where the upstream's version comes from: the upstream's Go module path, read from the go.mod of the wrapper's latest release, or tag for the release tag without its build suffix (n9.0.1-8 gives n9.0.1). Empty prints no version. |
A line is added when the question names the upstream or one of its aliases, or names the wrapper
and uses one of its terms. Names and terms match whole words, case-insensitively, so a name
that is also an ordinary word (vault) belongs in terms, where the wrapper has to be named too.
At most two lines are added, in the order declared.
answer:
upstreams:
- upstream: FFmpeg
wrapper: phpboyscout/ffmpeg-wasi
names: [ffmpeg-wasi]
terms: [bitrate, bitrates, codec, codecs, encoder, encoders, filter, filters]
docs: https://ffmpeg.org/documentation.html
version: tag
- upstream: disgo
wrapper: phpboyscout/go/comms-discord
names: [comms-discord]
terms: [intent, intents, gateway, shard, shards]
docs: https://pkg.go.dev/github.com/disgoorg/disgo
version: github.com/disgoorg/disgo
An entry that does not validate stops serve and ask from starting, and a change to one while
serve runs is refused whole, keeping the entries in use and logging why.
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. |
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.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. With
index.ssh.key set, cloning uses that key and never the token.
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 and by serve when they compose 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. |
A server can override either with discord.guilds[].ai.provider and
discord.guilds[].ai.model, so stage can trial a model before production gets it.
Which providers actually work¶
Only the Anthropic provider module is linked into the binary, so two names work:
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, gemini, anything else |
no | — | Not linked in; refused when the client is built |
openai-compatible |
no | — | Refused earlier still, for want of a base URL, and there is no config key for one |
Any other name fails before a request is made:
This is not a configuration mistake you can work around from the config file. Adding a provider
means linking its module into the build, and it would also have to support the one-shot client
ask builds. 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.
Which model you get¶
With ai.provider: claude and ai.model unset, requests go to the chat module's default model,
DefaultModelClaude, a larger model that moves with that module's releases. That is not 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.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: unable to construct 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 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 |
The same space twice |
guilds[1] (123): ingest: a server is configured more than once |
A guild with no space |
guilds[0]: ingest: guild space must not be empty |
A guild that still names auth |
guilds[0] (123): ingest: discord.guilds[].auth is no longer supported, with the hint "The bot is one application with one token. Move the credential to discord.auth." |
A negative answer_budget on a guild |
guilds[0] (123): ingest: answer_budget must not be negative, got -1 |
An empty corpus entry |
guilds[0] (123): ingest: a corpus prefix must not be empty |
A forum_scopes entry with no prefix |
guilds[0] (123): ingest: forum_scopes for <channel> must name at least one non-empty prefix |
A guild that still names channels |
guilds[0] (123): ingest: discord.guilds[].channels is no longer supported, with the hint "The bot now reads every channel everyone in the server can read. Remove channels, and name any channel it must not read under exclude_channels." |
Two discord.auth modes set |
discord.auth: 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 negative supervision value | serve: discord: health.threshold -1: transport: value must be positive |
index.ssh.key without index.ssh.known_hosts |
index.ssh.key is set without index.ssh.known_hosts: the forge's host keys must be pinned |
index.ssh.key or index.ssh.known_hosts naming a missing file |
index.ssh: stat /path/to/known_hosts: no such file or directory |
index.refresh_interval below a minute |
serve: index.refresh_interval 30s is below the 1m0s minimum — every refresh fetches each source that may have moved, and a shorter interval is a polling loop against the forge |
Upgrading: auth moved to discord.auth¶
The credential used to sit on each guild, as discord.guilds[].auth. It is now
discord.auth, set once, because the bot is one application with one token. A
configuration that still names auth on a guild is refused at startup. A
key that was read and silently ignored would be the quieter surprise.
To upgrade, move the auth block up a level:
Upgrading from channels¶
discord.guilds[].channels, the old channel allowlist, has been removed. A configuration that
still names it is refused at startup. Ignoring it would quietly widen what the bot reads the
moment it was upgraded, and that has to be the operator's choice.
To upgrade, delete channels. Then list any public channel the bot must not read under
exclude_channels. See
spec 0024.
Which channels the bot reads¶
The bot reads every text channel and forum that the server's @everyone role can view, and the
public threads under them. It works this out from the server's @everyone permissions and the
channel's own @everyone overwrite. A channel only some roles can see is not read.
Visibility is decided per message, so there is no list to keep in step. A channel made private stops being read from its next message. A new public channel is read without a restart. When visibility cannot be established, the message is dropped. Missing a public message is the cheaper mistake.
Private threads are never read. Announcement channels and the text chat of voice channels are never read, even when public.
Excluding a channel¶
Excluding a channel also excludes 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 a thread is judged by its parent. An excluded channel cannot leak through a thread that outlived the exclusion. A thread whose parent cannot be determined is not read.
An excluded channel's messages are counted with every other dropped message, as one unlabelled number. Nothing about them is logged.
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: no such file or directory.
Hot-reload¶
discord.guilds[].exclude_channels applies immediately. space, discord.auth and
post_answers 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. A guild's answer_budget,
ai.*, corpus and forum_scopes also need a restart, without a warning.
answer.upstreams applies immediately; a change that does not validate is refused and the
running entries stay.
index.refresh_interval, index.path, index.clones and index.ssh.* are
read once at start. Changing any of them requires a restart. Clones under a directory the
configuration no longer names are left where they are.
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 or issue creation. Neither 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.