Adding a Setting
Settings are single-source: a field is declared once on its Config sub-struct, and the TUI, web dashboard, server validation, profile and repo overrides, and config.toml all derive from that declaration. In the common case, adding one is a single edit.
The one-edit case
Add the field to the relevant #[derive(SettingsSection)] struct (in src/session/config/mod.rs, src/sound/config.rs, or src/status_hooks.rs) with a doc comment and a #[setting(...)] annotation:
/// Doc comment becomes the field's description on every surface.
#[serde(default)]
#[setting(label = "My Setting", widget = "toggle")]
pub my_setting: bool,
The SettingsSection derive turns it into a FieldDescriptor in settings_schema::schema(), and from there the TUI builds its row (src/tui/settings/fields.rs), the web renders the matching control from GET /api/settings/schema, the server validates PATCH leaves against the field’s web_write policy and validation rule, profile and repo overrides merge generically, and config.toml round-trips through serde. Run cargo test and cargo build --features web; the field is live everywhere.
Section and widget
The section comes from the struct’s #[setting_section(name = "...", category = "...")]: name is the [section] table in config.toml, category the TUI tab, and an optional repo_default = "allow" | "deny" sets the repo-config policy its fields inherit.
| Widget | Backing type | Control |
|---|---|---|
toggle |
bool |
switch |
text |
String |
text input (multiline / mono flags) |
optional_text |
Option<String> |
text input that clears to unset |
number |
integer | number input (min / max) |
slider |
integer | slider (min / max / step) |
select |
string enum | dropdown (options = "value:Label,...") |
list |
Vec<String> |
add/remove list |
custom:<id> |
anything | a bespoke control, see below |
Attributes
Beyond label, desc (defaults to the doc comment), widget, options, min / max / step, and multiline / mono:
validate: the server-authoritative check (range:MIN[:MAX],nonempty,memory_limit,volume_list,env_list,port_mapping_list,capability_list,security_opt_list,network). If none fits, add aValidationKindvariant and avalidate=keyword; that one rule drives both the client UX validator and the server gate.web:elevation:<reason>(passphrase step-up to save from the web) orlocal_only:<reason>(a host-execution surface the server rejects and the dashboard never renders, such as a binary path or command argv). Omit for a plain allow.repo:allowordeny, defaulting to the section’srepo_default. Global-only fields are never repo-settable.categoryoverrides the section’s TUI tab,advancedgroups the field under an Advanced fold,global_onlyshows it but makes it non-overridable per profile, andskipexcludes it from the schema entirely.
Custom widgets
For a field with no flat representation (a tagged enum, a float, a nested map), use widget = "custom:<id>" and register the id on both surfaces: custom_value_from_json / custom_value_to_json (plus the validate() and edit paths if needed) in src/tui/settings/fields.rs, and a component in web/src/components/settings/customWidgets.tsx wired into customWidgetRegistry.ts. An unregistered web id renders a visible “no control” placeholder rather than dropping the field silently.
Existing examples: theme-name (dynamic select plus repaint), sound-volume (a float slider), logging-targets (a per-target matrix), and acp-defaults (a validated JSON-object editor). For a cross-surface side effect after a save, pass onAfterSave to the web SchemaSection.
What stays out of the schema
#[setting(skip)] is for fields that are not user-facing settings. Some things are deliberately unschematized:
hookshas noSettingsSectionat all. Hooks are arbitrary commands, so the hard exclusion is defense in depth against a future policy change making them web-writable.Config.environmentis a root-levelVec<String>with no section, so it is TUI andconfig.tomlonly. Surfacing it would need a config-layout migration.diffis schema-backed for the TUI, but the web Diff tab is intentionally client-local.telemetryis in the schema, but the web toggle uses a dedicated consent endpoint that records “has responded” and honorsDO_NOT_TRACK.app_stateis global-only runtime bookkeeping persisted tostate.toml(see the configuration reference). Read and write it throughupdate_app_state/AppStateConfig::load, neverupdate_config, which writesconfig.tomland stripsapp_stateon save. Before adding a field shaped like this, reconsider whether it is a setting at all.
A plugin declares its own settings in its aoe-plugin.toml instead, and the host turns each into a virtual plugin:<id> schema section that renders and validates through the same path.
Renaming or relocating a stored field is a breaking change to config.toml, so route it through a migration in src/migrations/ rather than an inline fallback.
Tests
The schema, server policy, and validators have unit tests under src/session/config/settings_schema/. A custom widget needs a TUI round-trip test and a web contract test (web/src/components/settings/__tests__/customWidgets.test.tsx). A user-facing dashboard settings flow must also update web/tests/coverage-matrix.json and add or extend the matching test; see web/AGENTS.md.