Saphan StudioDocs
Configuration

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 typeWhat 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]

KeyMeaning
actorwho is acting; the only key with the full flag/env/file ladder. Unset = act verbs refuse.
display_namename used for commit authorship and the record
emailoptional

[engine]

KeyMeaning
binarypath (or bare PATH-resolved name) of the engine binary; default shape saphan

[workspace]

KeyMeaning
defaultworkspace used when a command does not name one
worktree_grant_classthe 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]

KeyMeaning
binarythe agent backend binary saphan run launches (claude, codex) — distinct from engine.binary
envcomma-separated list of environment variable names to pass through to runs (e.g. HOME,PATH,USER)
lawsabsolute path of the standing-law index a composed dispatch injects. Absent = the stream loses dispatch eligibility with a named reason — never a guessed default.
grantdeprecated alias of workspace.worktree_grant_class; read only when the newer key is unset
confinementengine (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_namecommit 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_emailthe address half of the same pair; R50 requires BOTH or neither key satisfies it.
role_liveness_freshnesshow recent an executor's liveness must be to count as "live" for the per-(stream, role) dispatch lock; a Go duration, default 90m

[store]

KeyMeaning
backendrecord backend: sqlite (default — one WAL-mode file under <workspace>/.saphan/state) or postgres for central deployments
postgres_dsn_envname of the environment variable holding the DSN — never the DSN itself

[evidence]

KeyMeaning
base_urlCouchDB 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_namedatabase name
couchdb_credentials_envname 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.issuer is somebody else's name; oauth_server.issuer is ours. An operator who sets one where the other belongs gets a server that trusts itself.

KeyMeaning
modeself-issued (default) or external
issuerexternal 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 — the saphan-oauth binary: its own name, its own certificate, its own database. The section above configures which other authorization server the engine believes. Setting oauth.issuer when you meant oauth_server.issuer makes the engine trust a server that does not exist; setting oauth_server.issuer when you meant oauth.issuer makes 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.

KeyMeaning
issuerRequired. 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.
listenhost: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: --listenSAPHAN_OAUTH_LISTEN → this key → the default.
tls_modeRequired, 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_certPEM certificate file. Required when tls_mode = "native".
tls_keyPEM private key file for that certificate. Required when tls_mode = "native".
tls_min_version1.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_proxiesComma-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_dirRequired. 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_backendsqlite (default, one file under state_dir) or postgres.
postgres_dsn_envname 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:

KeyMeaning
listenLoopback 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: --listenSAPHAN_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]

KeyValuesNote
enforceoff | warn | enforceinstruction-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]

KeyValuesNote
supersede_policyflag | refuse-merge | owner-decidesorganisation-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 configures saphan server — the ENGINE, which is a resource server: it accepts tokens. [oauth_server] configures the authorization server that issues them. Both have an issuer-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.

KeyMeaning
listenhost: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_urlThe 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_proxiesComma-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-Host from 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:7654 used to advertise http://localhost:7654 and 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]

KeyMeaning
warn_usdwarning 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]

KeyMeaning
claude-codecomma-separated models tried, in order, for each Claude Code seat; default opus
codexcomma-separated models tried, in order, for each Codex seat; default gpt-5.6-sol

Set the lists through the dotted config keys:

saphan config set probe.model.claude-code "opus,fable"
saphan config set probe.model.codex "gpt-5.6-sol,gpt-5.5"

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]

KeyMeaning
host_max_liveper-host ceiling on concurrently live runs; default 6
exhausted_window_hourshow long a lane-exhausted observation keeps a lane out of consideration; default 5.0
usage_probe_after_exhaustedpolicy for when a /usage probe is due after a lane-exhausted row — defined but read by nothing yet; setting it changes nothing today

[fleet]

KeyValuesNote
ssh_host_key_policyrefuse | accept_rotationclosed 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

KeyMeaning
sinkthe kind of destination this binding delivers to
destinationwhere in that destination — for the chat sink, a conversation id. ⛔ A user id is refused at set time; the Slack page says why
formathow the notice is rendered for this destination
credentials_envname of the environment variable holding the token — never the token
credentials_filepath of a file holding it instead. See Secrets for the rules a credentials file on this machine is held to
localewhich language catalogue renders it; a catalogue that is still a draft is refused
timezonethe local zone shown beside UTC wherever a notice carries a time
no_reply_linea switch, true — turns on the product's own line saying this conversation does not take replies. Not a sentence you write
noteone 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

KeyMeaning
typewhich notice type this route carries: owner.act_owed, gate.signed, merge.landed or delivery.failed
severity_minthe floor below which this route does not fire
bindingwhich binding it delivers through
actornarrows the route to one actor — on a type that carries one, which owner.act_owed does not

[notice.sender]

KeyMeaning
primarythe 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

KeyValuesNote
ssh_host_key_policyas aboveoverrides [fleet] for one machine; empty means inherit

[lane.<alias>] — repeated section, keyed by alias

KeyMeaning
base_urllane endpoint (an OpenAI-compatible root, e.g. a LiteLLM proxy, Ollama, vLLM)
credentials_envname of the environment variable carrying the key
credentials_fileabsolute, 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.