Every key
Every configuration key that exists, by section, with its meaning and default.
The authority is the error message config set prints for an unknown key. It lists
exactly what the binary in front of you accepts, and no page can be more current than the
build it is describing.
⚠ This table and that error message are two different lists, and neither one contains the
other. This table describes the configuration file, which is the larger surface: the
[oauth_server] and [gateway] sections below are read when those servers start and are not
keys config set writes at all. In the other direction, this table is maintained by hand, so
a key can be accepted by the product before it is described here. ⇒ Where the two disagree,
the error message decides what your build takes, and this page decides nothing — if you
are refused a key that appears below, read that as an answer about your build rather than as
a typo.
How a key on this page becomes a line in the file
saphan config set takes one dotted key. The file has sections. The two spellings are
related by one rule, and it is the same rule for every family below: the last dotted segment
is the key; everything before it is the section header.
| What you type | What the file carries |
|---|---|
saphan config set lane.local.credentials_env LITELLM_API_KEY | [lane.local], then credentials_env = "LITELLM_API_KEY" — the worked file under Configuration prints exactly that |
saphan config set probe.model.claude-code "opus,fable" | [probe.model], then claude-code = "opus,fable" |
saphan config set notice.binding.person.sink slack | [notice.binding.person], then sink = "slack" |
That is why a repeated section below is headed [lane.<alias>] and [host.<id>] rather than
[lane] and [host]: the variable segment belongs to the section name, not to the key.
⚠ So no key name on this page contains a dot. If you are reading a dotted name anywhere,
you are reading a config set argument, and it splits at its last dot before it reaches the
file. ⛔ Getting the split wrong matters, because most sections will not tell you. Only the
three sections marked below refuse an unknown key by name at load; in the rest, a key written
under the wrong header is not refused — it is simply not read, and a configuration that is
quietly not read looks exactly like one that is.
[identity]
| Key | Meaning |
|---|---|
actor | who is acting; the only key with the full flag/env/file ladder. Unset = act verbs refuse. |
display_name | name used for commit authorship and the record |
email | optional |
[engine]
| Key | Meaning |
|---|---|
binary | path (or bare PATH-resolved name) of the engine binary; default shape saphan |
[workspace]
| Key | Meaning |
|---|---|
default | workspace used when a command does not name one |
worktree_grant_class | the declared permission-grant settings file every copy surface reads as its source (worktree creation and dispatch command). Relative paths resolve against the workspace root. Absent → each surface keeps reading the workspace's own .claude/settings.json. When set, it wins over the deprecated run.grant, and composition says so with a named warning. ⚠ Setting this key does not mean every backend receives a grant. WHERE the file lands — or whether it lands at all — is a per-backend answer, and today only claude-code takes one. See §4.1 Which backends receive a grant. |
[run]
| Key | Meaning |
|---|---|
binary | the agent backend binary saphan run launches (claude, codex) — distinct from engine.binary |
env | comma-separated list of environment variable names to pass through to runs (e.g. HOME,PATH,USER) |
laws | absolute path of the standing-law index a composed dispatch injects. Absent = the stream loses dispatch eligibility with a named reason — never a guessed default. |
grant | deprecated alias of workspace.worktree_grant_class; read only when the newer key is unset |
confinement | engine (the engine composes the OS sandbox itself — the default) or vendor (the agent tool confines itself, and the run record says the engine makes no confinement claim). Any other value refuses before anything runs. See Security §8. |
git_user_name | commit authorship a spawned leg composes with, for BOTH the seated-local and ssh lanes. Not a host default: a leg without it commits as whatever the far host happens to be configured with, so R50 refuses the spawn rather than let the record acquire an author nobody chose. |
git_user_email | the address half of the same pair; R50 requires BOTH or neither key satisfies it. |
role_liveness_freshness | how recent an executor's liveness must be to count as "live" for the per-(stream, role) dispatch lock; a Go duration, default 90m |
[store]
| Key | Meaning |
|---|---|
backend | record backend: sqlite (default — one WAL-mode file under <workspace>/.saphan/state) or postgres for central deployments |
postgres_dsn_env | name of the environment variable holding the DSN — never the DSN itself |
[evidence]
| Key | Meaning |
|---|---|
base_url | CouchDB evidence-store base URL (e.g. http://localhost:5984). The evidence store is an optional blob lane additive to [store]; absent config degrades gracefully. |
db_name | database name |
couchdb_credentials_env | name of the variable holding user:password |
[oauth]
⚠ Not
[oauth_server]. This section says which authorization server the engine trusts as a resource server. The next section says what our own authorization server is.oauth.issueris somebody else's name;oauth_server.issueris ours. An operator who sets one where the other belongs gets a server that trusts itself.
| Key | Meaning |
|---|---|
mode | self-issued (default) or external |
issuer | external authorization-server issuer URL; required when mode = external, ignored otherwise. See the MCP server §7. |
[oauth_server]
⚠ Not
[oauth]. This section configures the authorization server this product runs — thesaphan-oauthbinary: its own name, its own certificate, its own database. The section above configures which other authorization server the engine believes. Settingoauth.issuerwhen you meantoauth_server.issuermakes the engine trust a server that does not exist; settingoauth_server.issuerwhen you meantoauth.issuermakes this product announce itself under a name it cannot serve.
Unknown keys in this section are refused by name at load, unlike most sections (§6.1). The error message lists exactly the keys in the table below; if the two ever disagree, the error message is the authoritative one.
| Key | Meaning |
|---|---|
issuer | Required. This server's own address, exactly as clients use it, e.g. https://auth.example.com. It appears in every token this server signs and in both discovery documents. It is never derived from a request: a server that learns its own name from a header can be told it is somebody else. Must be https://, with no query, no #fragment and no trailing slash. An http:// address is accepted only on 127.0.0.1, [::1] or localhost, for local testing. |
listen | host:port to bind. Default 127.0.0.1:7655. A host-less value (":7655") is refused as ambiguous at every tier — configuration cannot widen the bind more quietly than the --listen flag can. Precedence: --listen → SAPHAN_OAUTH_LISTEN → this key → the default. |
tls_mode | Required, closed enum, no default. native — this server presents its own certificate and handles encryption itself. proxy — a load balancer, reverse proxy or tunnel in front of it handles encryption, and this listener speaks plain HTTP to the local machine only. There is no mode by silence: an absent value is a refusal, because the two differ in whether this server's traffic is encrypted on the wire. |
tls_cert | PEM certificate file. Required when tls_mode = "native". |
tls_key | PEM private key file for that certificate. Required when tls_mode = "native". |
tls_min_version | 1.3 (default) or 1.2. Stated rather than inherited: a floor that moves when the toolchain is upgraded is a floor nobody chose. Use 1.2 only for clients that cannot speak 1.3. |
trusted_proxies | Comma-separated CIDR ranges of proxies whose X-Forwarded-* and RFC 7239 Forwarded: headers may be believed. This list ADDS to loopback; it never replaces it — a proxy on this same machine is trusted structurally and needs no entry. Two refusals: a range covering 0.0.0.0/0 or ::/0 is refused (every client on the internet would be believed), and a key written as trusted_proxies = "" is refused (an empty list does not say whether you meant no proxies or all of them). To trust nothing beyond this machine, delete the line. |
state_dir | Required. Directory this server may write. Its database is auth/auth.db beneath it; its signing-key files are auth/keys/*.pem, mode 0600; its pidfile and log sit at the top. |
store_backend | sqlite (default, one file under state_dir) or postgres. |
postgres_dsn_env | name of the environment variable holding the PostgreSQL connection string — never the connection string itself. Required when store_backend = "postgres". state_dir is still required, for the pidfile. |
Signing keys are never created automatically. A server with no published key refuses to start and names the command that fixes it. See §13 for the runbook.
[gateway]
This section configures saphan-gateway, the separate stateless process serving the client
HTTP contract. It is intentionally smaller than [oauth_server]: the gateway terminates no
TLS and binds only to loopback; a local reverse proxy owns the public certificate.
Unknown keys in this section are refused by name at load. There is one key:
| Key | Meaning |
|---|---|
listen | Loopback host:port to bind. Default 127.0.0.1:7656. A host-less value (:7656), a relative/non-address value, or a non-loopback bind is refused. Precedence: --listen → SAPHAN_GATEWAY_LISTEN → this key → the default. |
The required --workspace is deliberately not a config key: every start names the absolute
workspace it serves. The complete human start/check/stop ceremony and the current operation
coverage are in the client-contract gateway.
[trust]
| Key | Values | Note |
|---|---|---|
enforce | off | warn | enforce | instruction-signature enforcement; closed enum, validated at load and at config set — a value outside the set is a named refusal. Default off; see Security §11 for the tightening path. |
[org]
| Key | Values | Note |
|---|---|---|
supersede_policy | flag | refuse-merge | owner-decides | organisation-wide handling of a supersede condition; closed enum, validated at load and at config set. Absent means the explicit default owner-decides; there is no ignore value. This release only parses and stores the policy—the merge path does not consume it yet. See §7 for the choice. |
[server]
⚠ Not
[oauth_server]. This section configuressaphan server— the ENGINE, which is a resource server: it accepts tokens.[oauth_server]configures the authorization server that issues them. Both have anissuer-shaped key and they are different names.
Unknown keys in this section are refused by name at load (since
server.public_base_url landed), unlike most sections (§6.1). A typo here is the one class
of mistake that must never be silent: public_base_urls = "https://…" quietly ignored leaves
the engine naming itself from its own socket while the operator reads a file that says
otherwise.
| Key | Meaning |
|---|---|
listen | host:port for saphan server; a host-less value (":7654") is refused as ambiguous at every tier — configuration cannot widen the bind more quietly than the flag can |
public_base_url | The origin this engine asserts as its OAuth issuer: the iss of every token it mints, the issuer and both endpoint URLs of the RFC 8414 document, the resource and authorization_servers of the RFC 9728 document, and the origin inside every WWW-Authenticate challenge. It is an origin — scheme and host, nothing else: no path, no trailing slash, no query, no #fragment, no user:password@, and http/https only. Each of those is refused at load, by name, because this value is concatenated with /authorize, /token and /mcp and a trailing slash alone produces https://host//authorize. It is never derived from a request. Absent is legal and decided, not defaulted — see the box below. |
trusted_proxies | Comma-separated CIDR ranges (a bare IP such as 10.0.0.7 is accepted and read as its single-address block) of proxies whose X-Forwarded-* and RFC 7239 Forwarded: headers this engine may believe. This list ADDS to loopback; it never replaces it — a proxy on this same machine is trusted structurally and needs no entry. A hop this engine trusts may report the scheme it terminated. It may not move this engine's name: see the box below. |
What public_base_url decides, and what happens when it is absent
Set it, and nothing a caller sends can change the name this engine asserts. Not the
Host: header, not X-Forwarded-Host, not RFC 7239 Forwarded: host=, not an absolute-form
request target, not HTTP/2 :authority, not the TLS server name the client asked for — and
not a trusted proxy either. The value stops depending on the request at all.
Leave it absent, and the engine names itself from the address it observed on its own
socket — the local address of the connection, which no client can set. On the solo tier
(nothing configured, bound to 127.0.0.1:7654) that is http://127.0.0.1:7654, byte for
byte what earlier releases produced. If a trusted hop reports X-Forwarded-Proto: https,
the scheme — and only the scheme — follows it.
An engine behind a proxy MUST set this key. Unconfigured, it will name itself by its own loopback address, which its clients cannot reach. Earlier releases papered over this by believing
X-Forwarded-Hostfrom any loopback peer — which on that host means every local process, so any of them could rename the server and have tokens signed under that name. That hole is closed; the key is what replaces it.
Setting it changes one thing for local clients: an engine reached as
http://localhost:7654used to advertisehttp://localhost:7654and now advertises the address it is actually on. Clients follow the discovery documents, so they follow the change.
The ISSUERS.md row that goes with it
Enrolling an authorization server is an owner act — the engine never writes that record.
The row's audience column is this engine's resource identifier, which is
public_base_url + /mcp. See Issuer enrolment for the numbered
runbook and the exact line to paste.
[headroom]
| Key | Meaning |
|---|---|
warn_usd | warning threshold for the fleet-capacity pre-flight (R17-HEADROOM-WARN); empty means the built-in default 5.00 USD. A warning flags; it never blocks. |
[probe.model]
| Key | Meaning |
|---|---|
claude-code | comma-separated models tried, in order, for each Claude Code seat; default opus |
codex | comma-separated models tried, in order, for each Codex seat; default gpt-5.6-sol |
Set the lists through the dotted config keys:
The list distinguishes “this seat is spent” from “this seat is spent for this model.”
fleet headroom stops at the first model that answers and reports that model plus every
earlier model that did not. The trade-off is direct: each failed model before the answer is
another real, potentially billable probe spawn. A seat that answers the first listed model
still receives exactly one spawn. Omitting a key applies and prints the documented default;
an empty list is refused, and any backend other than claude-code or codex is refused by
name at both load and config set.
The command-line fleet headroom --model <name> remains an explicit one-model override for
the separate question “can every applicable seat run this exact model?” It applies that
one-element list to every backend for that invocation.
[scheduler]
| Key | Meaning |
|---|---|
host_max_live | per-host ceiling on concurrently live runs; default 6 |
exhausted_window_hours | how long a lane-exhausted observation keeps a lane out of consideration; default 5.0 |
usage_probe_after_exhausted | policy for when a /usage probe is due after a lane-exhausted row — defined but read by nothing yet; setting it changes nothing today |
[fleet]
| Key | Values | Note |
|---|---|---|
ssh_host_key_policy | refuse | accept_rotation | closed enum, validated at load and at config set; defaults to refuse. Full description in §8. |
The three [notice…] sections — the outbound chat notices
These are the fourteen keys behind Notifications: a binding is a
destination plus the credential and rendering to reach it, a route says which notice type
goes to which binding, and one key names the single machine that does the sending. <name> is
a binding name you choose; <n> is a route number — both are variable segments, so by the rule
at the top of this page both belong to the section header and neither appears in a key.
⚠ These fourteen keys are not in the Known keys: list on the generated
saphan config reference, which was produced from one build's
own help text, and which release first accepts them is not stated anywhere on this site.
So the paragraph at the top of this page is the one that applies here in particular: if
config set refuses one of them, the refusal is telling you about your build.
[notice.binding.<name>] — repeated section, keyed by the binding name you choose
| Key | Meaning |
|---|---|
sink | the kind of destination this binding delivers to |
destination | where in that destination — for the chat sink, a conversation id. ⛔ A user id is refused at set time; the Slack page says why |
format | how the notice is rendered for this destination |
credentials_env | name of the environment variable holding the token — never the token |
credentials_file | path of a file holding it instead. See Secrets for the rules a credentials file on this machine is held to |
locale | which language catalogue renders it; a catalogue that is still a draft is refused |
timezone | the local zone shown beside UTC wherever a notice carries a time |
no_reply_line | a switch, true — turns on the product's own line saying this conversation does not take replies. Not a sentence you write |
note | one line of your own, at most 200 characters, no line break, no braces, no control characters — refused, never truncated |
[notice.route.<n>] — repeated section, keyed by route number
| Key | Meaning |
|---|---|
type | which notice type this route carries: owner.act_owed, gate.signed, merge.landed or delivery.failed |
severity_min | the floor below which this route does not fire |
binding | which binding it delivers through |
actor | narrows the route to one actor — on a type that carries one, which owner.act_owed does not |
[notice.sender]
| Key | Meaning |
|---|---|
primary | the operating system hostname of the machine that sends — what hostname prints on that host. Not the name of a binding, and ⛔ not the registry machine id that keys [host.<id>] below. This is the one key on this site that names a machine by hostname rather than by id; the two strings are the same only when hostname on that host already prints the short form and nobody overrode the id — a dotted hostname differs from the default id with nothing overridden, and the Slack page works both cases |
So a binding called person is [notice.binding.person] in the file, its first route is
[notice.route.1], and the sending machine is primary under [notice.sender] —
saphan config set notice.route.1.binding person writes binding = "person" under
[notice.route.1], the same split this page applies to lane.local.credentials_env and
probe.model.claude-code.
[host.<id>] — repeated section, keyed by machine id
| Key | Values | Note |
|---|---|---|
ssh_host_key_policy | as above | overrides [fleet] for one machine; empty means inherit |
[lane.<alias>] — repeated section, keyed by alias
| Key | Meaning |
|---|---|
base_url | lane endpoint (an OpenAI-compatible root, e.g. a LiteLLM proxy, Ollama, vLLM) |
credentials_env | name of the environment variable carrying the key |
credentials_file | absolute, host-local path to a file holding <NAME>=<value>; must be mode 0600, checked at read time, read fresh at the moment of use and never cached |
A [lane] with no alias, or a [host] with no id, is a named load failure, not an
ignored block: an unaddressable lane is not a lane, and a per-machine override with no
machine is not an override.