diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs
index 2d36929b8d..2da11199b1 100644
--- a/crates/buzz-cli/src/commands/users.rs
+++ b/crates/buzz-cli/src/commands/users.rs
@@ -227,46 +227,13 @@ pub async fn cmd_set_profile(
     // Read-merge-write: fetch current profile, merge in the new fields, then sign.
     let current = fetch_current_profile(client).await?;
 
-    // Merge: caller-supplied fields win; fall back to current profile values.
-    let merged_name = display_name
-        .map(|s| s.to_string())
-        .or_else(|| {
-            current
-                .get("display_name")
-                .and_then(|v| v.as_str())
-                .map(|s| s.to_string())
-        })
-        .or_else(|| {
-            current
-                .get("name")
-                .and_then(|v| v.as_str())
-                .map(|s| s.to_string())
-        });
-    let merged_picture = avatar_url.map(|s| s.to_string()).or_else(|| {
-        current
-            .get("picture")
-            .and_then(|v| v.as_str())
-            .map(|s| s.to_string())
-    });
-    let merged_about = about.map(|s| s.to_string()).or_else(|| {
-        current
-            .get("about")
-            .and_then(|v| v.as_str())
-            .map(|s| s.to_string())
-    });
-    let merged_nip05 = nip05_handle.map(|s| s.to_string()).or_else(|| {
-        current
-            .get("nip05")
-            .and_then(|v| v.as_str())
-            .map(|s| s.to_string())
-    });
-
-    let builder = buzz_sdk::build_profile(
-        merged_name.as_deref(),
-        None, // `name` field (username) — not exposed by CLI
-        merged_picture.as_deref(),
-        merged_about.as_deref(),
-        merged_nip05.as_deref(),
+    let builder = buzz_sdk::build_profile_with_existing(
+        &current,
+        display_name,
+        None, // `name` is not exposed by the CLI, so preserve its existing value.
+        avatar_url,
+        about,
+        nip05_handle,
     )
     .map_err(|e| CliError::Other(format!("build_profile failed: {e}")))?;
 
diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs
index f9e54de9c5..a038cb0d7c 100644
--- a/crates/buzz-sdk/src/builders.rs
+++ b/crates/buzz-sdk/src/builders.rs
@@ -541,7 +541,30 @@ pub fn build_profile(
     about: Option<&str>,
     nip05: Option<&str>,
 ) -> Result<EventBuilder, SdkError> {
-    let mut map = serde_json::Map::new();
+    build_profile_with_existing(
+        &serde_json::Map::new(),
+        display_name,
+        name,
+        picture,
+        about,
+        nip05,
+    )
+}
+
+/// Build a NIP-01 profile metadata event while preserving unmodeled fields
+/// from an existing kind:0 snapshot.
+///
+/// Kind 0 is replaceable, so updates must merge into the complete current
+/// object or fields introduced by other clients and future NIPs are deleted.
+pub fn build_profile_with_existing(
+    existing: &serde_json::Map<String, serde_json::Value>,
+    display_name: Option<&str>,
+    name: Option<&str>,
+    picture: Option<&str>,
+    about: Option<&str>,
+    nip05: Option<&str>,
+) -> Result<EventBuilder, SdkError> {
+    let mut map = existing.clone();
     if let Some(v) = display_name {
         map.insert("display_name".into(), serde_json::Value::String(v.into()));
     }
@@ -2354,6 +2377,32 @@ mod tests {
         assert!(v.as_object().unwrap().is_empty());
     }
 
+    #[test]
+    fn profile_update_preserves_unmodeled_fields() {
+        let existing = serde_json::json!({
+            "display_name": "Old name",
+            "bot": true,
+            "website": "https://example.com",
+            "lud16": "alice@example.com"
+        });
+        let ev = sign(
+            build_profile_with_existing(
+                existing.as_object().unwrap(),
+                Some("New name"),
+                None,
+                None,
+                None,
+                None,
+            )
+            .unwrap(),
+        );
+        let value: serde_json::Value = serde_json::from_str(&ev.content).unwrap();
+        assert_eq!(value["display_name"], "New name");
+        assert_eq!(value["bot"], true);
+        assert_eq!(value["website"], "https://example.com");
+        assert_eq!(value["lud16"], "alice@example.com");
+    }
+
     #[test]
     fn add_member_with_role() {
         let cid = uuid();
diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index 8de72ec1a8..9eefb075b4 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -409,7 +409,9 @@ const overrides = new Map([
   // (NIP-98 signed /query with explicit agent keys + optional x-auth-tag) for
   // bounded-auth agent relay-membership discovery. Load-bearing; queued to
   // split alongside the test-helper split.
-  ["src-tauri/src/relay.rs", 1077],
+  // +1: preserve unmodeled kind:0 metadata when the managed-agent profile is
+  // updated. The merge logic and regression test remain at the relay boundary.
+  ["src-tauri/src/relay.rs", 1078],
   // degraded-network resilience: visibleChannelId field + getter/setter, NOTICE
   // handler for relay back-pressure, and rate-limit gate imports add ~74 lines
   // of load-bearing degraded-network recovery code. Queued to split.
@@ -480,6 +482,9 @@ const overrides = new Map([
   // the embedded runtime and coordinator. Probe/re-arm logic lives in
   // mesh_llm/recovery.rs rather than growing AppState or command modules.
   ["src-tauri/src/app_state.rs", 1085],
+  // +1: profile snapshots now start from the existing kind:0 map so fields
+  // Buzz does not model survive an avatar or display-name update.
+  ["src-tauri/src/events.rs", 1001],
   // multi-slot splitting + no-op suppression (#1309): the ReadStateManager
   // class grew from ~700 lines to ~1019 with the addition of
   // splitContextsIntoBudgetedSlots (pure fn + 5 tests), publishSplitSlots,
diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs
index e32fc1cfe4..ba11702d60 100644
--- a/desktop/src-tauri/src/commands/agents_tests.rs
+++ b/desktop/src-tauri/src/commands/agents_tests.rs
@@ -291,6 +291,7 @@ fn profile(name: Option<&str>, picture: Option<&str>) -> crate::relay::AgentProf
     crate::relay::AgentProfileInfo {
         display_name: name.map(str::to_string),
         picture: picture.map(str::to_string),
+        metadata: serde_json::Map::new(),
     }
 }
 
diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs
index ef67fac570..f095bacade 100644
--- a/desktop/src-tauri/src/commands/profile.rs
+++ b/desktop/src-tauri/src/commands/profile.rs
@@ -58,26 +58,24 @@ pub async fn update_profile(
 
     // Pull the current content as a JSON object so we can merge with
     // the caller's overrides.
-    let current: Value = prior_events
+    let current = prior_events
         .first()
-        .and_then(|ev| serde_json::from_str::<Value>(&ev.content).ok())
-        .unwrap_or(Value::Null);
-
-    let dn = display_name
-        .as_deref()
-        .or_else(|| current.get("display_name").and_then(Value::as_str));
-    let name = current.get("name").and_then(Value::as_str);
-    let picture = avatar_url
-        .as_deref()
-        .or_else(|| current.get("picture").and_then(Value::as_str));
-    let ab = about
-        .as_deref()
-        .or_else(|| current.get("about").and_then(Value::as_str));
-    let nip05 = nip05_handle
-        .as_deref()
-        .or_else(|| current.get("nip05").and_then(Value::as_str));
-
-    let builder = events::build_profile(dn, name, picture, ab, nip05)?;
+        .and_then(|event| {
+            serde_json::from_str::<serde_json::Value>(&event.content)
+                .ok()?
+                .as_object()
+                .cloned()
+        })
+        .unwrap_or_default();
+
+    let builder = events::build_profile_with_existing(
+        &current,
+        display_name.as_deref(),
+        None,
+        avatar_url.as_deref(),
+        about.as_deref(),
+        nip05_handle.as_deref(),
+    )?;
     submit_event(builder, &state).await?;
 
     // Re-fetch to return canonical profile.
@@ -156,13 +154,19 @@ fn build_deferred_profile_event(
     let name = current.get("name").and_then(Value::as_str);
     let about = current.get("about").and_then(Value::as_str);
     let nip05 = current.get("nip05").and_then(Value::as_str);
-
-    Ok(
-        events::build_profile(display_name, name, Some(avatar_url), about, nip05)?
-            .custom_created_at(monotonic_created_at(
-                prior_event.map(|event| event.created_at.as_secs() as i64),
-            )),
-    )
+    let existing = current.as_object().cloned().unwrap_or_default();
+
+    Ok(events::build_profile_with_existing(
+        &existing,
+        display_name,
+        name,
+        Some(avatar_url),
+        about,
+        nip05,
+    )?
+    .custom_created_at(monotonic_created_at(
+        prior_event.map(|event| event.created_at.as_secs() as i64),
+    )))
 }
 
 fn capture_expected_signer(state: &AppState, expected_pubkey: &str) -> Result<nostr::Keys, String> {
diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs
index 777d56d02e..01aa7cf6bb 100644
--- a/desktop/src-tauri/src/events.rs
+++ b/desktop/src-tauri/src/events.rs
@@ -470,15 +470,16 @@ pub fn build_set_canvas(channel_id: Uuid, content: &str) -> Result<EventBuilder,
 
 // ── Profile ──────────────────────────────────────────────────────────────────
 
-/// Kind 0 — NIP-01 profile metadata (full snapshot).
-pub fn build_profile(
+/// Build a kind:0 profile snapshot while retaining fields Buzz does not model.
+pub fn build_profile_with_existing(
+    existing: &serde_json::Map<String, serde_json::Value>,
     display_name: Option<&str>,
     name: Option<&str>,
     picture: Option<&str>,
     about: Option<&str>,
     nip05: Option<&str>,
 ) -> Result<EventBuilder, String> {
-    let mut map = serde_json::Map::new();
+    let mut map = existing.clone();
     if let Some(v) = display_name {
         map.insert("display_name".into(), serde_json::Value::String(v.into()));
     }
diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs
index 1c9ba0095a..99a9c560b7 100644
--- a/desktop/src-tauri/src/relay.rs
+++ b/desktop/src-tauri/src/relay.rs
@@ -400,11 +400,19 @@ pub fn parse_command_response<T: DeserializeOwned>(message: &str) -> Result<T, S
 /// same wire format.
 fn build_profile_event(
     agent_keys: &nostr::Keys,
+    existing_profile: &serde_json::Map<String, serde_json::Value>,
     display_name: &str,
     avatar_url: Option<&str>,
     auth_tag_json: Option<&str>,
 ) -> Result<nostr::Event, String> {
-    let builder = crate::events::build_profile(Some(display_name), None, avatar_url, None, None)?;
+    let builder = crate::events::build_profile_with_existing(
+        existing_profile,
+        Some(display_name),
+        None,
+        avatar_url,
+        None,
+        None,
+    )?;
 
     let builder = if let Some(tag_json) = auth_tag_json {
         // Bridge nostr 0.37 PublicKey → nostr 0.36 PublicKey via hex encoding.
@@ -446,8 +454,19 @@ pub async fn sync_managed_agent_profile(
     auth_tag: Option<&str>, // NIP-OA auth tag JSON
 ) -> Result<(), String> {
     crate::relay_admission::wait_for_rate_limit().await;
+    let agent_pubkey = agent_keys.public_key().to_hex();
+    let existing_profile = query_agent_profile(state, relay_url, &agent_pubkey)
+        .await?
+        .map(|profile| profile.metadata)
+        .unwrap_or_default();
     // Build a signed kind:0 profile event (with optional NIP-OA auth tag).
-    let event = build_profile_event(agent_keys, display_name, avatar_url, auth_tag)?;
+    let event = build_profile_event(
+        agent_keys,
+        &existing_profile,
+        display_name,
+        avatar_url,
+        auth_tag,
+    )?;
     let event_json = event.as_json();
     let body_bytes = event_json.into_bytes();
 
@@ -519,6 +538,7 @@ pub async fn query_agent_profile(
             .get("picture")
             .and_then(|v| v.as_str())
             .map(str::to_string),
+        metadata: content.as_object().cloned().unwrap_or_default(),
     }))
 }
 
@@ -527,6 +547,7 @@ pub async fn query_agent_profile(
 pub struct AgentProfileInfo {
     pub display_name: Option<String>,
     pub picture: Option<String>,
+    pub metadata: serde_json::Map<String, serde_json::Value>,
 }
 
 // ── Signed-event submission ─────────────────────────────────────────────────
@@ -971,8 +992,14 @@ mod tests {
     fn profile_event_with_valid_auth_tag() {
         let agent_keys = nostr::Keys::generate();
         let tag_json = make_valid_auth_tag(&agent_keys);
-        let event = build_profile_event(&agent_keys, "TestBot", None, Some(&tag_json))
-            .expect("should succeed with a valid auth tag");
+        let event = build_profile_event(
+            &agent_keys,
+            &serde_json::Map::new(),
+            "TestBot",
+            None,
+            Some(&tag_json),
+        )
+        .expect("should succeed with a valid auth tag");
 
         // Exactly one "auth" tag must be present.
         let auth_tags: Vec<_> = event
@@ -989,8 +1016,9 @@ mod tests {
     #[test]
     fn profile_event_without_auth_tag() {
         let agent_keys = nostr::Keys::generate();
-        let event = build_profile_event(&agent_keys, "TestBot", None, None)
-            .expect("should succeed without an auth tag");
+        let event =
+            build_profile_event(&agent_keys, &serde_json::Map::new(), "TestBot", None, None)
+                .expect("should succeed without an auth tag");
 
         // No "auth" tags should be present.
         let auth_tags: Vec<_> = event
@@ -1008,11 +1036,42 @@ mod tests {
         let agent_keys = nostr::Keys::generate();
         // Structurally valid JSON array but with a bogus signature — verification must fail.
         let bad_json = format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128));
-        let result = build_profile_event(&agent_keys, "TestBot", None, Some(&bad_json));
+        let result = build_profile_event(
+            &agent_keys,
+            &serde_json::Map::new(),
+            "TestBot",
+            None,
+            Some(&bad_json),
+        );
         assert!(result.is_err(), "should reject an invalid auth tag");
         assert!(
             result.unwrap_err().contains("verification failed"),
             "error message should mention verification failure"
         );
     }
+
+    #[test]
+    fn profile_event_preserves_unmodeled_metadata() {
+        let agent_keys = nostr::Keys::generate();
+        let existing = serde_json::json!({
+            "display_name": "Old bot",
+            "bot": true,
+            "website": "https://example.com",
+            "nip05": "bot@example.com"
+        });
+        let event = build_profile_event(
+            &agent_keys,
+            existing.as_object().unwrap(),
+            "Renamed bot",
+            None,
+            None,
+        )
+        .expect("profile merge should succeed");
+        let content: serde_json::Value =
+            serde_json::from_str(&event.content).expect("profile content should be JSON");
+        assert_eq!(content["display_name"], "Renamed bot");
+        assert_eq!(content["bot"], true);
+        assert_eq!(content["website"], "https://example.com");
+        assert_eq!(content["nip05"], "bot@example.com");
+    }
 }
