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.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:

ssh-keyscan gitlab.com > known_hosts
ssh-keygen -lf known_hosts

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:

ERRO ask: build openai client: unable to construct client: unsupported provider: openai

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:

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.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:

discord:
  auth:
    env: DISCORD_PHPBOTSCOUT_TOKEN
  guilds:
    - space: "1531227937678954747"

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.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: 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 --limit bounds 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.