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", summary = "One line for users on what it does.")]
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 xtask gen-settings-keys to refresh web/src/lib/generated/settingsKeys.ts (CI fails if it is stale; a renamed field then fails tsc wherever the dashboard still reads the old name), then 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.
A new section struct is not the one-edit case: add it to build_schema in settings_schema/registry.rs, which every surface and the PATCH policy read, give a new category a SettingsCategory tab in src/tui/settings/, and give the section a web tab in SettingsView.tsx plus its SECTION_TO_TAB entry.
| 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:
-
summary: one short line for users, served assummaryfromGET /api/settings/schema(omitted when unset). For now it is served metadata only: both apps still show thedescriptionunder the label until #5302 renders summaries, so don’t put guidance only in the summary. The doc comment stays thedescription, written for developers and the docs site. Write the label in sentence case with glossary terms (“Agent”, “Session”, “Structured view”), and the summary as what the setting does for the user, not how it is implemented. -
validate: the server-authoritative check (range:MIN[:MAX],nonempty,memory_limit,volume_list,env_list,port_mapping_list,capability_list,security_opt_list,network,systemd_slice). 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,tui_onlymarks a field only the TUI reads so the web folds it under “Terminal UI”,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.