Skip to main content

Sourced from docs/reference/config-and-nvs.md in the firmware repo.

Config and NVS Reference

How Nimbus persists device state: the solide NVS namespace and every key it holds, the write-only credential fields, the model-vs-human credential-gate rails, the sparse-override Config model and its SD-backed blob (plus the no-SD non-persistence limitation, F10), and the set-once embed-config invariant.

Related: Tools & Commands (provisioning over serial), Orchestrator World (the security rails in context).

Two backends

Persistence rides solide::memory (solide-drivers, solide/memory.h), which splits by size:

BackendStoreSurvivesUsed for
NVS (Arduino Preferences)flashreflash; atomictyped key-value config - keys must be ≤ 15 chars
SD under /memory/SD cardonly if a card is mountedlarger JSON / blobs; returns false/0 when the card is absent

The NVS namespace is solide (opened via solide::memory::begin("solide")). Every typed config value below is an NVS key in that namespace. The one blob (nimbus_cfg, the profile override Config) rides the SD half - see Sparse-override Config + the SD blob.

NVS keys

Two families write to the solide namespace: the system keys (src/sys/config_nvs.cpp) and the agent/orchestrator keys (src/agent/store.cpp, names in src/agent/agent_config.h). "Read back?" = No marks write-only fields the UI never reads back into itself.

System keys (src/sys/config_nvs.cpp)

KeyTypeDefaultHoldsRead back?
nimbus_modeintNotifier (0)Operating mode: 0=Notifier, 1=Orchestrator (Mode enum). Any value other than 1 resolves to Notifier.Yes
nimbus_bleint (bool)1 (on)Bluetooth advertising enable (Notifier-mode nsn transport). Runtime-applied, no reboot.Yes
nimbus_namestring"" (auto)Device name / identity; "" = auto-derived. Sanitized on write.Yes
nimbus_cfgSD blobabsentVersioned profile-override Config (see below). Not an NVS key - rides putBlob/getBlob on SD.-

Provider credentials (src/agent/store.cpp)

Every credential getter returns "" until provisioned - this is the credential gate (see below). has*() helpers let the turn loop degrade gracefully instead of firing a doomed TLS call.

KeyTypeDefaultHoldsRead back?
oaiKeystring""OpenAI API keyNo - write-only; UI exposes only a "has key" flag
antKeystring""Anthropic API keyNo - write-only
mistralKeystring""Mistral API key (== Nuage store::hasKey())No - write-only
tavilyKeystring""Tavily API key for the orchestrator web.search / deep-research toolNo - write-only
custBasestring""Custom / proxy endpoint base (registers backend custom when non-empty)Yes
custKeystring""Custom endpoint keyNo - write-only
custConvstring"openai"Custom wire convention: openai|mistral|anthropicYes
custModelstring""Custom endpoint modelYes
zaiKeystring""Z.ai (GLM) API keyNo - write-only
zaiBasestring""Z.ai probed working host (api.z.ai | open.bigmodel.cn)Yes
cumuloKeystring""Cumulo router key (one key, every upstream); see Use your Cumulo keyNo - write-only
cumuloBasestring""Cumulo router host or full base URL; "" uses the built-in default host (app.cumulo-nimbus.ai)Yes

Writing or clearing a provider key resets that provider's verify cache to -1, 0 (couldn't-verify / never), so a swapped key can't ride the old verdict.

Orchestrator routing + models (src/agent/store.cpp)

KeyTypeDefaultHoldsRead back?
orchHoststring""Explicit host provider; "" = top of priority. Human-only + model-protected.Yes
orchConvstring"""host|convId" per-host conversation stateYes
provPriostring"openai,anthropic,mistral"Orchestrator-host priority list (alias orchPriority). Human-only + model-protected.Yes
subPriostring"openai,anthropic,mistral"Sub-session provider priority - the only model-writable routing knob (setSubPriority)Yes
orchM_<provider>string"" → provider flagshipPer-provider orchestrator model override (prefix orchM_ + provider)Yes
subM_<provider>string"" → provider flagshipPer-provider sub-session model override (prefix subM_)Yes
agFabricstring"code:openai,research:openai,ops:anthropic"Legacy category→provider bindingsYes
sysPromptstring"" (empty uses the shipped default directive)The owner directive - immutable by the model, owner-only. Bounded to 1500 bytes (honest error on the web route + store-layer clamp). Empty = the compiled-in default (kOwnerDirectiveDefault); set in the setup wizard or the web Directive box, with "Revert to default"Yes

All three hosts (OpenAI, Anthropic, Mistral) are in the default provPrio / subPrio lists; list order is the failover order, and an explicit orchHost overrides it.

Default per-provider models: OPENAI_MODEL = gpt-5.5, ANT_MODEL = claude-sonnet-4-6, MISTRAL_MODEL = mistral-large-latest; custom → custModel.

Turn behavior + TLS transport (src/agent/store.cpp)

KeyTypeDefaultHoldsRead back?
midFailbooltrue (on)Mid-turn provider failover: on a tool-loop turn, fall back to the next host in priority order when the active provider call fails.Yes
tlsSlotsint1Concurrent outbound work-TLS sessions the arbiter permits, clamped to 1..2. Latched at boot; 1 keeps a heavy turn's contiguous heap intact, 2 raises throughput on a board with headroom.Yes
tlsVerifybooltrue (on)Validate provider TLS certificates against the bundled CA set.Yes

Telegram (src/agent/store.cpp)

KeyTypeDefaultHoldsRead back?
tgTokenstring""Telegram bot tokenNo - WRITE-ONLY. UI exposes only hasTg (a boolean); the token is never read back into the UI
tgAllowstring""Allowlisted chat ids (comma-separated)Yes
tgOwnersstring""Owner chat ids (comma-separated subset of tgAllow); empty means the first allow entry is owner.Yes
tgNamesstring""Display-name sidecar for known chats (id:name,...).Yes
tgBotNamestring""The connected bot's @username (from getMe), display only.Yes
tgPublicboolfalseOpen access: accept anyone who messages the bot. Default off.Yes
tgOffsetint0Telegram long-poll offsetYes (internal)

Cloud access / relay (src/agent/store.cpp)

Reaching the device from anywhere through CumuloNimbus. Orchestrator-only; ships dark (off). See Cloud access for the pairing flow and the security model.

KeyTypeDefaultHoldsRead back?
cloudOptInboolfalseCloud relay enabled.Yes
cloudDevIdstring""Cloud device id assigned at pairing.Yes
cloudCredstring""Cloud-minted device credential (bearer); wiped on unpair.No - write-only
cloudHoststringapp.cumulo-nimbus.aiRelay host.Yes
cloudNamestring""Paired device display name for the web status line.Yes

OTA / update engine (src/agent/store.cpp)

Persisted across the install reboot so the device can validate a fresh image and roll back if it fails. See OTA updates.

KeyTypeDefaultHoldsRead back?
otaPendboolfalseA fresh image is awaiting first-boot validation.Yes (internal)
otaBootsint0Boot attempts since the slot flip.Yes (internal)
otaPrevstring""Previous app slot label (app0/app1) for rollback.Yes (internal)
otaLaststring""Last OTA outcome (ok vX / rollback vX / ...).Yes
autoUpdboolfalseAuto-install a pending update in an idle window.Yes
otaNotifstring""Last version already Telegram-notified (no re-nag).Yes (internal)
otaTypestringboard-derivedTyped-OTA device slug (nimbus-tft, freenove-28, ...) so a board is only offered a matching image.Yes
otaNotesstring"""ver|notes" carried across the install reboot.Yes (internal)

Voice, LED, and misc (src/agent/store.cpp)

KeyTypeDefaultHoldsRead back?
sttProvstring"mistral" (Voxtral)STT provider (mistral|openai). Freely changeable.Yes
ttsProvstring"mistral"TTS provider (mistral|openai). Freely changeable.Yes
ttsVoicestring""TTS voice id/slug; "" = provider defaultYes
tftFlipboolfalseDisplay flip: rotate the color panel 180 degrees for an upside-down mount (touch panel only). Applies live; touch mapping follows. The stored value is a delta from the board's correct orientation, which the firmware sets per board, so a fresh unit is upright out of the box on every board (the all-in-one panel is mounted 180 from the hand-built board).Yes
ttsEnabledboolfalseSpoken-reply enableYes
themestring"teal"LED color theme slugYes
ledBrightint128LED brightness (ledBright NVS key)Yes
sfxLvlNint0 (Off)Sound-effect level in Notifier mode: 0 Off / 1 Low / 2 Medium / 3 High.Yes
sfxLvlOint2 (Medium)Sound-effect level in Orchestrator mode (same scale).Yes
sfxThemestring"pulse"Sound theme slug. pulse is the only shipping theme; an unknown value is coerced back to pulse on write.Yes
sfxVolint50Master speaker volume, 0..100.Yes
saverMinint-1 (mode default)Screensaver idle threshold in minutes; 0 = off, unset (-1) uses the battery-mode default (5 min).Yes
webTokstringgen on first usePer-device web/MCP auth token behind the LAN surface.No - printed only over UART (WEBTOK?)
apPassstringgen on first usePer-device setup-network passphrase, shown on the setup screen.Yes (on-device only)
codeSbxboolfalseCode sandbox toggle (Assistant > Tools).Yes
orchPromptV2boolfalseUse the simplified v2 system prompt (A/B flag).Yes
fetchPolint1 (ask)Download trust for files.fetch: 0 off / 1 ask per link / 2 scan then keep / 3 full trust (Assistant > Safety > Downloads).Yes
onbrdedboolfalseFirst-run onboarding completed. Plain NVS bool (survives a reboot with no SD), not the profile override blob.Yes
scrModelstring"tft"Display type (eink|tft), boot-applied. Exempt from Revert to Defaults (hardware identity). "tft" is the only supported value and the default: a fresh or NVS-erased device comes up on the color panel silently. Only an explicit stored "eink" (a real e-ink migration) boots the unsupported-display notice - an absent key never does. On all-in-one boards it is fixed to tft and the selector is locked - see the note below.Yes
devTzstring"" (= UTC)POSIX timezone for daily/weekly routines + the device clock display (Settings → Mode & identity). Applies immediately; wall-clock routines rebase budget-neutrally.Yes
dreamScrHashstring""fnv64-hex of the scratchpad after the last dream - the quiet-night skip baseline. Device-managed.Yes
tchCalstring""Touch-panel calibration (XPT2046); "" = the board-model default (also the fresh-boot state, which arms the one-time first-run calibration on a resistive panel). Set by the first-run step, Settings > Display > Calibrate touch, or the web/console field; clearing it restores the board-model default.Yes
lbRingboolfalseLow-battery ring cue (owner opt-in; off by default)Yes
lbSaverbooltrueAuto-drop to a lower battery mode on low batteryYes
battMonboolboard-derivedBattery monitoring on/off. Default is on for hand-built boards (a pack is part of the build) and off for the all-in-one board (a battery is optional, so it is opt-in). Off means the sense pin is never read, the glyph is hidden, and low-battery sleep never fires. Applied at boot.Yes

Battery hardware and chemistry (src/agent/store.cpp)

What each value means and how to estimate it without lab tooling is in Battery settings and estimation. Divider, capacity, chemistry, cells, and a custom curve apply live (a divider or chemistry change re-scales the reading, so the device asks the owner to Recalibrate). Defaults reproduce the shipped behavior exactly.

KeyTypeDefaultHoldsRead back?
battRtopint (ohms)220000Voltage-divider top resistor. With battRbot sets the pack:node ratio. Clamped 1000..10000000.Yes
battRbotint (ohms)100000Voltage-divider bottom resistor.Yes
battCapMahint3500Pack capacity in mAh. Drives the measured-load time-to-empty and the capacity = health x capacity readout. Clamped 100..20000.Yes
battChemstringliionBattery chemistry: liion (Li-ion / LiPo) or lifepo4 (lithium iron phosphate). Picks the per-cell voltage to state-of-charge curve.Yes
battCellsint0 (board)Series-cell count override (1 or 2); 0/absent uses the board default (1S Freenove, 2S Solide). Pack mV / cells = per-cell mV.Yes
battCurvestring""Optional custom per-cell curve, "mv:pct,mv:pct,..." high-mV first, strictly descending in mV. Empty uses the chemistry curve. A malformed string is rejected, never stored.Yes
sleepMvint3000 x cells (2S 6000, 1S 3000)Low-battery deep-sleep threshold in pack mV; 0 disarms the protection. The default and the clamp ceiling scale with the series-cell count, so a full 1S pack (~4200 mV) is not judged against a 2S floor and slept immediately.Yes
wakeMvint3250 x cells (2S 6500, 1S 3250)Stay-awake bar after a low-battery sleep (rested-empty packs read a bit higher than the sleep mark). Scales per cell like sleepMv.Yes

scrModel vs. the board pinout. scrModel selects the display renderer on a hand-built Solide S3 board. "tft" is the only supported value; "eink" is a frozen legacy value that boots an unsupported-display notice rather than binding a display. The board pinout is a separate, coarser identity fixed at compile time by SOLIDE_BOARD (solide_s3 or freenove_s3): a Freenove all-in-one runs its own firmware image, so its pinout is baked in, its scrModel is fixed to tft, and the web display selector is locked (driven by board id, not by scrModel). See the hardware reference.

Head tool-loop caps (src/agent/store.cpp)

The multi-round tool-use loop the head runs per turn. 0/absent on the byte caps means "auto" (the engine derives the cap from the model's context window); a set value is the owner's override and wins under the listed clamp.

KeyTypeDefaultHoldsRead back?
orchLoopbooltrue (on)Head multi-round tool-use loop enable.Yes
orchLoopRndsint12Max tool-dispatch rounds per turn. Clamped 1..32.Yes
orchLoopDlSint600Wall-clock budget for the loop, in seconds. Clamped 30..3600.Yes
orchLoopRCapint0 (auto)Per-tool-result byte clamp. 0 = auto; else 512..65536.Yes
orchLoopTCapint0 (auto)Cumulative tool-output byte budget. 0 = auto; else 2048..1048576.Yes

Local Loops governor caps (src/agent/store.cpp)

Owner overrides for the routine/scheduler governor. Each defaults to the hard ceiling in lib/core/include/nimbus/orch/caps.h; an override may only make a cap stricter, never looser, and the model can never touch any of it. 0/absent means "no override, use the default". The fold is nimbus::orch::clampLoopCaps (a looser value is ignored, not trusted), applied at loops begin() and live on each web write.

KeyTypeDefault (cap)HoldsRead back?
loopMaxCntint8Most routines that can exist at once. Override may only lower it.Yes
loopMinIvlint300Minimum seconds between fires. Override may only raise it.Yes
loopFiresint24Per-routine daily fire ceiling. Override may only lower it.Yes
loopTokensint120000Per-routine daily token ceiling. Override may only lower it.Yes
loopDevTokint400000Device-wide daily token ceiling. Override may only lower it.Yes
loopDevFirint6Device-wide fires per rate window. Override may only lower it.Yes

Guest moderation gates (src/agent/store.cpp)

Owner opt-in checks that screen non-admin traffic only (the owner is never classified). Each costs one classifier call per screened item. Fail behavior is fixed per gate (see security.md). Default off.

KeyTypeDefaultHoldsRead back?
modInboundboolfalseScreen inbound guest/member text before a turn (fail-closed).Yes
modOutboundboolfalseScreen outbound replies to guests (fail-open).Yes
modInjectboolfalseInjection-screen fetched world content (fail-open, marks untrusted).Yes

Anthropic managed-agents caches (src/agent/store.cpp)

KeyTypeDefaultHolds
antEnvstring""Managed-agents environment id
antAgentsstring""Agent map cache
antOrchAgstring""Orchestrator agent id

Provider verify cache (src/agent/store.cpp, written by provider_verify)

One key per provider: vfy_<provider> (e.g. vfy_anthropic = 13 chars, within the ≤ 15-char NVS limit). Value is "R:TS" where:

RMeaning
1verified (HTTP 200)
0rejected (401/403)
-1couldn't verify (no key / connect failed / never attempted)

TS = millis() when the result landed. An absent key reads R = -1, TS = 0, which distinguishes "never verified" from a real verdict.

Capability validation (src/agent/store.cpp)

Controls whether the device claims a provider capability is "verified" and how often it re-checks. Both feed the provider catalog (connectors::catalog()) and are read back into the web UI.

KeyTypeDefaultHoldsRead back?
capProbeint1 (passive)Capability-validation mode: 0 = off (trust key presence, make no "verified" claim); 1 = passive (report the cached verify verdict); 2 = active (passive plus a periodic free provider re-verify). Clamped 0..2.Yes
capProbeHint24Active-mode re-verify interval, in hours. Clamped 1..168.Yes

Embedding config for the vector memory (src/agent/store.cpp)

Set-once - see the invariant below. Defaults are OpenAI text-embedding-3-small truncated to 256 dims.

KeyTypeDefaultHolds
embProvstring"openai"Embedding provider (openai|mistral)
embModelstring"text-embedding-3-small"Embedding model
embDimsint256Embedding width; 0 = provider-native
embLockedboolfalseFlips true the first time a vector is embedded

Credential-gate rails (model vs. human)

The rails keep the model from redirecting its own brain or granting itself access. There are two enforcement layers:

1. store.cpp - human-only setters. The write side of the credential gate. The setters for keys, orchHost, provPrio (provider-host list), and sysPrompt may be called only from surfaces a human drives directly (the config web page, the provision/test consoles) - never from any code path the model can reach (device-action executor, turn loop, tools). setSubPriority() is the only model-writable routing knob; it targets the sub-session list (subPrio), never the orchestrator-host list.

2. lib/core - the deny policy (portable security core). The model may tune exactly two config keys: ledBrightness (0..255) and priority (a routing preference, i.e. the sub-session list - not a host). Everything else is ignored (unknown) or BLOCKED. The protected list (lib/core/src/orch_device_actions.cpp, nullptr-terminated) is:

KeyWhy blocked
password, token, connectorSecrets - an LLM-issued chat action must never set a credential
allowlistThe device's own auth gate - the model must not grant itself access
orchHost, fabricProvider / sub-agent routing - the model must not redirect its own brain / sub-agent backends

If any protected key is present, the whole config action is refused (reason "protected-BLOCKED"). The reason string reports only the policy tag, never the blocked key's value, so a secret can never leak into the log or the next-turn context.

See Orchestrator World for these rails in the wider control-surface context.

Sparse-override Config + the SD blob

Battery modes (machine ids BatterySaver, Balanced, Desk - displayed as Dark / Balanced / Full) are named sets of defaults. The Config model (lib/core/include/nimbus/profile.h) layers sparse user overrides on top of the active profile:

effective(key) = userOverride(key) ?? activeProfile.preset(key)

Only keys the user actually touched are stored, so switching profiles never loses user intent. Config holds the active ProfileId plus a per-Param has_[]/val_[] override pair over the Param enum (kParamCount = Param::COUNT).

Serialization (lib/core/include/nimbus/config_store.h, kConfigStoreVersion = 1): little-endian, magic 'N' 'C', then version, profile, count, and count × { param:u8, value:i32 } records - only the overridden params. Header is kConfigHeaderBytes = 5, each record kConfigRecordBytes = 5, cap kConfigMaxBytes = kConfigHeaderBytes + kParamCount * kConfigRecordBytes. deserializeConfig() is all-or-nothing: any bad magic/version/param/length leaves out untouched and returns false, so a corrupt blob falls back to defaults rather than a half-applied config.

Persistence glue (src/sys/config_nvs.cpp): the serialized Config blob is stored under name nimbus_cfg via solide::memory::putBlob - the SD-backed half of the store. Everything degrades gracefully: with no backing store (solide::memory::ok() false, blob absent, or corrupt) loadConfig returns false / leaves defaults and saveConfig returns false, never crashing the caller.

F10 - no-SD non-persistence limitation

Because the Config override blob rides the SD half of solide::memory (putBlob), with no SD card the overrides do not survive reboot - only nimbus_mode (an NVS int) and the other NVS keys persist. This is a known open bug (F10), flagged in P4 review and not yet fixed. The device test_persist_across_reboot / R_F10_persist_no_sd test encodes the fix and xfails until the blob is moved to a store that survives without SD.

See Tools & Commands for provisioning over serial ([env:provision] or the test console WIFI / MODE commands).

Set-once embed-config invariant

Vectors embedded under different provider/model/dims are incomparable, so the embed config is set-once: embLocked flips true the first time any vector is embedded, and after that changing the config invalidates the whole vector DB (VDB).

store::setEmbedConfig() itself is a plain accessor - the reset/lock policy lives with the caller. The server-side guard is POST /api/mem/embedcfg (src/net/web_memory.cpp): while locked, a change is refused with HTTP 409 unless reset=1 is passed. reset=1 is the destructive path - it flushes and wipes the VDB (flushAll + persistVectors), clears embLocked, then writes the new config and reconfigures the engine to the new dims. A change can therefore never silently strand incomparable vectors; the web UI must warn and require the explicit reset. provider must be openai or mistral and model is required (else HTTP 400).