Repository: zeroclaw-labs/zeroclaw (2026-04-02T01:20:56Z)

Issue:
[Feature]: zeroclaw props CLI for config property management
**Affected component:** runtime/daemon
**Severity:** S2 - degraded behavior

## Problem statement

There is no CLI command to manage individual config properties or set
encrypted secrets in config.toml. Users must either re-run the full
`zeroclaw onboard` wizard or manually edit config.toml.

## Solution (PR #5165)

Evolved from a secret-only CLI into a general-purpose config property
management system:

### Configurable derive macro (`crates/zeroclaw-macros/`)

- `#[derive(Configurable)]` with `#[secret]`, `#[nested]`, `#[prefix]` attributes
- Generates `prop_fields()`, `get_prop()`, `set_prop()`, `prop_is_secret()`,
  `init_defaults()`, `secret_fields()`, `encrypt_secrets()`, `decrypt_secrets()`
- `get_prop`/`set_prop` use serde roundtrip — macro generates only metadata,
  not per-field type dispatch
- `HasPropKind` trait maps types to `PropKind` at compile time; scalars in
  `traits.rs`, config enums via `impl_enum_prop_kind!`
- Handles `HashMap<String, T>` with `#[nested]` and `Vec<String>` with `#[secret]`
- All ~80 config structs annotated — every property individually addressable

### `zeroclaw props` CLI

- `props list [--filter PREFIX] [--secrets]` — enumerate all properties with values, types, categories
- `props get <path>` — get a value (secrets show set/unset status only)
- `props set <path> [value]` — set a property; secrets auto-detect masked input; enums offer interactive `dialoguer::Select`
- `props init [section]` — instantiate unconfigured sections with defaults (`enabled=false`)
- `props complete [partial]` — dynamic shell tab-completion for property paths

### Additional changes

- `enabled: bool` field on all channel configs for explicit activation
- `backfill_enabled()` auto-enables channels present in config.toml without explicit `enabled` key
- Shell completion hooks (bash/zsh/fish) wrap clap's generated function
- Manual encrypt/decrypt helpers replaced by macro-derived methods
- Exhaustive walk tests verify every prop and every enum variant

## Future work

- **`api_key_env` pattern replacement**: Several configs (Firecrawl, LinkedIn image providers, ImageGen) store env var names (`api_key_env: "STABILITY_API_KEY"`) instead of actual secret values. These should be replaced with `#[secret] api_key` fields that store the key directly (encrypted at rest), with env var fallback handled at runtime. This prevents users from accidentally putting actual keys in the env-var-name field.
- **`props unset`**: Dedicated subcommand to clear Option fields (currently works via `props set path ""` but not discoverable)
- **`props get` for secrets**: Decrypt and display secret values (with confirmation prompt)
- **Vec/HashMap field support in props**: Currently skipped — users can't set list or map fields via props
- **User-facing docs**: `docs/cli/props.md` reference page for the props subcommand

Part of umbrella tracker #4657.

## Non-goals

- Not a general key-value store
- Not a replacement for `zeroclaw onboard`

## Breaking change?

No — existing encrypted secrets continue to work. New `enabled` field
defaults to `false`; `backfill_enabled()` preserves existing channel
activation.

---

**Data hygiene checks:**
- [x] Removed personal/sensitive data from examples
- [x] Used neutral, project-focused wording


Discussion:

Patch:
diff --git a/.gitignore b/.gitignore
index 642998f4d5..41b7d45c09 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,4 +57,7 @@ lcov.info
 playground/
 
 # Temporary rustc output
-rust_out
\ No newline at end of file
+rust_out
+
+# Auto-generated Tauri schemas
+apps/tauri/gen/schemas/linux-schema.json
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e6986696f8..67dc932b3d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -455,6 +455,75 @@ impl Channel for YourChannel {
 }
 ```
 
+## How to Mark Config Fields as Secrets
+
+ZeroClaw uses a `#[derive(Configurable)]` proc macro to automatically handle secret
+field discovery, encryption, decryption, and CLI management. When adding a new
+channel, provider, or integration with sensitive fields (API keys, tokens, passwords):
+
+1. Add `Configurable` and `Default` to the derive list, `#[prefix]` on the struct,
+   and an `enabled` field:
+
+```rust
+use zeroclaw_macros::Configurable;
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.your-channel"]
+pub struct YourChannelConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
+    #[secret]
+    pub bot_token: String,
+    #[secret]
+    pub webhook_secret: Option<String>,
+    // Non-secret fields — no annotation needed
+    pub room_id: String,
+}
+```
+
+2. If your struct is nested inside a parent (e.g., `ChannelsConfig`), add `#[nested]`
+   on the parent's field so the tree traversal finds it:
+
+```rust
+pub struct ChannelsConfig {
+    #[nested]
+    pub your_channel: Option<YourChannelConfig>,
+}
+```
+
+That's it. The `#[secret]` annotation automatically:
+- Includes the field in `zeroclaw props list --secrets`
+- Makes it settable via `zeroclaw props set channels.your-channel.bot-token`
+- Encrypts it on config save and decrypts on load
+- Converts the field name from `snake_case` to `kebab-case` in the CLI
+
+Field names are derived automatically: `bot_token` on a struct with
+`#[prefix = "channels.your-channel"]` becomes `channels.your-channel.bot-token`.
+
+### Adding enum fields
+
+If your config struct has an enum field (e.g. `stream_mode: StreamMode`), the enum
+type must implement `HasPropKind`. Add it to the `impl_enum_prop_kind!` block in
+`src/config/schema.rs`:
+
+```rust
+impl_enum_prop_kind!(
+    // ... existing enums ...
+    YourNewEnum,
+);
+```
+
+If the enum is defined outside `schema.rs`, add the impl at the enum's definition site:
+
+```rust
+impl crate::config::HasPropKind for YourNewEnum {
+    const PROP_KIND: crate::config::PropKind = crate::config::PropKind::Enum;
+}
+```
+
+The compiler will error if this is missing — the error names the trait and the type.
+
 ## How to Add a New Observer
 
 Create `src/observability/your_observer.rs`:
diff --git a/Cargo.lock b/Cargo.lock
index 5337e89126..1e3582c387 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -12952,6 +12952,15 @@ dependencies = [
  "tokio",
 ]
 
+[[package]]
+name = "zeroclaw-macros"
+version = "0.1.0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
 [[package]]
 name = "zeroclaw-robot-kit"
 version = "0.1.0"
@@ -13075,6 +13084,7 @@ dependencies = [
  "webpki-roots 1.0.6",
  "which",
  "wiremock",
+ "zeroclaw-macros",
  "zip",
 ]
 
diff --git a/Cargo.toml b/Cargo.toml
index 8566746480..c8fa3d8ab1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
 [workspace]
-members = [".", "crates/robot-kit", "crates/aardvark-sys", "apps/tauri"]
+members = [".", "crates/robot-kit", "crates/aardvark-sys", "crates/zeroclaw-macros", "apps/tauri"]
 resolver = "2"
 
 [package]
@@ -34,6 +34,8 @@ name = "zeroclaw"
 path = "src/lib.rs"
 
 [dependencies]
+# Internal proc macros
+zeroclaw-macros = { path = "crates/zeroclaw-macros" }
 # CLI - minimal and fast
 clap = { version = "4.5", features = ["derive"] }
 clap_complete = "4.5"
diff --git a/crates/zeroclaw-macros/Cargo.toml b/crates/zeroclaw-macros/Cargo.toml
new file mode 100644
index 0000000000..e4bcb3b595
--- /dev/null
+++ b/crates/zeroclaw-macros/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "zeroclaw-macros"
+version = "0.1.0"
+edition = "2024"
+license = "MIT OR Apache-2.0"
+description = "Proc macros for ZeroClaw (config field derivation)"
+
+[lib]
+proc-macro = true
+
+[dependencies]
+syn = { version = "2", features = ["full", "extra-traits"] }
+quote = "1"
+proc-macro2 = "1"
diff --git a/crates/zeroclaw-macros/src/lib.rs b/crates/zeroclaw-macros/src/lib.rs
new file mode 100644
index 0000000000..f787ab560d
--- /dev/null
+++ b/crates/zeroclaw-macros/src/lib.rs
@@ -0,0 +1,701 @@
+use proc_macro::TokenStream;
+use quote::{ToTokens, quote};
+use syn::{
+    Data, DeriveInput, Fields, GenericArgument, Lit, Meta, PathArguments, parse_macro_input,
+};
+
+/// Check if a type is a known compound container (Vec, HashMap, etc.)
+/// that should be skipped from property enumeration.
+fn is_compound_type(ty: &syn::Type) -> bool {
+    let syn::Type::Path(type_path) = ty else {
+        return false;
+    };
+    let Some(ident) = type_path.path.segments.last().map(|s| &s.ident) else {
+        return false;
+    };
+    ident == "Vec" || ident == "HashMap" || ident == "PathBuf"
+}
+
+/// Check if any `#[serde(...)]` attribute on the field contains `skip`.
+fn has_serde_skip(field: &syn::Field) -> bool {
+    for attr in &field.attrs {
+        if attr.path().is_ident("serde") {
+            // Parse the token stream inside the parens and look for `skip`
+            if let Ok(nested) = attr.parse_args_with(
+                syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
+            ) {
+                for meta in &nested {
+                    if meta.path().is_ident("skip") {
+                        return true;
+                    }
+                }
+            }
+        }
+    }
+    false
+}
+
+/// Derive macro that generates secret and property methods for config structs.
+///
+/// # Attributes
+///
+/// - `#[secret]` on a `String` or `Option<String>` field marks it as a secret.
+/// - `#[nested]` on a nested struct or `Option<StructWithSecrets>` field
+///   delegates secret discovery and setting to the child.
+/// - `#[prefix = "channels.matrix"]` on the struct sets the dotted path prefix.
+///
+/// # Generated methods
+///
+/// ## Secret methods (unchanged)
+/// - `secret_fields(&self) -> Vec<SecretFieldInfo>`
+/// - `set_secret(&mut self, name: &str, value: String) -> Result<()>`
+/// - `encrypt_secrets(&mut self, store: &SecretStore) -> Result<()>`
+/// - `decrypt_secrets(&mut self, store: &SecretStore) -> Result<()>`
+///
+/// ## Property methods (new)
+/// - `prop_fields(&self) -> Vec<PropFieldInfo>` — enumerate all fields
+/// - `get_prop(&self, name: &str) -> Result<String>` — get current value as string
+/// - `set_prop(&mut self, name: &str, value_str: &str) -> Result<()>` — parse string and set
+/// - `prop_is_secret(name: &str) -> bool` — static check
+/// - `init_defaults(&mut self, prefix: Option<&str>) -> Vec<&'static str>` — instantiate None nested sections
+#[proc_macro_derive(Configurable, attributes(secret, nested, prefix, serde))]
+pub fn derive_configurable(input: TokenStream) -> TokenStream {
+    let input = parse_macro_input!(input as DeriveInput);
+    let struct_name = &input.ident;
+
+    let prefix = extract_prefix(&input);
+    let category = derive_category(&prefix);
+
+    let fields = match &input.data {
+        Data::Struct(data) => match &data.fields {
+            Fields::Named(fields) => &fields.named,
+            _ => {
+                return syn::Error::new_spanned(
+                    &input,
+                    "Configurable only supports structs with named fields",
+                )
+                .to_compile_error()
+                .into();
+            }
+        },
+        _ => {
+            return syn::Error::new_spanned(&input, "Configurable can only be derived for structs")
+                .to_compile_error()
+                .into();
+        }
+    };
+
+    // ── Secret codegen accumulators (unchanged) ──
+    let mut secret_field_entries = Vec::new();
+    let mut set_arms = Vec::new();
+    let mut encrypt_ops = Vec::new();
+    let mut decrypt_ops = Vec::new();
+    let mut nested_collect = Vec::new();
+    let mut nested_set = Vec::new();
+    let mut nested_encrypt = Vec::new();
+    let mut nested_decrypt = Vec::new();
+
+    // ── Property codegen accumulators ──
+    let mut prop_field_entries = Vec::new();
+    let mut prop_names: Vec<String> = Vec::new();
+    let mut prop_kind_tokens = Vec::new();
+    let mut prop_is_option_flags = Vec::new();
+    let mut prop_is_secret_arms = Vec::new();
+    let mut nested_prop_fields = Vec::new();
+    let mut nested_get_prop = Vec::new();
+    let mut nested_set_prop = Vec::new();
+    let mut nested_prop_is_secret = Vec::new();
+    let mut init_defaults_ops = Vec::new();
+
+    for field in fields {
+        let field_ident = field.ident.as_ref().expect("Named field must have ident");
+        let is_secret = has_attr(field, "secret");
+        let is_nested = has_attr(field, "nested");
+        let serde_skip = has_serde_skip(field);
+
+        // ── Secret handling ──
+        if is_secret {
+            let field_name_kebab = snake_to_kebab(&field_ident.to_string());
+            let full_name = if prefix.is_empty() {
+                field_name_kebab.clone()
+            } else {
+                format!("{}.{}", prefix, field_name_kebab)
+            };
+
+            let is_option = is_option_type(&field.ty);
+            let is_vec_string = extract_vec_inner(&field.ty)
+                .map(|inner| inner.to_token_stream().to_string() == "String")
+                .unwrap_or(false);
+            let full_name_lit = &full_name;
+            let category_lit = &category;
+
+            if is_vec_string {
+                // Vec<String> with #[secret]: iterate elements for encrypt/decrypt
+                secret_field_entries.push(quote! {
+                    crate::config::SecretFieldInfo {
+                        name: #full_name_lit,
+                        category: #category_lit,
+                        is_set: !self.#field_ident.is_empty(),
+                    }
+                });
+                encrypt_ops.push(quote! {
+                    for element in &mut self.#field_ident {
+                        if !element.is_empty() && !crate::security::SecretStore::is_encrypted(element) {
+                            *element = store.encrypt(element)
+                                .with_context(|| format!("Failed to encrypt {}[]", #full_name_lit))?;
+                        }
+                    }
+                });
+                decrypt_ops.push(quote! {
+                    for element in &mut self.#field_ident {
+                        if crate::security::SecretStore::is_encrypted(element) {
+                            *element = store.decrypt(element)
+                                .with_context(|| format!("Failed to decrypt {}[]", #full_name_lit))?;
+                        }
+                    }
+                });
+            } else if is_option {
+                secret_field_entries.push(quote! {
+                    crate::config::SecretFieldInfo {
+                        name: #full_name_lit,
+                        category: #category_lit,
+                        is_set: self.#field_ident.as_ref().is_some_and(|v| !v.is_empty()),
+                    }
+                });
+                set_arms.push(quote! {
+                    #full_name_lit => { self.#field_ident = Some(value); Ok(()) }
+                });
+                encrypt_ops.push(quote! {
+                    if let Some(raw) = &self.#field_ident {
+                        if !crate::security::SecretStore::is_encrypted(raw) {
+                            self.#field_ident = Some(
+                                store.encrypt(raw)
+                                    .with_context(|| format!("Failed to encrypt {}", #full_name_lit))?
+                            );
+                        }
+                    }
+                });
+                decrypt_ops.push(quote! {
+                    if let Some(raw) = &self.#field_ident {
+                        if crate::security::SecretStore::is_encrypted(raw) {
+                            self.#field_ident = Some(
+                                store.decrypt(raw)
+                                    .with_context(|| format!("Failed to decrypt {}", #full_name_lit))?
+                            );
+                        }
+                    }
+                });
+            } else {
+                secret_field_entries.push(quote! {
+                    crate::config::SecretFieldInfo {
+                        name: #full_name_lit,
+                        category: #category_lit,
+                        is_set: !self.#field_ident.is_empty(),
+                    }
+                });
+                set_arms.push(quote! {
+                    #full_name_lit => { self.#field_ident = value; Ok(()) }
+                });
+                encrypt_ops.push(quote! {
+                    if !self.#field_ident.is_empty() && !crate::security::SecretStore::is_encrypted(&self.#field_ident) {
+                        self.#field_ident = store.encrypt(&self.#field_ident)
+                            .with_context(|| format!("Failed to encrypt {}", #full_name_lit))?;
+                    }
+                });
+                decrypt_ops.push(quote! {
+                    if crate::security::SecretStore::is_encrypted(&self.#field_ident) {
+                        self.#field_ident = store.decrypt(&self.#field_ident)
+                            .with_context(|| format!("Failed to decrypt {}", #full_name_lit))?;
+                    }
+                });
+            }
+        }
+
+        if is_nested {
+            // ── Nested delegation ──
+            let is_option = is_option_type(&field.ty);
+            let hashmap_value_ty = extract_hashmap_value_type(&field.ty);
+
+            if let Some(value_ty) = hashmap_value_ty {
+                // HashMap<String, T> with #[nested]: iterate values for secret ops
+                nested_collect.push(quote! {
+                    for inner in self.#field_ident.values() {
+                        fields.extend(inner.secret_fields());
+                    }
+                });
+                nested_set.push(quote! {
+                    for inner in self.#field_ident.values_mut() {
+                        if let Ok(()) = inner.set_secret(name, value.clone()) {
+                            return Ok(());
+                        }
+                    }
+                });
+                nested_encrypt.push(quote! {
+                    for inner in self.#field_ident.values_mut() {
+                        inner.encrypt_secrets(store)?;
+                    }
+                });
+                nested_decrypt.push(quote! {
+                    for inner in self.#field_ident.values_mut() {
+                        inner.decrypt_secrets(store)?;
+                    }
+                });
+                nested_prop_is_secret.push(quote! {
+                    if <#value_ty>::prop_is_secret(name) { return true; }
+                });
+
+                continue;
+            } else if is_option {
+                nested_collect.push(quote! {
+                    if let Some(inner) = &self.#field_ident {
+                        fields.extend(inner.secret_fields());
+                    }
+                });
+                nested_set.push(quote! {
+                    if let Some(inner) = &mut self.#field_ident {
+                        if let Ok(()) = inner.set_secret(name, value.clone()) {
+                            return Ok(());
+                        }
+                    }
+                });
+                nested_encrypt.push(quote! {
+                    if let Some(inner) = &mut self.#field_ident {
+                        inner.encrypt_secrets(store)?;
+                    }
+                });
+                nested_decrypt.push(quote! {
+                    if let Some(inner) = &mut self.#field_ident {
+                        inner.decrypt_secrets(store)?;
+                    }
+                });
+
+                // ── Nested property delegation (Option<T>) ──
+                nested_prop_fields.push(quote! {
+                    if let Some(inner) = &self.#field_ident {
+                        fields.extend(inner.prop_fields());
+                    }
+                });
+                nested_get_prop.push(quote! {
+                    if let Some(inner) = &self.#field_ident {
+                        if let Ok(val) = inner.get_prop(name) {
+                            return Ok(val);
+                        }
+                    }
+                });
+                nested_set_prop.push(quote! {
+                    if let Some(inner) = &mut self.#field_ident {
+                        if let Ok(()) = inner.set_prop(name, value_str) {
+                            return Ok(());
+                        }
+                    }
+                });
+                nested_prop_is_secret.push(quote! {
+                    // Extract inner type from Option for static dispatch
+                    // We need to know the inner type at compile time
+                });
+
+                // For Option<T> nested, extract inner type for Default::default()
+                if let Some(inner_ty) = extract_option_inner(&field.ty) {
+                    let inner_ty_tokens = quote! { #inner_ty };
+                    init_defaults_ops.push(quote! {
+                        if self.#field_ident.is_none() {
+                            let child_prefix = <#inner_ty_tokens>::configurable_prefix();
+                            let dominated = prefix.map_or(true, |p| {
+                                child_prefix.starts_with(p) || p.starts_with(child_prefix)
+                            });
+                            if dominated {
+                                let mut probe = <#inner_ty_tokens as Default>::default();
+                                let child_results = probe.init_defaults(prefix);
+                                initialized.push(child_prefix);
+                                initialized.extend(child_results);
+                                self.#field_ident = Some(probe);
+                            }
+                        } else if let Some(inner) = &mut self.#field_ident {
+                            initialized.extend(inner.init_defaults(prefix));
+                        }
+                    });
+
+                    // For prop_is_secret delegation on Option<T> nested, we need the inner type
+                    nested_prop_is_secret.pop(); // Remove the placeholder
+                    nested_prop_is_secret.push(quote! {
+                        if <#inner_ty_tokens>::prop_is_secret(name) {
+                            return true;
+                        }
+                    });
+                }
+            } else {
+                nested_collect.push(quote! {
+                    fields.extend(self.#field_ident.secret_fields());
+                });
+                nested_set.push(quote! {
+                    if let Ok(()) = self.#field_ident.set_secret(name, value.clone()) {
+                        return Ok(());
+                    }
+                });
+                nested_encrypt.push(quote! {
+                    self.#field_ident.encrypt_secrets(store)?;
+                });
+                nested_decrypt.push(quote! {
+                    self.#field_ident.decrypt_secrets(store)?;
+                });
+
+                // ── Nested property delegation (non-Option) ──
+                nested_prop_fields.push(quote! {
+                    fields.extend(self.#field_ident.prop_fields());
+                });
+                nested_get_prop.push(quote! {
+                    if let Ok(val) = self.#field_ident.get_prop(name) {
+                        return Ok(val);
+                    }
+                });
+                nested_set_prop.push(quote! {
+                    if let Ok(()) = self.#field_ident.set_prop(name, value_str) {
+                        return Ok(());
+                    }
+                });
+
+                // Get the field type for static method dispatch
+                let field_ty = &field.ty;
+                nested_prop_is_secret.push(quote! {
+                    if <#field_ty>::prop_is_secret(name) {
+                        return true;
+                    }
+                });
+
+                // init_defaults for non-Option nested: delegate
+                init_defaults_ops.push(quote! {
+                    initialized.extend(self.#field_ident.init_defaults(prefix));
+                });
+            }
+
+            continue; // nested fields handled above
+        }
+
+        // ── Property handling for non-nested, non-skip fields ──
+        if serde_skip {
+            continue;
+        }
+
+        // Unwrap Option<T> → T for type inspection
+        let is_option = is_option_type(&field.ty);
+        let inner_ty = extract_option_inner(&field.ty).unwrap_or(&field.ty);
+
+        // Skip compound types (Vec, HashMap, PathBuf)
+        if is_compound_type(inner_ty) {
+            continue;
+        }
+
+        let field_name_kebab = snake_to_kebab(&field_ident.to_string());
+        let serde_name = field_ident.to_string();
+        let full_name = if prefix.is_empty() {
+            field_name_kebab.clone()
+        } else {
+            format!("{}.{}", prefix, field_name_kebab)
+        };
+        let full_name_lit = &full_name;
+        let serde_name_lit = &serde_name;
+        let category_lit = &category;
+        let type_str = field.ty.to_token_stream().to_string().replace(' ', "");
+        let type_hint_lit = &type_str;
+
+        // PropKind resolved at compile time via HasPropKind trait.
+        // All field types must implement HasPropKind — scalars in traits.rs,
+        // config enums in schema.rs via impl_enum_prop_kind!.
+        let kind_token = quote! { <#inner_ty as crate::config::HasPropKind>::PROP_KIND };
+        let enum_variants_expr = quote! {
+            if <#inner_ty as crate::config::HasPropKind>::PROP_KIND == crate::config::PropKind::Enum {
+                Some(|| {
+                    crate::config::enum_variants::<#inner_ty>()
+                        .split(", ")
+                        .map(|s| s.to_string())
+                        .collect()
+                })
+            } else {
+                None
+            }
+        };
+
+        if is_secret {
+            prop_is_secret_arms.push(quote! { #full_name_lit => true, });
+        }
+
+        prop_names.push(full_name.clone());
+        prop_kind_tokens.push(kind_token.clone());
+        prop_is_option_flags.push(is_option);
+
+        prop_field_entries.push(quote! {
+            crate::config::make_prop_field(
+                __table.as_ref(),
+                #full_name_lit,
+                #serde_name_lit,
+                #category_lit,
+                #type_hint_lit,
+                #kind_token,
+                #is_secret,
+                #enum_variants_expr,
+            )
+        });
+    }
+
+    let prefix_lit = &prefix;
+
+    let expanded = quote! {
+        impl #struct_name {
+            /// Returns the `#[prefix]` value for this Configurable struct.
+            pub fn configurable_prefix() -> &'static str {
+                #prefix_lit
+            }
+
+            /// Returns metadata about all `#[secret]` fields on this struct and nested children.
+            pub fn secret_fields(&self) -> Vec<crate::config::SecretFieldInfo> {
+                let mut fields = vec![#(#secret_field_entries),*];
+                #(#nested_collect)*
+                fields
+            }
+
+            /// Encrypt all secret fields in place using the provided store.
+            pub fn encrypt_secrets(&mut self, store: &crate::security::SecretStore) -> anyhow::Result<()> {
+                use anyhow::Context;
+                #(#encrypt_ops)*
+                #(#nested_encrypt)*
+                Ok(())
+            }
+
+            /// Decrypt all secret fields in place using the provided store.
+            pub fn decrypt_secrets(&mut self, store: &crate::security::SecretStore) -> anyhow::Result<()> {
+                use anyhow::Context;
+                #(#decrypt_ops)*
+                #(#nested_decrypt)*
+                Ok(())
+            }
+
+            /// Set a secret field by its full dotted name, dispatching to nested children.
+            pub fn set_secret(&mut self, name: &str, value: String) -> anyhow::Result<()> {
+                // Try direct secret fields first
+                match name {
+                    #(#set_arms,)*
+                    _ => {
+                        // Try nested children
+                        #(#nested_set)*
+                        anyhow::bail!("Unknown secret '{}'", name)
+                    }
+                }
+            }
+
+            /// Returns metadata about all property fields on this struct and nested children.
+            pub fn prop_fields(&self) -> Vec<crate::config::PropFieldInfo> {
+                let __table = toml::Value::try_from(self)
+                    .ok()
+                    .and_then(|v| match v { toml::Value::Table(t) => Some(t), _ => None });
+                let mut fields = vec![#(#prop_field_entries),*];
+                #(#nested_prop_fields)*
+                fields
+            }
+
+            /// Get a property value by its full dotted name, returning it as a display string.
+            pub fn get_prop(&self, name: &str) -> anyhow::Result<String> {
+                #(#nested_get_prop)*
+                const KNOWN: &[&str] = &[#(#prop_names),*];
+                if !KNOWN.contains(&name) {
+                    anyhow::bail!("Unknown property '{}'", name);
+                }
+                crate::config::serde_get_prop(self, Self::configurable_prefix(), name, Self::prop_is_secret(name))
+            }
+
+            /// Set a property value by its full dotted name, parsing from string.
+            pub fn set_prop(&mut self, name: &str, value_str: &str) -> anyhow::Result<()> {
+                #(#nested_set_prop)*
+                const KNOWN: &[&str] = &[#(#prop_names),*];
+                const KINDS: &[crate::config::PropKind] = &[#(#prop_kind_tokens),*];
+                const IS_OPTION: &[bool] = &[#(#prop_is_option_flags),*];
+                let idx = KNOWN.iter().position(|&n| n == name)
+                    .ok_or_else(|| anyhow::anyhow!("Unknown property '{}'", name))?;
+                crate::config::serde_set_prop(self, Self::configurable_prefix(), name, value_str, KINDS[idx], IS_OPTION[idx])
+            }
+
+            /// Check if a property name refers to a secret field (static, no instance needed).
+            pub fn prop_is_secret(name: &str) -> bool {
+                match name {
+                    #(#prop_is_secret_arms)*
+                    _ => {
+                        #(#nested_prop_is_secret)*
+                        false
+                    }
+                }
+            }
+
+            /// Instantiate `None` nested sections whose prefix matches.
+            /// Returns the prefixes that were initialized.
+            pub fn init_defaults(&mut self, prefix: Option<&str>) -> Vec<&'static str> {
+                let mut initialized: Vec<&'static str> = Vec::new();
+                #(#init_defaults_ops)*
+                initialized
+            }
+        }
+    };
+
+    TokenStream::from(expanded)
+}
+
+fn derive_category(prefix: &str) -> String {
+    if prefix.is_empty() {
+        return "Core".to_string();
+    }
+    let first = prefix.split('.').next().unwrap_or("");
+    match first {
+        "channels" => "Channels".to_string(),
+        "tts" => "TTS".to_string(),
+        "transcription" => "Transcription".to_string(),
+        other => {
+            let mut chars = other.chars();
+            match chars.next() {
+                Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
+                None => "Core".to_string(),
+            }
+        }
+    }
+}
+
+fn extract_prefix(input: &DeriveInput) -> String {
+    for attr in &input.attrs {
+        if !attr.path().is_ident("prefix") {
+            continue;
+        }
+        let Meta::NameValue(nv) = &attr.meta else {
+            continue;
+        };
+        let syn::Expr::Lit(expr_lit) = &nv.value else {
+            continue;
+        };
+        let Lit::Str(lit_str) = &expr_lit.lit else {
+            continue;
+        };
+        return lit_str.value();
+    }
+    String::new()
+}
+
+fn has_attr(field: &syn::Field, name: &str) -> bool {
+    field.attrs.iter().any(|attr| attr.path().is_ident(name))
+}
+
+fn snake_to_kebab(s: &str) -> String {
+    s.replace('_', "-")
+}
+
+fn is_option_type(ty: &syn::Type) -> bool {
+    let syn::Type::Path(type_path) = ty else {
+        return false;
+    };
+    type_path
+        .path
+        .segments
+        .last()
+        .is_some_and(|s| s.ident == "Option")
+}
+
+/// Extract the Nth type argument from a generic type matching `expected_ident`.
+/// e.g. `extract_type_arg("Option", 0, ty)` returns `T` from `Option<T>`.
+fn extract_type_arg<'a>(
+    expected_ident: &str,
+    index: usize,
+    ty: &'a syn::Type,
+) -> Option<&'a syn::Type> {
+    let syn::Type::Path(type_path) = ty else {
+        return None;
+    };
+    let segment = type_path.path.segments.last()?;
+    if segment.ident != expected_ident {
+        return None;
+    }
+    let PathArguments::AngleBracketed(args) = &segment.arguments else {
+        return None;
+    };
+    args.args
+        .iter()
+        .filter_map(|a| {
+            if let GenericArgument::Type(t) = a {
+                Some(t)
+            } else {
+                None
+            }
+        })
+        .nth(index)
+}
+
+fn extract_option_inner(ty: &syn::Type) -> Option<&syn::Type> {
+    extract_type_arg("Option", 0, ty)
+}
+fn extract_vec_inner(ty: &syn::Type) -> Option<&syn::Type> {
+    extract_type_arg("Vec", 0, ty)
+}
+fn extract_hashmap_value_type(ty: &syn::Type) -> Option<&syn::Type> {
+    extract_type_arg("HashMap", 1, ty)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use syn::parse_quote;
+
+    #[test]
+    fn snake_to_kebab_converts_underscores() {
+        assert_eq!(snake_to_kebab("access_token"), "access-token");
+        assert_eq!(snake_to_kebab("api_key"), "api-key");
+        assert_eq!(snake_to_kebab("bot_token"), "bot-token");
+        assert_eq!(snake_to_kebab("simple"), "simple");
+    }
+
+    #[test]
+    fn derive_category_from_prefix() {
+        assert_eq!(derive_category("channels.matrix"), "Channels");
+        assert_eq!(derive_category("channels.discord"), "Channels");
+        assert_eq!(derive_category("tts.openai"), "TTS");
+        assert_eq!(derive_category("tts.elevenlabs"), "TTS");
+        assert_eq!(derive_category("transcription"), "Transcription");
+        assert_eq!(derive_category("transcription.openai"), "Transcription");
+        assert_eq!(derive_category(""), "Core");
+    }
+
+    #[test]
+    fn has_serde_skip_detects_skip() {
+        let field: syn::Field = parse_quote! {
+            #[serde(skip)]
+            pub workspace_dir: String
+        };
+        assert!(has_serde_skip(&field));
+    }
+
+    #[test]
+    fn has_serde_skip_ignores_other_serde_attrs() {
+        let field: syn::Field = parse_quote! {
+            #[serde(default)]
+            pub enabled: bool
+        };
+        assert!(!has_serde_skip(&field));
+
+        let field: syn::Field = parse_quote! {
+            #[serde(default, skip_serializing_if = "Option::is_none")]
+            pub value: Option<String>
+        };
+        assert!(!has_serde_skip(&field));
+    }
+
+    #[test]
+    fn has_serde_skip_no_serde_attr() {
+        let field: syn::Field = parse_quote! {
+            pub name: String
+        };
+        assert!(!has_serde_skip(&field));
+    }
+
+    #[test]
+    fn has_serde_skip_with_other_attrs() {
+        let field: syn::Field = parse_quote! {
+            #[secret]
+            #[serde(skip)]
+            pub token: String
+        };
+        assert!(has_serde_skip(&field));
+    }
+}
diff --git a/docs/reference/cli/commands-reference.md b/docs/reference/cli/commands-reference.md
index a02da38337..82920269c9 100644
--- a/docs/reference/cli/commands-reference.md
+++ b/docs/reference/cli/commands-reference.md
@@ -24,6 +24,7 @@ Last verified: **March 26, 2026**.
 | `integrations` | Inspect integration details |
 | `skills` | List/install/remove skills |
 | `migrate` | Import from external runtimes (currently OpenClaw) |
+| `props` | View, set, or initialize config properties |
 | `config` | Export machine-readable config schema |
 | `completions` | Generate shell completion scripts to stdout |
 | `hardware` | Discover and introspect USB hardware |
@@ -224,6 +225,36 @@ Skill manifests (`SKILL.toml`) support `prompts` and `[[tools]]`; both are injec
 - `zeroclaw peripheral setup-uno-q [--host <ip_or_host>]`
 - `zeroclaw peripheral flash-nucleo`
 
+### `props`
+
+Manage individual config properties without editing `config.toml` directly.
+Properties are addressed by dotted path (e.g. `channels.matrix.mention-only`).
+
+- `zeroclaw props list` — list all properties with current values
+- `zeroclaw props list --secrets` — list only secret (encrypted) fields
+- `zeroclaw props list --filter channels.matrix` — filter by path prefix
+- `zeroclaw props get <path>` — get a single property value (secrets show set/unset status)
+- `zeroclaw props set <path> <value>` — set a property value
+- `zeroclaw props set <path>` — secret fields prompt for masked input; enum fields offer interactive selection
+- `zeroclaw props set --no-interactive <path> <value>` — scripted mode, no prompts
+- `zeroclaw props init <section>` — create an unconfigured section with defaults (`enabled=false`)
+- `zeroclaw props init` — initialize all unconfigured sections
+
+Secret fields (API keys, tokens, passwords) are automatically detected via `#[secret]`
+annotations. When setting a secret, input is masked regardless of whether a value is
+provided on the command line.
+
+Enum fields (e.g. `stream-mode`, `search-mode`) offer interactive selection via arrow
+keys when the value is omitted. Provide the value directly to skip the prompt.
+
+Shell tab-completion for property paths is included in `zeroclaw completions <shell>`.
+
+#### Adding new config fields
+
+Config structs derive `Configurable` with `#[prefix]` and `#[nested]` attributes.
+Adding a new field to an existing struct makes it immediately available via `props`.
+New enum types require a one-line `HasPropKind` impl. See `CONTRIBUTING.md` for details.
+
 ## Validation Tip
 
 To verify docs against your current binary quickly:
diff --git a/src/agent/context_compressor.rs b/src/agent/context_compressor.rs
index 543cf2dc3a..a8086c6370 100644
--- a/src/agent/context_compressor.rs
+++ b/src/agent/context_compressor.rs
@@ -4,6 +4,7 @@ use std::time::Duration;
 use anyhow::Result;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
+use zeroclaw_macros::Configurable;
 
 use std::sync::Arc;
 
@@ -45,7 +46,8 @@ fn default_tool_result_retrim_chars() -> usize {
     2_000
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "agent.context-compression"]
 pub struct ContextCompressionConfig {
     /// Enable automatic context compression. Default: `true`.
     #[serde(default = "default_enabled")]
diff --git a/src/agent/eval.rs b/src/agent/eval.rs
index 50a1598950..ab6b70c404 100644
--- a/src/agent/eval.rs
+++ b/src/agent/eval.rs
@@ -1,6 +1,7 @@
 use serde::{Deserialize, Serialize};
 
 use schemars::JsonSchema;
+use zeroclaw_macros::Configurable;
 
 // ── Complexity estimation ───────────────────────────────────────
 
@@ -73,7 +74,8 @@ pub fn estimate_complexity(message: &str) -> ComplexityTier {
 /// When the rule-based classifier in `QueryClassificationConfig` produces no
 /// match, the eval layer can fall back to `estimate_complexity` and map the
 /// resulting tier to a routing hint.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "agent.auto-classify"]
 pub struct AutoClassifyConfig {
     /// Hint to use for `Simple` complexity tier (e.g. `"fast"`).
     #[serde(default)]
@@ -118,7 +120,8 @@ impl AutoClassifyConfig {
 // ── Post-response eval ──────────────────────────────────────────
 
 /// Configuration for the post-response quality evaluator.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "agent.eval"]
 pub struct EvalConfig {
     /// Enable the eval quality gate.
     #[serde(default)]
diff --git a/src/agent/history_pruner.rs b/src/agent/history_pruner.rs
index 0791373dcb..259be4a00b 100644
--- a/src/agent/history_pruner.rs
+++ b/src/agent/history_pruner.rs
@@ -1,6 +1,7 @@
 use crate::providers::traits::ChatMessage;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
+use zeroclaw_macros::Configurable;
 
 // ---------------------------------------------------------------------------
 // Config
@@ -18,7 +19,8 @@ fn default_collapse() -> bool {
     true
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "agent.history-pruning"]
 pub struct HistoryPrunerConfig {
     /// Enable history pruning. Default: false.
     #[serde(default)]
diff --git a/src/agent/thinking.rs b/src/agent/thinking.rs
index ab38fcfad2..ee52fe33f0 100644
--- a/src/agent/thinking.rs
+++ b/src/agent/thinking.rs
@@ -16,6 +16,7 @@
 
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
+use zeroclaw_macros::Configurable;
 
 /// How deeply the model should reason for a given message.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
@@ -36,6 +37,10 @@ pub enum ThinkingLevel {
     Max,
 }
 
+impl crate::config::HasPropKind for ThinkingLevel {
+    const PROP_KIND: crate::config::PropKind = crate::config::PropKind::Enum;
+}
+
 impl ThinkingLevel {
     /// Parse a thinking level from a string (case-insensitive).
     pub fn from_str_insensitive(s: &str) -> Option<Self> {
@@ -52,7 +57,8 @@ impl ThinkingLevel {
 }
 
 /// Configuration for thinking/reasoning level control.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "agent.thinking"]
 pub struct ThinkingConfig {
     /// Default thinking level when no directive is present.
     #[serde(default)]
diff --git a/src/channels/clawdtalk.rs b/src/channels/clawdtalk.rs
index e3f73522a0..247c748ca3 100644
--- a/src/channels/clawdtalk.rs
+++ b/src/channels/clawdtalk.rs
@@ -29,9 +29,17 @@ pub struct ClawdTalkChannel {
 }
 
 /// Configuration for ClawdTalk channel from config.toml
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(
+    Debug, Clone, Default, Serialize, Deserialize, JsonSchema, zeroclaw_macros::Configurable,
+)]
+#[prefix = "channels.clawdtalk"]
 pub struct ClawdTalkConfig {
+    /// Whether this channel is active. Default: false.
+    /// Channels must be explicitly enabled after configuring required fields.
+    #[serde(default)]
+    pub enabled: bool,
     /// Telnyx API key
+    #[secret]
     pub api_key: String,
     /// Telnyx connection ID for SIP
     pub connection_id: String,
@@ -42,6 +50,7 @@ pub struct ClawdTalkConfig {
     pub allowed_destinations: Vec<String>,
     /// Webhook secret for signature verification
     #[serde(default)]
+    #[secret]
     pub webhook_secret: Option<String>,
 }
 
@@ -368,6 +377,7 @@ mod tests {
 
     fn test_config() -> ClawdTalkConfig {
         ClawdTalkConfig {
+            enabled: true,
             api_key: "test-key".to_string(),
             connection_id: "test-connection".to_string(),
             from_number: "+15551234567".to_string(),
diff --git a/src/channels/email_channel.rs b/src/channels/email_channel.rs
index b43b4b95f1..ed764fd145 100644
--- a/src/channels/email_channel.rs
+++ b/src/channels/email_channel.rs
@@ -37,7 +37,8 @@ use uuid::Uuid;
 use super::traits::{Channel, ChannelMessage, SendMessage};
 
 /// Email channel configuration
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, zeroclaw_macros::Configurable)]
+#[prefix = "channels.email"]
 pub struct EmailConfig {
     /// IMAP server hostname
     pub imap_host: String,
@@ -58,6 +59,7 @@ pub struct EmailConfig {
     /// Email username for authentication
     pub username: String,
     /// Email password for authentication
+    #[secret]
     pub password: String,
     /// From address for outgoing emails
     pub from_address: String,
diff --git a/src/channels/gmail_push.rs b/src/channels/gmail_push.rs
index 9919b81a57..39609c1c5e 100644
--- a/src/channels/gmail_push.rs
+++ b/src/channels/gmail_push.rs
@@ -34,7 +34,8 @@ use super::traits::{Channel, ChannelMessage, SendMessage};
 // ── Configuration ────────────────────────────────────────────────
 
 /// Gmail Pub/Sub push notification channel configuration.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, zeroclaw_macros::Configurable)]
+#[prefix = "channels.gmail"]
 pub struct GmailPushConfig {
     /// Enable the Gmail push channel. Default: `false`.
     #[serde(default)]
@@ -47,6 +48,7 @@ pub struct GmailPushConfig {
     /// OAuth2 access token for the Gmail API.
     /// Falls back to `GMAIL_PUSH_OAUTH_TOKEN` env var.
     #[serde(default)]
+    #[secret]
     pub oauth_token: String,
     /// Allowed sender addresses/domains. Empty = deny all, `["*"]` = allow all.
     #[serde(default)]
diff --git a/src/channels/irc.rs b/src/channels/irc.rs
index fad32e7461..7dc30df24c 100644
--- a/src/channels/irc.rs
+++ b/src/channels/irc.rs
@@ -944,6 +944,7 @@ mod tests {
         use crate::config::schema::IrcConfig;
 
         let config = IrcConfig {
+            enabled: true,
             server: "irc.example.com".into(),
             port: 6697,
             nickname: "zcbot".into(),
diff --git a/src/channels/lark.rs b/src/channels/lark.rs
index 4958e32ce7..7929aea406 100644
--- a/src/channels/lark.rs
+++ b/src/channels/lark.rs
@@ -2980,6 +2980,7 @@ mod tests {
     fn lark_config_serde() {
         use crate::config::schema::{LarkConfig, LarkReceiveMode};
         let lc = LarkConfig {
+            enabled: true,
             app_id: "cli_app123".into(),
             app_secret: "secret456".into(),
             encrypt_key: None,
@@ -3003,6 +3004,7 @@ mod tests {
     fn lark_config_toml_roundtrip() {
         use crate::config::schema::{LarkConfig, LarkReceiveMode};
         let lc = LarkConfig {
+            enabled: true,
             app_id: "app".into(),
             app_secret: "secret".into(),
             encrypt_key: None,
@@ -3038,6 +3040,7 @@ mod tests {
         use crate::config::schema::{LarkConfig, LarkReceiveMode};
 
         let cfg = LarkConfig {
+            enabled: true,
             app_id: "cli_app123".into(),
             app_secret: "secret456".into(),
             encrypt_key: None,
@@ -3063,6 +3066,7 @@ mod tests {
         use crate::config::schema::{LarkConfig, LarkReceiveMode};
 
         let cfg = LarkConfig {
+            enabled: true,
             app_id: "cli_app123".into(),
             app_secret: "secret456".into(),
             encrypt_key: None,
@@ -3087,6 +3091,7 @@ mod tests {
         use crate::config::schema::{FeishuConfig, LarkReceiveMode};
 
         let cfg = FeishuConfig {
+            enabled: true,
             app_id: "cli_feishu_app123".into(),
             app_secret: "secret456".into(),
             encrypt_key: None,
@@ -3286,6 +3291,7 @@ mod tests {
         );
 
         let feishu_cfg = crate::config::schema::FeishuConfig {
+            enabled: true,
             app_id: "cli_app123".into(),
             app_secret: "secret456".into(),
             encrypt_key: None,
diff --git a/src/channels/matrix.rs b/src/channels/matrix.rs
index 85fc3b97c8..9b2968d80f 100644
--- a/src/channels/matrix.rs
+++ b/src/channels/matrix.rs
@@ -2312,7 +2312,7 @@ mod tests {
     }
 
     #[test]
-    fn sanitize_error_for_log_scrubs_secret_prefixes() {
+    fn sanitize_error_for_log_scrubs_prefixes() {
         let sanitized = MatrixChannel::sanitize_error_for_log(&"auth failed: sk-proj-abc123xyz");
         assert!(!sanitized.contains("sk-proj-abc123xyz"));
         assert!(sanitized.contains("[REDACTED]"));
diff --git a/src/channels/mod.rs b/src/channels/mod.rs
index 40a8940a3e..e96ac87957 100644
--- a/src/channels/mod.rs
+++ b/src/channels/mod.rs
@@ -4533,147 +4533,175 @@ fn collect_configured_channels(
     let mut channels = Vec::new();
 
     if let Some(ref tg) = config.channels_config.telegram {
-        let ack = tg
-            .ack_reactions
-            .unwrap_or(config.channels_config.ack_reactions);
-        channels.push(ConfiguredChannel {
-            display_name: "Telegram",
-            channel: Arc::new(
-                TelegramChannel::new(
-                    tg.bot_token.clone(),
-                    tg.allowed_users.clone(),
-                    tg.mention_only,
-                )
-                .with_ack_reactions(ack)
-                .with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
-                .with_transcription(config.transcription.clone())
-                .with_tts(config.tts.clone())
-                .with_workspace_dir(config.workspace_dir.clone())
-                .with_proxy_url(tg.proxy_url.clone()),
-            ),
-        });
+        if tg.enabled {
+            let ack = tg
+                .ack_reactions
+                .unwrap_or(config.channels_config.ack_reactions);
+            channels.push(ConfiguredChannel {
+                display_name: "Telegram",
+                channel: Arc::new(
+                    TelegramChannel::new(
+                        tg.bot_token.clone(),
+                        tg.allowed_users.clone(),
+                        tg.mention_only,
+                    )
+                    .with_ack_reactions(ack)
+                    .with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
+                    .with_transcription(config.transcription.clone())
+                    .with_tts(config.tts.clone())
+                    .with_workspace_dir(config.workspace_dir.clone())
+                    .with_proxy_url(tg.proxy_url.clone()),
+                ),
+            });
+        } else {
+            tracing::info!("Telegram channel configured but disabled (enabled = false)");
+        }
     }
 
     if let Some(ref dc) = config.channels_config.discord {
-        channels.push(ConfiguredChannel {
-            display_name: "Discord",
-            channel: Arc::new(
-                DiscordChannel::new(
-                    dc.bot_token.clone(),
-                    dc.guild_id.clone(),
-                    dc.allowed_users.clone(),
-                    dc.listen_to_bots,
-                    dc.mention_only,
-                )
-                .with_streaming(
-                    dc.stream_mode,
-                    dc.draft_update_interval_ms,
-                    dc.multi_message_delay_ms,
-                )
-                .with_proxy_url(dc.proxy_url.clone())
-                .with_transcription(config.transcription.clone())
-                .with_stall_timeout(dc.stall_timeout_secs),
-            ),
-        });
+        if dc.enabled {
+            channels.push(ConfiguredChannel {
+                display_name: "Discord",
+                channel: Arc::new(
+                    DiscordChannel::new(
+                        dc.bot_token.clone(),
+                        dc.guild_id.clone(),
+                        dc.allowed_users.clone(),
+                        dc.listen_to_bots,
+                        dc.mention_only,
+                    )
+                    .with_streaming(
+                        dc.stream_mode,
+                        dc.draft_update_interval_ms,
+                        dc.multi_message_delay_ms,
+                    )
+                    .with_proxy_url(dc.proxy_url.clone())
+                    .with_transcription(config.transcription.clone())
+                    .with_stall_timeout(dc.stall_timeout_secs),
+                ),
+            });
+        } else {
+            tracing::info!("Discord channel configured but disabled (enabled = false)");
+        }
     }
 
     if let Some(ref dh) = config.channels_config.discord_history {
-        match crate::memory::SqliteMemory::new_named(&config.workspace_dir, "discord") {
-            Ok(discord_mem) => {
-                channels.push(ConfiguredChannel {
-                    display_name: "Discord History",
-                    channel: Arc::new(
-                        DiscordHistoryChannel::new(
-                            dh.bot_token.clone(),
-                            dh.guild_id.clone(),
-                            dh.allowed_users.clone(),
-                            dh.channel_ids.clone(),
-                            Arc::new(discord_mem),
-                            dh.store_dms,
-                            dh.respond_to_dms,
-                        )
-                        .with_proxy_url(dh.proxy_url.clone()),
-                    ),
-                });
-            }
-            Err(e) => {
-                tracing::error!("discord_history: failed to open discord.db: {e}");
+        if dh.enabled {
+            match crate::memory::SqliteMemory::new_named(&config.workspace_dir, "discord") {
+                Ok(discord_mem) => {
+                    channels.push(ConfiguredChannel {
+                        display_name: "Discord History",
+                        channel: Arc::new(
+                            DiscordHistoryChannel::new(
+                                dh.bot_token.clone(),
+                                dh.guild_id.clone(),
+                                dh.allowed_users.clone(),
+                                dh.channel_ids.clone(),
+                                Arc::new(discord_mem),
+                                dh.store_dms,
+                                dh.respond_to_dms,
+                            )
+                            .with_proxy_url(dh.proxy_url.clone()),
+                        ),
+                    });
+                }
+                Err(e) => {
+                    tracing::error!("discord_history: failed to open discord.db: {e}");
+                }
             }
+        } else {
+            tracing::info!("Discord History channel configured but disabled (enabled = false)");
         }
     }
 
     if let Some(ref sl) = config.channels_config.slack {
-        channels.push(ConfiguredChannel {
-            display_name: "Slack",
-            channel: Arc::new(
-                SlackChannel::new(
-                    sl.bot_token.clone(),
-                    sl.app_token.clone(),
-                    sl.channel_id.clone(),
-                    sl.channel_ids.clone(),
-                    sl.allowed_users.clone(),
-                )
-                .with_thread_replies(sl.thread_replies.unwrap_or(true))
-                .with_group_reply_policy(sl.mention_only, Vec::new())
-                .with_workspace_dir(config.workspace_dir.clone())
-                .with_markdown_blocks(sl.use_markdown_blocks)
-                .with_proxy_url(sl.proxy_url.clone())
-                .with_transcription(config.transcription.clone())
-                .with_streaming(sl.stream_drafts, sl.draft_update_interval_ms)
-                .with_cancel_reaction(sl.cancel_reaction.clone()),
-            ),
-        });
+        if sl.enabled {
+            channels.push(ConfiguredChannel {
+                display_name: "Slack",
+                channel: Arc::new(
+                    SlackChannel::new(
+                        sl.bot_token.clone(),
+                        sl.app_token.clone(),
+                        sl.channel_id.clone(),
+                        sl.channel_ids.clone(),
+                        sl.allowed_users.clone(),
+                    )
+                    .with_thread_replies(sl.thread_replies.unwrap_or(true))
+                    .with_group_reply_policy(sl.mention_only, Vec::new())
+                    .with_workspace_dir(config.workspace_dir.clone())
+                    .with_markdown_blocks(sl.use_markdown_blocks)
+                    .with_proxy_url(sl.proxy_url.clone())
+                    .with_transcription(config.transcription.clone())
+                    .with_streaming(sl.stream_drafts, sl.draft_update_interval_ms)
+                    .with_cancel_reaction(sl.cancel_reaction.clone()),
+                ),
+            });
+        } else {
+            tracing::info!("Slack channel configured but disabled (enabled = false)");
+        }
     }
 
     if let Some(ref mm) = config.channels_config.mattermost {
-        channels.push(ConfiguredChannel {
-            display_name: "Mattermost",
-            channel: Arc::new(
-                MattermostChannel::new(
-                    mm.url.clone(),
-                    mm.bot_token.clone(),
-                    mm.channel_id.clone(),
-                    mm.allowed_users.clone(),
-                    mm.thread_replies.unwrap_or(true),
-                    mm.mention_only.unwrap_or(false),
-                )
-                .with_proxy_url(mm.proxy_url.clone())
-                .with_transcription(config.transcription.clone()),
-            ),
-        });
+        if mm.enabled {
+            channels.push(ConfiguredChannel {
+                display_name: "Mattermost",
+                channel: Arc::new(
+                    MattermostChannel::new(
+                        mm.url.clone(),
+                        mm.bot_token.clone(),
+                        mm.channel_id.clone(),
+                        mm.allowed_users.clone(),
+                        mm.thread_replies.unwrap_or(true),
+                        mm.mention_only.unwrap_or(false),
+                    )
+                    .with_proxy_url(mm.proxy_url.clone())
+                    .with_transcription(config.transcription.clone()),
+                ),
+            });
+        } else {
+            tracing::info!("Mattermost channel configured but disabled (enabled = false)");
+        }
     }
 
     if let Some(ref im) = config.channels_config.imessage {
-        channels.push(ConfiguredChannel {
-            display_name: "iMessage",
-            channel: Arc::new(IMessageChannel::new(im.allowed_contacts.clone())),
-        });
+        if im.enabled {
+            channels.push(ConfiguredChannel {
+                display_name: "iMessage",
+                channel: Arc::new(IMessageChannel::new(im.allowed_contacts.clone())),
+            });
+        } else {
+            tracing::info!("iMessage channel configured but disabled (enabled = false)");
+        }
     }
 
     #[cfg(feature = "channel-matrix")]
     if let Some(ref mx) = config.channels_config.matrix {
-        channels.push(ConfiguredChannel {
-            display_name: "Matrix",
-            channel: Arc::new(
-                MatrixChannel::new_full(
-                    mx.homeserver.clone(),
-                    mx.access_token.clone(),
-                    mx.room_id.clone(),
-                    mx.allowed_users.clone(),
-                    mx.allowed_rooms.clone(),
-                    mx.user_id.clone(),
-                    mx.device_id.clone(),
-                    config.config_path.parent().map(|path| path.to_path_buf()),
-                    mx.recovery_key.clone(),
-                )
-                .with_streaming(
-                    mx.stream_mode,
-                    mx.draft_update_interval_ms,
-                    mx.multi_message_delay_ms,
-                )
-                .with_transcription(config.transcription.clone()),
-            ),
-        });
+        if mx.enabled {
+            channels.push(ConfiguredChannel {
+                display_name: "Matrix",
+                channel: Arc::new(
+                    MatrixChannel::new_full(
+                        mx.homeserver.clone(),
+                        mx.access_token.clone(),
+                        mx.room_id.clone(),
+                        mx.allowed_users.clone(),
+                        mx.allowed_rooms.clone(),
+                        mx.user_id.clone(),
+                        mx.device_id.clone(),
+                        config.config_path.parent().map(|path| path.to_path_buf()),
+                        mx.recovery_key.clone(),
+                    )
+                    .with_streaming(
+                        mx.stream_mode,
+                        mx.draft_update_interval_ms,
+                        mx.multi_message_delay_ms,
+                    )
+                    .with_transcription(config.transcription.clone()),
+                ),
+            });
+        } else {
+            tracing::info!("Matrix channel configured but disabled (enabled = false)");
+        }
     }
 
     #[cfg(not(feature = "channel-matrix"))]
@@ -4685,137 +4713,157 @@ fn collect_configured_channels(
     }
 
     if let Some(ref sig) = config.channels_config.signal {
-        channels.push(ConfiguredChannel {
-            display_name: "Signal",
-            channel: Arc::new(
-                SignalChannel::new(
-                    sig.http_url.clone(),
-                    sig.account.clone(),
-                    sig.group_id.clone(),
-                    sig.allowed_from.clone(),
-                    sig.ignore_attachments,
-                    sig.ignore_stories,
-                )
-                .with_proxy_url(sig.proxy_url.clone()),
-            ),
-        });
+        if sig.enabled {
+            channels.push(ConfiguredChannel {
+                display_name: "Signal",
+                channel: Arc::new(
+                    SignalChannel::new(
+                        sig.http_url.clone(),
+                        sig.account.clone(),
+                        sig.group_id.clone(),
+                        sig.allowed_from.clone(),
+                        sig.ignore_attachments,
+                        sig.ignore_stories,
+                    )
+                    .with_proxy_url(sig.proxy_url.clone()),
+                ),
+            });
+        } else {
+            tracing::info!("Signal channel configured but disabled (enabled = false)");
+        }
     }
 
     if let Some(ref wa) = config.channels_config.whatsapp {
-        if wa.is_ambiguous_config() {
-            tracing::warn!(
-                "WhatsApp config has both phone_number_id and session_path set; preferring Cloud API mode. Remove one selector to avoid ambiguity."
-            );
-        }
-        // Runtime negotiation: detect backend type from config
-        match wa.backend_type() {
-            "cloud" => {
-                // Cloud API mode: requires phone_number_id, access_token, verify_token
-                if wa.is_cloud_config() {
-                    channels.push(ConfiguredChannel {
-                        display_name: "WhatsApp",
-                        channel: Arc::new(
-                            WhatsAppChannel::new(
-                                wa.access_token.clone().unwrap_or_default(),
-                                wa.phone_number_id.clone().unwrap_or_default(),
-                                wa.verify_token.clone().unwrap_or_default(),
-                                wa.allowed_numbers.clone(),
-                            )
-                            .with_proxy_url(wa.proxy_url.clone())
-                            .with_dm_mention_patterns(wa.dm_mention_patterns.clone())
-                            .with_group_mention_patterns(wa.group_mention_patterns.clone()),
-                        ),
-                    });
-                } else {
-                    tracing::warn!(
-                        "WhatsApp Cloud API configured but missing required fields (phone_number_id, access_token, verify_token)"
-                    );
-                }
+        if wa.enabled {
+            if wa.is_ambiguous_config() {
+                tracing::warn!(
+                    "WhatsApp config has both phone_number_id and session_path set; preferring Cloud API mode. Remove one selector to avoid ambiguity."
+                );
             }
-            "web" => {
-                // Web mode: requires session_path
-                #[cfg(feature = "whatsapp-web")]
-                if wa.is_web_config() {
-                    channels.push(ConfiguredChannel {
-                        display_name: "WhatsApp",
-                        channel: Arc::new(
-                            WhatsAppWebChannel::new(
-                                wa.session_path.clone().unwrap_or_default(),
-                                wa.pair_phone.clone(),
-                                wa.pair_code.clone(),
-                                wa.allowed_numbers.clone(),
-                                wa.mention_only,
-                                wa.mode.clone(),
-                                wa.dm_policy.clone(),
-                                wa.group_policy.clone(),
-                                wa.self_chat_mode,
-                            )
-                            .with_transcription(config.transcription.clone())
-                            .with_tts(config.tts.clone())
-                            .with_dm_mention_patterns(wa.dm_mention_patterns.clone())
-                            .with_group_mention_patterns(wa.group_mention_patterns.clone()),
-                        ),
-                    });
-                } else {
-                    tracing::warn!("WhatsApp Web configured but session_path not set");
+            // Runtime negotiation: detect backend type from config
+            match wa.backend_type() {
+                "cloud" => {
+                    // Cloud API mode: requires phone_number_id, access_token, verify_token
+                    if wa.is_cloud_config() {
+                        channels.push(ConfiguredChannel {
+                            display_name: "WhatsApp",
+                            channel: Arc::new(
+                                WhatsAppChannel::new(
+                                    wa.access_token.clone().unwrap_or_default(),
+                                    wa.phone_number_id.clone().unwrap_or_default(),
+                                    wa.verify_token.clone().unwrap_or_default(),
+                                    wa.allowed_numbers.clone(),
+                                )
+                                .with_proxy_url(wa.proxy_url.clone())
+                                .with_dm_mention_patterns(wa.dm_mention_patterns.clone())
+                                .with_group_mention_patterns(wa.group_mention_patterns.clone()),
+                            ),
+                        });
+                    } else {
+                        tracing::warn!(
+                            "WhatsApp Cloud API configured but missing required fields (phone_number_id, access_token, verify_token)"
+                        );
+                    }
                 }
-                #[cfg(not(feature = "whatsapp-web"))]
-                {
+                "web" => {
+                    // Web mode: requires session_path
+                    #[cfg(feature = "whatsapp-web")]
+                    if wa.is_web_config() {
+                        channels.push(ConfiguredChannel {
+                            display_name: "WhatsApp",
+                            channel: Arc::new(
+                                WhatsAppWebChannel::new(
+                                    wa.session_path.clone().unwrap_or_default(),
+                                    wa.pair_phone.clone(),
+                                    wa.pair_code.clone(),
+                                    wa.allowed_numbers.clone(),
+                                    wa.mention_only,
+                                    wa.mode.clone(),
+                                    wa.dm_policy.clone(),
+                                    wa.group_policy.clone(),
+                                    wa.self_chat_mode,
+                                )
+                                .with_transcription(config.transcription.clone())
+                                .with_tts(config.tts.clone())
+                                .with_dm_mention_patterns(wa.dm_mention_patterns.clone())
+                                .with_group_mention_patterns(wa.group_mention_patterns.clone()),
+                            ),
+                        });
+                    } else {
+                        tracing::warn!("WhatsApp Web configured but session_path not set");
+                    }
+                    #[cfg(not(feature = "whatsapp-web"))]
+                    {
+                        tracing::warn!(
+                            "WhatsApp Web backend requires 'whatsapp-web' feature. Enable with: cargo build --features whatsapp-web"
+                        );
+                        eprintln!(
+                            "  ⚠ WhatsApp Web is configured but the 'whatsapp-web' feature is not compiled in."
+                        );
+                        eprintln!("    Rebuild with: cargo build --features whatsapp-web");
+                    }
+                }
+                _ => {
                     tracing::warn!(
-                        "WhatsApp Web backend requires 'whatsapp-web' feature. Enable with: cargo build --features whatsapp-web"
-                    );
-                    eprintln!(
-                        "  ⚠ WhatsApp Web is configured but the 'whatsapp-web' feature is not compiled in."
+                        "WhatsApp config invalid: neither phone_number_id (Cloud API) nor session_path (Web) is set"
                     );
-                    eprintln!("    Rebuild with: cargo build --features whatsapp-web");
                 }
             }
-            _ => {
-                tracing::warn!(
-                    "WhatsApp config invalid: neither phone_number_id (Cloud API) nor session_path (Web) is set"
-                );
-            }
+        } else {
+            tracing::info!("WhatsApp channel configured but disabled (enabled = false)");
         }
     }
 
     if let Some(ref lq) = config.channels_config.linq {
-        channels.push(ConfiguredChannel {
-            display_name: "Linq",
-            channel: Arc::new(LinqChannel::new(
-                lq.api_token.clone(),
-                lq.from_phone.clone(),
-                lq.allowed_senders.clone(),
-            )),
-        });
+        if lq.enabled {
+            channels.push(ConfiguredChannel {
+                display_name: "Linq",
+                channel: Arc::new(LinqChannel::new(
+                    lq.api_token.clone(),
+                    lq.from_phone.clone(),
+                    lq.allowed_senders.clone(),
+                )),
+            });
+        } else {
+            tracing::info!("Linq channel configured but disabled (enabled = false)");
+        }
     }
 
     if let Some(ref wati_cfg) = config.channels_config.wati {
-        let wati_channel = WatiChannel::new_with_proxy(
-            wati_cfg.api_token.clone(),
-            wati_cfg.api_url.clone(),
-            wati_cfg.tenant_id.clone(),
-            wati_cfg.allowed_numbers.clone(),
-            wati_cfg.proxy_url.clone(),
-        )
-        .with_transcription(config.transcription.clone());
+        if wati_cfg.enabled {
+            let wati_channel = WatiChannel::new_with_proxy(
+                wati_cfg.api_token.clone(),
+                wati_cfg.api_url.clone(),
+                wati_cfg.tenant_id.clone(),
+                wati_cfg.allowed_numbers.clone(),
+                wati_cfg.proxy_url.clone(),
+            )
+            .with_transcription(config.transcription.clone());
 
-        channels.push(ConfiguredChannel {
-            display_name: "WATI",
-            channel: Arc::new(wati_channel),
-        });
+            channels.push(ConfiguredChannel {
+                display_name: "WATI",
+                channel: Arc::new(wati_channel),
+            });
+        } else {
+            tracing::info!("WATI channel configured but disabled (enabled = false)");
+        }
     }
 
     if let Some(ref nc) = config.channels_config.nextcloud_talk {
-        channels.push(ConfiguredChannel {
-            display_name: "Nextcloud Talk",
-            channel: Arc::new(NextcloudTalkChannel::new_with_proxy(
-                nc.base_url.clone(),
-                nc.app_token.clone(),
-                nc.bot_name.clone().unwrap_or_default(),
-                nc.allowed_users.clone(),
-                nc.proxy_url.clone(),
-            )),
-        });
+        if nc.enabled {
+            channels.push(ConfiguredChannel {
+                display_name: "Nextcloud Talk",
+                channel: Arc::new(NextcloudTalkChannel::new_with_proxy(
+                    nc.base_url.clone(),
+                    nc.app_token.clone(),
+                    nc.bot_name.clone().unwrap_or_default(),
+                    nc.allowed_users.clone(),
+                    nc.proxy_url.clone(),
+                )),
+            });
+        } else {
+            tracing::info!("Nextcloud Talk channel configured but disabled (enabled = false)");
+        }
     }
 
     if let Some(ref email_cfg) = config.channels_config.email {
@@ -4835,64 +4883,76 @@ fn collect_configured_channels(
     }
 
     if let Some(ref irc) = config.channels_config.irc {
-        channels.push(ConfiguredChannel {
-            display_name: "IRC",
-            channel: Arc::new(IrcChannel::new(irc::IrcChannelConfig {
-                server: irc.server.clone(),
-                port: irc.port,
-                nickname: irc.nickname.clone(),
-                username: irc.username.clone(),
-                channels: irc.channels.clone(),
-                allowed_users: irc.allowed_users.clone(),
-                server_password: irc.server_password.clone(),
-                nickserv_password: irc.nickserv_password.clone(),
-                sasl_password: irc.sasl_password.clone(),
-                verify_tls: irc.verify_tls.unwrap_or(true),
-            })),
-        });
+        if irc.enabled {
+            channels.push(ConfiguredChannel {
+                display_name: "IRC",
+                channel: Arc::new(IrcChannel::new(irc::IrcChannelConfig {
+                    server: irc.server.clone(),
+                    port: irc.port,
+                    nickname: irc.nickname.clone(),
+                    username: irc.username.clone(),
+                    channels: irc.channels.clone(),
+                    allowed_users: irc.allowed_users.clone(),
+                    server_password: irc.server_password.clone(),
+                    nickserv_password: irc.nickserv_password.clone(),
+                    sasl_password: irc.sasl_password.clone(),
+                    verify_tls: irc.verify_tls.unwrap_or(true),
+                })),
+            });
+        } else {
+            tracing::info!("IRC channel configured but disabled (enabled = false)");
+        }
     }
 
     #[cfg(feature = "channel-lark")]
     if let Some(ref lk) = config.channels_config.lark {
-        if lk.use_feishu {
-            if config.channels_config.feishu.is_some() {
-                tracing::warn!(
-                    "Both [channels_config.feishu] and legacy [channels_config.lark].use_feishu=true are configured; ignoring legacy Feishu fallback in lark."
-                );
+        if lk.enabled {
+            if lk.use_feishu {
+                if config.channels_config.feishu.is_some() {
+                    tracing::warn!(
+                        "Both [channels_config.feishu] and legacy [channels_config.lark].use_feishu=true are configured; ignoring legacy Feishu fallback in lark."
+                    );
+                } else {
+                    tracing::warn!(
+                        "Using legacy [channels_config.lark].use_feishu=true compatibility path; prefer [channels_config.feishu]."
+                    );
+                    channels.push(ConfiguredChannel {
+                        display_name: "Feishu",
+                        channel: Arc::new(
+                            LarkChannel::from_config(lk)
+                                .with_transcription(config.transcription.clone()),
+                        ),
+                    });
+                }
             } else {
-                tracing::warn!(
-                    "Using legacy [channels_config.lark].use_feishu=true compatibility path; prefer [channels_config.feishu]."
-                );
                 channels.push(ConfiguredChannel {
-                    display_name: "Feishu",
+                    display_name: "Lark",
                     channel: Arc::new(
-                        LarkChannel::from_config(lk)
+                        LarkChannel::from_lark_config(lk)
                             .with_transcription(config.transcription.clone()),
                     ),
                 });
             }
         } else {
+            tracing::info!("Lark channel configured but disabled (enabled = false)");
+        }
+    }
+
+    #[cfg(feature = "channel-lark")]
+    if let Some(ref fs) = config.channels_config.feishu {
+        if fs.enabled {
             channels.push(ConfiguredChannel {
-                display_name: "Lark",
+                display_name: "Feishu",
                 channel: Arc::new(
-                    LarkChannel::from_lark_config(lk)
+                    LarkChannel::from_feishu_config(fs)
                         .with_transcription(config.transcription.clone()),
                 ),
             });
+        } else {
+            tracing::info!("Feishu channel configured but disabled (enabled = false)");
         }
     }
 
-    #[cfg(feature = "channel-lark")]
-    if let Some(ref fs) = config.channels_config.feishu {
-        channels.push(ConfiguredChannel {
-            display_name: "Feishu",
-            channel: Arc::new(
-                LarkChannel::from_feishu_config(fs)
-                    .with_transcription(config.transcription.clone()),
-            ),
-        });
-    }
-
     #[cfg(not(feature = "channel-lark"))]
     if config.channels_config.lark.is_some() || config.channels_config.feishu.is_some() {
         tracing::warn!(
@@ -4901,32 +4961,40 @@ fn collect_configured_channels(
     }
 
     if let Some(ref dt) = config.channels_config.dingtalk {
-        channels.push(ConfiguredChannel {
-            display_name: "DingTalk",
-            channel: Arc::new(
-                DingTalkChannel::new(
-                    dt.client_id.clone(),
-                    dt.client_secret.clone(),
-                    dt.allowed_users.clone(),
-                )
-                .with_proxy_url(dt.proxy_url.clone()),
-            ),
-        });
+        if dt.enabled {
+            channels.push(ConfiguredChannel {
+                display_name: "DingTalk",
+                channel: Arc::new(
+                    DingTalkChannel::new(
+                        dt.client_id.clone(),
+                        dt.client_secret.clone(),
+                        dt.allowed_users.clone(),
+                    )
+                    .with_proxy_url(dt.proxy_url.clone()),
+                ),
+            });
+        } else {
+            tracing::info!("DingTalk channel configured but disabled (enabled = false)");
+        }
     }
 
     if let Some(ref qq) = config.channels_config.qq {
-        channels.push(ConfiguredChannel {
-            display_name: "QQ",
-            channel: Arc::new(
-                QQChannel::new(
-                    qq.app_id.clone(),
-                    qq.app_secret.clone(),
-                    qq.allowed_users.clone(),
-                )
-                .with_workspace_dir(config.workspace_dir.clone())
-                .with_proxy_url(qq.proxy_url.clone()),
-            ),
-        });
+        if qq.enabled {
+            channels.push(ConfiguredChannel {
+                display_name: "QQ",
+                channel: Arc::new(
+                    QQChannel::new(
+                        qq.app_id.clone(),
+                        qq.app_secret.clone(),
+                        qq.allowed_users.clone(),
+                    )
+                    .with_workspace_dir(config.workspace_dir.clone())
+                    .with_proxy_url(qq.proxy_url.clone()),
+                ),
+            });
+        } else {
+            tracing::info!("QQ channel configured but disabled (enabled = false)");
+        }
     }
 
     if let Some(ref tw) = config.channels_config.twitter {
@@ -4952,20 +5020,28 @@ fn collect_configured_channels(
     }
 
     if let Some(ref wc) = config.channels_config.wecom {
-        channels.push(ConfiguredChannel {
-            display_name: "WeCom",
-            channel: Arc::new(WeComChannel::new(
-                wc.webhook_key.clone(),
-                wc.allowed_users.clone(),
-            )),
-        });
+        if wc.enabled {
+            channels.push(ConfiguredChannel {
+                display_name: "WeCom",
+                channel: Arc::new(WeComChannel::new(
+                    wc.webhook_key.clone(),
+                    wc.allowed_users.clone(),
+                )),
+            });
+        } else {
+            tracing::info!("WeCom channel configured but disabled (enabled = false)");
+        }
     }
 
     if let Some(ref ct) = config.channels_config.clawdtalk {
-        channels.push(ConfiguredChannel {
-            display_name: "ClawdTalk",
-            channel: Arc::new(ClawdTalkChannel::new(ct.clone())),
-        });
+        if ct.enabled {
+            channels.push(ConfiguredChannel {
+                display_name: "ClawdTalk",
+                channel: Arc::new(ClawdTalkChannel::new(ct.clone())),
+            });
+        } else {
+            tracing::info!("ClawdTalk channel configured but disabled (enabled = false)");
+        }
     }
 
     // Notion database poller channel
@@ -5031,17 +5107,21 @@ fn collect_configured_channels(
     }
 
     if let Some(ref wh) = config.channels_config.webhook {
-        channels.push(ConfiguredChannel {
-            display_name: "Webhook",
-            channel: Arc::new(WebhookChannel::new(
-                wh.port,
-                wh.listen_path.clone(),
-                wh.send_url.clone(),
-                wh.send_method.clone(),
-                wh.auth_header.clone(),
-                wh.secret.clone(),
-            )),
-        });
+        if wh.enabled {
+            channels.push(ConfiguredChannel {
+                display_name: "Webhook",
+                channel: Arc::new(WebhookChannel::new(
+                    wh.port,
+                    wh.listen_path.clone(),
+                    wh.send_url.clone(),
+                    wh.send_method.clone(),
+                    wh.auth_header.clone(),
+                    wh.secret.clone(),
+                )),
+            });
+        } else {
+            tracing::info!("Webhook channel configured but disabled (enabled = false)");
+        }
     }
 
     channels
@@ -10277,6 +10357,7 @@ This is an example JSON object for profile settings."#;
     fn collect_configured_channels_includes_mattermost_when_configured() {
         let mut config = Config::default();
         config.channels_config.mattermost = Some(crate::config::schema::MattermostConfig {
+            enabled: true,
             url: "https://mattermost.example.com".to_string(),
             bot_token: "test-token".to_string(),
             channel_id: Some("channel-1".to_string()),
@@ -11369,6 +11450,7 @@ This is an example JSON object for profile settings."#;
     fn build_channel_by_id_configured_telegram_succeeds() {
         let mut config = Config::default();
         config.channels_config.telegram = Some(crate::config::schema::TelegramConfig {
+            enabled: true,
             bot_token: "test-token".to_string(),
             allowed_users: vec![],
             stream_mode: crate::config::StreamMode::Off,
diff --git a/src/channels/mqtt.rs b/src/channels/mqtt.rs
index 90159aeb95..422f8c42df 100644
--- a/src/channels/mqtt.rs
+++ b/src/channels/mqtt.rs
@@ -126,6 +126,7 @@ mod tests {
     #[test]
     fn mqtt_config_validation_rejects_bad_qos() {
         let config = MqttConfig {
+            enabled: true,
             broker_url: "mqtt://localhost:1883".into(),
             client_id: "zeroclaw".into(),
             topics: vec!["test".into()],
@@ -142,6 +143,7 @@ mod tests {
     #[test]
     fn mqtt_config_validation_rejects_bad_url() {
         let config = MqttConfig {
+            enabled: true,
             broker_url: "http://localhost:1883".into(),
             client_id: "zeroclaw".into(),
             topics: vec!["test".into()],
@@ -158,6 +160,7 @@ mod tests {
     #[test]
     fn mqtt_config_validation_rejects_empty_topics() {
         let config = MqttConfig {
+            enabled: true,
             broker_url: "mqtt://localhost:1883".into(),
             client_id: "zeroclaw".into(),
             topics: vec![],
@@ -174,6 +177,7 @@ mod tests {
     #[test]
     fn mqtt_config_validation_rejects_empty_client_id() {
         let config = MqttConfig {
+            enabled: true,
             broker_url: "mqtt://localhost:1883".into(),
             client_id: String::new(),
             topics: vec!["test".into()],
@@ -190,6 +194,7 @@ mod tests {
     #[test]
     fn mqtt_config_validation_accepts_valid() {
         let config = MqttConfig {
+            enabled: true,
             broker_url: "mqtt://localhost:1883".into(),
             client_id: "zeroclaw".into(),
             topics: vec!["sensors/#".into()],
@@ -205,6 +210,7 @@ mod tests {
     #[test]
     fn mqtt_tls_flag_rejects_mqtt_scheme_with_use_tls() {
         let config = MqttConfig {
+            enabled: true,
             broker_url: "mqtt://localhost:1883".into(),
             client_id: "zeroclaw".into(),
             topics: vec!["test".into()],
@@ -221,6 +227,7 @@ mod tests {
     #[test]
     fn mqtt_tls_flag_rejects_mqtts_scheme_without_use_tls() {
         let config = MqttConfig {
+            enabled: true,
             broker_url: "mqtts://localhost:8883".into(),
             client_id: "zeroclaw".into(),
             topics: vec!["test".into()],
@@ -237,6 +244,7 @@ mod tests {
     #[test]
     fn mqtt_tls_flag_accepts_mqtts_with_use_tls() {
         let config = MqttConfig {
+            enabled: true,
             broker_url: "mqtts://localhost:8883".into(),
             client_id: "zeroclaw".into(),
             topics: vec!["test".into()],
diff --git a/src/channels/voice_call.rs b/src/channels/voice_call.rs
index 10c37bb478..0a7fbb890d 100644
--- a/src/channels/voice_call.rs
+++ b/src/channels/voice_call.rs
@@ -14,6 +14,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use tokio::sync::{Mutex, mpsc};
 use tracing::{debug, info, warn};
+use zeroclaw_macros::Configurable;
 
 use super::traits::{Channel, ChannelMessage, SendMessage};
 
@@ -29,6 +30,10 @@ pub enum VoiceProvider {
     Plivo,
 }
 
+impl crate::config::HasPropKind for VoiceProvider {
+    const PROP_KIND: crate::config::PropKind = crate::config::PropKind::Enum;
+}
+
 impl fmt::Display for VoiceProvider {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         match self {
@@ -40,7 +45,8 @@ impl fmt::Display for VoiceProvider {
 }
 
 /// Configuration for the voice call channel.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.voice-call"]
 pub struct VoiceCallConfig {
     /// Telephony provider: `twilio`, `telnyx`, or `plivo`.
     #[serde(default)]
@@ -84,6 +90,23 @@ fn default_max_call_duration() -> u64 {
     3600
 }
 
+impl Default for VoiceCallConfig {
+    fn default() -> Self {
+        Self {
+            provider: VoiceProvider::default(),
+            account_id: String::new(),
+            auth_token: String::new(),
+            from_number: String::new(),
+            webhook_port: default_webhook_port(),
+            require_outbound_approval: default_true(),
+            transcription_logging: default_true(),
+            tts_voice: None,
+            max_call_duration_secs: default_max_call_duration(),
+            webhook_base_url: None,
+        }
+    }
+}
+
 // ── Call state ────────────────────────────────────────────────────
 
 /// Lifecycle state of a phone call.
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 21eb911660..ec3b8942da 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -36,10 +36,163 @@ pub use schema::{
     set_runtime_proxy_config, ws_connect_with_proxy,
 };
 
+pub use traits::HasPropKind;
+pub use traits::PropFieldInfo;
+pub use traits::PropKind;
+pub use traits::SecretFieldInfo;
+
+/// Return a comma-separated string of valid enum variant names for display in error messages.
+///
+/// Uses the JSON schema generated by `schemars` to discover variant names.
+pub fn enum_variants<T: schemars::JsonSchema>() -> String {
+    let schema = schemars::schema_for!(T);
+    let json = match serde_json::to_value(&schema) {
+        Ok(v) => v,
+        Err(_) => return "(unknown variants)".to_string(),
+    };
+
+    // Try top-level `enum` array (simple string enums with rename_all)
+    if let Some(variants) = json.get("enum").and_then(|v| v.as_array()) {
+        let names: Vec<&str> = variants.iter().filter_map(|v| v.as_str()).collect();
+        if !names.is_empty() {
+            return names.join(", ");
+        }
+    }
+
+    // Try `oneOf` for tagged/complex enums
+    if let Some(one_of) = json.get("oneOf").and_then(|v| v.as_array()) {
+        let names: Vec<&str> = one_of
+            .iter()
+            .filter_map(|s| {
+                // Each variant may have a `const` or an `enum` with one entry
+                s.get("const").and_then(|v| v.as_str()).or_else(|| {
+                    s.get("enum")
+                        .and_then(|v| v.as_array())
+                        .and_then(|arr| arr.first())
+                        .and_then(|v| v.as_str())
+                })
+            })
+            .collect();
+        if !names.is_empty() {
+            return names.join(", ");
+        }
+    }
+
+    "(unknown variants)".to_string()
+}
+
 pub fn name_and_presence<T: traits::ChannelConfig>(channel: Option<&T>) -> (&'static str, bool) {
     (T::name(), channel.is_some())
 }
 
+// ── Serde-based property helpers ──────────────────────────────────
+
+/// Build a `PropFieldInfo` by reading the display value from a serialized TOML table.
+pub fn make_prop_field(
+    table: Option<&toml::Table>,
+    name: &'static str,
+    serde_name: &str,
+    category: &'static str,
+    type_hint: &'static str,
+    kind: PropKind,
+    is_secret: bool,
+    enum_variants: Option<fn() -> Vec<String>>,
+) -> PropFieldInfo {
+    let display_value = if is_secret {
+        match table.and_then(|t| t.get(serde_name)) {
+            Some(toml::Value::String(s)) if !s.is_empty() => "****".to_string(),
+            _ => "<unset>".to_string(),
+        }
+    } else {
+        toml_value_to_display(table.and_then(|t| t.get(serde_name)))
+    };
+    PropFieldInfo {
+        name,
+        category,
+        display_value,
+        type_hint,
+        kind,
+        is_secret,
+        enum_variants,
+    }
+}
+
+/// Get a property value via serde serialization.
+pub fn serde_get_prop<T: serde::Serialize>(
+    target: &T,
+    prefix: &str,
+    name: &str,
+    is_secret: bool,
+) -> anyhow::Result<String> {
+    if is_secret {
+        return Ok("**** (encrypted)".to_string());
+    }
+    let serde_name = prop_name_to_serde_field(prefix, name)?;
+    let table = toml::Value::try_from(target)?;
+    Ok(toml_value_to_display(
+        table.as_table().and_then(|t| t.get(&serde_name)),
+    ))
+}
+
+/// Set a property value via serde roundtrip.
+pub fn serde_set_prop<T: serde::Serialize + serde::de::DeserializeOwned>(
+    target: &mut T,
+    prefix: &str,
+    name: &str,
+    value_str: &str,
+    kind: PropKind,
+    is_option: bool,
+) -> anyhow::Result<()> {
+    let serde_name = prop_name_to_serde_field(prefix, name)?;
+    let mut table: toml::Table = toml::from_str(&toml::to_string(target)?)?;
+    if value_str.is_empty() && is_option {
+        table.remove(&serde_name);
+    } else {
+        table.insert(serde_name, parse_prop_value(value_str, kind)?);
+    }
+    *target = toml::from_str(&toml::to_string(&table)?)?;
+    Ok(())
+}
+
+fn toml_value_to_display(value: Option<&toml::Value>) -> String {
+    match value {
+        None => "<unset>".to_string(),
+        Some(toml::Value::String(s)) => s.clone(),
+        Some(v) => v.to_string(),
+    }
+}
+
+fn prop_name_to_serde_field(prefix: &str, name: &str) -> anyhow::Result<String> {
+    let suffix = if prefix.is_empty() {
+        name
+    } else {
+        name.strip_prefix(prefix)
+            .and_then(|s| s.strip_prefix('.'))
+            .ok_or_else(|| anyhow::anyhow!("Unknown property '{name}'"))?
+    };
+    let field_part = suffix.split('.').next().unwrap_or(suffix);
+    Ok(field_part.replace('-', "_"))
+}
+
+fn parse_prop_value(value_str: &str, kind: PropKind) -> anyhow::Result<toml::Value> {
+    match kind {
+        PropKind::Bool => Ok(toml::Value::Boolean(value_str.parse().map_err(|_| {
+            anyhow::anyhow!("Invalid bool value '{value_str}' — expected 'true' or 'false'")
+        })?)),
+        PropKind::Integer => {
+            Ok(toml::Value::Integer(value_str.parse().map_err(|_| {
+                anyhow::anyhow!("Invalid integer value '{value_str}'")
+            })?))
+        }
+        PropKind::Float => {
+            Ok(toml::Value::Float(value_str.parse().map_err(|_| {
+                anyhow::anyhow!("Invalid float value '{value_str}'")
+            })?))
+        }
+        PropKind::String | PropKind::Enum => Ok(toml::Value::String(value_str.to_string())),
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -56,6 +209,7 @@ mod tests {
     #[test]
     fn reexported_channel_configs_are_constructible() {
         let telegram = TelegramConfig {
+            enabled: true,
             bot_token: "token".into(),
             allowed_users: vec!["alice".into()],
             stream_mode: StreamMode::default(),
@@ -67,6 +221,7 @@ mod tests {
         };
 
         let discord = DiscordConfig {
+            enabled: true,
             bot_token: "token".into(),
             guild_id: Some("123".into()),
             allowed_users: vec![],
@@ -81,6 +236,7 @@ mod tests {
         };
 
         let lark = LarkConfig {
+            enabled: true,
             app_id: "app-id".into(),
             app_secret: "app-secret".into(),
             encrypt_key: None,
@@ -93,6 +249,7 @@ mod tests {
             proxy_url: None,
         };
         let feishu = FeishuConfig {
+            enabled: true,
             app_id: "app-id".into(),
             app_secret: "app-secret".into(),
             encrypt_key: None,
@@ -104,6 +261,7 @@ mod tests {
         };
 
         let nextcloud_talk = NextcloudTalkConfig {
+            enabled: true,
             base_url: "https://cloud.example.com".into(),
             app_token: "app-token".into(),
             webhook_secret: None,
diff --git a/src/config/schema.rs b/src/config/schema.rs
index 78c0be7d96..a14bb3ae49 100644
--- a/src/config/schema.rs
+++ b/src/config/schema.rs
@@ -1,4 +1,4 @@
-use crate::config::traits::ChannelConfig;
+use crate::config::traits::{ChannelConfig, HasPropKind, PropKind};
 use crate::providers::{is_glm_alias, is_zai_alias};
 use crate::security::{AutonomyLevel, DomainMatcher};
 use anyhow::{Context, Result};
@@ -12,6 +12,7 @@ use std::sync::{OnceLock, RwLock};
 use tokio::fs::File;
 use tokio::fs::{self, OpenOptions};
 use tokio::io::AsyncWriteExt;
+use zeroclaw_macros::Configurable;
 
 const SUPPORTED_PROXY_SERVICE_KEYS: &[&str] = &[
     "provider.anthropic",
@@ -63,7 +64,7 @@ static RUNTIME_PROXY_CLIENT_CACHE: OnceLock<RwLock<HashMap<String, reqwest::Clie
 /// Top-level ZeroClaw configuration, loaded from `config.toml`.
 ///
 /// Resolution order: `ZEROCLAW_WORKSPACE` env → `active_workspace.toml` marker → `~/.zeroclaw/config.toml`.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
 pub struct Config {
     /// Workspace directory - computed from home, not serialized
     #[serde(skip)]
@@ -72,6 +73,7 @@ pub struct Config {
     #[serde(skip)]
     pub config_path: PathBuf,
     /// API key for the selected provider. Overridden by `ZEROCLAW_API_KEY` or `API_KEY` env vars.
+    #[secret]
     pub api_key: Option<String>,
     /// Base URL override for provider API (e.g. "http://10.0.0.1:11434" for remote Ollama)
     pub api_url: Option<String>,
@@ -123,30 +125,37 @@ pub struct Config {
 
     /// Observability backend configuration (`[observability]`).
     #[serde(default)]
+    #[nested]
     pub observability: ObservabilityConfig,
 
     /// Autonomy and security policy configuration (`[autonomy]`).
     #[serde(default)]
+    #[nested]
     pub autonomy: AutonomyConfig,
 
     /// Trust scoring and regression detection configuration (`[trust]`).
     #[serde(default)]
+    #[nested]
     pub trust: crate::trust::TrustConfig,
 
     /// Security subsystem configuration (`[security]`).
     #[serde(default)]
+    #[nested]
     pub security: SecurityConfig,
 
     /// Backup tool configuration (`[backup]`).
     #[serde(default)]
+    #[nested]
     pub backup: BackupConfig,
 
     /// Data retention and purge configuration (`[data_retention]`).
     #[serde(default)]
+    #[nested]
     pub data_retention: DataRetentionConfig,
 
     /// Cloud transformation accelerator configuration (`[cloud_ops]`).
     #[serde(default)]
+    #[nested]
     pub cloud_ops: CloudOpsConfig,
 
     /// Conversational AI agent builder configuration (`[conversational_ai]`).
@@ -156,38 +165,47 @@ pub struct Config {
     /// Existing configs that already contain this section will continue to
     /// deserialize correctly thanks to `#[serde(default)]`.
     #[serde(default, skip_serializing_if = "ConversationalAiConfig::is_disabled")]
+    #[nested]
     pub conversational_ai: ConversationalAiConfig,
 
     /// Managed cybersecurity service configuration (`[security_ops]`).
     #[serde(default)]
+    #[nested]
     pub security_ops: SecurityOpsConfig,
 
     /// Runtime adapter configuration (`[runtime]`). Controls native vs Docker execution.
     #[serde(default)]
+    #[nested]
     pub runtime: RuntimeConfig,
 
     /// Reliability settings: retries, fallback providers, backoff (`[reliability]`).
     #[serde(default)]
+    #[nested]
     pub reliability: ReliabilityConfig,
 
     /// Scheduler configuration for periodic task execution (`[scheduler]`).
     #[serde(default)]
+    #[nested]
     pub scheduler: SchedulerConfig,
 
     /// Agent orchestration settings (`[agent]`).
     #[serde(default)]
+    #[nested]
     pub agent: AgentConfig,
 
     /// Pacing controls for slow/local LLM workloads (`[pacing]`).
     #[serde(default)]
+    #[nested]
     pub pacing: PacingConfig,
 
     /// Skills loading and community repository behavior (`[skills]`).
     #[serde(default)]
+    #[nested]
     pub skills: SkillsConfig,
 
     /// Pipeline tool configuration (`[pipeline]`).
     #[serde(default)]
+    #[nested]
     pub pipeline: PipelineConfig,
 
     /// Model routing rules — route `hint:<name>` to specific provider+model combos.
@@ -200,50 +218,62 @@ pub struct Config {
 
     /// Automatic query classification — maps user messages to model hints.
     #[serde(default)]
+    #[nested]
     pub query_classification: QueryClassificationConfig,
 
     /// Heartbeat configuration for periodic health pings (`[heartbeat]`).
     #[serde(default)]
+    #[nested]
     pub heartbeat: HeartbeatConfig,
 
     /// Cron job configuration (`[cron]`).
     #[serde(default)]
+    #[nested]
     pub cron: CronConfig,
 
     /// Channel configurations: Telegram, Discord, Slack, etc. (`[channels_config]`).
     #[serde(default)]
+    #[nested]
     pub channels_config: ChannelsConfig,
 
     /// Memory backend configuration: sqlite, markdown, embeddings (`[memory]`).
     #[serde(default)]
+    #[nested]
     pub memory: MemoryConfig,
 
     /// Persistent storage provider configuration (`[storage]`).
     #[serde(default)]
+    #[nested]
     pub storage: StorageConfig,
 
     /// Tunnel configuration for exposing the gateway publicly (`[tunnel]`).
     #[serde(default)]
+    #[nested]
     pub tunnel: TunnelConfig,
 
     /// Gateway server configuration: host, port, pairing, rate limits (`[gateway]`).
     #[serde(default)]
+    #[nested]
     pub gateway: GatewayConfig,
 
     /// Composio managed OAuth tools integration (`[composio]`).
     #[serde(default)]
+    #[nested]
     pub composio: ComposioConfig,
 
     /// Microsoft 365 Graph API integration (`[microsoft365]`).
     #[serde(default)]
+    #[nested]
     pub microsoft365: Microsoft365Config,
 
     /// Secrets encryption configuration (`[secrets]`).
     #[serde(default)]
+    #[nested]
     pub secrets: SecretsConfig,
 
     /// Browser automation configuration (`[browser]`).
     #[serde(default)]
+    #[nested]
     pub browser: BrowserConfig,
 
     /// Browser delegation configuration (`[browser_delegate]`).
@@ -268,66 +298,82 @@ pub struct Config {
     /// Compatibility: additive and disabled by default; existing configs remain valid when omitted.
     /// Rollback/migration: remove `[browser_delegate]` or keep `enabled = false` to disable.
     #[serde(default)]
+    #[nested]
     pub browser_delegate: crate::tools::browser_delegate::BrowserDelegateConfig,
 
     /// HTTP request tool configuration (`[http_request]`).
     #[serde(default)]
+    #[nested]
     pub http_request: HttpRequestConfig,
 
     /// Multimodal (image) handling configuration (`[multimodal]`).
     #[serde(default)]
+    #[nested]
     pub multimodal: MultimodalConfig,
 
     /// Automatic media understanding pipeline (`[media_pipeline]`).
     #[serde(default)]
+    #[nested]
     pub media_pipeline: MediaPipelineConfig,
 
     /// Web fetch tool configuration (`[web_fetch]`).
     #[serde(default)]
+    #[nested]
     pub web_fetch: WebFetchConfig,
 
     /// Link enricher configuration (`[link_enricher]`).
     #[serde(default)]
+    #[nested]
     pub link_enricher: LinkEnricherConfig,
 
     /// Text browser tool configuration (`[text_browser]`).
     #[serde(default)]
+    #[nested]
     pub text_browser: TextBrowserConfig,
 
     /// Web search tool configuration (`[web_search]`).
     #[serde(default)]
+    #[nested]
     pub web_search: WebSearchConfig,
 
     /// Project delivery intelligence configuration (`[project_intel]`).
     #[serde(default)]
+    #[nested]
     pub project_intel: ProjectIntelConfig,
 
     /// Google Workspace CLI (`gws`) tool configuration (`[google_workspace]`).
     #[serde(default)]
+    #[nested]
     pub google_workspace: GoogleWorkspaceConfig,
 
     /// Proxy configuration for outbound HTTP/HTTPS/SOCKS5 traffic (`[proxy]`).
     #[serde(default)]
+    #[nested]
     pub proxy: ProxyConfig,
 
     /// Identity format configuration: OpenClaw or AIEOS (`[identity]`).
     #[serde(default)]
+    #[nested]
     pub identity: IdentityConfig,
 
     /// Cost tracking and budget enforcement configuration (`[cost]`).
     #[serde(default)]
+    #[nested]
     pub cost: CostConfig,
 
     /// Peripheral board configuration for hardware integration (`[peripherals]`).
     #[serde(default)]
+    #[nested]
     pub peripherals: PeripheralsConfig,
 
     /// Delegate tool global default configuration (`[delegate]`).
     #[serde(default)]
+    #[nested]
     pub delegate: DelegateToolConfig,
 
     /// Delegate agent configurations for multi-agent workflows.
     #[serde(default)]
+    #[nested]
     pub agents: HashMap<String, DelegateAgentConfig>,
 
     /// Swarm configurations for multi-agent orchestration.
@@ -336,58 +382,72 @@ pub struct Config {
 
     /// Hooks configuration (lifecycle hooks and built-in hook toggles).
     #[serde(default)]
+    #[nested]
     pub hooks: HooksConfig,
 
     /// Hardware configuration (wizard-driven physical world setup).
     #[serde(default)]
+    #[nested]
     pub hardware: HardwareConfig,
 
     /// Voice transcription configuration (Whisper API via Groq).
     #[serde(default)]
+    #[nested]
     pub transcription: TranscriptionConfig,
 
     /// Text-to-Speech configuration (`[tts]`).
     #[serde(default)]
+    #[nested]
     pub tts: TtsConfig,
 
     /// External MCP server connections (`[mcp]`).
     #[serde(default, alias = "mcpServers")]
+    #[nested]
     pub mcp: McpConfig,
 
     /// Dynamic node discovery configuration (`[nodes]`).
     #[serde(default)]
+    #[nested]
     pub nodes: NodesConfig,
 
     /// Multi-client workspace isolation configuration (`[workspace]`).
     #[serde(default)]
+    #[nested]
     pub workspace: WorkspaceConfig,
 
     /// Notion integration configuration (`[notion]`).
     #[serde(default)]
+    #[nested]
     pub notion: NotionConfig,
 
     /// Jira integration configuration (`[jira]`).
     #[serde(default)]
+    #[nested]
     pub jira: JiraConfig,
 
     /// Secure inter-node transport configuration (`[node_transport]`).
     #[serde(default)]
+    #[nested]
     pub node_transport: NodeTransportConfig,
 
     /// Knowledge graph configuration (`[knowledge]`).
     #[serde(default)]
+    #[nested]
     pub knowledge: KnowledgeConfig,
 
     /// LinkedIn integration configuration (`[linkedin]`).
     #[serde(default)]
+    #[nested]
     pub linkedin: LinkedInConfig,
 
     /// Standalone image generation tool configuration (`[image_gen]`).
     #[serde(default)]
+    #[nested]
     pub image_gen: ImageGenConfig,
 
     /// Plugin system configuration (`[plugins]`).
     #[serde(default)]
+    #[nested]
     pub plugins: PluginsConfig,
 
     /// Locale for tool descriptions (e.g. `"en"`, `"zh-CN"`).
@@ -403,34 +463,42 @@ pub struct Config {
 
     /// Verifiable Intent (VI) credential verification and issuance (`[verifiable_intent]`).
     #[serde(default)]
+    #[nested]
     pub verifiable_intent: VerifiableIntentConfig,
 
     /// Claude Code tool configuration (`[claude_code]`).
     #[serde(default)]
+    #[nested]
     pub claude_code: ClaudeCodeConfig,
 
     /// Claude Code task runner with Slack progress and SSH session handoff (`[claude_code_runner]`).
     #[serde(default)]
+    #[nested]
     pub claude_code_runner: ClaudeCodeRunnerConfig,
 
     /// Codex CLI tool configuration (`[codex_cli]`).
     #[serde(default)]
+    #[nested]
     pub codex_cli: CodexCliConfig,
 
     /// Gemini CLI tool configuration (`[gemini_cli]`).
     #[serde(default)]
+    #[nested]
     pub gemini_cli: GeminiCliConfig,
 
     /// OpenCode CLI tool configuration (`[opencode_cli]`).
     #[serde(default)]
+    #[nested]
     pub opencode_cli: OpenCodeCliConfig,
 
     /// Standard Operating Procedures engine configuration (`[sop]`).
     #[serde(default)]
+    #[nested]
     pub sop: SopConfig,
 
     /// Shell tool configuration (`[shell_tool]`).
     #[serde(default)]
+    #[nested]
     pub shell_tool: ShellToolConfig,
 }
 
@@ -439,7 +507,8 @@ pub struct Config {
 /// When enabled, each client engagement gets an isolated workspace with
 /// separate memory, audit, secrets, and tool restrictions.
 #[allow(clippy::struct_excessive_bools)]
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "workspace"]
 pub struct WorkspaceConfig {
     /// Enable workspace isolation. Default: false.
     #[serde(default)]
@@ -527,7 +596,8 @@ pub struct ModelProviderConfig {
 // ── Delegate Tool Configuration ─────────────────────────────────
 
 /// Global delegate tool configuration for default timeout values.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "delegate"]
 pub struct DelegateToolConfig {
     /// Default timeout in seconds for non-agentic sub-agent provider calls.
     /// Can be overridden per-agent in `[agents.<name>]` config.
@@ -553,7 +623,8 @@ impl Default for DelegateToolConfig {
 // ── Delegate Agents ──────────────────────────────────────────────
 
 /// Configuration for a delegate sub-agent used by the `delegate` tool.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "delegate-agent"]
 pub struct DelegateAgentConfig {
     /// Provider name (e.g. "ollama", "openrouter", "anthropic")
     pub provider: String,
@@ -564,6 +635,7 @@ pub struct DelegateAgentConfig {
     pub system_prompt: Option<String>,
     /// Optional API key override
     #[serde(default)]
+    #[secret]
     pub api_key: Option<String>,
     /// Temperature override
     #[serde(default)]
@@ -744,7 +816,8 @@ impl std::fmt::Display for HardwareTransport {
 }
 
 /// Wizard-driven hardware configuration for physical world interaction.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "hardware"]
 pub struct HardwareConfig {
     /// Whether hardware access is enabled
     #[serde(default)]
@@ -824,7 +897,8 @@ fn default_google_stt_language_code() -> String {
 ///
 /// The top-level `api_url`, `model`, and `api_key` fields remain for backward
 /// compatibility with existing Groq-based configurations.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "transcription"]
 pub struct TranscriptionConfig {
     /// Enable voice transcription for channels that support it.
     #[serde(default)]
@@ -836,6 +910,7 @@ pub struct TranscriptionConfig {
     ///
     /// If unset, runtime falls back to `GROQ_API_KEY` for backward compatibility.
     #[serde(default)]
+    #[secret]
     pub api_key: Option<String>,
     /// Whisper API endpoint URL (Groq provider).
     #[serde(default = "default_transcription_api_url")]
@@ -856,18 +931,23 @@ pub struct TranscriptionConfig {
     pub max_duration_secs: u64,
     /// OpenAI Whisper STT provider configuration.
     #[serde(default)]
+    #[nested]
     pub openai: Option<OpenAiSttConfig>,
     /// Deepgram STT provider configuration.
     #[serde(default)]
+    #[nested]
     pub deepgram: Option<DeepgramSttConfig>,
     /// AssemblyAI STT provider configuration.
     #[serde(default)]
+    #[nested]
     pub assemblyai: Option<AssemblyAiSttConfig>,
     /// Google Cloud Speech-to-Text provider configuration.
     #[serde(default)]
+    #[nested]
     pub google: Option<GoogleSttConfig>,
     /// Local/self-hosted Whisper-compatible STT provider.
     #[serde(default)]
+    #[nested]
     pub local_whisper: Option<LocalWhisperConfig>,
     /// Also transcribe non-PTT (forwarded/regular) audio messages on WhatsApp,
     /// not just voice notes.  Default: `false` (preserves legacy behavior).
@@ -940,7 +1020,8 @@ pub struct McpServerConfig {
 }
 
 /// External MCP client configuration (`[mcp]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "mcp"]
 pub struct McpConfig {
     /// Enable MCP tool loading.
     #[serde(default)]
@@ -971,7 +1052,8 @@ impl Default for McpConfig {
 }
 
 /// Verifiable Intent (VI) credential verification and issuance (`[verifiable_intent]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "verifiable-intent"]
 pub struct VerifiableIntentConfig {
     /// Enable VI credential verification on commerce tool calls (default: false).
     #[serde(default)]
@@ -1003,7 +1085,8 @@ impl Default for VerifiableIntentConfig {
 ///
 /// When enabled, external processes/devices can connect via WebSocket
 /// at `/ws/nodes` and advertise their capabilities at runtime.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "nodes"]
 pub struct NodesConfig {
     /// Enable dynamic node discovery endpoint.
     #[serde(default)]
@@ -1081,7 +1164,8 @@ fn default_piper_tts_api_url() -> String {
 }
 
 /// Text-to-Speech configuration (`[tts]`).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "tts"]
 pub struct TtsConfig {
     /// Enable TTS synthesis.
     #[serde(default)]
@@ -1100,18 +1184,23 @@ pub struct TtsConfig {
     pub max_text_length: usize,
     /// OpenAI TTS provider configuration (`[tts.openai]`).
     #[serde(default)]
+    #[nested]
     pub openai: Option<OpenAiTtsConfig>,
     /// ElevenLabs TTS provider configuration (`[tts.elevenlabs]`).
     #[serde(default)]
+    #[nested]
     pub elevenlabs: Option<ElevenLabsTtsConfig>,
     /// Google Cloud TTS provider configuration (`[tts.google]`).
     #[serde(default)]
+    #[nested]
     pub google: Option<GoogleTtsConfig>,
     /// Edge TTS provider configuration (`[tts.edge]`).
     #[serde(default)]
+    #[nested]
     pub edge: Option<EdgeTtsConfig>,
     /// Piper TTS provider configuration (`[tts.piper]`).
     #[serde(default)]
+    #[nested]
     pub piper: Option<PiperTtsConfig>,
 }
 
@@ -1133,10 +1222,12 @@ impl Default for TtsConfig {
 }
 
 /// OpenAI TTS provider configuration.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "tts.openai"]
 pub struct OpenAiTtsConfig {
     /// API key for OpenAI TTS.
     #[serde(default)]
+    #[secret]
     pub api_key: Option<String>,
     /// Model name (default `"tts-1"`).
     #[serde(default = "default_openai_tts_model")]
@@ -1147,10 +1238,12 @@ pub struct OpenAiTtsConfig {
 }
 
 /// ElevenLabs TTS provider configuration.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "tts.elevenlabs"]
 pub struct ElevenLabsTtsConfig {
     /// API key for ElevenLabs.
     #[serde(default)]
+    #[secret]
     pub api_key: Option<String>,
     /// Model ID (default `"eleven_monolingual_v1"`).
     #[serde(default = "default_elevenlabs_model_id")]
@@ -1164,10 +1257,12 @@ pub struct ElevenLabsTtsConfig {
 }
 
 /// Google Cloud TTS provider configuration.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "tts.google"]
 pub struct GoogleTtsConfig {
     /// API key for Google Cloud TTS.
     #[serde(default)]
+    #[secret]
     pub api_key: Option<String>,
     /// Language code (default `"en-US"`).
     #[serde(default = "default_google_tts_language_code")]
@@ -1175,21 +1270,39 @@ pub struct GoogleTtsConfig {
 }
 
 /// Edge TTS provider configuration (free, subprocess-based).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "tts.edge"]
 pub struct EdgeTtsConfig {
     /// Path to the `edge-tts` binary (default `"edge-tts"`).
     #[serde(default = "default_edge_tts_binary_path")]
     pub binary_path: String,
 }
 
+impl Default for EdgeTtsConfig {
+    fn default() -> Self {
+        Self {
+            binary_path: default_edge_tts_binary_path(),
+        }
+    }
+}
+
 /// Piper TTS provider configuration (local GPU-accelerated, OpenAI-compatible endpoint).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "tts.piper"]
 pub struct PiperTtsConfig {
     /// Base URL for the Piper TTS HTTP server (e.g. `"http://127.0.0.1:5000/v1/audio/speech"`).
     #[serde(default = "default_piper_tts_api_url")]
     pub api_url: String,
 }
 
+impl Default for PiperTtsConfig {
+    fn default() -> Self {
+        Self {
+            api_url: default_piper_tts_api_url(),
+        }
+    }
+}
+
 /// Determines when a `ToolFilterGroup` is active.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
 #[serde(rename_all = "snake_case")]
@@ -1239,10 +1352,12 @@ pub struct ToolFilterGroup {
 }
 
 /// OpenAI Whisper STT provider configuration (`[transcription.openai]`).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "transcription.openai"]
 pub struct OpenAiSttConfig {
     /// OpenAI API key for Whisper transcription.
     #[serde(default)]
+    #[secret]
     pub api_key: Option<String>,
     /// Whisper model name (default: "whisper-1").
     #[serde(default = "default_openai_stt_model")]
@@ -1250,10 +1365,12 @@ pub struct OpenAiSttConfig {
 }
 
 /// Deepgram STT provider configuration (`[transcription.deepgram]`).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "transcription.deepgram"]
 pub struct DeepgramSttConfig {
     /// Deepgram API key.
     #[serde(default)]
+    #[secret]
     pub api_key: Option<String>,
     /// Deepgram model name (default: "nova-2").
     #[serde(default = "default_deepgram_stt_model")]
@@ -1261,18 +1378,22 @@ pub struct DeepgramSttConfig {
 }
 
 /// AssemblyAI STT provider configuration (`[transcription.assemblyai]`).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "transcription.assemblyai"]
 pub struct AssemblyAiSttConfig {
     /// AssemblyAI API key.
     #[serde(default)]
+    #[secret]
     pub api_key: Option<String>,
 }
 
 /// Google Cloud Speech-to-Text provider configuration (`[transcription.google]`).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "transcription.google"]
 pub struct GoogleSttConfig {
     /// Google Cloud API key.
     #[serde(default)]
+    #[secret]
     pub api_key: Option<String>,
     /// BCP-47 language code (default: "en-US").
     #[serde(default = "default_google_stt_language_code")]
@@ -1282,13 +1403,15 @@ pub struct GoogleSttConfig {
 /// Local/self-hosted Whisper-compatible STT endpoint (`[transcription.local_whisper]`).
 ///
 /// Configures a self-hosted STT endpoint. Can be on localhost, a private network host, or any reachable URL.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "transcription.local-whisper"]
 pub struct LocalWhisperConfig {
     /// HTTP or HTTPS endpoint URL, e.g. `"http://10.10.0.1:8001/v1/transcribe"`.
     pub url: String,
     /// Bearer token for endpoint authentication.
     /// Omit for unauthenticated local endpoints.
     #[serde(default)]
+    #[secret]
     pub bearer_token: Option<String>,
     /// Maximum audio file size in bytes accepted by this endpoint.
     /// Defaults to 25 MB — matching the cloud API cap for a safe out-of-the-box
@@ -1311,7 +1434,8 @@ fn default_local_whisper_timeout_secs() -> u64 {
 }
 
 /// Agent orchestration configuration (`[agent]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "agent"]
 pub struct AgentConfig {
     /// When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models.
     #[serde(default)]
@@ -1352,10 +1476,12 @@ pub struct AgentConfig {
     pub max_system_prompt_chars: usize,
     /// Thinking/reasoning level control. Configures how deeply the model reasons
     /// per message. Users can override per-message with `/think:<level>` directives.
+    #[nested]
     #[serde(default)]
     pub thinking: crate::agent::thinking::ThinkingConfig,
 
     /// History pruning configuration for token efficiency.
+    #[nested]
     #[serde(default)]
     pub history_pruning: crate::agent::history_pruner::HistoryPrunerConfig,
 
@@ -1364,14 +1490,17 @@ pub struct AgentConfig {
     pub context_aware_tools: bool,
 
     /// Post-response quality evaluator configuration.
+    #[nested]
     #[serde(default)]
     pub eval: crate::agent::eval::EvalConfig,
 
     /// Automatic complexity-based classification fallback.
+    #[nested]
     #[serde(default)]
     pub auto_classify: Option<crate::agent::eval::AutoClassifyConfig>,
 
     /// Context compression configuration for automatic conversation compaction.
+    #[nested]
     #[serde(default)]
     pub context_compression: crate::agent::context_compressor::ContextCompressionConfig,
 
@@ -1449,7 +1578,8 @@ impl Default for AgentConfig {
 /// All fields are optional and default to values that preserve existing
 /// behavior. When set, they extend — not replace — the existing timeout
 /// and loop-detection subsystems.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "pacing"]
 pub struct PacingConfig {
     /// Per-step timeout in seconds: the maximum time allowed for a single
     /// LLM inference turn, independent of the total message budget.
@@ -1540,7 +1670,8 @@ fn parse_skills_prompt_injection_mode(raw: &str) -> Option<SkillsPromptInjection
 }
 
 /// Skills loading configuration (`[skills]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, Configurable)]
+#[prefix = "skills"]
 pub struct SkillsConfig {
     /// Enable loading and syncing the community open-skills repository.
     /// Default: `false` (opt-in).
@@ -1560,14 +1691,17 @@ pub struct SkillsConfig {
     pub prompt_injection_mode: SkillsPromptInjectionMode,
     /// Autonomous skill creation from successful multi-step task executions.
     #[serde(default)]
+    #[nested]
     pub skill_creation: SkillCreationConfig,
     /// Automatic skill self-improvement after successful skill usage.
     #[serde(default)]
+    #[nested]
     pub skill_improvement: SkillImprovementConfig,
 }
 
 /// Autonomous skill creation configuration (`[skills.skill_creation]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "skills.skill-creation"]
 #[serde(default)]
 pub struct SkillCreationConfig {
     /// Enable automatic skill creation after successful multi-step tasks.
@@ -1592,7 +1726,8 @@ impl Default for SkillCreationConfig {
 }
 
 /// Skill self-improvement configuration (`[skills.auto_improve]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "skills.skill-improvement"]
 pub struct SkillImprovementConfig {
     /// Enable automatic skill improvement after successful skill usage.
     /// Default: `true`.
@@ -1618,7 +1753,8 @@ impl Default for SkillImprovementConfig {
 }
 
 /// Pipeline tool configuration (`[pipeline]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "pipeline"]
 pub struct PipelineConfig {
     /// Enable the `execute_pipeline` meta-tool.
     /// Default: `false`.
@@ -1649,7 +1785,8 @@ impl Default for PipelineConfig {
 }
 
 /// Multimodal (image) handling configuration (`[multimodal]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "multimodal"]
 pub struct MultimodalConfig {
     /// Maximum number of image attachments accepted per request.
     #[serde(default = "default_multimodal_max_images")]
@@ -1708,7 +1845,8 @@ impl Default for MultimodalConfig {
 /// pre-processed before reaching the agent: audio is transcribed, images are
 /// annotated, and videos are summarised.
 #[allow(clippy::struct_excessive_bools)]
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "media-pipeline"]
 pub struct MediaPipelineConfig {
     /// Master toggle for the media pipeline (default: false).
     #[serde(default)]
@@ -1743,7 +1881,8 @@ impl Default for MediaPipelineConfig {
 /// Identity format configuration (`[identity]` section).
 ///
 /// Supports `"openclaw"` (default) or `"aieos"` identity documents.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "identity"]
 pub struct IdentityConfig {
     /// Identity format: "openclaw" (default) or "aieos"
     #[serde(default = "default_identity_format")]
@@ -1773,7 +1912,8 @@ impl Default for IdentityConfig {
 // ── Cost tracking and budget enforcement ───────────────────────────
 
 /// Cost tracking and budget enforcement configuration (`[cost]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "cost"]
 pub struct CostConfig {
     /// Enable cost tracking (default: true)
     #[serde(default = "default_cost_enabled")]
@@ -1801,11 +1941,13 @@ pub struct CostConfig {
 
     /// Cost enforcement behavior when budget limits are approached or exceeded.
     #[serde(default)]
+    #[nested]
     pub enforcement: CostEnforcementConfig,
 }
 
 /// Configuration for cost enforcement behavior when budget limits are reached.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "cost.enforcement"]
 pub struct CostEnforcementConfig {
     /// Enforcement mode: "warn", "block", or "route_down".
     #[serde(default = "default_cost_enforcement_mode")]
@@ -1959,7 +2101,8 @@ fn get_default_pricing() -> std::collections::HashMap<String, ModelPricing> {
 /// Peripheral board integration configuration (`[peripherals]` section).
 ///
 /// Boards become agent tools when enabled.
-#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, Configurable)]
+#[prefix = "peripherals"]
 pub struct PeripheralsConfig {
     /// Enable peripheral support (boards become agent tools)
     #[serde(default)]
@@ -2013,7 +2156,8 @@ impl Default for PeripheralBoardConfig {
 /// Gateway server configuration (`[gateway]` section).
 ///
 /// Controls the HTTP gateway for webhook and pairing endpoints.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "gateway"]
 #[allow(clippy::struct_excessive_bools)]
 pub struct GatewayConfig {
     /// Gateway port (default: 42617)
@@ -2030,6 +2174,7 @@ pub struct GatewayConfig {
     pub allow_public_bind: bool,
     /// Paired bearer tokens (managed automatically, not user-edited)
     #[serde(default)]
+    #[secret]
     pub paired_tokens: Vec<String>,
 
     /// Max `/pair` requests per minute per client key.
@@ -2073,10 +2218,12 @@ pub struct GatewayConfig {
 
     /// Pairing dashboard configuration
     #[serde(default)]
+    #[nested]
     pub pairing_dashboard: PairingDashboardConfig,
 
     /// TLS configuration for the gateway server (`[gateway.tls]`).
     #[serde(default)]
+    #[nested]
     pub tls: Option<GatewayTlsConfig>,
 }
 
@@ -2140,7 +2287,8 @@ impl Default for GatewayConfig {
 }
 
 /// Pairing dashboard configuration (`[gateway.pairing_dashboard]`).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "gateway.pairing-dashboard"]
 pub struct PairingDashboardConfig {
     /// Length of pairing codes (default: 8)
     #[serde(default = "default_pairing_code_length")]
@@ -2188,7 +2336,8 @@ impl Default for PairingDashboardConfig {
 }
 
 /// TLS configuration for the gateway server (`[gateway.tls]`).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "gateway.tls"]
 pub struct GatewayTlsConfig {
     /// Enable TLS for the gateway (default: false).
     #[serde(default)]
@@ -2199,16 +2348,19 @@ pub struct GatewayTlsConfig {
     pub key_path: String,
     /// Client certificate authentication (mutual TLS) settings.
     #[serde(default)]
+    #[nested]
     pub client_auth: Option<GatewayClientAuthConfig>,
 }
 
 /// Client certificate authentication (mTLS) configuration (`[gateway.tls.client_auth]`).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "gateway.tls.client-auth"]
 pub struct GatewayClientAuthConfig {
     /// Enable client certificate verification (default: false).
     #[serde(default)]
     pub enabled: bool,
     /// Path to the PEM-encoded CA certificate used to verify client certs.
+    #[serde(default)]
     pub ca_cert_path: String,
     /// Reject connections that do not present a valid client certificate (default: true).
     #[serde(default = "default_true")]
@@ -2219,8 +2371,20 @@ pub struct GatewayClientAuthConfig {
     pub pinned_certs: Vec<String>,
 }
 
+impl Default for GatewayClientAuthConfig {
+    fn default() -> Self {
+        Self {
+            enabled: false,
+            ca_cert_path: String::new(),
+            require_client_cert: default_true(),
+            pinned_certs: Vec::new(),
+        }
+    }
+}
+
 /// Secure transport configuration for inter-node communication (`[node_transport]`).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "node-transport"]
 pub struct NodeTransportConfig {
     /// Enable the secure transport layer.
     #[serde(default = "default_node_transport_enabled")]
@@ -2285,13 +2449,15 @@ impl Default for NodeTransportConfig {
 /// Composio managed OAuth tools integration (`[composio]` section).
 ///
 /// Provides access to 1000+ OAuth-connected tools via the Composio platform.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "composio"]
 pub struct ComposioConfig {
     /// Enable Composio integration for 1000+ OAuth tools
     #[serde(default, alias = "enable")]
     pub enabled: bool,
     /// Composio API key (stored encrypted when secrets.encrypt = true)
     #[serde(default)]
+    #[secret]
     pub api_key: Option<String>,
     /// Default entity ID for multi-user setups
     #[serde(default = "default_entity_id")]
@@ -2318,7 +2484,8 @@ impl Default for ComposioConfig {
 ///
 /// Provides access to Outlook mail, Teams messages, Calendar events,
 /// OneDrive files, and SharePoint search.
-#[derive(Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "ms365"]
 pub struct Microsoft365Config {
     /// Enable Microsoft 365 integration
     #[serde(default, alias = "enable")]
@@ -2331,6 +2498,7 @@ pub struct Microsoft365Config {
     pub client_id: Option<String>,
     /// Azure AD client secret (stored encrypted when secrets.encrypt = true)
     #[serde(default)]
+    #[secret]
     pub client_secret: Option<String>,
     /// Authentication flow: "client_credentials" or "device_code"
     #[serde(default = "default_ms365_auth_flow")]
@@ -2387,7 +2555,8 @@ impl Default for Microsoft365Config {
 // ── Secrets (encrypted credential store) ────────────────────────
 
 /// Secrets encryption configuration (`[secrets]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "secrets"]
 pub struct SecretsConfig {
     /// Enable encryption for API keys and tokens in config.toml
     #[serde(default = "default_true")]
@@ -2405,13 +2574,15 @@ impl Default for SecretsConfig {
 /// Computer-use sidecar configuration (`[browser.computer_use]` section).
 ///
 /// Delegates OS-level mouse, keyboard, and screenshot actions to a local sidecar.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "browser.computer-use"]
 pub struct BrowserComputerUseConfig {
     /// Sidecar endpoint for computer-use actions (OS-level mouse/keyboard/screenshot)
     #[serde(default = "default_browser_computer_use_endpoint")]
     pub endpoint: String,
     /// Optional bearer token for computer-use sidecar
     #[serde(default)]
+    #[secret]
     pub api_key: Option<String>,
     /// Per-action request timeout in milliseconds
     #[serde(default = "default_browser_computer_use_timeout_ms")]
@@ -2455,7 +2626,8 @@ impl Default for BrowserComputerUseConfig {
 /// Browser automation configuration (`[browser]` section).
 ///
 /// Controls the `browser_open` tool and browser automation backends.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "browser"]
 pub struct BrowserConfig {
     /// Enable `browser_open` tool (opens URLs in the system browser without scraping)
     #[serde(default = "default_true")]
@@ -2480,6 +2652,7 @@ pub struct BrowserConfig {
     pub native_chrome_path: Option<String>,
     /// Computer-use sidecar configuration
     #[serde(default)]
+    #[nested]
     pub computer_use: BrowserComputerUseConfig,
 }
 
@@ -2517,7 +2690,8 @@ impl Default for BrowserConfig {
 /// Domain filtering: `allowed_domains` controls which hosts are reachable (use `["*"]`
 /// for all public hosts, which is the default). If `allowed_domains` is empty, all
 /// requests are rejected.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "http-request"]
 pub struct HttpRequestConfig {
     /// Enable `http_request` tool for API interactions
     #[serde(default)]
@@ -2565,7 +2739,8 @@ fn default_http_timeout_secs() -> u64 {
 /// Domain filtering: `allowed_domains` controls which hosts are reachable (use `["*"]`
 /// for all public hosts). `blocked_domains` takes priority over `allowed_domains`.
 /// If `allowed_domains` is empty, all requests are rejected (deny-by-default).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "web-fetch"]
 pub struct WebFetchConfig {
     /// Enable `web_fetch` tool for fetching web page content
     #[serde(default)]
@@ -2587,6 +2762,7 @@ pub struct WebFetchConfig {
     pub timeout_secs: u64,
     /// Firecrawl fallback configuration (`[web_fetch.firecrawl]`)
     #[serde(default)]
+    #[nested]
     pub firecrawl: FirecrawlConfig,
 }
 
@@ -2607,7 +2783,8 @@ pub enum FirecrawlMode {
 /// When enabled, if the standard web fetch fails (HTTP error, empty body, or
 /// body shorter than 100 characters suggesting a JS-only page), the tool
 /// falls back to the Firecrawl API for stealth content extraction.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "web-fetch.firecrawl"]
 pub struct FirecrawlConfig {
     /// Enable Firecrawl fallback
     #[serde(default)]
@@ -2676,7 +2853,8 @@ impl Default for WebFetchConfig {
 /// summarised. The summary is prepended to the message before the agent
 /// processes it, giving the LLM context about linked pages without an
 /// explicit tool call.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "link-enricher"]
 pub struct LinkEnricherConfig {
     /// Enable the link enricher pipeline stage (default: false)
     #[serde(default)]
@@ -2713,7 +2891,8 @@ impl Default for LinkEnricherConfig {
 ///
 /// Uses text-based browsers (lynx, links, w3m) to render web pages as plain
 /// text. Designed for headless/SSH environments without graphical browsers.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "text-browser"]
 pub struct TextBrowserConfig {
     /// Enable `text_browser` tool
     #[serde(default)]
@@ -2747,7 +2926,8 @@ impl Default for TextBrowserConfig {
 /// Controls the behaviour of the `shell` execution tool. The main
 /// tunable is `timeout_secs` — the maximum wall-clock time a single
 /// shell command may run before it is killed.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "shell-tool"]
 pub struct ShellToolConfig {
     /// Maximum shell command execution time in seconds (default: 60).
     #[serde(default = "default_shell_tool_timeout_secs")]
@@ -2769,7 +2949,8 @@ impl Default for ShellToolConfig {
 // ── Web search ───────────────────────────────────────────────────
 
 /// Web search tool configuration (`[web_search]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "web-search"]
 pub struct WebSearchConfig {
     /// Enable `web_search_tool` for web searches
     #[serde(default)]
@@ -2779,6 +2960,7 @@ pub struct WebSearchConfig {
     pub provider: String,
     /// Brave Search API key (required if provider is "brave")
     #[serde(default)]
+    #[secret]
     pub brave_api_key: Option<String>,
     /// SearXNG instance URL (required if provider is "searxng"), e.g. "https://searx.example.com"
     #[serde(default)]
@@ -2819,7 +3001,8 @@ impl Default for WebSearchConfig {
 // ── Project Intelligence ────────────────────────────────────────
 
 /// Project delivery intelligence configuration (`[project_intel]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "project-intel"]
 pub struct ProjectIntelConfig {
     /// Enable the project_intel tool. Default: false.
     #[serde(default)]
@@ -2877,7 +3060,8 @@ impl Default for ProjectIntelConfig {
 // ── Backup ──────────────────────────────────────────────────────
 
 /// Backup tool configuration (`[backup]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "backup"]
 pub struct BackupConfig {
     /// Enable the `backup` tool.
     #[serde(default = "default_true")]
@@ -2940,7 +3124,8 @@ impl Default for BackupConfig {
 // ── Data Retention ──────────────────────────────────────────────
 
 /// Data retention and purge configuration (`[data_retention]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "data-retention"]
 pub struct DataRetentionConfig {
     /// Enable the `data_management` tool.
     #[serde(default)]
@@ -3064,7 +3249,8 @@ pub struct GoogleWorkspaceAllowedOperation {
 /// To revert, remove the `[google_workspace]` section from the config file (or
 /// set `enabled = false`). No data migration is required; the tool simply stops
 /// being registered.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "google-workspace"]
 pub struct GoogleWorkspaceConfig {
     /// Enable the `google_workspace` tool. Default: `false`.
     #[serde(default)]
@@ -3142,7 +3328,8 @@ impl Default for GoogleWorkspaceConfig {
 
 /// Knowledge graph configuration for capturing and reusing expertise.
 #[allow(clippy::struct_excessive_bools)]
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "knowledge"]
 pub struct KnowledgeConfig {
     /// Enable the knowledge graph tool. Default: false.
     #[serde(default)]
@@ -3191,7 +3378,8 @@ impl Default for KnowledgeConfig {
 ///
 /// When enabled, the `linkedin` tool is registered in the agent tool surface.
 /// Requires `LINKEDIN_*` credentials in the workspace `.env` file.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "linkedin"]
 pub struct LinkedInConfig {
     /// Enable the LinkedIn tool.
     #[serde(default)]
@@ -3203,10 +3391,12 @@ pub struct LinkedInConfig {
 
     /// Content strategy for automated posting.
     #[serde(default)]
+    #[nested]
     pub content: LinkedInContentConfig,
 
     /// Image generation for posts (`[linkedin.image]`).
     #[serde(default)]
+    #[nested]
     pub image: LinkedInImageConfig,
 }
 
@@ -3226,7 +3416,8 @@ fn default_linkedin_api_version() -> String {
 }
 
 /// Plugin system configuration.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "plugins"]
 pub struct PluginsConfig {
     /// Enable the plugin system (default: false)
     #[serde(default)]
@@ -3242,6 +3433,7 @@ pub struct PluginsConfig {
     pub max_plugins: usize,
     /// Plugin signature verification security settings
     #[serde(default)]
+    #[nested]
     pub security: PluginSecurityConfig,
 }
 
@@ -3251,7 +3443,8 @@ pub struct PluginsConfig {
 /// In `strict` mode, only plugins signed by a trusted publisher key are loaded.
 /// In `permissive` mode, unsigned or untrusted plugins produce warnings but are
 /// still loaded. In `disabled` mode (the default), no signature checking occurs.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "plugins.security"]
 pub struct PluginSecurityConfig {
     /// Signature enforcement mode: "disabled", "permissive", or "strict".
     #[serde(default = "default_signature_mode")]
@@ -3298,7 +3491,8 @@ impl Default for PluginsConfig {
 ///
 /// The agent reads this via the `linkedin get_content_strategy` action to know
 /// what feeds to check, which repos to highlight, and how to write posts.
-#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "linkedin.content"]
 pub struct LinkedInContentConfig {
     /// RSS feed URLs to monitor for topic inspiration (titles only).
     #[serde(default)]
@@ -3326,7 +3520,8 @@ pub struct LinkedInContentConfig {
 }
 
 /// Image generation configuration for LinkedIn posts (`[linkedin.image]`).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "linkedin.image"]
 pub struct LinkedInImageConfig {
     /// Enable image generation for posts.
     #[serde(default)]
@@ -3350,18 +3545,22 @@ pub struct LinkedInImageConfig {
 
     /// Stability AI provider settings.
     #[serde(default)]
+    #[nested]
     pub stability: ImageProviderStabilityConfig,
 
     /// Google Imagen (Vertex AI) provider settings.
     #[serde(default)]
+    #[nested]
     pub imagen: ImageProviderImagenConfig,
 
     /// OpenAI DALL-E provider settings.
     #[serde(default)]
+    #[nested]
     pub dalle: ImageProviderDalleConfig,
 
     /// Flux (fal.ai) provider settings.
     #[serde(default)]
+    #[nested]
     pub flux: ImageProviderFluxConfig,
 }
 
@@ -3399,7 +3598,8 @@ impl Default for LinkedInImageConfig {
 }
 
 /// Stability AI image generation settings (`[linkedin.image.stability]`).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "linkedin.image.stability"]
 pub struct ImageProviderStabilityConfig {
     /// Environment variable name holding the API key.
     #[serde(default = "default_stability_api_key_env")]
@@ -3426,7 +3626,8 @@ impl Default for ImageProviderStabilityConfig {
 }
 
 /// Google Imagen (Vertex AI) settings (`[linkedin.image.imagen]`).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "linkedin.image.imagen"]
 pub struct ImageProviderImagenConfig {
     /// Environment variable name holding the API key.
     #[serde(default = "default_imagen_api_key_env")]
@@ -3460,7 +3661,8 @@ impl Default for ImageProviderImagenConfig {
 }
 
 /// OpenAI DALL-E settings (`[linkedin.image.dalle]`).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "linkedin.image.dalle"]
 pub struct ImageProviderDalleConfig {
     /// Environment variable name holding the OpenAI API key.
     #[serde(default = "default_dalle_api_key_env")]
@@ -3494,7 +3696,8 @@ impl Default for ImageProviderDalleConfig {
 }
 
 /// Flux (fal.ai) image generation settings (`[linkedin.image.flux]`).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "linkedin.image.flux"]
 pub struct ImageProviderFluxConfig {
     /// Environment variable name holding the fal.ai API key.
     #[serde(default = "default_flux_api_key_env")]
@@ -3527,7 +3730,8 @@ impl Default for ImageProviderFluxConfig {
 /// When enabled, registers an `image_gen` tool that generates images via
 /// fal.ai's synchronous API (Flux / Nano Banana models) and saves them
 /// to the workspace `images/` directory.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "image-gen"]
 pub struct ImageGenConfig {
     /// Enable the standalone image generation tool. Default: false.
     #[serde(default)]
@@ -3567,7 +3771,8 @@ impl Default for ImageGenConfig {
 /// Delegates coding tasks to the `claude -p` CLI. Authentication uses the
 /// binary's own OAuth session (Max subscription) by default — no API key
 /// needed unless `env_passthrough` includes `ANTHROPIC_API_KEY`.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "claude-code"]
 pub struct ClaudeCodeConfig {
     /// Enable the `claude_code` tool
     #[serde(default)]
@@ -3621,7 +3826,8 @@ impl Default for ClaudeCodeConfig {
 /// Spawns Claude Code in a tmux session with HTTP hooks that POST tool
 /// execution events back to ZeroClaw's gateway, updating a Slack message
 /// in-place with progress plus an SSH handoff link.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "claude-code-runner"]
 pub struct ClaudeCodeRunnerConfig {
     /// Enable the `claude_code_runner` tool
     #[serde(default)]
@@ -3663,7 +3869,8 @@ impl Default for ClaudeCodeRunnerConfig {
 /// Delegates coding tasks to the `codex -q` CLI. Authentication uses the
 /// binary's own session by default — no API key needed unless
 /// `env_passthrough` includes `OPENAI_API_KEY`.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "codex-cli"]
 pub struct CodexCliConfig {
     /// Enable the `codex_cli` tool
     #[serde(default)]
@@ -3705,7 +3912,8 @@ impl Default for CodexCliConfig {
 /// Delegates coding tasks to the `gemini -p` CLI. Authentication uses the
 /// binary's own session by default — no API key needed unless
 /// `env_passthrough` includes `GOOGLE_API_KEY`.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "gemini-cli"]
 pub struct GeminiCliConfig {
     /// Enable the `gemini_cli` tool
     #[serde(default)]
@@ -3747,7 +3955,8 @@ impl Default for GeminiCliConfig {
 /// Delegates coding tasks to the `opencode run` CLI. Authentication uses the
 /// binary's own session by default — no API key needed unless
 /// `env_passthrough` includes provider-specific keys.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "opencode-cli"]
 pub struct OpenCodeCliConfig {
     /// Enable the `opencode_cli` tool
     #[serde(default)]
@@ -3798,7 +4007,8 @@ pub enum ProxyScope {
 }
 
 /// Proxy configuration for outbound HTTP/HTTPS/SOCKS5 traffic (`[proxy]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "proxy"]
 pub struct ProxyConfig {
     /// Enable proxy support for selected scope.
     #[serde(default)]
@@ -4738,23 +4948,28 @@ fn parse_proxy_enabled(raw: &str) -> Option<bool> {
 // ── Memory ───────────────────────────────────────────────────
 
 /// Persistent storage configuration (`[storage]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, Configurable)]
+#[prefix = "storage"]
 pub struct StorageConfig {
     /// Storage provider settings (e.g. sqlite, postgres).
     #[serde(default)]
+    #[nested]
     pub provider: StorageProviderSection,
 }
 
 /// Wrapper for the storage provider configuration section.
-#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, Configurable)]
+#[prefix = "storage.provider"]
 pub struct StorageProviderSection {
     /// Storage provider backend settings.
     #[serde(default)]
+    #[nested]
     pub config: StorageProviderConfig,
 }
 
 /// Storage provider backend configuration for remote storage backends.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "storage.provider"]
 pub struct StorageProviderConfig {
     /// Storage engine key (e.g. "sqlite", "qdrant").
     #[serde(default)]
@@ -4768,6 +4983,7 @@ pub struct StorageProviderConfig {
         alias = "database_url",
         alias = "databaseUrl"
     )]
+    #[secret]
     pub db_url: Option<String>,
 
     /// Database schema for SQL backends.
@@ -4809,7 +5025,8 @@ impl Default for StorageProviderConfig {
 /// and memory snapshot/hydration.
 /// Configuration for Qdrant vector database backend (`[memory.qdrant]`).
 /// Used when `[memory].backend = "qdrant"`.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "memory.qdrant"]
 pub struct QdrantConfig {
     /// Qdrant server URL (e.g. "http://localhost:6333").
     /// Falls back to `QDRANT_URL` env var if not set.
@@ -4852,7 +5069,8 @@ pub enum SearchMode {
     Hybrid,
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "memory"]
 #[allow(clippy::struct_excessive_bools)]
 pub struct MemoryConfig {
     /// "sqlite" | "lucid" | "qdrant" | "markdown" | "none" (`none` = explicit no-op memory)
@@ -4963,6 +5181,7 @@ pub struct MemoryConfig {
     // ── Policy Engine ───────────────────────────────────────────
     /// Memory policy configuration.
     #[serde(default)]
+    #[nested]
     pub policy: MemoryPolicyConfig,
 
     // ── SQLite backend options ─────────────────────────────────
@@ -4975,11 +5194,13 @@ pub struct MemoryConfig {
     /// Configuration for Qdrant vector database backend.
     /// Only used when `backend = "qdrant"`.
     #[serde(default)]
+    #[nested]
     pub qdrant: QdrantConfig,
 }
 
 /// Memory policy configuration (`[memory.policy]` section).
-#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "memory.policy"]
 pub struct MemoryPolicyConfig {
     /// Maximum entries per namespace (0 = unlimited).
     #[serde(default)]
@@ -5104,7 +5325,8 @@ impl Default for MemoryConfig {
 // ── Observability ─────────────────────────────────────────────────
 
 /// Observability backend configuration (`[observability]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "observability"]
 pub struct ObservabilityConfig {
     /// "none" | "log" | "verbose" | "prometheus" | "otel"
     pub backend: String,
@@ -5158,7 +5380,8 @@ fn default_runtime_trace_max_entries() -> usize {
 
 // ── Hooks ────────────────────────────────────────────────────────
 
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "hooks"]
 pub struct HooksConfig {
     /// Enable lifecycle hook execution.
     ///
@@ -5166,6 +5389,7 @@ pub struct HooksConfig {
     /// Keep enabled hook handlers narrowly scoped and auditable.
     pub enabled: bool,
     #[serde(default)]
+    #[nested]
     pub builtin: BuiltinHooksConfig,
 }
 
@@ -5178,7 +5402,8 @@ impl Default for HooksConfig {
     }
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, Configurable)]
+#[prefix = "hooks.builtin"]
 pub struct BuiltinHooksConfig {
     /// Enable the command-logger hook (logs tool calls for auditing).
     pub command_logger: bool,
@@ -5187,6 +5412,7 @@ pub struct BuiltinHooksConfig {
     /// When enabled, POSTs a JSON payload to `url` for every tool invocation
     /// that matches one of `tool_patterns`.
     #[serde(default)]
+    #[nested]
     pub webhook_audit: WebhookAuditConfig,
 }
 
@@ -5195,7 +5421,8 @@ pub struct BuiltinHooksConfig {
 /// Sends an HTTP POST with a JSON body to an external endpoint each time
 /// a tool call matches one of the configured patterns. Useful for
 /// centralised audit logging, SIEM ingestion, or compliance pipelines.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "hooks.builtin.webhook-audit"]
 pub struct WebhookAuditConfig {
     /// Enable the webhook-audit hook. Default: `false`.
     #[serde(default)]
@@ -5242,7 +5469,8 @@ impl Default for WebhookAuditConfig {
 /// Controls what the agent is allowed to do: shell commands, filesystem access,
 /// risk approval gates, and per-policy budgets.
 #[allow(clippy::struct_excessive_bools)]
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "autonomy"]
 #[serde(default)]
 pub struct AutonomyConfig {
     /// Autonomy level: `read_only`, `supervised` (default), or `full`.
@@ -5407,7 +5635,8 @@ impl Default for AutonomyConfig {
 // ── Runtime ──────────────────────────────────────────────────────
 
 /// Runtime adapter configuration (`[runtime]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "runtime"]
 pub struct RuntimeConfig {
     /// Runtime kind (`native` | `docker`).
     #[serde(default = "default_runtime_kind")]
@@ -5415,6 +5644,7 @@ pub struct RuntimeConfig {
 
     /// Docker runtime settings (used when `kind = "docker"`).
     #[serde(default)]
+    #[nested]
     pub docker: DockerRuntimeConfig,
 
     /// Global reasoning override for providers that expose explicit controls.
@@ -5429,7 +5659,8 @@ pub struct RuntimeConfig {
 }
 
 /// Docker runtime configuration (`[runtime.docker]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "runtime.docker"]
 pub struct DockerRuntimeConfig {
     /// Runtime image used to execute shell commands.
     #[serde(default = "default_docker_image")]
@@ -5510,7 +5741,8 @@ impl Default for RuntimeConfig {
 /// Reliability and supervision configuration (`[reliability]` section).
 ///
 /// Controls provider retries, fallback chains, API key rotation, and channel restart backoff.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "reliability"]
 pub struct ReliabilityConfig {
     /// Retries per provider before failing over.
     #[serde(default = "default_provider_retries")]
@@ -5586,7 +5818,8 @@ impl Default for ReliabilityConfig {
 // ── Scheduler ────────────────────────────────────────────────────
 
 /// Scheduler configuration for periodic task execution (`[scheduler]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "scheduler"]
 pub struct SchedulerConfig {
     /// Enable the built-in scheduler loop.
     #[serde(default = "default_scheduler_enabled")]
@@ -5685,7 +5918,8 @@ pub struct EmbeddingRouteConfig {
 
 /// Automatic query classification — classifies user messages by keyword/pattern
 /// and routes to the appropriate model hint. Disabled by default.
-#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, Configurable)]
+#[prefix = "query-classification"]
 pub struct QueryClassificationConfig {
     /// Enable automatic query classification. Default: `false`.
     #[serde(default)]
@@ -5720,7 +5954,8 @@ pub struct ClassificationRule {
 // ── Heartbeat ────────────────────────────────────────────────────
 
 /// Heartbeat configuration for periodic health pings (`[heartbeat]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "heartbeat"]
 #[allow(clippy::struct_excessive_bools)]
 pub struct HeartbeatConfig {
     /// Enable periodic heartbeat pings. Default: `true`.
@@ -5832,7 +6067,8 @@ impl Default for HeartbeatConfig {
 // ── Cron ────────────────────────────────────────────────────────
 
 /// Cron job configuration (`[cron]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "cron"]
 pub struct CronConfig {
     /// Enable the cron subsystem. Default: `true`.
     #[serde(default = "default_true")]
@@ -5957,33 +6193,40 @@ impl Default for CronConfig {
 /// Tunnel configuration for exposing the gateway publicly (`[tunnel]` section).
 ///
 /// Supported providers: `"none"` (default), `"cloudflare"`, `"tailscale"`, `"ngrok"`, `"openvpn"`, `"pinggy"`, `"custom"`.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "tunnel"]
 pub struct TunnelConfig {
     /// Tunnel provider: `"none"`, `"cloudflare"`, `"tailscale"`, `"ngrok"`, `"openvpn"`, `"pinggy"`, or `"custom"`. Default: `"none"`.
     pub provider: String,
 
     /// Cloudflare Tunnel configuration (used when `provider = "cloudflare"`).
     #[serde(default)]
+    #[nested]
     pub cloudflare: Option<CloudflareTunnelConfig>,
 
     /// Tailscale Funnel/Serve configuration (used when `provider = "tailscale"`).
     #[serde(default)]
+    #[nested]
     pub tailscale: Option<TailscaleTunnelConfig>,
 
     /// ngrok tunnel configuration (used when `provider = "ngrok"`).
     #[serde(default)]
+    #[nested]
     pub ngrok: Option<NgrokTunnelConfig>,
 
     /// OpenVPN tunnel configuration (used when `provider = "openvpn"`).
     #[serde(default)]
+    #[nested]
     pub openvpn: Option<OpenVpnTunnelConfig>,
 
     /// Custom tunnel command configuration (used when `provider = "custom"`).
     #[serde(default)]
+    #[nested]
     pub custom: Option<CustomTunnelConfig>,
 
     /// Pinggy tunnel configuration (used when `provider = "pinggy"`).
     #[serde(default)]
+    #[nested]
     pub pinggy: Option<PinggyTunnelConfig>,
 }
 
@@ -6001,26 +6244,35 @@ impl Default for TunnelConfig {
     }
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "tunnel.cloudflare"]
 pub struct CloudflareTunnelConfig {
     /// Cloudflare Tunnel token (from Zero Trust dashboard)
+    #[serde(default)]
+    #[secret]
     pub token: String,
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "tunnel.tailscale"]
 pub struct TailscaleTunnelConfig {
     /// Use Tailscale Funnel (public internet) vs Serve (tailnet only)
     #[serde(default)]
     pub funnel: bool,
     /// Optional hostname override
+    #[serde(default)]
     pub hostname: Option<String>,
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "tunnel.ngrok"]
 pub struct NgrokTunnelConfig {
     /// ngrok auth token
+    #[serde(default)]
+    #[secret]
     pub auth_token: String,
     /// Optional custom domain
+    #[serde(default)]
     pub domain: Option<String>,
 }
 
@@ -6031,7 +6283,8 @@ pub struct NgrokTunnelConfig {
 /// the `[tunnel.openvpn]` block) cleanly reverts to no-tunnel mode.
 ///
 /// Defaults: `connect_timeout_secs = 30`.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "tunnel.openvpn"]
 pub struct OpenVpnTunnelConfig {
     /// Path to `.ovpn` configuration file (must not be empty).
     pub config_file: String,
@@ -6054,24 +6307,42 @@ fn default_openvpn_timeout() -> u64 {
     30
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+impl Default for OpenVpnTunnelConfig {
+    fn default() -> Self {
+        Self {
+            config_file: String::new(),
+            auth_file: None,
+            advertise_address: None,
+            connect_timeout_secs: default_openvpn_timeout(),
+            extra_args: Vec::new(),
+        }
+    }
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "tunnel.pinggy"]
 pub struct PinggyTunnelConfig {
     /// Pinggy access token (optional — free tier works without one).
     #[serde(default)]
+    #[secret]
     pub token: Option<String>,
     /// Server region: `"us"` (USA), `"eu"` (Europe), `"ap"` (Asia), `"br"` (South America), `"au"` (Australia), or omit for auto.
     #[serde(default)]
     pub region: Option<String>,
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "tunnel.custom"]
 pub struct CustomTunnelConfig {
     /// Command template to start the tunnel. Use {port} and {host} placeholders.
     /// Example: "bore local {port} --to bore.pub"
+    #[serde(default)]
     pub start_command: String,
     /// Optional URL to check tunnel health
+    #[serde(default)]
     pub health_url: Option<String>,
     /// Optional regex to extract public URL from command stdout
+    #[serde(default)]
     pub url_pattern: Option<String>,
 }
 
@@ -6099,71 +6370,102 @@ impl<T: ChannelConfig> crate::config::traits::ConfigHandle for ConfigWrapper<T>
 /// Each channel sub-section (e.g. `telegram`, `discord`) is optional;
 /// setting it to `Some(...)` enables that channel.
 #[allow(clippy::struct_excessive_bools)]
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels"]
 pub struct ChannelsConfig {
     /// Enable the CLI interactive channel. Default: `true`.
     #[serde(default = "default_true")]
     pub cli: bool,
     /// Telegram bot channel configuration.
+    #[nested]
     pub telegram: Option<TelegramConfig>,
     /// Discord bot channel configuration.
+    #[nested]
     pub discord: Option<DiscordConfig>,
     /// Discord history channel — logs ALL messages and forwards @mentions to agent.
+    #[nested]
     pub discord_history: Option<DiscordHistoryConfig>,
     /// Slack bot channel configuration.
+    #[nested]
     pub slack: Option<SlackConfig>,
     /// Mattermost bot channel configuration.
+    #[nested]
     pub mattermost: Option<MattermostConfig>,
     /// Webhook channel configuration.
+    #[nested]
     pub webhook: Option<WebhookConfig>,
     /// iMessage channel configuration (macOS only).
+    #[nested]
     pub imessage: Option<IMessageConfig>,
     /// Matrix channel configuration.
+    #[nested]
     pub matrix: Option<MatrixConfig>,
     /// Signal channel configuration.
+    #[nested]
     pub signal: Option<SignalConfig>,
     /// WhatsApp channel configuration (Cloud API or Web mode).
+    #[nested]
     pub whatsapp: Option<WhatsAppConfig>,
     /// Linq Partner API channel configuration.
+    #[nested]
     pub linq: Option<LinqConfig>,
     /// WATI WhatsApp Business API channel configuration.
+    #[nested]
     pub wati: Option<WatiConfig>,
     /// Nextcloud Talk bot channel configuration.
+    #[nested]
     pub nextcloud_talk: Option<NextcloudTalkConfig>,
     /// Email channel configuration.
+    #[nested]
     pub email: Option<crate::channels::email_channel::EmailConfig>,
     /// Gmail Pub/Sub push notification channel configuration.
+    #[nested]
     pub gmail_push: Option<crate::channels::gmail_push::GmailPushConfig>,
     /// IRC channel configuration.
+    #[nested]
     pub irc: Option<IrcConfig>,
     /// Lark channel configuration.
+    #[nested]
     pub lark: Option<LarkConfig>,
     /// Feishu channel configuration.
+    #[nested]
     pub feishu: Option<FeishuConfig>,
     /// DingTalk channel configuration.
+    #[nested]
     pub dingtalk: Option<DingTalkConfig>,
     /// WeCom (WeChat Enterprise) Bot Webhook channel configuration.
+    #[nested]
     pub wecom: Option<WeComConfig>,
     /// QQ Official Bot channel configuration.
+    #[nested]
     pub qq: Option<QQConfig>,
     /// X/Twitter channel configuration.
+    #[nested]
     pub twitter: Option<TwitterConfig>,
     /// Mochat customer service channel configuration.
+    #[nested]
     pub mochat: Option<MochatConfig>,
     #[cfg(feature = "channel-nostr")]
+    #[nested]
     pub nostr: Option<NostrConfig>,
     /// ClawdTalk voice channel configuration.
+    #[nested]
     pub clawdtalk: Option<crate::channels::ClawdTalkConfig>,
     /// Reddit channel configuration (OAuth2 bot).
+    #[nested]
     pub reddit: Option<RedditConfig>,
     /// Bluesky channel configuration (AT Protocol).
+    #[nested]
     pub bluesky: Option<BlueskyConfig>,
     /// Voice call channel configuration (Twilio/Telnyx/Plivo).
+    #[nested]
     pub voice_call: Option<crate::channels::voice_call::VoiceCallConfig>,
     /// Voice wake word detection channel configuration.
     #[cfg(feature = "voice-wake")]
+    #[nested]
     pub voice_wake: Option<VoiceWakeConfig>,
     /// MQTT channel configuration (SOP listener).
+    #[nested]
     pub mqtt: Option<MqttConfig>,
     /// Base timeout in seconds for processing a single channel message (LLM + tools).
     /// Runtime uses this as a per-turn budget that scales with tool-loop depth
@@ -6200,6 +6502,34 @@ pub struct ChannelsConfig {
 }
 
 impl ChannelsConfig {
+    /// Backfill `enabled = true` for channel sections present in the raw TOML
+    /// that don't have an explicit `enabled` key. This preserves backward
+    /// compatibility: configs written before `enabled` was introduced continue
+    /// to activate their channels.
+    pub fn backfill_enabled(&mut self, raw_toml: &str) {
+        let table = match raw_toml.parse::<toml::Table>() {
+            Ok(t) => t,
+            Err(_) => return,
+        };
+        let channels = match table.get("channels_config").and_then(|v| v.as_table()) {
+            Some(t) => t,
+            None => return,
+        };
+        for (key, value) in channels {
+            let is_section = value.as_table().is_some();
+            let has_explicit_enabled = value
+                .as_table()
+                .map_or(false, |t| t.contains_key("enabled"));
+            if is_section && !has_explicit_enabled {
+                // Section exists without explicit `enabled` — backfill true
+                let prop_path = format!("channels.{}.enabled", key.replace('_', "-"));
+                if let Err(e) = self.set_prop(&prop_path, "true") {
+                    tracing::warn!("backfill_enabled: failed to set {prop_path}: {e}");
+                }
+            }
+        }
+    }
+
     /// get channels' metadata and `.is_some()`, except webhook
     #[rustfmt::skip]
     pub fn channels_except_webhook(&self) -> Vec<(Box<dyn super::traits::ConfigHandle>, bool)> {
@@ -6357,12 +6687,12 @@ impl Default for ChannelsConfig {
             #[cfg(feature = "channel-nostr")]
             nostr: None,
             clawdtalk: None,
-            mqtt: None,
             reddit: None,
             bluesky: None,
             voice_call: None,
             #[cfg(feature = "voice-wake")]
             voice_wake: None,
+            mqtt: None,
             message_timeout_secs: default_channel_message_timeout_secs(),
             ack_reactions: true,
             show_tool_calls: false,
@@ -6401,9 +6731,14 @@ fn default_matrix_draft_update_interval_ms() -> u64 {
 }
 
 /// Telegram bot channel configuration.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.telegram"]
 pub struct TelegramConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// Telegram Bot API token (from @BotFather).
+    #[secret]
     pub bot_token: String,
     /// Allowed Telegram user IDs or usernames. Empty = deny all.
     pub allowed_users: Vec<String>,
@@ -6442,9 +6777,15 @@ impl ChannelConfig for TelegramConfig {
 }
 
 /// Discord bot channel configuration.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.discord"]
+#[allow(clippy::struct_excessive_bools)]
 pub struct DiscordConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// Discord bot token (from Discord Developer Portal).
+    #[secret]
     pub bot_token: String,
     /// Optional guild (server) ID to restrict the bot to a single guild.
     pub guild_id: Option<String>,
@@ -6496,9 +6837,14 @@ impl ChannelConfig for DiscordConfig {
 }
 
 /// Discord history channel — logs ALL messages to discord.db and forwards @mentions to the agent.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.discord-history"]
 pub struct DiscordHistoryConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// Discord bot token (from Discord Developer Portal).
+    #[secret]
     pub bot_token: String,
     /// Optional guild (server) ID to restrict logging to a single guild.
     pub guild_id: Option<String>,
@@ -6529,12 +6875,18 @@ impl ChannelConfig for DiscordHistoryConfig {
 }
 
 /// Slack bot channel configuration.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.slack"]
 #[allow(clippy::struct_excessive_bools)]
 pub struct SlackConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// Slack bot OAuth token (xoxb-...).
+    #[secret]
     pub bot_token: String,
     /// Slack app-level token for Socket Mode (xapp-...).
+    #[secret]
     pub app_token: Option<String>,
     /// Optional channel ID to restrict the bot to a single channel.
     /// Omit (or set `"*"`) to listen across all accessible channels.
@@ -6594,11 +6946,16 @@ impl ChannelConfig for SlackConfig {
 }
 
 /// Mattermost bot channel configuration.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.mattermost"]
 pub struct MattermostConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// Mattermost server URL (e.g. `"https://mattermost.example.com"`).
     pub url: String,
     /// Mattermost bot access token.
+    #[secret]
     pub bot_token: String,
     /// Optional channel ID to restrict the bot to a single channel.
     pub channel_id: Option<String>,
@@ -6636,8 +6993,12 @@ impl ChannelConfig for MattermostConfig {
 ///
 /// Receives messages via HTTP POST and sends replies to a configurable outbound URL.
 /// This is the "universal adapter" for any system that supports webhooks.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.webhook"]
 pub struct WebhookConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// Port to listen on for incoming webhooks.
     pub port: u16,
     /// URL path to listen on (default: `/webhook`).
@@ -6653,6 +7014,7 @@ pub struct WebhookConfig {
     #[serde(default)]
     pub auth_header: Option<String>,
     /// Optional shared secret for webhook signature verification (HMAC-SHA256).
+    #[secret]
     pub secret: Option<String>,
 }
 
@@ -6666,8 +7028,12 @@ impl ChannelConfig for WebhookConfig {
 }
 
 /// iMessage channel configuration (macOS only).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.imessage"]
 pub struct IMessageConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// Allowed iMessage contacts (phone numbers or email addresses). Empty = deny all.
     pub allowed_contacts: Vec<String>,
 }
@@ -6682,11 +7048,16 @@ impl ChannelConfig for IMessageConfig {
 }
 
 /// Matrix channel configuration.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.matrix"]
 pub struct MatrixConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// Matrix homeserver URL (e.g. `"https://matrix.org"`).
     pub homeserver: String,
     /// Matrix access token for the bot account.
+    #[secret]
     pub access_token: String,
     /// Optional Matrix user ID (e.g. `"@bot:matrix.org"`).
     #[serde(default)]
@@ -6718,6 +7089,7 @@ pub struct MatrixConfig {
     pub multi_message_delay_ms: u64,
     /// Optional Matrix recovery key for automatic E2EE key backup restore.
     /// When set, ZeroClaw recovers room keys and cross-signing secrets on startup.
+    #[secret]
     #[serde(default)]
     pub recovery_key: Option<String>,
 }
@@ -6731,8 +7103,12 @@ impl ChannelConfig for MatrixConfig {
     }
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.signal"]
 pub struct SignalConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// Base URL for the signal-cli HTTP daemon (e.g. "http://127.0.0.1:8686").
     pub http_url: String,
     /// E.164 phone number of the signal-cli account (e.g. "+1234567890").
@@ -6800,10 +7176,15 @@ pub enum WhatsAppChatPolicy {
 /// WhatsApp channel configuration (Cloud API or Web mode).
 ///
 /// Set `phone_number_id` for Cloud API mode, or `session_path` for Web mode.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.whatsapp"]
 pub struct WhatsAppConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// Access token from Meta Business Suite (Cloud API mode)
     #[serde(default)]
+    #[secret]
     pub access_token: Option<String>,
     /// Phone number ID from Meta Business API (Cloud API mode)
     #[serde(default)]
@@ -6811,11 +7192,13 @@ pub struct WhatsAppConfig {
     /// Webhook verify token (you define this, Meta sends it back for verification)
     /// Only used in Cloud API mode
     #[serde(default)]
+    #[secret]
     pub verify_token: Option<String>,
     /// App secret from Meta Business Suite (for webhook signature verification)
     /// Can also be set via `ZEROCLAW_WHATSAPP_APP_SECRET` environment variable
     /// Only used in Cloud API mode
     #[serde(default)]
+    #[secret]
     pub app_secret: Option<String>,
     /// Session database path for WhatsApp Web client (Web mode)
     /// When set, enables native WhatsApp Web mode with wa-rs
@@ -6882,14 +7265,20 @@ impl ChannelConfig for WhatsAppConfig {
     }
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.linq"]
 pub struct LinqConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// Linq Partner API token (Bearer auth)
+    #[secret]
     pub api_token: String,
     /// Phone number to send from (E.164 format)
     pub from_phone: String,
     /// Webhook signing secret for signature verification
     #[serde(default)]
+    #[secret]
     pub signing_secret: Option<String>,
     /// Allowed sender handles (phone numbers) or "*" for all
     #[serde(default)]
@@ -6906,9 +7295,14 @@ impl ChannelConfig for LinqConfig {
 }
 
 /// WATI WhatsApp Business API channel configuration.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.wati"]
 pub struct WatiConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// WATI API token (Bearer auth).
+    #[secret]
     pub api_token: String,
     /// WATI API base URL (default: https://live-mt-server.wati.io).
     #[serde(default = "default_wati_api_url")]
@@ -6939,16 +7333,22 @@ impl ChannelConfig for WatiConfig {
 }
 
 /// Nextcloud Talk bot configuration (webhook receive + OCS send API).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.nextcloud-talk"]
 pub struct NextcloudTalkConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// Nextcloud base URL (e.g. "https://cloud.example.com").
     pub base_url: String,
     /// Bot app token used for OCS API bearer auth.
+    #[secret]
     pub app_token: String,
     /// Shared secret for webhook signature verification.
     ///
     /// Can also be set via `ZEROCLAW_NEXTCLOUD_TALK_WEBHOOK_SECRET`.
     #[serde(default)]
+    #[secret]
     pub webhook_secret: Option<String>,
     /// Allowed Nextcloud actor IDs (`[]` = deny all, `"*"` = allow all).
     #[serde(default)]
@@ -7009,8 +7409,12 @@ impl WhatsAppConfig {
 ///
 /// Subscribes to MQTT topics and dispatches incoming messages
 /// to the SOP engine for processing.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.mqtt"]
 pub struct MqttConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// MQTT broker URL (e.g., `mqtt://localhost:1883` or `mqtts://broker.example.com:8883`).
     /// Use `mqtt://` for plain connections or `mqtts://` for TLS.
     pub broker_url: String,
@@ -7026,6 +7430,7 @@ pub struct MqttConfig {
     /// Username for authentication (optional).
     pub username: Option<String>,
     /// Password for authentication (optional).
+    #[secret]
     pub password: Option<String>,
     /// Enable TLS encryption. Must match the broker_url scheme:
     /// - `mqtt://` → `use_tls: false`
@@ -7106,8 +7511,12 @@ fn default_mqtt_keep_alive_secs() -> u64 {
 }
 
 /// IRC channel configuration.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.irc"]
 pub struct IrcConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// IRC server hostname
     pub server: String,
     /// IRC server port (default: 6697 for TLS)
@@ -7124,10 +7533,13 @@ pub struct IrcConfig {
     #[serde(default)]
     pub allowed_users: Vec<String>,
     /// Server password (for bouncers like ZNC)
+    #[secret]
     pub server_password: Option<String>,
     /// NickServ IDENTIFY password
+    #[secret]
     pub nickserv_password: Option<String>,
     /// SASL PLAIN password (IRCv3)
+    #[secret]
     pub sasl_password: Option<String>,
     /// Verify TLS certificate (default: true)
     pub verify_tls: Option<bool>,
@@ -7160,17 +7572,24 @@ pub enum LarkReceiveMode {
 
 /// Lark/Feishu configuration for messaging integration.
 /// Lark is the international version; Feishu is the Chinese version.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.lark"]
 pub struct LarkConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// App ID from Lark/Feishu developer console
     pub app_id: String,
     /// App Secret from Lark/Feishu developer console
+    #[secret]
     pub app_secret: String,
     /// Encrypt key for webhook message decryption (optional)
     #[serde(default)]
+    #[secret]
     pub encrypt_key: Option<String>,
     /// Verification token for webhook validation (optional)
     #[serde(default)]
+    #[secret]
     pub verification_token: Option<String>,
     /// Allowed user IDs or union IDs (empty = deny all, "*" = allow all)
     #[serde(default)]
@@ -7205,17 +7624,24 @@ impl ChannelConfig for LarkConfig {
 }
 
 /// Feishu configuration for messaging integration.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.feishu"]
 pub struct FeishuConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// App ID from Feishu developer console
     pub app_id: String,
     /// App Secret from Feishu developer console
+    #[secret]
     pub app_secret: String,
     /// Encrypt key for webhook message decryption (optional)
     #[serde(default)]
+    #[secret]
     pub encrypt_key: Option<String>,
     /// Verification token for webhook validation (optional)
     #[serde(default)]
+    #[secret]
     pub verification_token: Option<String>,
     /// Allowed user IDs or union IDs (empty = deny all, "*" = allow all)
     #[serde(default)]
@@ -7245,34 +7671,42 @@ impl ChannelConfig for FeishuConfig {
 // ── Security Config ─────────────────────────────────────────────────
 
 /// Security configuration for sandboxing, resource limits, and audit logging
-#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, Configurable)]
+#[prefix = "security"]
 pub struct SecurityConfig {
     /// Sandbox configuration
     #[serde(default)]
+    #[nested]
     pub sandbox: SandboxConfig,
 
     /// Resource limits
     #[serde(default)]
+    #[nested]
     pub resources: ResourceLimitsConfig,
 
     /// Audit logging configuration
     #[serde(default)]
+    #[nested]
     pub audit: AuditConfig,
 
     /// OTP gating configuration for sensitive actions/domains.
     #[serde(default)]
+    #[nested]
     pub otp: OtpConfig,
 
     /// Emergency-stop state machine configuration.
     #[serde(default)]
+    #[nested]
     pub estop: EstopConfig,
 
     /// Nevis IAM integration for SSO/MFA authentication and role-based access.
     #[serde(default)]
+    #[nested]
     pub nevis: NevisConfig,
 
     /// WebAuthn / FIDO2 hardware key authentication configuration.
     #[serde(default)]
+    #[nested]
     pub webauthn: WebAuthnConfig,
 }
 
@@ -7280,7 +7714,8 @@ pub struct SecurityConfig {
 ///
 /// Enables registration and authentication via hardware security keys
 /// (YubiKey, SoloKey, etc.) and platform authenticators (Touch ID, Windows Hello).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "security.webauthn"]
 pub struct WebAuthnConfig {
     /// Enable WebAuthn authentication. Default: false.
     #[serde(default)]
@@ -7333,7 +7768,8 @@ pub enum OtpMethod {
 }
 
 /// Security OTP configuration.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "security.otp"]
 #[serde(deny_unknown_fields)]
 pub struct OtpConfig {
     /// Enable OTP gating. Defaults to disabled for backward compatibility.
@@ -7407,7 +7843,8 @@ impl Default for OtpConfig {
 }
 
 /// Emergency stop configuration.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "security.estop"]
 #[serde(deny_unknown_fields)]
 pub struct EstopConfig {
     /// Enable emergency stop controls.
@@ -7441,7 +7878,8 @@ impl Default for EstopConfig {
 ///
 /// When `enabled` is true, ZeroClaw validates incoming requests against a Nevis
 /// Security Suite instance and maps Nevis roles to tool/workspace permissions.
-#[derive(Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "security.nevis"]
 #[serde(deny_unknown_fields)]
 pub struct NevisConfig {
     /// Enable Nevis IAM integration. Defaults to false for backward compatibility.
@@ -7462,6 +7900,7 @@ pub struct NevisConfig {
 
     /// OAuth2 client secret. Encrypted via SecretStore when stored on disk.
     #[serde(default)]
+    #[secret]
     pub client_secret: Option<String>,
 
     /// Token validation strategy: `"local"` (JWKS) or `"remote"` (introspection).
@@ -7595,7 +8034,8 @@ pub struct NevisRoleMappingConfig {
 }
 
 /// Sandbox configuration for OS-level isolation
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "security.sandbox"]
 pub struct SandboxConfig {
     /// Enable sandboxing (None = auto-detect, Some = explicit)
     #[serde(default)]
@@ -7643,7 +8083,8 @@ pub enum SandboxBackend {
 }
 
 /// Resource limits for command execution
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "security.resources"]
 pub struct ResourceLimitsConfig {
     /// Maximum memory in MB per command
     #[serde(default = "default_max_memory_mb")]
@@ -7690,7 +8131,8 @@ impl Default for ResourceLimitsConfig {
 }
 
 /// Audit logging configuration
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "security.audit"]
 pub struct AuditConfig {
     /// Enable audit logging
     #[serde(default = "default_audit_enabled")]
@@ -7733,11 +8175,16 @@ impl Default for AuditConfig {
 }
 
 /// DingTalk configuration for Stream Mode messaging
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.dingtalk"]
 pub struct DingTalkConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// Client ID (AppKey) from DingTalk developer console
     pub client_id: String,
     /// Client Secret (AppSecret) from DingTalk developer console
+    #[secret]
     pub client_secret: String,
     /// Allowed user IDs (staff IDs). Empty = deny all, "*" = allow all
     #[serde(default)]
@@ -7758,9 +8205,14 @@ impl ChannelConfig for DingTalkConfig {
 }
 
 /// WeCom (WeChat Enterprise) Bot Webhook configuration
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.wecom"]
 pub struct WeComConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// Webhook key from WeCom Bot configuration
+    #[secret]
     pub webhook_key: String,
     /// Allowed user IDs. Empty = deny all, "*" = allow all
     #[serde(default)]
@@ -7777,11 +8229,16 @@ impl ChannelConfig for WeComConfig {
 }
 
 /// QQ Official Bot configuration (Tencent QQ Bot SDK)
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.qq"]
 pub struct QQConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// App ID from QQ Bot developer console
     pub app_id: String,
     /// App Secret from QQ Bot developer console
+    #[secret]
     pub app_secret: String,
     /// Allowed user IDs. Empty = deny all, "*" = allow all
     #[serde(default)]
@@ -7802,9 +8259,14 @@ impl ChannelConfig for QQConfig {
 }
 
 /// X/Twitter channel configuration (Twitter API v2)
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.twitter"]
 pub struct TwitterConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// Twitter API v2 Bearer Token (OAuth 2.0)
+    #[secret]
     pub bearer_token: String,
     /// Allowed usernames or user IDs. Empty = deny all, "*" = allow all
     #[serde(default)]
@@ -7821,11 +8283,16 @@ impl ChannelConfig for TwitterConfig {
 }
 
 /// Mochat channel configuration (Mochat customer service API)
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.mochat"]
 pub struct MochatConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// Mochat API base URL
     pub api_url: String,
     /// Mochat API token
+    #[secret]
     pub api_token: String,
     /// Allowed user IDs. Empty = deny all, "*" = allow all
     #[serde(default)]
@@ -7849,13 +8316,19 @@ impl ChannelConfig for MochatConfig {
 }
 
 /// Reddit channel configuration (OAuth2 bot).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.reddit"]
 pub struct RedditConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// Reddit OAuth2 client ID.
     pub client_id: String,
     /// Reddit OAuth2 client secret.
+    #[secret]
     pub client_secret: String,
     /// Reddit OAuth2 refresh token for persistent access.
+    #[secret]
     pub refresh_token: String,
     /// Reddit bot username (without `u/` prefix).
     pub username: String,
@@ -7875,11 +8348,16 @@ impl ChannelConfig for RedditConfig {
 }
 
 /// Bluesky channel configuration (AT Protocol).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.bluesky"]
 pub struct BlueskyConfig {
+    /// Whether this channel is active (must be explicitly enabled). Default: false.
+    #[serde(default)]
+    pub enabled: bool,
     /// Bluesky handle (e.g. `"mybot.bsky.social"`).
     pub handle: String,
     /// App-specific password (from Bluesky settings).
+    #[secret]
     pub app_password: String,
 }
 
@@ -7962,9 +8440,11 @@ impl ChannelConfig for VoiceWakeConfig {
 
 /// Nostr channel configuration (NIP-04 + NIP-17 private messages)
 #[cfg(feature = "channel-nostr")]
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "channels.nostr"]
 pub struct NostrConfig {
     /// Private key in hex or nsec bech32 format
+    #[secret]
     pub private_key: String,
     /// Relay URLs (wss://). Defaults to popular public relays if omitted.
     #[serde(default = "default_nostr_relays")]
@@ -8001,11 +8481,13 @@ pub fn default_nostr_relays() -> Vec<String> {
 /// When `enabled = true`, the agent polls a Notion database for pending tasks
 /// and exposes a `notion` tool for querying, reading, creating, and updating pages.
 /// Requires `api_key` (or the `NOTION_API_KEY` env var) and `database_id`.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "notion"]
 pub struct NotionConfig {
     #[serde(default)]
     pub enabled: bool,
     #[serde(default)]
+    #[secret]
     pub api_key: String,
     #[serde(default)]
     pub database_id: String,
@@ -8073,7 +8555,8 @@ impl Default for NotionConfig {
 /// ## Auth
 /// Jira Cloud uses HTTP Basic auth: `email` + `api_token`.
 /// `api_token` is stored encrypted at rest; set it here or via `JIRA_API_TOKEN`.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "jira"]
 pub struct JiraConfig {
     /// Enable the `jira` tool. Default: `false`.
     #[serde(default)]
@@ -8086,6 +8569,7 @@ pub struct JiraConfig {
     pub email: String,
     /// Jira API token. Encrypted at rest. Falls back to `JIRA_API_TOKEN` env var.
     #[serde(default)]
+    #[secret]
     pub api_token: String,
     /// Actions the agent is permitted to call.
     /// Valid values: `"get_ticket"`, `"search_tickets"`, `"comment_ticket"`.
@@ -8121,7 +8605,8 @@ impl Default for JiraConfig {
 ///
 /// Controls the read-only cloud transformation analysis tools:
 /// IaC review, migration assessment, cost analysis, and architecture review.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "cloud-ops"]
 pub struct CloudOpsConfig {
     /// Enable cloud operations tools. Default: false.
     #[serde(default)]
@@ -8241,7 +8726,8 @@ fn default_conversational_ai_timeout_secs() -> u64 {
 ///
 /// **Status: Reserved for future use.** This configuration is parsed but not yet
 /// consumed by the runtime. Setting `enabled = true` will produce a startup warning.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "conversational-ai"]
 pub struct ConversationalAiConfig {
     /// Enable conversational AI features. Default: false.
     #[serde(default)]
@@ -8302,7 +8788,8 @@ impl Default for ConversationalAiConfig {
 // ── Security ops config ─────────────────────────────────────────
 
 /// Managed Cybersecurity Service (MCSS) dashboard agent configuration (`[security_ops]`).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "security-ops"]
 pub struct SecurityOpsConfig {
     /// Enable security operations tools.
     #[serde(default)]
@@ -8749,66 +9236,6 @@ async fn resolve_runtime_config_dirs(
     ))
 }
 
-fn decrypt_optional_secret(
-    store: &crate::security::SecretStore,
-    value: &mut Option<String>,
-    field_name: &str,
-) -> Result<()> {
-    if let Some(raw) = value.clone() {
-        if crate::security::SecretStore::is_encrypted(&raw) {
-            *value = Some(
-                store
-                    .decrypt(&raw)
-                    .with_context(|| format!("Failed to decrypt {field_name}"))?,
-            );
-        }
-    }
-    Ok(())
-}
-
-fn decrypt_secret(
-    store: &crate::security::SecretStore,
-    value: &mut String,
-    field_name: &str,
-) -> Result<()> {
-    if crate::security::SecretStore::is_encrypted(value) {
-        *value = store
-            .decrypt(value)
-            .with_context(|| format!("Failed to decrypt {field_name}"))?;
-    }
-    Ok(())
-}
-
-fn encrypt_optional_secret(
-    store: &crate::security::SecretStore,
-    value: &mut Option<String>,
-    field_name: &str,
-) -> Result<()> {
-    if let Some(raw) = value.clone() {
-        if !crate::security::SecretStore::is_encrypted(&raw) {
-            *value = Some(
-                store
-                    .encrypt(&raw)
-                    .with_context(|| format!("Failed to encrypt {field_name}"))?,
-            );
-        }
-    }
-    Ok(())
-}
-
-fn encrypt_secret(
-    store: &crate::security::SecretStore,
-    value: &mut String,
-    field_name: &str,
-) -> Result<()> {
-    if !crate::security::SecretStore::is_encrypted(value) {
-        *value = store
-            .encrypt(value)
-            .with_context(|| format!("Failed to encrypt {field_name}"))?;
-    }
-    Ok(())
-}
-
 fn config_dir_creation_error(path: &Path) -> String {
     format!(
         "Failed to create config directory: {}. If running as an OpenRC service, \
@@ -9006,6 +9433,12 @@ impl Config {
             // `auto_approve` in the approval decision (see approval/mod.rs).
             config.autonomy.ensure_default_auto_approve();
 
+            // Backward-compatible `enabled` backfill: if a channel section
+            // exists in the TOML but has no explicit `enabled` key, the user
+            // configured it before `enabled` was introduced — treat it as
+            // enabled so existing setups don't silently break.
+            config.channels_config.backfill_enabled(&contents);
+
             // Detect unknown top-level config keys by comparing the raw
             // TOML table keys against what Config actually deserializes.
             // This replaces the previous serde_ignored-based approach which
@@ -9033,347 +9466,8 @@ impl Config {
             config.config_path = config_path.clone();
             config.workspace_dir = workspace_dir;
             let store = crate::security::SecretStore::new(&zeroclaw_dir, config.secrets.encrypt);
-            decrypt_optional_secret(&store, &mut config.api_key, "config.api_key")?;
-            decrypt_optional_secret(
-                &store,
-                &mut config.composio.api_key,
-                "config.composio.api_key",
-            )?;
-            if let Some(ref mut pinggy) = config.tunnel.pinggy {
-                decrypt_optional_secret(&store, &mut pinggy.token, "config.tunnel.pinggy.token")?;
-            }
-            decrypt_optional_secret(
-                &store,
-                &mut config.microsoft365.client_secret,
-                "config.microsoft365.client_secret",
-            )?;
-
-            decrypt_optional_secret(
-                &store,
-                &mut config.browser.computer_use.api_key,
-                "config.browser.computer_use.api_key",
-            )?;
-
-            decrypt_optional_secret(
-                &store,
-                &mut config.web_search.brave_api_key,
-                "config.web_search.brave_api_key",
-            )?;
-
-            decrypt_optional_secret(
-                &store,
-                &mut config.storage.provider.config.db_url,
-                "config.storage.provider.config.db_url",
-            )?;
-
-            for agent in config.agents.values_mut() {
-                decrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
-            }
-
-            // Decrypt TTS provider API keys
-            if let Some(ref mut openai) = config.tts.openai {
-                decrypt_optional_secret(&store, &mut openai.api_key, "config.tts.openai.api_key")?;
-            }
-            if let Some(ref mut elevenlabs) = config.tts.elevenlabs {
-                decrypt_optional_secret(
-                    &store,
-                    &mut elevenlabs.api_key,
-                    "config.tts.elevenlabs.api_key",
-                )?;
-            }
-            if let Some(ref mut google) = config.tts.google {
-                decrypt_optional_secret(&store, &mut google.api_key, "config.tts.google.api_key")?;
-            }
-
-            // Decrypt nested STT provider API keys
-            decrypt_optional_secret(
-                &store,
-                &mut config.transcription.api_key,
-                "config.transcription.api_key",
-            )?;
-            if let Some(ref mut openai) = config.transcription.openai {
-                decrypt_optional_secret(
-                    &store,
-                    &mut openai.api_key,
-                    "config.transcription.openai.api_key",
-                )?;
-            }
-            if let Some(ref mut deepgram) = config.transcription.deepgram {
-                decrypt_optional_secret(
-                    &store,
-                    &mut deepgram.api_key,
-                    "config.transcription.deepgram.api_key",
-                )?;
-            }
-            if let Some(ref mut assemblyai) = config.transcription.assemblyai {
-                decrypt_optional_secret(
-                    &store,
-                    &mut assemblyai.api_key,
-                    "config.transcription.assemblyai.api_key",
-                )?;
-            }
-            if let Some(ref mut google) = config.transcription.google {
-                decrypt_optional_secret(
-                    &store,
-                    &mut google.api_key,
-                    "config.transcription.google.api_key",
-                )?;
-            }
-            if let Some(ref mut local) = config.transcription.local_whisper {
-                decrypt_optional_secret(
-                    &store,
-                    &mut local.bearer_token,
-                    "config.transcription.local_whisper.bearer_token",
-                )?;
-            }
-
-            #[cfg(feature = "channel-nostr")]
-            if let Some(ref mut ns) = config.channels_config.nostr {
-                decrypt_secret(
-                    &store,
-                    &mut ns.private_key,
-                    "config.channels_config.nostr.private_key",
-                )?;
-            }
-            if let Some(ref mut fs) = config.channels_config.feishu {
-                decrypt_secret(
-                    &store,
-                    &mut fs.app_secret,
-                    "config.channels_config.feishu.app_secret",
-                )?;
-                decrypt_optional_secret(
-                    &store,
-                    &mut fs.encrypt_key,
-                    "config.channels_config.feishu.encrypt_key",
-                )?;
-                decrypt_optional_secret(
-                    &store,
-                    &mut fs.verification_token,
-                    "config.channels_config.feishu.verification_token",
-                )?;
-            }
-
-            // Decrypt channel secrets
-            if let Some(ref mut tg) = config.channels_config.telegram {
-                decrypt_secret(
-                    &store,
-                    &mut tg.bot_token,
-                    "config.channels_config.telegram.bot_token",
-                )?;
-            }
-            if let Some(ref mut dc) = config.channels_config.discord {
-                decrypt_secret(
-                    &store,
-                    &mut dc.bot_token,
-                    "config.channels_config.discord.bot_token",
-                )?;
-            }
-            if let Some(ref mut sl) = config.channels_config.slack {
-                decrypt_secret(
-                    &store,
-                    &mut sl.bot_token,
-                    "config.channels_config.slack.bot_token",
-                )?;
-                decrypt_optional_secret(
-                    &store,
-                    &mut sl.app_token,
-                    "config.channels_config.slack.app_token",
-                )?;
-            }
-            if let Some(ref mut mm) = config.channels_config.mattermost {
-                decrypt_secret(
-                    &store,
-                    &mut mm.bot_token,
-                    "config.channels_config.mattermost.bot_token",
-                )?;
-            }
-            if let Some(ref mut mx) = config.channels_config.matrix {
-                decrypt_secret(
-                    &store,
-                    &mut mx.access_token,
-                    "config.channels_config.matrix.access_token",
-                )?;
-                decrypt_optional_secret(
-                    &store,
-                    &mut mx.recovery_key,
-                    "config.channels_config.matrix.recovery_key",
-                )?;
-            }
-            if let Some(ref mut wa) = config.channels_config.whatsapp {
-                decrypt_optional_secret(
-                    &store,
-                    &mut wa.access_token,
-                    "config.channels_config.whatsapp.access_token",
-                )?;
-                decrypt_optional_secret(
-                    &store,
-                    &mut wa.app_secret,
-                    "config.channels_config.whatsapp.app_secret",
-                )?;
-                decrypt_optional_secret(
-                    &store,
-                    &mut wa.verify_token,
-                    "config.channels_config.whatsapp.verify_token",
-                )?;
-            }
-            if let Some(ref mut lq) = config.channels_config.linq {
-                decrypt_secret(
-                    &store,
-                    &mut lq.api_token,
-                    "config.channels_config.linq.api_token",
-                )?;
-                decrypt_optional_secret(
-                    &store,
-                    &mut lq.signing_secret,
-                    "config.channels_config.linq.signing_secret",
-                )?;
-            }
-            if let Some(ref mut wt) = config.channels_config.wati {
-                decrypt_secret(
-                    &store,
-                    &mut wt.api_token,
-                    "config.channels_config.wati.api_token",
-                )?;
-            }
-            if let Some(ref mut nc) = config.channels_config.nextcloud_talk {
-                decrypt_secret(
-                    &store,
-                    &mut nc.app_token,
-                    "config.channels_config.nextcloud_talk.app_token",
-                )?;
-                decrypt_optional_secret(
-                    &store,
-                    &mut nc.webhook_secret,
-                    "config.channels_config.nextcloud_talk.webhook_secret",
-                )?;
-            }
-            if let Some(ref mut em) = config.channels_config.email {
-                decrypt_secret(
-                    &store,
-                    &mut em.password,
-                    "config.channels_config.email.password",
-                )?;
-            }
-            if let Some(ref mut gp) = config.channels_config.gmail_push {
-                decrypt_secret(
-                    &store,
-                    &mut gp.oauth_token,
-                    "config.channels_config.gmail_push.oauth_token",
-                )?;
-            }
-            if let Some(ref mut irc) = config.channels_config.irc {
-                decrypt_optional_secret(
-                    &store,
-                    &mut irc.server_password,
-                    "config.channels_config.irc.server_password",
-                )?;
-                decrypt_optional_secret(
-                    &store,
-                    &mut irc.nickserv_password,
-                    "config.channels_config.irc.nickserv_password",
-                )?;
-                decrypt_optional_secret(
-                    &store,
-                    &mut irc.sasl_password,
-                    "config.channels_config.irc.sasl_password",
-                )?;
-            }
-            if let Some(ref mut lk) = config.channels_config.lark {
-                decrypt_secret(
-                    &store,
-                    &mut lk.app_secret,
-                    "config.channels_config.lark.app_secret",
-                )?;
-                decrypt_optional_secret(
-                    &store,
-                    &mut lk.encrypt_key,
-                    "config.channels_config.lark.encrypt_key",
-                )?;
-                decrypt_optional_secret(
-                    &store,
-                    &mut lk.verification_token,
-                    "config.channels_config.lark.verification_token",
-                )?;
-            }
-            if let Some(ref mut fs) = config.channels_config.feishu {
-                decrypt_secret(
-                    &store,
-                    &mut fs.app_secret,
-                    "config.channels_config.feishu.app_secret",
-                )?;
-                decrypt_optional_secret(
-                    &store,
-                    &mut fs.encrypt_key,
-                    "config.channels_config.feishu.encrypt_key",
-                )?;
-                decrypt_optional_secret(
-                    &store,
-                    &mut fs.verification_token,
-                    "config.channels_config.feishu.verification_token",
-                )?;
-            }
-            if let Some(ref mut dt) = config.channels_config.dingtalk {
-                decrypt_secret(
-                    &store,
-                    &mut dt.client_secret,
-                    "config.channels_config.dingtalk.client_secret",
-                )?;
-            }
-            if let Some(ref mut wc) = config.channels_config.wecom {
-                decrypt_secret(
-                    &store,
-                    &mut wc.webhook_key,
-                    "config.channels_config.wecom.webhook_key",
-                )?;
-            }
-            if let Some(ref mut qq) = config.channels_config.qq {
-                decrypt_secret(
-                    &store,
-                    &mut qq.app_secret,
-                    "config.channels_config.qq.app_secret",
-                )?;
-            }
-            if let Some(ref mut wh) = config.channels_config.webhook {
-                decrypt_optional_secret(
-                    &store,
-                    &mut wh.secret,
-                    "config.channels_config.webhook.secret",
-                )?;
-            }
-            if let Some(ref mut ct) = config.channels_config.clawdtalk {
-                decrypt_secret(
-                    &store,
-                    &mut ct.api_key,
-                    "config.channels_config.clawdtalk.api_key",
-                )?;
-                decrypt_optional_secret(
-                    &store,
-                    &mut ct.webhook_secret,
-                    "config.channels_config.clawdtalk.webhook_secret",
-                )?;
-            }
-
-            // Decrypt gateway paired tokens
-            for token in &mut config.gateway.paired_tokens {
-                decrypt_secret(&store, token, "config.gateway.paired_tokens[]")?;
-            }
-
-            // Decrypt Nevis IAM secret
-            decrypt_optional_secret(
-                &store,
-                &mut config.security.nevis.client_secret,
-                "config.security.nevis.client_secret",
-            )?;
-
-            // Notion API key (top-level, not in ChannelsConfig)
-            if !config.notion.api_key.is_empty() {
-                decrypt_secret(&store, &mut config.notion.api_key, "config.notion.api_key")?;
-            }
-
-            // Jira API token
-            if !config.jira.api_token.is_empty() {
-                decrypt_secret(&store, &mut config.jira.api_token, "config.jira.api_token")?;
-            }
+            // Decrypt all #[secret]-annotated fields via Configurable derive
+            config.decrypt_secrets(&store)?;
 
             config.apply_env_overrides();
             config.validate()?;
@@ -10507,355 +10601,8 @@ impl Config {
             .context("Config path must have a parent directory")?;
         let store = crate::security::SecretStore::new(zeroclaw_dir, self.secrets.encrypt);
 
-        encrypt_optional_secret(&store, &mut config_to_save.api_key, "config.api_key")?;
-        encrypt_optional_secret(
-            &store,
-            &mut config_to_save.composio.api_key,
-            "config.composio.api_key",
-        )?;
-        if let Some(ref mut pinggy) = config_to_save.tunnel.pinggy {
-            encrypt_optional_secret(&store, &mut pinggy.token, "config.tunnel.pinggy.token")?;
-        }
-        encrypt_optional_secret(
-            &store,
-            &mut config_to_save.microsoft365.client_secret,
-            "config.microsoft365.client_secret",
-        )?;
-
-        encrypt_optional_secret(
-            &store,
-            &mut config_to_save.browser.computer_use.api_key,
-            "config.browser.computer_use.api_key",
-        )?;
-
-        encrypt_optional_secret(
-            &store,
-            &mut config_to_save.web_search.brave_api_key,
-            "config.web_search.brave_api_key",
-        )?;
-
-        encrypt_optional_secret(
-            &store,
-            &mut config_to_save.storage.provider.config.db_url,
-            "config.storage.provider.config.db_url",
-        )?;
-
-        for agent in config_to_save.agents.values_mut() {
-            encrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
-        }
-
-        // Encrypt TTS provider API keys
-        if let Some(ref mut openai) = config_to_save.tts.openai {
-            encrypt_optional_secret(&store, &mut openai.api_key, "config.tts.openai.api_key")?;
-        }
-        if let Some(ref mut elevenlabs) = config_to_save.tts.elevenlabs {
-            encrypt_optional_secret(
-                &store,
-                &mut elevenlabs.api_key,
-                "config.tts.elevenlabs.api_key",
-            )?;
-        }
-        if let Some(ref mut google) = config_to_save.tts.google {
-            encrypt_optional_secret(&store, &mut google.api_key, "config.tts.google.api_key")?;
-        }
-
-        // Encrypt nested STT provider API keys
-        encrypt_optional_secret(
-            &store,
-            &mut config_to_save.transcription.api_key,
-            "config.transcription.api_key",
-        )?;
-        if let Some(ref mut openai) = config_to_save.transcription.openai {
-            encrypt_optional_secret(
-                &store,
-                &mut openai.api_key,
-                "config.transcription.openai.api_key",
-            )?;
-        }
-        if let Some(ref mut deepgram) = config_to_save.transcription.deepgram {
-            encrypt_optional_secret(
-                &store,
-                &mut deepgram.api_key,
-                "config.transcription.deepgram.api_key",
-            )?;
-        }
-        if let Some(ref mut assemblyai) = config_to_save.transcription.assemblyai {
-            encrypt_optional_secret(
-                &store,
-                &mut assemblyai.api_key,
-                "config.transcription.assemblyai.api_key",
-            )?;
-        }
-        if let Some(ref mut google) = config_to_save.transcription.google {
-            encrypt_optional_secret(
-                &store,
-                &mut google.api_key,
-                "config.transcription.google.api_key",
-            )?;
-        }
-        if let Some(ref mut local) = config_to_save.transcription.local_whisper {
-            encrypt_optional_secret(
-                &store,
-                &mut local.bearer_token,
-                "config.transcription.local_whisper.bearer_token",
-            )?;
-        }
-
-        #[cfg(feature = "channel-nostr")]
-        if let Some(ref mut ns) = config_to_save.channels_config.nostr {
-            encrypt_secret(
-                &store,
-                &mut ns.private_key,
-                "config.channels_config.nostr.private_key",
-            )?;
-        }
-        if let Some(ref mut fs) = config_to_save.channels_config.feishu {
-            encrypt_secret(
-                &store,
-                &mut fs.app_secret,
-                "config.channels_config.feishu.app_secret",
-            )?;
-            encrypt_optional_secret(
-                &store,
-                &mut fs.encrypt_key,
-                "config.channels_config.feishu.encrypt_key",
-            )?;
-            encrypt_optional_secret(
-                &store,
-                &mut fs.verification_token,
-                "config.channels_config.feishu.verification_token",
-            )?;
-        }
-
-        // Encrypt channel secrets
-        if let Some(ref mut tg) = config_to_save.channels_config.telegram {
-            encrypt_secret(
-                &store,
-                &mut tg.bot_token,
-                "config.channels_config.telegram.bot_token",
-            )?;
-        }
-        if let Some(ref mut dc) = config_to_save.channels_config.discord {
-            encrypt_secret(
-                &store,
-                &mut dc.bot_token,
-                "config.channels_config.discord.bot_token",
-            )?;
-        }
-        if let Some(ref mut sl) = config_to_save.channels_config.slack {
-            encrypt_secret(
-                &store,
-                &mut sl.bot_token,
-                "config.channels_config.slack.bot_token",
-            )?;
-            encrypt_optional_secret(
-                &store,
-                &mut sl.app_token,
-                "config.channels_config.slack.app_token",
-            )?;
-        }
-        if let Some(ref mut mm) = config_to_save.channels_config.mattermost {
-            encrypt_secret(
-                &store,
-                &mut mm.bot_token,
-                "config.channels_config.mattermost.bot_token",
-            )?;
-        }
-        if let Some(ref mut mx) = config_to_save.channels_config.matrix {
-            encrypt_secret(
-                &store,
-                &mut mx.access_token,
-                "config.channels_config.matrix.access_token",
-            )?;
-            encrypt_optional_secret(
-                &store,
-                &mut mx.recovery_key,
-                "config.channels_config.matrix.recovery_key",
-            )?;
-        }
-        if let Some(ref mut wa) = config_to_save.channels_config.whatsapp {
-            encrypt_optional_secret(
-                &store,
-                &mut wa.access_token,
-                "config.channels_config.whatsapp.access_token",
-            )?;
-            encrypt_optional_secret(
-                &store,
-                &mut wa.app_secret,
-                "config.channels_config.whatsapp.app_secret",
-            )?;
-            encrypt_optional_secret(
-                &store,
-                &mut wa.verify_token,
-                "config.channels_config.whatsapp.verify_token",
-            )?;
-        }
-        if let Some(ref mut lq) = config_to_save.channels_config.linq {
-            encrypt_secret(
-                &store,
-                &mut lq.api_token,
-                "config.channels_config.linq.api_token",
-            )?;
-            encrypt_optional_secret(
-                &store,
-                &mut lq.signing_secret,
-                "config.channels_config.linq.signing_secret",
-            )?;
-        }
-        if let Some(ref mut wt) = config_to_save.channels_config.wati {
-            encrypt_secret(
-                &store,
-                &mut wt.api_token,
-                "config.channels_config.wati.api_token",
-            )?;
-        }
-        if let Some(ref mut nc) = config_to_save.channels_config.nextcloud_talk {
-            encrypt_secret(
-                &store,
-                &mut nc.app_token,
-                "config.channels_config.nextcloud_talk.app_token",
-            )?;
-            encrypt_optional_secret(
-                &store,
-                &mut nc.webhook_secret,
-                "config.channels_config.nextcloud_talk.webhook_secret",
-            )?;
-        }
-        if let Some(ref mut em) = config_to_save.channels_config.email {
-            encrypt_secret(
-                &store,
-                &mut em.password,
-                "config.channels_config.email.password",
-            )?;
-        }
-        if let Some(ref mut gp) = config_to_save.channels_config.gmail_push {
-            encrypt_secret(
-                &store,
-                &mut gp.oauth_token,
-                "config.channels_config.gmail_push.oauth_token",
-            )?;
-        }
-        if let Some(ref mut irc) = config_to_save.channels_config.irc {
-            encrypt_optional_secret(
-                &store,
-                &mut irc.server_password,
-                "config.channels_config.irc.server_password",
-            )?;
-            encrypt_optional_secret(
-                &store,
-                &mut irc.nickserv_password,
-                "config.channels_config.irc.nickserv_password",
-            )?;
-            encrypt_optional_secret(
-                &store,
-                &mut irc.sasl_password,
-                "config.channels_config.irc.sasl_password",
-            )?;
-        }
-        if let Some(ref mut lk) = config_to_save.channels_config.lark {
-            encrypt_secret(
-                &store,
-                &mut lk.app_secret,
-                "config.channels_config.lark.app_secret",
-            )?;
-            encrypt_optional_secret(
-                &store,
-                &mut lk.encrypt_key,
-                "config.channels_config.lark.encrypt_key",
-            )?;
-            encrypt_optional_secret(
-                &store,
-                &mut lk.verification_token,
-                "config.channels_config.lark.verification_token",
-            )?;
-        }
-        if let Some(ref mut fs) = config_to_save.channels_config.feishu {
-            encrypt_secret(
-                &store,
-                &mut fs.app_secret,
-                "config.channels_config.feishu.app_secret",
-            )?;
-            encrypt_optional_secret(
-                &store,
-                &mut fs.encrypt_key,
-                "config.channels_config.feishu.encrypt_key",
-            )?;
-            encrypt_optional_secret(
-                &store,
-                &mut fs.verification_token,
-                "config.channels_config.feishu.verification_token",
-            )?;
-        }
-        if let Some(ref mut dt) = config_to_save.channels_config.dingtalk {
-            encrypt_secret(
-                &store,
-                &mut dt.client_secret,
-                "config.channels_config.dingtalk.client_secret",
-            )?;
-        }
-        if let Some(ref mut wc) = config_to_save.channels_config.wecom {
-            encrypt_secret(
-                &store,
-                &mut wc.webhook_key,
-                "config.channels_config.wecom.webhook_key",
-            )?;
-        }
-        if let Some(ref mut qq) = config_to_save.channels_config.qq {
-            encrypt_secret(
-                &store,
-                &mut qq.app_secret,
-                "config.channels_config.qq.app_secret",
-            )?;
-        }
-        if let Some(ref mut wh) = config_to_save.channels_config.webhook {
-            encrypt_optional_secret(
-                &store,
-                &mut wh.secret,
-                "config.channels_config.webhook.secret",
-            )?;
-        }
-        if let Some(ref mut ct) = config_to_save.channels_config.clawdtalk {
-            encrypt_secret(
-                &store,
-                &mut ct.api_key,
-                "config.channels_config.clawdtalk.api_key",
-            )?;
-            encrypt_optional_secret(
-                &store,
-                &mut ct.webhook_secret,
-                "config.channels_config.clawdtalk.webhook_secret",
-            )?;
-        }
-
-        // Encrypt gateway paired tokens
-        for token in &mut config_to_save.gateway.paired_tokens {
-            encrypt_secret(&store, token, "config.gateway.paired_tokens[]")?;
-        }
-
-        // Encrypt Nevis IAM secret
-        encrypt_optional_secret(
-            &store,
-            &mut config_to_save.security.nevis.client_secret,
-            "config.security.nevis.client_secret",
-        )?;
-
-        // Notion API key (top-level, not in ChannelsConfig)
-        if !config_to_save.notion.api_key.is_empty() {
-            encrypt_secret(
-                &store,
-                &mut config_to_save.notion.api_key,
-                "config.notion.api_key",
-            )?;
-        }
-
-        // Jira API token
-        if !config_to_save.jira.api_token.is_empty() {
-            encrypt_secret(
-                &store,
-                &mut config_to_save.jira.api_token,
-                "config.jira.api_token",
-            )?;
-        }
+        // Encrypt all #[secret]-annotated fields via Configurable derive
+        config_to_save.encrypt_secrets(&store)?;
 
         let toml_str =
             toml::to_string_pretty(&config_to_save).context("Failed to serialize config")?;
@@ -10999,7 +10746,8 @@ async fn sync_directory(path: &Path) -> Result<()> {
 /// The `default_execution_mode` field uses the `SopExecutionMode` type from
 /// `sop::types` (re-exported via `sop::SopExecutionMode`). To avoid circular
 /// module references, config stores it using the same enum definition.
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "sop"]
 pub struct SopConfig {
     /// Directory containing SOP definitions (subdirs with SOP.toml + SOP.md).
     /// Falls back to `<workspace>/sops` when omitted.
@@ -11056,6 +10804,33 @@ impl Default for SopConfig {
     }
 }
 
+// ── HasPropKind impls for config enums ──
+// Scalars (bool, String, integers, floats) are covered by impl_prop_kind! in traits.rs.
+// Config enums serialize as TOML strings and are classified as PropKind::Enum.
+macro_rules! impl_enum_prop_kind {
+    ($($ty:ty),+ $(,)?) => {
+        $(impl HasPropKind for $ty { const PROP_KIND: PropKind = PropKind::Enum; })+
+    };
+}
+impl_enum_prop_kind!(
+    SwarmStrategy,
+    HardwareTransport,
+    McpTransport,
+    ToolFilterGroupMode,
+    SkillsPromptInjectionMode,
+    FirecrawlMode,
+    ProxyScope,
+    SearchMode,
+    CronScheduleDecl,
+    StreamMode,
+    WhatsAppWebMode,
+    WhatsAppChatPolicy,
+    LarkReceiveMode,
+    OtpMethod,
+    SandboxBackend,
+    AutonomyLevel,
+);
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -11527,6 +11302,7 @@ auto_save = true
             channels_config: ChannelsConfig {
                 cli: true,
                 telegram: Some(TelegramConfig {
+                    enabled: true,
                     bot_token: "123:ABC".into(),
                     allowed_users: vec!["user1".into()],
                     stream_mode: StreamMode::default(),
@@ -12194,6 +11970,7 @@ default_temperature = 0.7
         config.web_search.brave_api_key = Some("brave-credential".into());
         config.storage.provider.config.db_url = Some("postgres://user:pw@host/db".into());
         config.channels_config.feishu = Some(FeishuConfig {
+            enabled: true,
             app_id: "cli_feishu_123".into(),
             app_secret: "feishu-secret".into(),
             encrypt_key: Some("feishu-encrypt".into()),
@@ -12342,6 +12119,7 @@ default_temperature = 0.7
     #[test]
     async fn telegram_config_serde() {
         let tc = TelegramConfig {
+            enabled: true,
             bot_token: "123:XYZ".into(),
             allowed_users: vec!["alice".into(), "bob".into()],
             stream_mode: StreamMode::Partial,
@@ -12372,6 +12150,7 @@ default_temperature = 0.7
     #[test]
     async fn discord_config_serde() {
         let dc = DiscordConfig {
+            enabled: true,
             bot_token: "discord-token".into(),
             guild_id: Some("12345".into()),
             allowed_users: vec![],
@@ -12393,6 +12172,7 @@ default_temperature = 0.7
     #[test]
     async fn discord_config_optional_guild() {
         let dc = DiscordConfig {
+            enabled: true,
             bot_token: "tok".into(),
             guild_id: None,
             allowed_users: vec![],
@@ -12415,6 +12195,7 @@ default_temperature = 0.7
     #[test]
     async fn imessage_config_serde() {
         let ic = IMessageConfig {
+            enabled: true,
             allowed_contacts: vec!["+1234567890".into(), "user@icloud.com".into()],
         };
         let json = serde_json::to_string(&ic).unwrap();
@@ -12426,6 +12207,7 @@ default_temperature = 0.7
     #[test]
     async fn imessage_config_empty_contacts() {
         let ic = IMessageConfig {
+            enabled: true,
             allowed_contacts: vec![],
         };
         let json = serde_json::to_string(&ic).unwrap();
@@ -12436,6 +12218,7 @@ default_temperature = 0.7
     #[test]
     async fn imessage_config_wildcard() {
         let ic = IMessageConfig {
+            enabled: true,
             allowed_contacts: vec!["*".into()],
         };
         let toml_str = toml::to_string(&ic).unwrap();
@@ -12446,6 +12229,7 @@ default_temperature = 0.7
     #[test]
     async fn matrix_config_serde() {
         let mc = MatrixConfig {
+            enabled: true,
             homeserver: "https://matrix.org".into(),
             access_token: "syt_token_abc".into(),
             user_id: Some("@bot:matrix.org".into()),
@@ -12472,6 +12256,7 @@ default_temperature = 0.7
     #[test]
     async fn matrix_config_toml_roundtrip() {
         let mc = MatrixConfig {
+            enabled: true,
             homeserver: "https://synapse.local:8448".into(),
             access_token: "tok".into(),
             user_id: None,
@@ -12509,6 +12294,7 @@ allowed_users = ["@ops:matrix.org"]
     #[test]
     async fn signal_config_serde() {
         let sc = SignalConfig {
+            enabled: true,
             http_url: "http://127.0.0.1:8686".into(),
             account: "+1234567890".into(),
             group_id: Some("group123".into()),
@@ -12530,6 +12316,7 @@ allowed_users = ["@ops:matrix.org"]
     #[test]
     async fn signal_config_toml_roundtrip() {
         let sc = SignalConfig {
+            enabled: true,
             http_url: "http://localhost:8080".into(),
             account: "+9876543210".into(),
             group_id: None,
@@ -12567,9 +12354,11 @@ allowed_users = ["@ops:matrix.org"]
             mattermost: None,
             webhook: None,
             imessage: Some(IMessageConfig {
+                enabled: true,
                 allowed_contacts: vec!["+1".into()],
             }),
             matrix: Some(MatrixConfig {
+                enabled: true,
                 homeserver: "https://m.org".into(),
                 access_token: "tok".into(),
                 user_id: None,
@@ -12797,6 +12586,7 @@ channel_ids = ["C123", "D456"]
     #[test]
     async fn whatsapp_config_serde() {
         let wc = WhatsAppConfig {
+            enabled: true,
             access_token: Some("EAABx...".into()),
             phone_number_id: Some("123456789".into()),
             verify_token: Some("my-verify-token".into()),
@@ -12825,6 +12615,7 @@ channel_ids = ["C123", "D456"]
     #[test]
     async fn whatsapp_config_toml_roundtrip() {
         let wc = WhatsAppConfig {
+            enabled: true,
             access_token: Some("tok".into()),
             phone_number_id: Some("12345".into()),
             verify_token: Some("verify".into()),
@@ -12858,6 +12649,7 @@ channel_ids = ["C123", "D456"]
     #[test]
     async fn whatsapp_config_wildcard_allowed() {
         let wc = WhatsAppConfig {
+            enabled: true,
             access_token: Some("tok".into()),
             phone_number_id: Some("123".into()),
             verify_token: Some("ver".into()),
@@ -12883,6 +12675,7 @@ channel_ids = ["C123", "D456"]
     #[test]
     async fn whatsapp_config_backend_type_cloud_precedence_when_ambiguous() {
         let wc = WhatsAppConfig {
+            enabled: true,
             access_token: Some("tok".into()),
             phone_number_id: Some("123".into()),
             verify_token: Some("ver".into()),
@@ -12907,6 +12700,7 @@ channel_ids = ["C123", "D456"]
     #[test]
     async fn whatsapp_config_backend_type_web() {
         let wc = WhatsAppConfig {
+            enabled: true,
             access_token: None,
             phone_number_id: None,
             verify_token: None,
@@ -12942,6 +12736,7 @@ channel_ids = ["C123", "D456"]
             matrix: None,
             signal: None,
             whatsapp: Some(WhatsAppConfig {
+                enabled: true,
                 access_token: Some("tok".into()),
                 phone_number_id: Some("123".into()),
                 verify_token: Some("ver".into()),
@@ -14052,6 +13847,7 @@ default_model = "legacy-model"
         config.workspace_dir = config_dir.join("workspace");
         config.secrets.encrypt = true;
         config.channels_config.feishu = Some(FeishuConfig {
+            enabled: true,
             app_id: "cli_feishu_123".into(),
             app_secret: "feishu-secret".into(),
             encrypt_key: Some("feishu-encrypt".into()),
@@ -14863,6 +14659,7 @@ default_model = "persisted-profile"
     #[test]
     async fn lark_config_serde() {
         let lc = LarkConfig {
+            enabled: true,
             app_id: "cli_123456".into(),
             app_secret: "secret_abc".into(),
             encrypt_key: Some("encrypt_key".into()),
@@ -14887,6 +14684,7 @@ default_model = "persisted-profile"
     #[test]
     async fn lark_config_toml_roundtrip() {
         let lc = LarkConfig {
+            enabled: true,
             app_id: "cli_123456".into(),
             app_secret: "secret_abc".into(),
             encrypt_key: Some("encrypt_key".into()),
@@ -14936,6 +14734,7 @@ default_model = "persisted-profile"
     #[test]
     async fn feishu_config_serde() {
         let fc = FeishuConfig {
+            enabled: true,
             app_id: "cli_feishu_123".into(),
             app_secret: "secret_abc".into(),
             encrypt_key: Some("encrypt_key".into()),
@@ -14957,6 +14756,7 @@ default_model = "persisted-profile"
     #[test]
     async fn feishu_config_toml_roundtrip() {
         let fc = FeishuConfig {
+            enabled: true,
             app_id: "cli_feishu_123".into(),
             app_secret: "secret_abc".into(),
             encrypt_key: Some("encrypt_key".into()),
@@ -14988,6 +14788,7 @@ default_model = "persisted-profile"
     #[test]
     async fn nextcloud_talk_config_serde() {
         let nc = NextcloudTalkConfig {
+            enabled: true,
             base_url: "https://cloud.example.com".into(),
             app_token: "app-token".into(),
             webhook_secret: Some("webhook-secret".into()),
@@ -15220,6 +15021,7 @@ require_otp_to_resume = true
         config.workspace_dir = dir.join("workspace");
         config.config_path = dir.join("config.toml");
         config.channels_config.telegram = Some(TelegramConfig {
+            enabled: true,
             bot_token: plaintext_token.into(),
             allowed_users: vec!["user1".into()],
             stream_mode: StreamMode::default(),
@@ -15258,14 +15060,7 @@ require_otp_to_resume = true
         let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
         loaded.config_path = dir.join("config.toml");
         let load_store = crate::security::SecretStore::new(&dir, loaded.secrets.encrypt);
-        if let Some(ref mut tg) = loaded.channels_config.telegram {
-            decrypt_secret(
-                &load_store,
-                &mut tg.bot_token,
-                "config.channels_config.telegram.bot_token",
-            )
-            .unwrap();
-        }
+        loaded.decrypt_secrets(&load_store).unwrap();
         assert_eq!(
             loaded.channels_config.telegram.as_ref().unwrap().bot_token,
             plaintext_token,
@@ -15644,12 +15439,7 @@ require_otp_to_resume = true
         let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
         loaded.config_path = dir.join("config.toml");
         let load_store = crate::security::SecretStore::new(&dir, loaded.secrets.encrypt);
-        decrypt_optional_secret(
-            &load_store,
-            &mut loaded.security.nevis.client_secret,
-            "config.security.nevis.client_secret",
-        )
-        .unwrap();
+        loaded.decrypt_secrets(&load_store).unwrap();
         assert_eq!(
             loaded.security.nevis.client_secret.as_deref().unwrap(),
             plaintext_secret,
@@ -16120,4 +15910,695 @@ auto_approve = ["file_read", "file_write", "file_edit", "memory_recall", "memory
         assert_eq!(config.enforcement.mode, "warn");
         assert_eq!(config.enforcement.reserve_percent, 10);
     }
+
+    // ── Configurable macro tests ──
+
+    #[test]
+    async fn matrix_secret_fields_discovered() {
+        let mx = MatrixConfig {
+            enabled: true,
+            homeserver: "https://m.org".into(),
+            access_token: "tok".into(),
+            user_id: None,
+            device_id: None,
+            room_id: "!r:m".into(),
+            allowed_users: vec![],
+            allowed_rooms: vec![],
+            interrupt_on_new_message: false,
+            stream_mode: StreamMode::default(),
+            draft_update_interval_ms: 1500,
+            multi_message_delay_ms: 800,
+            recovery_key: None,
+        };
+        let fields = mx.secret_fields();
+        assert_eq!(fields.len(), 2);
+        assert_eq!(fields[0].name, "channels.matrix.access-token");
+        assert_eq!(fields[0].category, "Channels");
+        assert!(fields[0].is_set);
+        assert_eq!(fields[1].name, "channels.matrix.recovery-key");
+        assert!(!fields[1].is_set);
+    }
+
+    #[test]
+    async fn matrix_secret_fields_empty_not_set() {
+        let mx = MatrixConfig {
+            enabled: true,
+            homeserver: "https://m.org".into(),
+            access_token: String::new(),
+            user_id: None,
+            device_id: None,
+            room_id: "!r:m".into(),
+            allowed_users: vec![],
+            allowed_rooms: vec![],
+            interrupt_on_new_message: false,
+            stream_mode: StreamMode::default(),
+            draft_update_interval_ms: 1500,
+            multi_message_delay_ms: 800,
+            recovery_key: None,
+        };
+        let fields = mx.secret_fields();
+        assert!(!fields[0].is_set);
+    }
+
+    #[test]
+    async fn set_secret_updates_field() {
+        let mut mx = MatrixConfig {
+            enabled: true,
+            homeserver: "https://m.org".into(),
+            access_token: "old".into(),
+            user_id: None,
+            device_id: None,
+            room_id: "!r:m".into(),
+            allowed_users: vec![],
+            allowed_rooms: vec![],
+            interrupt_on_new_message: false,
+            stream_mode: StreamMode::default(),
+            draft_update_interval_ms: 1500,
+            multi_message_delay_ms: 800,
+            recovery_key: None,
+        };
+        mx.set_secret("channels.matrix.access-token", "new-token".into())
+            .unwrap();
+        assert_eq!(mx.access_token, "new-token");
+    }
+
+    #[test]
+    async fn set_secret_unknown_name_fails() {
+        let mut mx = MatrixConfig {
+            enabled: true,
+            homeserver: "https://m.org".into(),
+            access_token: "tok".into(),
+            user_id: None,
+            device_id: None,
+            room_id: "!r:m".into(),
+            allowed_users: vec![],
+            allowed_rooms: vec![],
+            interrupt_on_new_message: false,
+            stream_mode: StreamMode::default(),
+            draft_update_interval_ms: 1500,
+            multi_message_delay_ms: 800,
+            recovery_key: None,
+        };
+        assert!(
+            mx.set_secret("channels.matrix.nonexistent", "val".into())
+                .is_err()
+        );
+    }
+
+    #[test]
+    async fn config_tree_traversal_discovers_nested_secrets() {
+        let mut config = Config::default();
+        config.api_key = Some("test-key".into());
+        config.channels_config.matrix = Some(MatrixConfig {
+            enabled: true,
+            homeserver: "https://m.org".into(),
+            access_token: "mx-tok".into(),
+            user_id: None,
+            device_id: None,
+            room_id: "!r:m".into(),
+            allowed_users: vec![],
+            allowed_rooms: vec![],
+            interrupt_on_new_message: false,
+            stream_mode: StreamMode::default(),
+            draft_update_interval_ms: 1500,
+            multi_message_delay_ms: 800,
+            recovery_key: None,
+        });
+
+        let fields = config.secret_fields();
+        let names: Vec<&str> = fields.iter().map(|f| f.name).collect();
+        assert!(names.contains(&"api-key"));
+        assert!(names.contains(&"channels.matrix.access-token"));
+        assert!(names.contains(&"channels.matrix.recovery-key"));
+    }
+
+    #[test]
+    async fn config_set_secret_dispatches_to_child() {
+        let mut config = Config::default();
+        config.channels_config.matrix = Some(MatrixConfig {
+            enabled: true,
+            homeserver: "https://m.org".into(),
+            access_token: "old".into(),
+            user_id: None,
+            device_id: None,
+            room_id: "!r:m".into(),
+            allowed_users: vec![],
+            allowed_rooms: vec![],
+            interrupt_on_new_message: false,
+            stream_mode: StreamMode::default(),
+            draft_update_interval_ms: 1500,
+            multi_message_delay_ms: 800,
+            recovery_key: None,
+        });
+
+        config
+            .set_secret("channels.matrix.access-token", "new".into())
+            .unwrap();
+        assert_eq!(
+            config.channels_config.matrix.as_ref().unwrap().access_token,
+            "new"
+        );
+    }
+
+    #[test]
+    async fn config_set_secret_top_level() {
+        let mut config = Config::default();
+        config.set_secret("api-key", "sk-test".into()).unwrap();
+        assert_eq!(config.api_key.as_deref(), Some("sk-test"));
+    }
+
+    #[test]
+    async fn config_set_secret_unknown_fails() {
+        let mut config = Config::default();
+        assert!(
+            config
+                .set_secret("nonexistent.field", "val".into())
+                .is_err()
+        );
+    }
+
+    #[test]
+    async fn encrypt_decrypt_roundtrip_via_macro() {
+        let dir = TempDir::new().unwrap();
+        let store = crate::security::SecretStore::new(dir.path(), true);
+
+        let mut mx = MatrixConfig {
+            enabled: true,
+            homeserver: "https://m.org".into(),
+            access_token: "plaintext-token".into(),
+            user_id: None,
+            device_id: None,
+            room_id: "!r:m".into(),
+            allowed_users: vec![],
+            allowed_rooms: vec![],
+            interrupt_on_new_message: false,
+            stream_mode: StreamMode::default(),
+            draft_update_interval_ms: 1500,
+            multi_message_delay_ms: 800,
+            recovery_key: None,
+        };
+
+        // Encrypt
+        mx.encrypt_secrets(&store).unwrap();
+        assert!(crate::security::SecretStore::is_encrypted(&mx.access_token));
+        assert_ne!(mx.access_token, "plaintext-token");
+
+        // Decrypt
+        mx.decrypt_secrets(&store).unwrap();
+        assert_eq!(mx.access_token, "plaintext-token");
+    }
+
+    #[test]
+    async fn encrypt_skips_already_encrypted() {
+        let dir = TempDir::new().unwrap();
+        let store = crate::security::SecretStore::new(dir.path(), true);
+
+        let mut mx = MatrixConfig {
+            enabled: true,
+            homeserver: "https://m.org".into(),
+            access_token: "plaintext-token".into(),
+            user_id: None,
+            device_id: None,
+            room_id: "!r:m".into(),
+            allowed_users: vec![],
+            allowed_rooms: vec![],
+            interrupt_on_new_message: false,
+            stream_mode: StreamMode::default(),
+            draft_update_interval_ms: 1500,
+            multi_message_delay_ms: 800,
+            recovery_key: None,
+        };
+
+        mx.encrypt_secrets(&store).unwrap();
+        let first_encrypted = mx.access_token.clone();
+
+        // Encrypt again — should be idempotent
+        mx.encrypt_secrets(&store).unwrap();
+        assert_eq!(mx.access_token, first_encrypted);
+    }
+
+    #[test]
+    async fn encrypt_no_op_on_disabled_store() {
+        let dir = TempDir::new().unwrap();
+        let store = crate::security::SecretStore::new(dir.path(), false);
+
+        let mut mx = MatrixConfig {
+            enabled: true,
+            homeserver: "https://m.org".into(),
+            access_token: "plaintext-token".into(),
+            user_id: None,
+            device_id: None,
+            room_id: "!r:m".into(),
+            allowed_users: vec![],
+            allowed_rooms: vec![],
+            interrupt_on_new_message: false,
+            stream_mode: StreamMode::default(),
+            draft_update_interval_ms: 1500,
+            multi_message_delay_ms: 800,
+            recovery_key: None,
+        };
+
+        mx.encrypt_secrets(&store).unwrap();
+        // With encryption disabled, value should stay plaintext
+        assert_eq!(mx.access_token, "plaintext-token");
+    }
+
+    // ── Property method tests ──
+
+    fn test_matrix_config() -> MatrixConfig {
+        MatrixConfig {
+            enabled: true,
+            homeserver: "https://m.org".into(),
+            access_token: "tok".into(),
+            user_id: Some("@bot:m.org".into()),
+            device_id: None,
+            room_id: "!r:m".into(),
+            allowed_users: vec![],
+            allowed_rooms: vec![],
+            interrupt_on_new_message: false,
+            stream_mode: StreamMode::default(),
+            draft_update_interval_ms: 1500,
+            multi_message_delay_ms: 800,
+            recovery_key: None,
+        }
+    }
+
+    #[test]
+    async fn prop_fields_returns_typed_entries() {
+        let mx = test_matrix_config();
+        let fields = mx.prop_fields();
+        let by_name: std::collections::HashMap<&str, &crate::config::PropFieldInfo> =
+            fields.iter().map(|f| (f.name, f)).collect();
+
+        // Bool field
+        let enabled = by_name["channels.matrix.enabled"];
+        assert_eq!(enabled.type_hint, "bool");
+        assert_eq!(enabled.display_value, "true");
+        assert!(!enabled.is_secret);
+        assert!(!enabled.is_enum());
+
+        // String field
+        let homeserver = by_name["channels.matrix.homeserver"];
+        assert_eq!(homeserver.type_hint, "String");
+        assert_eq!(homeserver.display_value, "https://m.org");
+
+        // Option<String> — set
+        let user_id = by_name["channels.matrix.user-id"];
+        assert_eq!(user_id.type_hint, "Option<String>");
+        assert_eq!(user_id.display_value, "@bot:m.org");
+
+        // Option<String> — unset
+        let device_id = by_name["channels.matrix.device-id"];
+        assert_eq!(device_id.display_value, "<unset>");
+
+        // u64 field
+        let interval = by_name["channels.matrix.draft-update-interval-ms"];
+        assert_eq!(interval.type_hint, "u64");
+        assert_eq!(interval.display_value, "1500");
+
+        // Enum field
+        let stream = by_name["channels.matrix.stream-mode"];
+        assert!(stream.is_enum());
+        assert!(stream.enum_variants.is_some());
+
+        // Secret field — masked
+        let token = by_name["channels.matrix.access-token"];
+        assert!(token.is_secret);
+        assert_eq!(token.display_value, "****");
+
+        // All fields have correct category
+        for field in &fields {
+            assert_eq!(field.category, "Channels");
+        }
+    }
+
+    #[test]
+    async fn get_prop_returns_values_by_path() {
+        let mx = test_matrix_config();
+
+        assert_eq!(
+            mx.get_prop("channels.matrix.homeserver").unwrap(),
+            "https://m.org"
+        );
+        assert_eq!(mx.get_prop("channels.matrix.enabled").unwrap(), "true");
+        assert_eq!(
+            mx.get_prop("channels.matrix.draft-update-interval-ms")
+                .unwrap(),
+            "1500"
+        );
+        assert_eq!(
+            mx.get_prop("channels.matrix.user-id").unwrap(),
+            "@bot:m.org"
+        );
+        assert_eq!(mx.get_prop("channels.matrix.device-id").unwrap(), "<unset>");
+        // Secrets return masked value
+        assert_eq!(
+            mx.get_prop("channels.matrix.access-token").unwrap(),
+            "**** (encrypted)"
+        );
+    }
+
+    #[test]
+    async fn get_prop_unknown_path_fails() {
+        let mx = test_matrix_config();
+        assert!(mx.get_prop("channels.matrix.nonexistent").is_err());
+    }
+
+    #[test]
+    async fn set_prop_string() {
+        let mut mx = test_matrix_config();
+        mx.set_prop("channels.matrix.homeserver", "https://new.org")
+            .unwrap();
+        assert_eq!(mx.homeserver, "https://new.org");
+    }
+
+    #[test]
+    async fn set_prop_bool() {
+        let mut mx = test_matrix_config();
+        mx.set_prop("channels.matrix.interrupt-on-new-message", "true")
+            .unwrap();
+        assert!(mx.interrupt_on_new_message);
+    }
+
+    #[test]
+    async fn set_prop_bool_rejects_invalid() {
+        let mut mx = test_matrix_config();
+        let err = mx.set_prop("channels.matrix.enabled", "yes").unwrap_err();
+        assert!(err.to_string().contains("bool"));
+    }
+
+    #[test]
+    async fn set_prop_u64() {
+        let mut mx = test_matrix_config();
+        mx.set_prop("channels.matrix.draft-update-interval-ms", "3000")
+            .unwrap();
+        assert_eq!(mx.draft_update_interval_ms, 3000);
+    }
+
+    #[test]
+    async fn set_prop_u64_rejects_invalid() {
+        let mut mx = test_matrix_config();
+        assert!(
+            mx.set_prop("channels.matrix.draft-update-interval-ms", "abc")
+                .is_err()
+        );
+    }
+
+    #[test]
+    async fn set_prop_option_string_set_and_clear() {
+        let mut mx = test_matrix_config();
+        mx.set_prop("channels.matrix.user-id", "@new:m.org")
+            .unwrap();
+        assert_eq!(mx.user_id.as_deref(), Some("@new:m.org"));
+
+        // Empty string clears Option
+        mx.set_prop("channels.matrix.user-id", "").unwrap();
+        assert!(mx.user_id.is_none());
+    }
+
+    #[test]
+    async fn set_prop_enum() {
+        let mut mx = test_matrix_config();
+        mx.set_prop("channels.matrix.stream-mode", "partial")
+            .unwrap();
+        assert_eq!(mx.stream_mode, StreamMode::Partial);
+
+        mx.set_prop("channels.matrix.stream-mode", "multi_message")
+            .unwrap();
+        assert_eq!(mx.stream_mode, StreamMode::MultiMessage);
+    }
+
+    #[test]
+    async fn set_prop_enum_rejects_invalid() {
+        let mut mx = test_matrix_config();
+        let err = mx
+            .set_prop("channels.matrix.stream-mode", "invalid")
+            .unwrap_err();
+        assert!(err.to_string().contains("expected one of"));
+    }
+
+    #[test]
+    async fn set_prop_unknown_path_fails() {
+        let mut mx = test_matrix_config();
+        assert!(mx.set_prop("channels.matrix.nonexistent", "val").is_err());
+    }
+
+    #[test]
+    async fn prop_is_secret_static_check() {
+        assert!(MatrixConfig::prop_is_secret("channels.matrix.access-token"));
+        assert!(MatrixConfig::prop_is_secret("channels.matrix.recovery-key"));
+        assert!(!MatrixConfig::prop_is_secret("channels.matrix.homeserver"));
+        assert!(!MatrixConfig::prop_is_secret("channels.matrix.enabled"));
+    }
+
+    #[test]
+    async fn enum_variants_callback_returns_values() {
+        let mx = test_matrix_config();
+        let fields = mx.prop_fields();
+        let stream_field = fields
+            .iter()
+            .find(|f| f.name == "channels.matrix.stream-mode")
+            .unwrap();
+        let variants = (stream_field.enum_variants.unwrap())();
+        assert!(variants.contains(&"off".to_string()));
+        assert!(variants.contains(&"partial".to_string()));
+        assert!(variants.contains(&"multi_message".to_string()));
+    }
+
+    #[test]
+    async fn init_defaults_instantiates_none_sections() {
+        let mut config = Config::default();
+        assert!(config.channels_config.matrix.is_none());
+
+        let initialized = config.init_defaults(Some("channels.matrix"));
+        assert!(initialized.contains(&"channels.matrix"));
+        assert!(config.channels_config.matrix.is_some());
+    }
+
+    #[test]
+    async fn init_defaults_skips_already_set() {
+        let mut config = Config::default();
+        config.channels_config.matrix = Some(test_matrix_config());
+
+        let initialized = config.init_defaults(Some("channels.matrix"));
+        // Already set — should not re-initialize
+        assert!(!initialized.contains(&"channels.matrix"));
+        // Original value preserved
+        assert_eq!(
+            config.channels_config.matrix.as_ref().unwrap().homeserver,
+            "https://m.org"
+        );
+    }
+
+    #[test]
+    async fn nested_get_set_prop_traverses_config_tree() {
+        let mut config = Config::default();
+        config.channels_config.matrix = Some(test_matrix_config());
+
+        // get_prop traverses Config → ChannelsConfig → MatrixConfig
+        assert_eq!(
+            config.get_prop("channels.matrix.homeserver").unwrap(),
+            "https://m.org"
+        );
+
+        // set_prop traverses the same path
+        config
+            .set_prop("channels.matrix.homeserver", "https://new.org")
+            .unwrap();
+        assert_eq!(
+            config.channels_config.matrix.as_ref().unwrap().homeserver,
+            "https://new.org"
+        );
+    }
+
+    #[test]
+    async fn hashmap_nested_encrypt_decrypt_traverses_values() {
+        let dir = TempDir::new().unwrap();
+        let store = crate::security::SecretStore::new(dir.path(), true);
+
+        let mut config = Config::default();
+        config.agents.insert(
+            "test-agent".into(),
+            DelegateAgentConfig {
+                api_key: Some("secret-key".into()),
+                ..Default::default()
+            },
+        );
+
+        config.encrypt_secrets(&store).unwrap();
+        let encrypted_key = config.agents["test-agent"].api_key.as_ref().unwrap();
+        assert!(crate::security::SecretStore::is_encrypted(encrypted_key));
+
+        config.decrypt_secrets(&store).unwrap();
+        assert_eq!(
+            config.agents["test-agent"].api_key.as_deref(),
+            Some("secret-key")
+        );
+    }
+
+    #[test]
+    async fn vec_secret_encrypt_decrypt_traverses_elements() {
+        let dir = TempDir::new().unwrap();
+        let store = crate::security::SecretStore::new(dir.path(), true);
+
+        let mut config = Config::default();
+        config.gateway.paired_tokens = vec!["token-a".into(), "token-b".into()];
+
+        config.encrypt_secrets(&store).unwrap();
+        for token in &config.gateway.paired_tokens {
+            assert!(crate::security::SecretStore::is_encrypted(token));
+        }
+
+        config.decrypt_secrets(&store).unwrap();
+        assert_eq!(config.gateway.paired_tokens, vec!["token-a", "token-b"]);
+    }
+
+    /// Walk every property on a default Config: get_prop must succeed,
+    /// and set_prop must round-trip for non-secret, non-enum scalar fields.
+    #[test]
+    async fn every_prop_is_gettable_and_settable() {
+        let mut config = Config::default();
+        // Initialize all Option<T> sections so their fields are reachable
+        config.init_defaults(None);
+
+        let fields = config.prop_fields();
+        assert!(
+            fields.len() > 50,
+            "Expected 50+ props, got {} — macro may be skipping fields",
+            fields.len()
+        );
+
+        for field in &fields {
+            // get_prop must not panic or error
+            let get_result = config.get_prop(field.name);
+            assert!(
+                get_result.is_ok(),
+                "get_prop failed for '{}': {}",
+                field.name,
+                get_result.unwrap_err()
+            );
+
+            // set_prop: round-trip the display value back through set_prop.
+            // Skip secrets (masked), enums (need valid variant), and <unset> Options.
+            if field.is_secret || field.is_enum() || field.display_value == "<unset>" {
+                continue;
+            }
+
+            let set_result = config.set_prop(field.name, &field.display_value);
+            assert!(
+                set_result.is_ok(),
+                "set_prop failed for '{}' with value '{}': {}",
+                field.name,
+                field.display_value,
+                set_result.unwrap_err()
+            );
+
+            // Value should survive the round-trip
+            let after = config.get_prop(field.name).unwrap();
+            assert_eq!(
+                after, field.display_value,
+                "round-trip mismatch for '{}': set '{}', got '{}'",
+                field.name, field.display_value, after
+            );
+        }
+    }
+
+    /// Every enum field must have a working enum_variants callback, and
+    /// set_prop must accept each variant it advertises.
+    #[test]
+    async fn every_enum_variant_is_settable() {
+        let mut config = Config::default();
+        config.init_defaults(None);
+
+        for field in config.prop_fields() {
+            if !field.is_enum() {
+                continue;
+            }
+            let get_variants = field.enum_variants.unwrap_or_else(|| {
+                panic!("enum field '{}' has no enum_variants callback", field.name)
+            });
+            let variants = get_variants();
+            assert!(
+                !variants.is_empty(),
+                "enum field '{}' returned no variants",
+                field.name
+            );
+
+            for variant in &variants {
+                let result = config.set_prop(field.name, variant);
+                assert!(
+                    result.is_ok(),
+                    "set_prop('{}', '{}') failed: {}",
+                    field.name,
+                    variant,
+                    result.unwrap_err()
+                );
+            }
+        }
+    }
+
+    #[test]
+    async fn backfill_enabled_activates_channel_without_explicit_enabled() {
+        let toml = r#"
+[channels_config.matrix]
+homeserver = "https://matrix.org"
+access_token = "tok"
+room_id = "!r:m"
+allowed_users = ["@u:m"]
+"#;
+        let mut config: Config = toml::from_str(toml).unwrap();
+        assert!(!config.channels_config.matrix.as_ref().unwrap().enabled);
+
+        config.channels_config.backfill_enabled(toml);
+        assert!(config.channels_config.matrix.as_ref().unwrap().enabled);
+    }
+
+    #[test]
+    async fn backfill_enabled_respects_explicit_false() {
+        let toml = r#"
+[channels_config.matrix]
+homeserver = "https://matrix.org"
+access_token = "tok"
+room_id = "!r:m"
+allowed_users = ["@u:m"]
+enabled = false
+"#;
+        let mut config: Config = toml::from_str(toml).unwrap();
+        config.channels_config.backfill_enabled(toml);
+        assert!(
+            !config.channels_config.matrix.as_ref().unwrap().enabled,
+            "explicit enabled=false must not be overwritten"
+        );
+    }
+
+    #[test]
+    async fn backfill_enabled_no_op_when_section_absent() {
+        let toml = r#"
+api_key = "sk-test"
+"#;
+        let mut config: Config = toml::from_str(toml).unwrap();
+        config.channels_config.backfill_enabled(toml);
+        assert!(config.channels_config.telegram.is_none());
+    }
+
+    #[test]
+    async fn backfill_enabled_works_with_toml_comments() {
+        let toml = r#"
+# My matrix setup
+[channels_config.matrix]
+homeserver = "https://matrix.org"  # production server
+access_token = "tok"
+room_id = "!r:m"
+allowed_users = ["@u:m"]
+# enabled intentionally omitted
+"#;
+        let mut config: Config = toml::from_str(toml).unwrap();
+        assert!(!config.channels_config.matrix.as_ref().unwrap().enabled);
+
+        config.channels_config.backfill_enabled(toml);
+        assert!(
+            config.channels_config.matrix.as_ref().unwrap().enabled,
+            "backfill should activate channel even when config has comments"
+        );
+    }
 }
diff --git a/src/config/traits.rs b/src/config/traits.rs
index 67096c676f..e11e07293f 100644
--- a/src/config/traits.rs
+++ b/src/config/traits.rs
@@ -1,3 +1,90 @@
+/// Describes a single secret field discovered via `#[derive(Configurable)]`.
+#[derive(Debug, Clone)]
+pub struct SecretFieldInfo {
+    /// Full dotted name (e.g. `channels.matrix.access-token`)
+    pub name: &'static str,
+    /// Category for grouping in `zeroclaw props list`
+    pub category: &'static str,
+    /// Whether this field currently has a non-empty value
+    pub is_set: bool,
+}
+
+/// Runtime type classification for config property values.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PropKind {
+    String,
+    Bool,
+    Integer,
+    Float,
+    /// An enum or other serde-serializable type (parsed as TOML string).
+    Enum,
+}
+
+/// Maps Rust types to PropKind at compile time.
+/// Scalars have explicit impls; the blanket impl catches everything
+/// else as `PropKind::Enum`.
+pub trait HasPropKind {
+    const PROP_KIND: PropKind;
+}
+
+macro_rules! impl_prop_kind {
+    ($kind:expr, $($ty:ty),+) => {
+        $(impl HasPropKind for $ty { const PROP_KIND: PropKind = $kind; })+
+    };
+}
+
+impl_prop_kind!(PropKind::Bool, bool);
+impl_prop_kind!(PropKind::String, String);
+impl_prop_kind!(PropKind::Float, f64, f32);
+impl_prop_kind!(
+    PropKind::Integer,
+    u8,
+    u16,
+    u32,
+    u64,
+    usize,
+    i8,
+    i16,
+    i32,
+    i64,
+    isize
+);
+
+/// Describes a single property field discovered via `#[derive(Configurable)]`.
+#[derive(Clone)]
+pub struct PropFieldInfo {
+    /// Full dotted name (e.g. `channels.telegram.draft-update-interval-ms`)
+    pub name: &'static str,
+    /// Category for grouping in property listings
+    pub category: &'static str,
+    /// Current value formatted for display (secrets show `"****"`)
+    pub display_value: String,
+    /// Raw Rust type string for display (e.g. `"bool"`, `"u64"`, `"Option<StreamMode>"`)
+    pub type_hint: &'static str,
+    /// Runtime type classification
+    pub kind: PropKind,
+    /// Whether this field is marked `#[secret]`
+    pub is_secret: bool,
+    /// Returns valid variant names for enum fields (None for non-enum fields)
+    pub enum_variants: Option<fn() -> Vec<String>>,
+}
+
+impl PropFieldInfo {
+    pub fn is_enum(&self) -> bool {
+        self.enum_variants.is_some()
+    }
+}
+
+impl std::fmt::Debug for PropFieldInfo {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("PropFieldInfo")
+            .field("name", &self.name)
+            .field("kind", &self.kind)
+            .field("is_secret", &self.is_secret)
+            .finish_non_exhaustive()
+    }
+}
+
 /// The trait for describing a channel
 pub trait ChannelConfig {
     /// human-readable name
diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs
index 7b535796bc..4af9a7a5b4 100644
--- a/src/daemon/mod.rs
+++ b/src/daemon/mod.rs
@@ -103,18 +103,23 @@ pub async fn run(config: Config, host: String, port: u16) -> Result<()> {
         }
     }
 
-    // Wire up MQTT SOP listener if configured
+    // Wire up MQTT SOP listener if configured and enabled
     if let Some(ref mqtt_config) = config.channels_config.mqtt {
-        let mqtt_cfg = mqtt_config.clone();
-        handles.push(spawn_component_supervisor(
-            "mqtt",
-            initial_backoff,
-            max_backoff,
-            move || {
-                let cfg = mqtt_cfg.clone();
-                async move { Box::pin(run_mqtt_sop_listener(&cfg)).await }
-            },
-        ));
+        if mqtt_config.enabled {
+            let mqtt_cfg = mqtt_config.clone();
+            handles.push(spawn_component_supervisor(
+                "mqtt",
+                initial_backoff,
+                max_backoff,
+                move || {
+                    let cfg = mqtt_cfg.clone();
+                    async move { Box::pin(run_mqtt_sop_listener(&cfg)).await }
+                },
+            ));
+        } else {
+            tracing::info!("MQTT channel configured but disabled (enabled = false)");
+            crate::health::mark_component_ok("mqtt");
+        }
     } else {
         crate::health::mark_component_ok("mqtt");
     }
@@ -995,6 +1000,7 @@ mod tests {
     fn detects_supervised_channels_present() {
         let mut config = Config::default();
         config.channels_config.telegram = Some(crate::config::TelegramConfig {
+            enabled: true,
             bot_token: "token".into(),
             allowed_users: vec![],
             stream_mode: crate::config::StreamMode::default(),
@@ -1011,6 +1017,7 @@ mod tests {
     fn detects_dingtalk_as_supervised_channel() {
         let mut config = Config::default();
         config.channels_config.dingtalk = Some(crate::config::schema::DingTalkConfig {
+            enabled: true,
             client_id: "client_id".into(),
             client_secret: "client_secret".into(),
             allowed_users: vec!["*".into()],
@@ -1023,6 +1030,7 @@ mod tests {
     fn detects_mattermost_as_supervised_channel() {
         let mut config = Config::default();
         config.channels_config.mattermost = Some(crate::config::schema::MattermostConfig {
+            enabled: true,
             url: "https://mattermost.example.com".into(),
             bot_token: "token".into(),
             channel_id: Some("channel-id".into()),
@@ -1039,6 +1047,7 @@ mod tests {
     fn detects_qq_as_supervised_channel() {
         let mut config = Config::default();
         config.channels_config.qq = Some(crate::config::schema::QQConfig {
+            enabled: true,
             app_id: "app-id".into(),
             app_secret: "app-secret".into(),
             allowed_users: vec!["*".into()],
@@ -1051,6 +1060,7 @@ mod tests {
     fn detects_nextcloud_talk_as_supervised_channel() {
         let mut config = Config::default();
         config.channels_config.nextcloud_talk = Some(crate::config::schema::NextcloudTalkConfig {
+            enabled: true,
             base_url: "https://cloud.example.com".into(),
             app_token: "app-token".into(),
             webhook_secret: None,
@@ -1120,6 +1130,7 @@ mod tests {
         config.heartbeat.target = Some("telegram".into());
         config.heartbeat.to = Some("123456".into());
         config.channels_config.telegram = Some(crate::config::TelegramConfig {
+            enabled: true,
             bot_token: "bot-token".into(),
             allowed_users: vec![],
             stream_mode: crate::config::StreamMode::default(),
@@ -1138,6 +1149,7 @@ mod tests {
     fn auto_detect_telegram_when_configured() {
         let mut config = Config::default();
         config.channels_config.telegram = Some(crate::config::TelegramConfig {
+            enabled: true,
             bot_token: "bot-token".into(),
             allowed_users: vec!["user123".into()],
             stream_mode: crate::config::StreamMode::default(),
diff --git a/src/gateway/api.rs b/src/gateway/api.rs
index 471729ba0b..c0352bd648 100644
--- a/src/gateway/api.rs
+++ b/src/gateway/api.rs
@@ -1680,6 +1680,7 @@ mod tests {
         });
         cfg.memory.qdrant.api_key = Some("qdrant-key".to_string());
         cfg.channels_config.wati = Some(crate::config::schema::WatiConfig {
+            enabled: true,
             api_token: "wati-token".to_string(),
             api_url: "https://live-mt-server.wati.io".to_string(),
             tenant_id: None,
@@ -1687,6 +1688,7 @@ mod tests {
             proxy_url: None,
         });
         cfg.channels_config.feishu = Some(crate::config::schema::FeishuConfig {
+            enabled: true,
             app_id: "cli_aabbcc".to_string(),
             app_secret: "feishu-secret".to_string(),
             encrypt_key: Some("feishu-encrypt".to_string()),
@@ -1817,6 +1819,7 @@ mod tests {
         });
         current.memory.qdrant.api_key = Some("qdrant-real".to_string());
         current.channels_config.wati = Some(crate::config::schema::WatiConfig {
+            enabled: true,
             api_token: "wati-real".to_string(),
             api_url: "https://live-mt-server.wati.io".to_string(),
             tenant_id: None,
@@ -1824,6 +1827,7 @@ mod tests {
             proxy_url: None,
         });
         current.channels_config.feishu = Some(crate::config::schema::FeishuConfig {
+            enabled: true,
             app_id: "cli_current".to_string(),
             app_secret: "feishu-secret-real".to_string(),
             encrypt_key: Some("feishu-encrypt-real".to_string()),
diff --git a/src/integrations/registry.rs b/src/integrations/registry.rs
index 47dfd5b34f..bc352edab8 100644
--- a/src/integrations/registry.rs
+++ b/src/integrations/registry.rs
@@ -834,6 +834,7 @@ mod tests {
     fn telegram_active_when_configured() {
         let mut config = Config::default();
         config.channels_config.telegram = Some(TelegramConfig {
+            enabled: true,
             bot_token: "123:ABC".into(),
             allowed_users: vec!["user".into()],
             stream_mode: StreamMode::default(),
@@ -863,6 +864,7 @@ mod tests {
     fn imessage_active_when_configured() {
         let mut config = Config::default();
         config.channels_config.imessage = Some(IMessageConfig {
+            enabled: true,
             allowed_contacts: vec!["*".into()],
         });
         let entries = all_integrations();
@@ -885,6 +887,7 @@ mod tests {
     fn matrix_active_when_configured() {
         let mut config = Config::default();
         config.channels_config.matrix = Some(MatrixConfig {
+            enabled: true,
             homeserver: "https://m.org".into(),
             access_token: "tok".into(),
             user_id: None,
diff --git a/src/main.rs b/src/main.rs
index a52b325ce0..68fa258bf5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -35,7 +35,7 @@
 
 use anyhow::{Context, Result, bail};
 use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
-use dialoguer::Password;
+use dialoguer::{Password, Select};
 use serde::{Deserialize, Serialize};
 use std::io::{IsTerminal, Write};
 use std::path::PathBuf;
@@ -587,6 +587,30 @@ Examples:
         install: bool,
     },
 
+    /// View or change config properties by dotted path
+    #[command(long_about = "\
+View, set, or initialize config properties.
+
+Properties are addressed by dotted path (e.g. channels.matrix.mention-only).
+Secret fields (API keys, tokens) automatically use masked input.
+Enum fields offer interactive selection when value is omitted.
+
+Examples:
+  zeroclaw props list                                  # list all properties
+  zeroclaw props list --secrets                        # list only secrets
+  zeroclaw props list --filter channels.matrix         # filter by prefix
+  zeroclaw props get channels.matrix.mention-only      # get a value
+  zeroclaw props set channels.matrix.mention-only true # set a value
+  zeroclaw props set channels.matrix.access-token      # secret: masked input
+  zeroclaw props set channels.matrix.stream-mode       # enum: interactive select
+  zeroclaw props init channels.matrix                  # init section with defaults
+
+Property path tab completion is included automatically in `zeroclaw completions <shell>`.")]
+    Props {
+        #[command(subcommand)]
+        props_command: PropsCommands,
+    },
+
     /// Manage WASM plugins
     #[cfg(feature = "plugins-wasm")]
     Plugin {
@@ -595,6 +619,45 @@ Examples:
     },
 }
 
+#[derive(Subcommand, Debug)]
+enum PropsCommands {
+    /// List all config properties with current values
+    List {
+        /// Filter by path prefix (e.g. "channels.telegram")
+        #[arg(short, long)]
+        filter: Option<String>,
+        /// Show only secret (encrypted) fields
+        #[arg(long)]
+        secrets: bool,
+    },
+    /// Get a config property value
+    Get {
+        /// Property path (e.g. channels.telegram.mention-only)
+        path: String,
+    },
+    /// Set a config property (secret fields auto-prompt for masked input)
+    Set {
+        /// Property path
+        path: String,
+        /// New value (omit for secret fields to get masked input)
+        value: Option<String>,
+        /// Skip interactive prompts — require value on command line, accept raw strings for enums
+        #[arg(long)]
+        no_interactive: bool,
+    },
+    /// Initialize unconfigured sections with defaults (enabled=false)
+    Init {
+        /// Section prefix (e.g. channels.matrix). Omit to init all.
+        section: Option<String>,
+    },
+    /// Print matching property paths for shell completion (hidden)
+    #[command(hide = true)]
+    Complete {
+        /// Partial path to complete
+        partial: Option<String>,
+    },
+}
+
 #[cfg(feature = "plugins-wasm")]
 #[derive(Subcommand, Debug)]
 enum PluginCommands {
@@ -1621,6 +1684,142 @@ async fn main() -> Result<()> {
             }
         },
 
+        Commands::Props { props_command } => match props_command {
+            PropsCommands::List { filter, secrets } => {
+                let entries = config.prop_fields();
+                let mut current_category = "";
+                for entry in &entries {
+                    if secrets && !entry.is_secret {
+                        continue;
+                    }
+                    if let Some(ref f) = filter {
+                        if !entry.name.starts_with(f.as_str()) {
+                            continue;
+                        }
+                    }
+                    if entry.category != current_category {
+                        if !current_category.is_empty() {
+                            println!();
+                        }
+                        println!("{}:", entry.category);
+                        current_category = entry.category;
+                    }
+                    let lock = if entry.is_secret { " \u{1f512}" } else { "" };
+                    println!(
+                        "  {:<45} = {:<20} ({}){lock}",
+                        entry.name, entry.display_value, entry.type_hint
+                    );
+                }
+                Ok(())
+            }
+            PropsCommands::Get { path } => {
+                if Config::prop_is_secret(&path) {
+                    let entries = config.prop_fields();
+                    let is_set = entries
+                        .iter()
+                        .find(|e| e.name == path)
+                        .map(|e| e.display_value != "<unset>")
+                        .unwrap_or(false);
+                    if is_set {
+                        println!("{path} is set (encrypted secret \u{2014} value not displayed)");
+                    } else {
+                        println!("{path} is not set (encrypted secret)");
+                    }
+                } else {
+                    match config.get_prop(&path) {
+                        Ok(value) => println!("{value}"),
+                        Err(e) => anyhow::bail!("{e}"),
+                    }
+                }
+                Ok(())
+            }
+            PropsCommands::Set {
+                path,
+                value,
+                no_interactive,
+            } => {
+                if no_interactive {
+                    // Scripted mode: require value on CLI, no prompts
+                    let val = value.ok_or_else(|| {
+                        anyhow::anyhow!(
+                            "Value required in --no-interactive mode. Usage: zeroclaw props set --no-interactive {path} <value>"
+                        )
+                    })?;
+                    config.set_prop(&path, &val)?;
+                } else if Config::prop_is_secret(&path) {
+                    if value.is_some() {
+                        eprintln!(
+                            "  \u{26a0} {path} is an encrypted secret \u{2014} using masked input."
+                        );
+                    }
+                    let secret_value = dialoguer::Password::new()
+                        .with_prompt(format!("Enter value for {path}"))
+                        .interact()?;
+                    let secret_value = secret_value.trim().to_string();
+                    if secret_value.is_empty() {
+                        anyhow::bail!("Value cannot be empty.");
+                    }
+                    config.set_prop(&path, &secret_value)?;
+                } else if let Some(val) = value {
+                    config.set_prop(&path, &val)?;
+                } else {
+                    // Enum fields get interactive selection; everything else needs a value
+                    let variants = config
+                        .prop_fields()
+                        .into_iter()
+                        .find(|f| f.name == path)
+                        .and_then(|info| {
+                            let get_variants = info.enum_variants?;
+                            let variants = get_variants();
+                            let current_index = variants
+                                .iter()
+                                .position(|v| v == &info.display_value)
+                                .unwrap_or(0);
+                            Some((variants, current_index))
+                        });
+                    if let Some((variants, current_index)) = variants {
+                        let selected = Select::new()
+                            .with_prompt(format!("Select value for {path}"))
+                            .items(&variants)
+                            .default(current_index)
+                            .interact()?;
+                        config.set_prop(&path, &variants[selected])?;
+                    } else {
+                        anyhow::bail!("Value required. Usage: zeroclaw props set {path} <value>");
+                    }
+                }
+                config.save().await?;
+                println!("{path} updated.");
+                Ok(())
+            }
+            PropsCommands::Init { section } => {
+                let initialized = config.init_defaults(section.as_deref());
+                if initialized.is_empty() {
+                    println!("All sections already configured.");
+                } else {
+                    println!(
+                        "Initialized {} section(s) with defaults:",
+                        initialized.len()
+                    );
+                    for name in &initialized {
+                        println!("  {name}");
+                    }
+                    config.save().await?;
+                    println!("\nRun `zeroclaw props list` to review, then set required fields.");
+                }
+                Ok(())
+            }
+            PropsCommands::Complete { partial } => {
+                let prefix = partial.as_deref().unwrap_or("");
+                for entry in config.prop_fields() {
+                    if entry.name.starts_with(prefix) {
+                        println!("{}", entry.name);
+                    }
+                }
+                Ok(())
+            }
+        },
+
         #[cfg(feature = "plugins-wasm")]
         Commands::Plugin { plugin_command } => match plugin_command {
             PluginCommands::List => {
@@ -1847,9 +2046,57 @@ fn write_shell_completion<W: Write>(shell: CompletionShell, writer: &mut W) -> R
     let bin_name = cmd.get_name().to_string();
 
     match shell {
-        CompletionShell::Bash => generate(shells::Bash, &mut cmd, bin_name.clone(), writer),
-        CompletionShell::Fish => generate(shells::Fish, &mut cmd, bin_name.clone(), writer),
-        CompletionShell::Zsh => generate(shells::Zsh, &mut cmd, bin_name.clone(), writer),
+        CompletionShell::Bash => {
+            generate(shells::Bash, &mut cmd, bin_name.clone(), writer);
+            // Wrap clap's _zeroclaw to inject dynamic props path completion
+            writeln!(
+                writer,
+                r#"
+# Dynamic completion for zeroclaw props get/set paths
+if type _zeroclaw &>/dev/null; then
+    _zeroclaw_clap_orig() {{ _zeroclaw "$@"; }}
+    _zeroclaw() {{
+        local cur="${{COMP_WORDS[COMP_CWORD]}}"
+        if [[ "${{COMP_WORDS[*]}}" =~ "props "(get|set)" " ]]; then
+            COMPREPLY=($(compgen -W "$(zeroclaw props complete "$cur" 2>/dev/null)" -- "$cur"))
+            return
+        fi
+        _zeroclaw_clap_orig "$@"
+    }}
+fi"#
+            )?;
+        }
+        CompletionShell::Fish => {
+            generate(shells::Fish, &mut cmd, bin_name.clone(), writer);
+            writeln!(
+                writer,
+                r#"
+# Dynamic completion for zeroclaw props get/set paths
+complete -c zeroclaw -n '__fish_seen_subcommand_from props; and __fish_seen_subcommand_from get set' \
+    -a '(zeroclaw props complete (commandline -ct) 2>/dev/null)' -f"#
+            )?;
+        }
+        CompletionShell::Zsh => {
+            generate(shells::Zsh, &mut cmd, bin_name.clone(), writer);
+            // Wrap clap's _zeroclaw to inject dynamic props path completion
+            writeln!(
+                writer,
+                r#"
+# Dynamic completion for zeroclaw props get/set paths
+if (( $+functions[_zeroclaw] )); then
+    functions[_zeroclaw_clap_orig]=$functions[_zeroclaw]
+    _zeroclaw() {{
+        if [[ "${{words[*]}}" == *"props "(get|set)* ]] && (( CURRENT > 3 )); then
+            local -a props
+            props=(${{(f)"$(zeroclaw props complete "$words[CURRENT]" 2>/dev/null)"}})
+            compadd -a props
+            return
+        fi
+        _zeroclaw_clap_orig "$@"
+    }}
+fi"#
+            )?;
+        }
         CompletionShell::PowerShell => {
             generate(shells::PowerShell, &mut cmd, bin_name.clone(), writer);
         }
diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs
index d7020a4b5d..0c59869310 100644
--- a/src/onboard/wizard.rs
+++ b/src/onboard/wizard.rs
@@ -3864,6 +3864,7 @@ fn setup_channels(existing: Option<ChannelsConfig>) -> Result<ChannelsConfig> {
 
                 let existing_tg = config.telegram.as_ref();
                 config.telegram = Some(TelegramConfig {
+                    enabled: true,
                     bot_token: token,
                     allowed_users,
                     stream_mode: existing_tg.map(|t| t.stream_mode).unwrap_or_default(),
@@ -3997,6 +3998,7 @@ fn setup_channels(existing: Option<ChannelsConfig>) -> Result<ChannelsConfig> {
 
                 let existing_dc = config.discord.as_ref();
                 config.discord = Some(DiscordConfig {
+                    enabled: true,
                     bot_token: token,
                     guild_id: if guild.is_empty() { None } else { Some(guild) },
                     allowed_users,
@@ -4162,6 +4164,7 @@ fn setup_channels(existing: Option<ChannelsConfig>) -> Result<ChannelsConfig> {
 
                 let existing_sl = config.slack.as_ref();
                 config.slack = Some(SlackConfig {
+                    enabled: true,
                     bot_token: token,
                     app_token: if app_token.is_empty() {
                         None
@@ -4230,7 +4233,10 @@ fn setup_channels(existing: Option<ChannelsConfig>) -> Result<ChannelsConfig> {
                         .collect()
                 };
 
-                config.imessage = Some(IMessageConfig { allowed_contacts });
+                config.imessage = Some(IMessageConfig {
+                    enabled: true,
+                    allowed_contacts,
+                });
                 println!(
                     "  {} iMessage configured (contacts: {})",
                     style("✅").green().bold(),
@@ -4389,6 +4395,7 @@ fn setup_channels(existing: Option<ChannelsConfig>) -> Result<ChannelsConfig> {
 
                 let existing_mx = config.matrix.as_ref();
                 config.matrix = Some(MatrixConfig {
+                    enabled: true,
                     homeserver: homeserver.trim_end_matches('/').to_string(),
                     access_token,
                     user_id: detected_user_id,
@@ -4500,6 +4507,7 @@ fn setup_channels(existing: Option<ChannelsConfig>) -> Result<ChannelsConfig> {
                     .interact()?;
 
                 config.signal = Some(SignalConfig {
+                    enabled: true,
                     http_url: http_url.trim_end_matches('/').to_string(),
                     account: account.trim().to_string(),
                     group_id,
@@ -4595,6 +4603,7 @@ fn setup_channels(existing: Option<ChannelsConfig>) -> Result<ChannelsConfig> {
 
                     let existing_wa = config.whatsapp.as_ref();
                     config.whatsapp = Some(WhatsAppConfig {
+                        enabled: true,
                         access_token: None,
                         phone_number_id: None,
                         verify_token: None,
@@ -4713,6 +4722,7 @@ fn setup_channels(existing: Option<ChannelsConfig>) -> Result<ChannelsConfig> {
 
                 let existing_wa = config.whatsapp.as_ref();
                 config.whatsapp = Some(WhatsAppConfig {
+                    enabled: true,
                     access_token: Some(access_token.trim().to_string()),
                     phone_number_id: Some(phone_number_id.trim().to_string()),
                     verify_token: Some(verify_token.trim().to_string()),
@@ -4819,6 +4829,7 @@ fn setup_channels(existing: Option<ChannelsConfig>) -> Result<ChannelsConfig> {
                     .interact_text()?;
 
                 config.linq = Some(LinqConfig {
+                    enabled: true,
                     api_token: api_token.trim().to_string(),
                     from_phone: from_phone.trim().to_string(),
                     signing_secret: if signing_secret.trim().is_empty() {
@@ -4944,6 +4955,7 @@ fn setup_channels(existing: Option<ChannelsConfig>) -> Result<ChannelsConfig> {
                 );
 
                 config.irc = Some(IrcConfig {
+                    enabled: true,
                     server: server.trim().to_string(),
                     port,
                     nickname: nickname.trim().to_string(),
@@ -4989,6 +5001,7 @@ fn setup_channels(existing: Option<ChannelsConfig>) -> Result<ChannelsConfig> {
 
                 let existing_wh = config.webhook.as_ref();
                 config.webhook = Some(WebhookConfig {
+                    enabled: true,
                     port: port.parse().unwrap_or(8080),
                     listen_path: existing_wh.and_then(|w| w.listen_path.clone()),
                     send_url: existing_wh.and_then(|w| w.send_url.clone()),
@@ -5062,6 +5075,7 @@ fn setup_channels(existing: Option<ChannelsConfig>) -> Result<ChannelsConfig> {
 
                 let existing_nc = config.nextcloud_talk.as_ref();
                 config.nextcloud_talk = Some(NextcloudTalkConfig {
+                    enabled: true,
                     base_url,
                     app_token: app_token.trim().to_string(),
                     webhook_secret: if webhook_secret.trim().is_empty() {
@@ -5141,6 +5155,7 @@ fn setup_channels(existing: Option<ChannelsConfig>) -> Result<ChannelsConfig> {
                     .collect();
 
                 config.dingtalk = Some(DingTalkConfig {
+                    enabled: true,
                     client_id,
                     client_secret,
                     allowed_users,
@@ -5218,6 +5233,7 @@ fn setup_channels(existing: Option<ChannelsConfig>) -> Result<ChannelsConfig> {
                     .collect();
 
                 config.qq = Some(QQConfig {
+                    enabled: true,
                     app_id,
                     app_secret,
                     allowed_users,
@@ -5416,6 +5432,7 @@ fn setup_channels(existing: Option<ChannelsConfig>) -> Result<ChannelsConfig> {
                 }
 
                 config.lark = Some(LarkConfig {
+                    enabled: true,
                     app_id,
                     app_secret,
                     verification_token,
@@ -7791,6 +7808,7 @@ mod tests {
         assert!(!has_launchable_channels(&channels));
 
         channels.signal = Some(crate::config::schema::SignalConfig {
+            enabled: true,
             http_url: "http://127.0.0.1:8686".into(),
             account: "+1234567890".into(),
             group_id: None,
@@ -7803,6 +7821,7 @@ mod tests {
 
         channels.signal = None;
         channels.mattermost = Some(crate::config::schema::MattermostConfig {
+            enabled: true,
             url: "https://mattermost.example.com".into(),
             bot_token: "token".into(),
             channel_id: Some("channel".into()),
@@ -7816,6 +7835,7 @@ mod tests {
 
         channels.mattermost = None;
         channels.qq = Some(crate::config::schema::QQConfig {
+            enabled: true,
             app_id: "app-id".into(),
             app_secret: "app-secret".into(),
             allowed_users: vec!["*".into()],
@@ -7825,6 +7845,7 @@ mod tests {
 
         channels.qq = None;
         channels.nextcloud_talk = Some(crate::config::schema::NextcloudTalkConfig {
+            enabled: true,
             base_url: "https://cloud.example.com".into(),
             app_token: "token".into(),
             webhook_secret: Some("secret".into()),
@@ -7836,6 +7857,7 @@ mod tests {
 
         channels.nextcloud_talk = None;
         channels.feishu = Some(crate::config::schema::FeishuConfig {
+            enabled: true,
             app_id: "cli_123".into(),
             app_secret: "secret".into(),
             encrypt_key: None,
@@ -7854,6 +7876,7 @@ mod tests {
 
         let mut existing = ChannelsConfig::default();
         existing.discord = Some(DiscordConfig {
+            enabled: true,
             bot_token: "keep-me".into(),
             guild_id: None,
             allowed_users: vec![],
@@ -7867,6 +7890,7 @@ mod tests {
             stall_timeout_secs: 0,
         });
         existing.matrix = Some(MatrixConfig {
+            enabled: true,
             homeserver: "https://m.org".into(),
             access_token: "old-token".into(),
             user_id: None,
@@ -7899,6 +7923,7 @@ mod tests {
 
         let mut existing = ChannelsConfig::default();
         existing.matrix = Some(MatrixConfig {
+            enabled: true,
             homeserver: "https://m.org".into(),
             access_token: "tok".into(),
             user_id: None,
diff --git a/src/tools/browser_delegate.rs b/src/tools/browser_delegate.rs
index 87bbdec160..420b536be3 100644
--- a/src/tools/browser_delegate.rs
+++ b/src/tools/browser_delegate.rs
@@ -18,9 +18,11 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::sync::Arc;
 use tokio::time::{Duration, timeout};
+use zeroclaw_macros::Configurable;
 
 /// Configuration for browser delegation (`[browser_delegate]` section).
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "browser-delegate"]
 pub struct BrowserDelegateConfig {
     /// Enable browser delegation tool.
     #[serde(default)]
diff --git a/src/trust/types.rs b/src/trust/types.rs
index 5c144adb4e..733ef7b85e 100644
--- a/src/trust/types.rs
+++ b/src/trust/types.rs
@@ -2,9 +2,11 @@ use chrono::{DateTime, Utc};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::collections::HashMap;
+use zeroclaw_macros::Configurable;
 
 /// Configuration for trust scoring
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Configurable)]
+#[prefix = "trust"]
 pub struct TrustConfig {
     /// Initial trust score for new domains (default 0.8)
     #[serde(default = "default_initial_score")]
diff --git a/src/tui/onboarding.rs b/src/tui/onboarding.rs
index ed1116ed25..9819c63cd1 100644
--- a/src/tui/onboarding.rs
+++ b/src/tui/onboarding.rs
@@ -739,6 +739,7 @@ fn apply_tui_selections_to_config(app: &App, config: &mut Config) {
         "Telegram" => {
             if config.channels_config.telegram.is_none() {
                 config.channels_config.telegram = Some(TelegramConfig {
+                    enabled: true,
                     bot_token: String::from("YOUR_TELEGRAM_BOT_TOKEN"),
                     allowed_users: vec![],
                     stream_mode: StreamMode::default(),
@@ -753,6 +754,7 @@ fn apply_tui_selections_to_config(app: &App, config: &mut Config) {
         "Discord" => {
             if config.channels_config.discord.is_none() {
                 config.channels_config.discord = Some(DiscordConfig {
+                    enabled: true,
                     bot_token: String::from("YOUR_DISCORD_BOT_TOKEN"),
                     guild_id: None,
                     allowed_users: vec![],
@@ -770,6 +772,7 @@ fn apply_tui_selections_to_config(app: &App, config: &mut Config) {
         "Slack" => {
             if config.channels_config.slack.is_none() {
                 config.channels_config.slack = Some(SlackConfig {
+                    enabled: true,
                     bot_token: String::from("xoxb-YOUR_SLACK_BOT_TOKEN"),
                     app_token: Some(String::from("xapp-YOUR_SLACK_APP_TOKEN")),
                     channel_id: None,
@@ -789,6 +792,7 @@ fn apply_tui_selections_to_config(app: &App, config: &mut Config) {
         "WhatsApp" => {
             if config.channels_config.whatsapp.is_none() {
                 config.channels_config.whatsapp = Some(WhatsAppConfig {
+                    enabled: true,
                     access_token: Some(String::from("YOUR_WHATSAPP_ACCESS_TOKEN")),
                     phone_number_id: Some(String::from("YOUR_PHONE_NUMBER_ID")),
                     verify_token: Some(String::from("YOUR_VERIFY_TOKEN")),
@@ -811,6 +815,7 @@ fn apply_tui_selections_to_config(app: &App, config: &mut Config) {
         "Signal" => {
             if config.channels_config.signal.is_none() {
                 config.channels_config.signal = Some(SignalConfig {
+                    enabled: true,
                     http_url: String::from("http://127.0.0.1:8080"),
                     account: String::from("YOUR_SIGNAL_PHONE_NUMBER"),
                     group_id: None,
@@ -824,6 +829,7 @@ fn apply_tui_selections_to_config(app: &App, config: &mut Config) {
         "IRC" => {
             if config.channels_config.irc.is_none() {
                 config.channels_config.irc = Some(IrcConfig {
+                    enabled: true,
                     server: String::from("irc.libera.chat"),
                     port: 6697,
                     nickname: String::from("zeroclaw-bot"),
@@ -840,6 +846,7 @@ fn apply_tui_selections_to_config(app: &App, config: &mut Config) {
         "iMessage" => {
             if config.channels_config.imessage.is_none() {
                 config.channels_config.imessage = Some(IMessageConfig {
+                    enabled: true,
                     allowed_contacts: vec![],
                 });
             }
@@ -847,6 +854,7 @@ fn apply_tui_selections_to_config(app: &App, config: &mut Config) {
         "Matrix" => {
             if config.channels_config.matrix.is_none() {
                 config.channels_config.matrix = Some(MatrixConfig {
+                    enabled: true,
                     homeserver: String::from("https://matrix.org"),
                     access_token: String::from("YOUR_MATRIX_ACCESS_TOKEN"),
                     user_id: None,
@@ -865,6 +873,7 @@ fn apply_tui_selections_to_config(app: &App, config: &mut Config) {
         "Mattermost" => {
             if config.channels_config.mattermost.is_none() {
                 config.channels_config.mattermost = Some(MattermostConfig {
+                    enabled: true,
                     url: String::from("https://mattermost.example.com"),
                     bot_token: String::from("YOUR_MATTERMOST_BOT_TOKEN"),
                     channel_id: None,
@@ -879,6 +888,7 @@ fn apply_tui_selections_to_config(app: &App, config: &mut Config) {
         "Nextcloud Talk" => {
             if config.channels_config.nextcloud_talk.is_none() {
                 config.channels_config.nextcloud_talk = Some(NextcloudTalkConfig {
+                    enabled: true,
                     base_url: String::from("https://cloud.example.com"),
                     app_token: String::from("YOUR_NEXTCLOUD_APP_TOKEN"),
                     webhook_secret: None,
@@ -891,6 +901,7 @@ fn apply_tui_selections_to_config(app: &App, config: &mut Config) {
         "Feishu/Lark" => {
             if config.channels_config.feishu.is_none() {
                 config.channels_config.feishu = Some(FeishuConfig {
+                    enabled: true,
                     app_id: String::from("YOUR_FEISHU_APP_ID"),
                     app_secret: String::from("YOUR_FEISHU_APP_SECRET"),
                     encrypt_key: None,
@@ -903,6 +914,7 @@ fn apply_tui_selections_to_config(app: &App, config: &mut Config) {
             }
             if config.channels_config.lark.is_none() {
                 config.channels_config.lark = Some(LarkConfig {
+                    enabled: true,
                     app_id: String::from("YOUR_LARK_APP_ID"),
                     app_secret: String::from("YOUR_LARK_APP_SECRET"),
                     encrypt_key: None,
@@ -3442,6 +3454,7 @@ mod tests {
         let mut config = Config::default();
         // Pre-set a Telegram config with a real token
         config.channels_config.telegram = Some(TelegramConfig {
+            enabled: true,
             bot_token: "REAL_TOKEN_123".to_string(),
             allowed_users: vec!["alice".to_string()],
             stream_mode: StreamMode::default(),


Test patch:
diff --git a/docs/security/matrix-e2ee-guide.md b/docs/security/matrix-e2ee-guide.md
index 9e99860486..a55c069bab 100644
--- a/docs/security/matrix-e2ee-guide.md
+++ b/docs/security/matrix-e2ee-guide.md
@@ -103,6 +103,10 @@ curl -sS -H "Authorization: Bearer $MATRIX_TOKEN" \
 
 - Check that returned `user_id` matches the bot account.
 - If `device_id` is missing, set `channels_config.matrix.device_id` manually.
+- To update the access token without re-running onboard:
+  ```bash
+  zeroclaw props set channels.matrix.access-token
+  ```
 
 ### D. E2EE-specific checks
 
@@ -241,16 +245,24 @@ When configuring the Matrix channel, the wizard prompts:
 E2EE recovery key (or Enter to skip): EsTj 3yST y93F SLpB jJsz ...
 ```
 
-Paste the recovery key. It will be stored in `config.toml` as `channels_config.matrix.recovery_key`.
+Paste the recovery key (input is masked). It will be encrypted and stored in `config.toml` as `channels_config.matrix.recovery_key`.
 
-Option B — edit `config.toml` directly:
+Option B — via the secret CLI (recommended for existing installs):
+
+```bash
+zeroclaw props set channels.matrix.recovery-key
+```
+
+Input is masked. The value is encrypted at rest immediately.
+
+Option C — edit `config.toml` directly:
 
 ```toml
 [channels_config.matrix]
 recovery_key = "EsTj 3yST y93F SLpB jJsz ..."
 ```
 
-If `secrets.encrypt = true` (the default), the value will be encrypted on next config save.
+If `secrets.encrypt = true` (the default), the value will be encrypted on next config save. Note: until a save is triggered, the value remains in plaintext. Using Option A or B is preferred.
 
 #### Step 3: Restart ZeroClaw
 
@@ -292,6 +304,7 @@ RUST_LOG=zeroclaw::channels::matrix=debug,matrix_sdk_crypto=debug zeroclaw daemo
 - Keep Matrix tokens out of logs and screenshots.
 - Start with permissive `allowed_users`, then tighten to explicit user IDs.
 - Prefer canonical room IDs in production to avoid alias drift.
+- **Threading behavior:** ZeroClaw always replies in a thread rooted at the user's original message. Each thread maintains its own isolated conversation context. The main room timeline is unaffected — threads do not share context with each other or with the room. In encrypted rooms, threading works identically — the SDK decrypts events transparently before thread context is evaluated.
 
 ---