Adding a Setting
This guide is for anyone — human or agent — who needs to add a new configuration knob to Aleph and have it appear in the management WebUI with the right label, help text, lifecycle semantics, and persistence. The short version: edit one .proto file, run task proto, and the WebUI renders the new field on the next reload.
Mental model
The Settings protobuf is the single source of truth for every non-ephemeral knob in the server. The WebUI reads Settings at runtime via Connect, walks the message descriptor with @bufbuild/protobuf v2, and dispatches each field to an input component based on its scalar kind. UI metadata (section label, help text, default, lifecycle, secret flag) lives as proto field options — (aleph.settings.v1.field) — and the renderer reads them through getOption(descriptor, fieldOptionsExt) at render time. There is no parallel TypeScript metadata table to maintain; deleting that mirror was the entire point of the rebuild.
This means: adding a setting is one proto edit. No TypeScript code, no Go REST handler, no metadata table. The renderer picks up the new field the moment the generated descriptor sees it.
The five-second version
- Edit
packages/aleph/proto/aleph/settings/v1/settings.proto. Add a field to the section that owns it; annotate it with(aleph.settings.v1.field). - Run
task proto(insidenix develop—buflives there). - Run
task server-build. - Restart
aleph. The new field appears in the WebUI with theui_labelandui_helpyou provided.
Step-by-step: scalar setting
Suppose you want to add Server.greeting, a string the LLM uses to greet the user on first turn.
1. Find the right section. Open packages/aleph/proto/aleph/settings/v1/settings.proto and locate message Server { … }. Sections in this tree map roughly to admin UI groupings; Server is server-wide knobs, Llm is model config, WebUi is the management UI itself, and so on. Pick the one that semantically owns your knob.
2. Pick the next free field number. Inside the section, find the highest field number in use:
grep -E "= [0-9]+ \[" packages/aleph/proto/aleph/settings/v1/settings.proto | sort -t= -k2 -n | tail -3Increment by one. Never reuse numbers.
3. Add the field with (aleph.settings.v1.field) options.
message Server {
// … existing fields …
string greeting = 4 [(aleph.settings.v1.field) = {
lifecycle: LIFECYCLE_LIVE,
ui_section: "Server",
ui_label: "First-turn greeting",
ui_help: "Phrase the assistant says on its first reply of a session.",
default: "Hello, how can I help?"
}];
}Every field must carry lifecycle. Every field that's not a secret should carry ui_section, ui_label, ui_help, and default. The renderer falls back to the proto field name in title case if ui_label is missing, but explicit is friendlier.
4. Regenerate stubs.
nix develop
mise install # builds the local Go protoc plugins (one-time)
bun install # ES plugins for the TS stubs (one-time)
task prototask proto generates with local, pinned plugins — no Buf Schema Registry, so it runs offline and deterministically: mise builds the Go plugins (mise.toml), bun provides the ES plugins, and the sidecar venv's grpcio-tools emits the Python stubs. It rewrites Go stubs under packages/aleph/server/internal/pb/, Python stubs under packages/aleph/sidecar/aleph/, and TS stubs under packages/aleph/webui/src/lib/pb/. CI's task proto-check fails if you commit changes without regenerating.
You rarely run this by hand: a git pre-commit hook (.githooks/pre-commit, installed by task setup) regenerates and re-stages stubs on any commit that touches a .proto, and a Claude PostToolUse hook (.claude/hooks/proto-gen.sh) does it live whenever Claude writes one. CI's task proto-check is the final backstop.
5. Rebuild the WebUI and the server binary.
task server-buildThe first target bundles the Vue SPA and copies it into packages/aleph/server/internal/webui/static/ for go:embed. The second builds the aleph binary against the new proto types.
6. Restart and verify. Run ./packages/aleph/server/aleph -debug, open http://127.0.0.1:5602/, and the new field renders in the Server section with a text input and the help text under it.
FieldOptions reference
Every annotation you can attach to a field:
| Option | Required | Effect |
|---|---|---|
lifecycle | yes | LIFECYCLE_LIVE (next read sees it), LIFECYCLE_COMPONENT_RELOAD (component drains + rebuilds), LIFECYCLE_PROCESS_RESTART (pending until next start). The server uses this to compute the impact of a patch. |
reload_component | with LIFECYCLE_COMPONENT_RELOAD | Names the component that drains and rebuilds when the field changes. The proto is the single source of truth for reload routing — the sidecar derives its path→slot dispatch from this annotation; there is no parallel table to edit. Sidecar slots: stt, llm, tts, turn, speaker, embedder. Server-owned components (reconciled in-process by the Go server): server_tools. Required on reload fields, forbidden on everything else — both runtimes enforce this (the sidecar fails startup with ReloadRouteError, CI fails TestReloadComponentMatchesLifecycle and the sidecar schema tests). |
secret | no | When true, the value is redacted on read (replaced with empty string), the WebUI renders a [set]/[unset] sentinel instead of the value, and the settings file is written with restrictive permissions. |
ui_section | no | Section label. Groups related fields under one heading in the form. |
ui_label | no | Short human-readable name. Falls back to title-cased field name. |
ui_help | no | Single-sentence help text rendered under the input. May include unit hints like "seconds" or "0.0–1.0". |
default | no | Canonical string form of the default value. The Go and Python materialisers parse this on first boot to seed an empty settings file. Empty / unset means there is no schema default and the user must supply a value before the field is read. |
Defaults are encoded as strings regardless of the field's runtime type: "5" for ints, "true"/"false" for bools, enum values by symbolic name, "[]" for empty repeated, JSON array literals for populated repeated.
Lifecycle semantics
Choose carefully — this controls what the user sees when they save the patch:
LIFECYCLE_LIVE— A simple branch read each time the value is consulted. Thresholds, toggles, prompts, top-K, temperature. Default for most new fields.LIFECYCLE_COMPONENT_RELOAD— Changing the value forces the owning component (STT engine, LLM session, TTS voice, turn classifier) to drain and rebuild. Satellites see the component as not-ready for the duration. Model paths, device hints, model ids. Must carryreload_componentnaming the component that rebuilds (see the FieldOptions table). The sidecar routes the change straight to that registry slot — no Python edit needed — and refuses to start if the name matches nothing it (or the server) can rebuild. Pick the slot that actually consumes the field; for tool-provider config owned by the Go server, useserver_tools.LIFECYCLE_PROCESS_RESTART— The value cannot change in the live process. The server records the pending change, persists it, and surfaces a "restart required" indicator. New value applies on the next process start. Listen ports, provider class paths, anything that affects process bootstrap.
If you're not sure, start with LIFECYCLE_LIVE and tighten later. Loosening (RESTART → LIVE) is a one-line proto change and maybe some plumbing in the consumer; tightening doesn't need a migration.
Step-by-step: collection setting (map<string, Message>)
Suppose you want to add a "named MCP servers" collection where the user can add/remove server entries with id, url, enabled.
1. Define the value message in its own proto file under packages/aleph/proto/aleph/<package>/v1/<name>.proto (or, for tightly-scoped values, inline in settings.proto):
// packages/aleph/proto/aleph/mcp/v1/server.proto
syntax = "proto3";
package aleph.mcp.v1;
option go_package = "aleph/server/internal/pb/aleph/mcp/v1;mcpv1";
message Server {
string id = 1; // matches the map key
string url = 2;
bool enabled = 3;
}2. Import it in settings.proto and add the map field:
import "aleph/mcp/v1/server.proto";
message Settings {
// … existing fields …
map<string, aleph.mcp.v1.Server> mcp_servers = 12 [(aleph.settings.v1.field) = {
lifecycle: LIFECYCLE_LIVE,
ui_section: "MCP servers",
ui_label: "MCP servers",
ui_help: "Model Context Protocol servers the LLM can call into."
}];
}3. Regenerate + rebuild as above. The WebUI renders this map automatically: each entry becomes a card with the value-message fields rendered recursively (so the card shows url and enabled inputs), plus an × remove button per card and a + Add button at the bottom. The card key is generated client-side as a ULID-ish identifier when the user clicks add.
Patch semantics for map collections:
- Add:
update_mask: ["mcp_servers.<new_id>"],settings: {mcp_servers: {<new_id>: {id, url, enabled}}}. - Update a field on an entry:
update_mask: ["mcp_servers.<id>.url"],settings: {mcp_servers: {<id>: {url: "…"}}}. - Delete an entry:
removed_paths: ["mcp_servers.<id>"](noupdate_maskneeded; the patch body can be empty).
The settings store knows how to traverse map paths (see packages/aleph/server/internal/settings/{validate,lifecycle,store}.go). You do not need to write any Go to support a new map field.
Special cases
Secrets
Mark secret: true on the field. Consequences:
- The server returns empty string for the value on
GetSettings. - The response also carries
secrets_state: {<path>: true}if the value is non-empty server-side. - The WebUI renders
SecretInput.vue, which shows[set]/[unset]and prompts for a new value on click. - The settings file on disk is written with mode
0600. - Logs redact the field.
Enum-like strings
Proto3 enums force numeric wire form, which is awkward for JSON config and HTTP. The convention here is to use a plain string field and register the allowed values in packages/aleph/server/internal/settings/validate.go's allowedEnumValues map (keyed by dotted path). Example:
"audio.turn_detection.mode": {
"off": {}, "livekit_only": {}, "smart_turn_only": {}, "dual": {},
},Set ui_choices (JSON array of the allowed values) and, optionally, ui_choice_labels (parallel human-readable labels) on the field. The renderer picks this up in settingsmeta.ts's choicesFor() and renders a segmented control (≤4 choices) or a <select> (more), the same as it does for real proto enums. TurnDetection.mode (audio.turn_detection.mode) is the worked example — a plain string field with ui_choices: "[\"off\",\"livekit_only\",\"smart_turn_only\",\"dual\"]".
Repeated strings
Use repeated string. The WebUI renders ChipList.vue — a chips-with-add-button input. Default is "[]" for empty.
repeated string allowed_origins = 3 [(aleph.settings.v1.field) = {
lifecycle: LIFECYCLE_LIVE,
ui_section: "WebUI",
ui_label: "Allowed origins",
ui_help: "Origin values accepted on streaming endpoints when binding non-loopback.",
default: "[]"
}];Cross-language consumers (sidecar)
The Python sidecar consumes the same Settings over its existing gRPC subscription. If your new field is consumed by the sidecar (e.g. a new STT parameter), the proto regeneration updates the Python stubs at packages/aleph/sidecar/aleph/settings/v1/ automatically. You still need to plumb the value through wherever the sidecar reads it (config.py, the provider, and — for LIVE knobs — the _apply_live poke in wiring.py).
For LIFECYCLE_COMPONENT_RELOAD fields the routing is automatic: the sidecar reads reload_component off the descriptor and swaps that registry slot. What you still own is making the rebuilt component actually see the new value — extend the slot's spec (aleph_sidecar/registry/specs.py), its _<slot>_spec derivation and provider factory in wiring.py, and config.py. If you forget the annotation entirely, the sidecar fails startup with ReloadRouteError and CI fails the schema tests — that loud failure replaced the old hand-maintained path→component dict that used to drift silently.
For user-identity-shaped collections, see the existing Settings.users map and the aleph_sidecar/users/ module — it's a worked example of consuming a map<string, Message> from the settings stream into an in-memory cache plus cascade hooks for deletes.
Testing your field
Once the binary builds, you can drive the new field from the command line without opening the browser.
Read the current snapshot:
curl -sS -X POST -H "Content-Type: application/json" -d '{}' \
http://127.0.0.1:5602/aleph.webui.v1.WebUIService/GetSettings | head -c 400Patch the field:
V=$(curl -sS -X POST -H "Content-Type: application/json" -d '{}' \
http://127.0.0.1:5602/aleph.webui.v1.WebUIService/GetSettings \
| grep -oE '"version":"[0-9]+"' | head -1 | grep -oE '[0-9]+')
curl -sS -X POST -H "Content-Type: application/json" -d "{
\"updateMask\":[\"server.greeting\"],
\"settings\":{\"server\":{\"greeting\":\"Hello there\"}},
\"ifVersion\":\"$V\"
}" http://127.0.0.1:5602/aleph.webui.v1.WebUIService/UpdateSettingsRead back:
curl -sS -X POST -H "Content-Type: application/json" -d '{}' \
http://127.0.0.1:5602/aleph.webui.v1.WebUIService/GetSettings \
| grep -oE '"greeting":"[^"]*"'Verify on-disk persistence: check ~/.aleph/settings.json and look for the new value under the right section.
Verify restart persistence: kill and restart the binary; the value should survive.
If UpdateSettings returns a conflict, your ifVersion is stale — re-fetch the snapshot first. If it returns a rejected map, the path is unknown or the value failed validation — read the message string.
What can go wrong
| Symptom | Cause | Fix |
|---|---|---|
| Field doesn't render in WebUI | task webui-build not run after task proto, or browser cached old bundle | Rebuild + hard-reload (Ctrl-Shift-R). |
task proto-check fails in CI | You forgot to commit regenerated stubs | Run task proto and commit the diff. |
UpdateSettings rejects the path | Field number reused, or path typo | grep "= <N> \[" settings.proto to confirm field number is unique; check the camelCase vs snake_case in your patch body (proto path uses snake_case, JSON body uses camelCase). |
Restart doesn't apply a LIVE field | The consumer reads the value once at startup instead of on every operation | Either change to LIFECYCLE_COMPONENT_RELOAD or fix the consumer to re-read. |
Sidecar exits with ReloadRouteError at startup | A LIFECYCLE_COMPONENT_RELOAD field is missing reload_component, names an unknown component, or a non-reload field carries one | Fix the annotation in settings.proto, run task proto. The valid names are in the FieldOptions table above. |
| Field name collides with a Go keyword | Generated Go stub renames it (e.g. type → Type_) | Pick a different field name; protobuf reserves the right to rename. |
New value not visible in WebUI save bar | Your patch lacks the if_version field, or the SSE/Connect subscription dropped | The store's optimistic concurrency requires if_version. The subscription auto-reconnects every 1s; check browser devtools. |
Files you almost never need to touch
If you're just adding a setting, none of these are in scope:
packages/aleph/webui/src/— the Svelte renderer readsFieldOptionsstraight off the generated descriptor (lib/settingsmeta.ts) and picks a widget (toggle/slider/select/segment/secret/chips/text) fromui_widget,ui_min/ui_max/ui_step/ui_unit,ui_off_at,ui_choices,secret, and the field's own scalar/enum kind — there is no hand-written schema mirror to update. Adding a setting never touches this file; adding its copy touchespackages/aleph/webui/src/locales/{de,en}.jsonundereinstellungen.fields.<path>.{label,desc}in both locales.packages/aleph/server/internal/webui/— the Connect handler is type-agnostic; it delegates tosettings.Store.
If you're adding a new kind of field — say, a duration type, a richer secret editor, a select-from-enum input — those files become in scope. Land an issue first; that's a bigger change than this guide covers.
Where things live
| Concern | File |
|---|---|
| Field definitions | packages/aleph/proto/aleph/settings/v1/settings.proto |
| FieldOptions / SectionOptions schema | packages/aleph/proto/aleph/settings/v1/options.proto |
| Enum allowed-values registry (string fields) | packages/aleph/server/internal/settings/validate.go |
| Defaults materialiser (Go) | packages/aleph/server/internal/pb/aleph/settings/v1/defaults.go |
| Path validation + lifecycle resolution | packages/aleph/server/internal/settings/{validate,lifecycle}.go |
| Patch application + persistence | packages/aleph/server/internal/settings/store.go |
| Connect handler | packages/aleph/server/internal/webui/handler.go |
| WebUI renderer | packages/aleph/webui/src/lib/settingsmeta.ts (descriptor → widget metadata), lib/pages/einstellungen/FieldRenderer.svelte + widgets/ (dispatch) |
| Codegen pipeline | packages/aleph/proto/buf.gen.{yaml,ts.yaml}, packages/protocol/alabama-go/proto/buf.gen.yaml + top-level Taskfile.yml |