Layered Configuration Reference
This guide documents how fullsend resolves per-repo configuration fields through the layered config system introduced by ADR 0069 Decision 2.
For initial setup instructions, see Configuring GitHub. For advanced installation variants, see Advanced Setup.
Overview
Per-repo configuration is stored in two files inside the target repository's .fullsend/ directory:
| File | Role | Writable? |
|---|---|---|
config.yaml | Overlay — repo-specific customization | Yes |
config.base.yaml | Base — vendor preset or shared baseline | Read-through only |
When an accessor reads a config field, it checks the overlay first, then the base layer, then compiled-in code defaults. Only the overlay (config.yaml) is writable — the base layer and code defaults are read-through layers that the overlay inherits from.
config.yaml (overlay)
↓ fallthrough when unset
config.base.yaml (base)
↓ fallthrough when unset
code defaults (compiled into fullsend)Existing installations without config.base.yaml are unaffected — the overlay falls through directly to code defaults.
Marshal behavior
Marshal (and any serialization path) emits only values explicitly set on the local layer. Values resolved through a parent layer are never written back. This means:
- Round-tripping a config through parse → marshal preserves only locally-set fields. For slice fields (
roles,allowed_remote_resources), the nil-vs-empty distinction is also preserved: an explicitly empty list (e.g.,roles: []) survives the roundtrip and does not collapse to nil, which would cause unwanted fallthrough to parent defaults. - Upgrading a base layer (e.g., refreshing
config.base.yamlfrom a new preset) does not require editingconfig.yaml— the overlay inherits new defaults automatically for any field it does not override.
Unset detection
Each field type uses a zero-value convention to distinguish "unset (fall through to parent)" from "explicitly set":
| Go type | Unset (falls through) | Set (uses local value) |
|---|---|---|
string | "" (empty string) | Any non-empty string |
[]T (slice) | nil (key omitted from YAML) | Non-nil, including [] (empty slice) |
*T (pointer) | nil | Non-nil (including pointer to zero value) |
This distinction matters most for slice and pointer fields, where the difference between "not specified" and "explicitly empty" drives different merge behavior.
Per-field merge rules
The table below documents how each per-repo config field resolves through the overlay → base → code defaults chain.
| Field | Type | Merge rule | Code default |
|---|---|---|---|
version | string | Scalar override | "1" |
runtime | string | Scalar override | "claude" |
kill_switch | *bool | Scalar override | false (inactive) |
keep_history | *bool | Scalar override | true (history appended) |
roles | []string | Replace if set | PerRepoDefaultRoles() |
agents | []AgentEntry | Keyed merge by DerivedName() | nil (none) |
allowed_remote_resources | []string | Union with deny-all | DefaultAllowedRemoteResources() |
forge | string | Scalar override | "" (GitHub) |
tracker | string | Scalar override | "" (none) |
mint_url | string | Scalar override | DefaultPerRepoMintURL (hosted public mint) |
inference.provider | string (nested) | Scalar override | "vertex" |
inference.project | string (nested) | Scalar override | "" (empty) |
inference.region | string (nested) | Scalar override | "global" |
inference.wif_provider | string (nested) | Scalar override | "" (empty) |
inference.openai.audience | string (nested) | Scalar override | "" (empty) |
inference.openai.identity_provider_id | string (nested) | Scalar override | "" (empty) |
inference.openai.service_account_id | string (nested) | Scalar override | "" (empty) |
models.aliases | map[string]string (nested) | Per-key merge | nil (fleet defaults) |
create_issues | *CreateIssuesConfig | Replace whole object if set | nil |
status_notifications | *StatusNotificationConfig | Replace whole object if set | nil |
Per-agent runtime, model, effort, subagents on agents: entries
An agents: entry may set runtime, model, effort and subagents for that agent. The ref field records the branch or tag that was resolved when the agent was adopted via agent add; agent update re-resolves against this ref instead of the default branch when it is present (empty for SHA-pinned or legacy entries).
An enabled entry without source: is an override-only entry that tunes a built-in agent by name (or, in an overlay, a custom agent registered in the base layer). The keyed merge by DerivedName() carries the four settings field by field: for runtime, model and effort the overlay's non-empty value wins and an empty value inherits the parent's. subagents uses per-key merge: overlay entries override or tombstone (~) individual persona keys while unstated keys inherit from the parent. There is no way to unset a parent's scalar value from the overlay short of restating the entry.
# config.base.yaml (preset)
agents:
- source: harness/lint.yaml # name derived from the file: lint
model: opus
# config.yaml (overlay)
agents:
- name: lint # merges onto the base entry: source kept, effort added
effort: medium
- name: code # built-in agent tuned by name
runtime: claudePrecedence at run time: flag > env var > the agent's entry > repo-wide runtime: / harness default.
Scalar override fields
version, runtime, and kill_switch use simple scalar override semantics: if the overlay sets the field, that value is used. If unset, the accessor falls through to the base layer, then to code defaults.
version: Schema version string. Unset ("") falls through to parent. Code default is"1".runtime: Agent runtime identifier. Unset ("") falls through to parent. Code default is"claude". Valid values:claude,pi,codex,dummy,dummy-playback.kill_switch: Pointer to bool (*bool). Using a pointer allows distinguishing between three states:nil(key omitted) — unset, falls through to parent.*false(explicitkill_switch: false) — locally set to inactive. Does not fall through.*true(explicitkill_switch: true) — locally set to active.
keep_history: Pointer to bool (*bool). Controls whether sticky comment updates (frompost-review,post-comment, andissues post-comment) append the previous body as a collapsed "Previous run"<details>block. Uses the same three-state pointer semantics askill_switch:nil(key omitted) — unset, falls through to parent. Code default istrue(history appended, preserving existing behavior).*true(explicitkeep_history: true) — updates collapse old content into history blocks.*false(explicitkeep_history: false) — updates replace the comment body in-place with no history. Useful when accumulated "Previous run" blocks add unwanted noise (e.g., when comments are synced to Jira where<details>does not render as collapsible).
mint_url and inference — scalar override (ADR 0069 Decision 1)
mint_url stores the token mint URL. It is a flat string field on perRepoConfig and follows the same scalar override semantics as runtime: unset ("") falls through to parent, then to code default DefaultPerRepoMintURL (the hosted public mint).
inference groups inference backend settings under a single YAML key (inference:) using the PerRepoInferenceConfig struct. Each subfield (provider, project, region, wif_provider, and the three under openai) resolves independently through scalar override semantics:
inference.provider: Inference provider identifier (e.g."vertex"). Unset ("") falls through to parent, then to code default"vertex".inference.project: GCP project ID for inference. Unset ("") falls through to parent (no code default — must be provided by the installer).inference.region: GCP region for inference. Unset ("") falls through to parent, then to code default"global".inference.wif_provider: Full WIF provider resource name. Unset ("") falls through to parent (no code default — must be provided by the installer).inference.openai.{audience,identity_provider_id,service_account_id}: the OpenAI Workload Identity identifiers for GPT on pi or codex (ADR 0092), written byfullsend github setup --openai-*. Each resolves independently through the layers; a run needs all three from one source. TheFULLSEND_OPENAI_*runner variables, when any is set, replace the resolved block entirely.
The inference pointer itself (*PerRepoInferenceConfig) uses nil to mean "no local inference settings" — if the entire inference: key is omitted from YAML, all subfields fall through to the parent layer. If the key is present, each subfield is checked independently.
Example:
# config.base.yaml (base layer)
mint_url: https://mint.example.com
inference:
provider: vertex
project: base-project
region: us-central1
wif_provider: projects/123/locations/global/workloadIdentityPools/pool/providers/base
# config.yaml (overlay) — override project only
inference:
project: my-project
# Effective:
# mint_url: https://mint.example.com (from base)
# inference.provider: vertex (from base)
# inference.project: my-project (from overlay)
# inference.region: us-central1 (from base)
# inference.wif_provider: ...base... (from base)models.aliases — per-key merge
models.aliases overrides fullsend's pinned model alias table per key (#6882). Keys are the existing alias vocabulary (opus, sonnet, haiku, fable); values are model ids or provider/id specs validated with ValidModelRef, and never another alias name — bare or as the id segment of a provider/id spec (aliases resolve once, so sonnet: opus or sonnet: anthropic-vertex/opus would reach the provider as the literal id opus). An unknown key is a config validation error.
Merge is per key across layers: an overlay that sets fable inherits the base's sonnet entry without restating it. A nil Models block (key omitted from YAML) falls through to the parent layer. Validation runs on the merged map, so a bad key in config.base.yaml fails an overlay write (and fullsend run) even when the overlay omits models:.
# config.base.yaml
models:
aliases:
sonnet: claude-sonnet-5
# config.yaml (overlay)
models:
aliases:
fable: claude-fable-5-1
# Effective: sonnet → claude-sonnet-5 (from base), fable → claude-fable-5-1 (from overlay),
# opus and haiku → fleet defaults (compiled-in).tracker — scalar override
tracker stores the default issue tracker for fullsend issues commands (github, gitlab, or jira). Unset ("") means no default — --tracker is required on every fullsend issues invocation. When set, it is used as the default for --tracker on both fullsend issues get and fullsend issues post-comment; an explicit --tracker flag overrides it. Distinct from forge: a repo's hosting forge does not imply its issue tracker (e.g. a GitHub-hosted repo may track issues in Jira).
roles — replace if set
The roles field uses replace-if-set semantics with no union:
nil(key omitted from YAML) — falls through to parent, then to code defaultPerRepoDefaultRoles().- Non-nil including
roles: [](explicit empty list) — replaces the parent value entirely. There is no merge or union of role lists across layers. An explicitroles: []is preserved through marshal roundtrips (it will not be dropped or collapse to nil).
Example:
# config.base.yaml (base layer)
roles:
- triage
- coder
- review
# config.yaml (overlay) — replaces, does not union
roles:
- triage
- coder
# Effective: [triage, coder] — review is NOT inheritedagents — keyed merge by DerivedName()
The agents field uses keyed merge semantics. Each agent is identified by its DerivedName() — the explicit name field if set, otherwise derived from the source filename (e.g., triage.yaml → triage).
Merge behavior:
nil(key omitted) — parent agents are returned unchanged.agents: [](explicit empty list) — no overlay entries, but parent agents remain visible. This is not deny-all.- Non-empty — overlay entries are merged with parent entries by name:
- If an overlay entry matches a parent entry by
DerivedName()(case- insensitive), the overlay fields are applied on top of the parent entry. Settingsourcein the overlay replaces the parent URL for that agent. Settingenabledtoggles the agent without replacing its source. - Overlay entries that do not match any parent agent are appended to the result.
- Parent agents with no matching overlay entry pass through unchanged.
- If an overlay entry matches a parent entry by
Example:
# config.base.yaml (base layer)
agents:
- source: https://example.com/triage.yaml#sha256=abc...
- source: https://example.com/review.yaml#sha256=def...
# config.yaml (overlay) — disable review, add custom agent
agents:
- name: review
enabled: false
- source: agents/my-custom.yaml
# Effective:
# - triage.yaml from base (unchanged)
# - review.yaml from base (disabled by overlay)
# - my-custom.yaml from overlay (new entry)allowed_remote_resources — union with deny-all
This field controls which URL prefixes are allowed for remote resources (agents, policies, skills, plugins, profiles, providers, and base composition). It uses special three-way semantics:
| Overlay value | Behavior |
|---|---|
nil (key omitted) | Falls through to parent → code defaults |
[] (explicit empty) | Deny-all — no remote resources allowed, no fallthrough |
| Non-empty list | Union of overlay entries ∪ parent entries |
When the overlay provides a non-empty list and a parent exists, the effective value is the union of both lists (overlay entries first, then any parent entries not already present). Code defaults are provided solely by the terminal perRepoDefaults parent — intermediate parents may return whatever allowlist they want, including omitting the built-in prefixes. The union does not force-append code defaults after the parent union.
Example:
# config.base.yaml (base layer)
allowed_remote_resources:
- https://raw.githubusercontent.com/fullsend-ai/fullsend/
- https://raw.githubusercontent.com/fullsend-ai/agents/
# config.yaml (overlay) — adds a custom prefix
allowed_remote_resources:
- https://raw.githubusercontent.com/my-org/agents/
# Effective (union):
# - https://raw.githubusercontent.com/my-org/agents/
# - https://raw.githubusercontent.com/fullsend-ai/fullsend/
# - https://raw.githubusercontent.com/fullsend-ai/agents/To deny all remote resources (local agents only):
# config.yaml (overlay) — explicit empty list
allowed_remote_resources: []
# Effective: [] — no remote resources allowedcreate_issues — replace whole object if set
The create_issues field uses replace-if-set semantics for the entire object:
nil(key omitted) — falls through to parent, then to code defaultnil.- Non-nil — replaces the parent value entirely. There is no merge of
allow_targetslists across layers.
status_notifications — replace whole object if set
The status_notifications field uses the same replace-if-set semantics as create_issues:
nil(key omitted) — falls through to parent, then to code defaultnil.- Non-nil — replaces the parent value entirely, including nested
comment.start/comment.completionsettings.
Code defaults reference
When neither the overlay nor the base layer sets a field, the following compiled-in defaults apply:
| Field | Default value |
|---|---|
version | "1" |
runtime | "claude" |
kill_switch | false (inactive) |
keep_history | true (history appended) |
roles | ["triage", "coder", "review", "fix", "retro", "prioritize"] |
agents | nil (none configured) |
allowed_remote_resources | ["https://raw.githubusercontent.com/fullsend-ai/fullsend/", "https://raw.githubusercontent.com/fullsend-ai/agents/"] |
forge | "" (GitHub) |
tracker | "" (none — --tracker is required unless set) |
mint_url | "https://mint.fullsend.sh" (hosted public mint) |
inference.provider | "vertex" |
inference.project | "" (empty — must be provided) |
inference.region | "global" |
inference.wif_provider | "" (empty — must be provided) |
models.aliases | nil (fleet alias table compiled into the runtimes) |
create_issues | nil |
status_notifications | nil |
Related
- Config Reference — canonical user-facing reference for every
.fullsend/config.yamlfield. - ADR 0069 — Ready-made configuration presets — the architectural decision that introduced layered configuration.
- ADR 0033 — Per-repo installation mode — per-repo config file location and format.
- Configuring GitHub — initial per-repo setup guide.
