diff --git a/NOSTR.md b/NOSTR.md
index fbf521a8d5..59df31b991 100644
--- a/NOSTR.md
+++ b/NOSTR.md
@@ -262,6 +262,7 @@ the sender to be authenticated (NIP-42) as the relay owner or an admin.
 | 9030 | Add member | `["p", "<hex-pubkey>"]`, optional `["role", "member\|admin"]` |
 | 9031 | Remove member | `["p", "<hex-pubkey>"]`, optional `["role", "member\|admin"]` |
 | 9032 | Change role | `["p", "<hex-pubkey>"]`, `["role", "member\|admin"]` |
+| 9033 | Set workspace profile (icon) | `["icon", "<https-url or data:image/* URL>"]` (empty clears) |
 
 Example using `nak`:
 
@@ -295,6 +296,13 @@ After each add/remove/role-change, the relay publishes a kind:13534 membership l
 nak req -k 13534 --auth --sec <privkey> ws://localhost:3000
 ```
 
+A kind:9033 command similarly makes the relay store the workspace icon (per
+community) and serve it in the standard NIP-11 `icon` field of its relay
+information document. Clients render it in the workspace rail/switcher; anyone
+can read it (`curl -H 'Accept: application/nostr+json' http://localhost:3000`),
+but only admins/owners can set it. Full spec:
+[docs/nips/NIP-WP.md](docs/nips/NIP-WP.md).
+
 ### Known Limitations
 
 1. **CLI intentionally does not emit kind 8000/8001 deltas** — `publish_nip43_delta` is
diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs
index f2e9184242..6b86b1c1fd 100644
--- a/crates/buzz-core/src/kind.rs
+++ b/crates/buzz-core/src/kind.rs
@@ -187,6 +187,8 @@ pub const RELAY_ADMIN_ADD_MEMBER: u32 = 9030;
 pub const RELAY_ADMIN_REMOVE_MEMBER: u32 = 9031;
 /// NIP-43: Change the role of an existing relay member.
 pub const RELAY_ADMIN_CHANGE_ROLE: u32 = 9032;
+/// Buzz: Set the workspace profile (icon). Admin/owner-signed command.
+pub const RELAY_ADMIN_SET_WORKSPACE_PROFILE: u32 = 9033;
 // NIP-43 relay membership announcement events (relay-signed)
 /// NIP-43: Relay membership list snapshot (relay-signed, replaceable by convention).
 pub const KIND_NIP43_MEMBERSHIP_LIST: u32 = 13534;
@@ -455,6 +457,7 @@ pub const ALL_KINDS: &[u32] = &[
     RELAY_ADMIN_ADD_MEMBER,
     RELAY_ADMIN_REMOVE_MEMBER,
     RELAY_ADMIN_CHANGE_ROLE,
+    RELAY_ADMIN_SET_WORKSPACE_PROFILE,
     KIND_NIP43_MEMBERSHIP_LIST,
     KIND_NIP43_MEMBER_ADDED,
     KIND_NIP43_MEMBER_REMOVED,
@@ -568,11 +571,15 @@ pub const fn is_workflow_execution_kind(kind: u32) -> bool {
     kind >= KIND_WORKFLOW_TRIGGERED && kind <= KIND_WORKFLOW_APPROVAL_DENIED
 }
 
-/// Returns `true` if `kind` is a NIP-43 relay membership admin command (9030–9032).
+/// Returns `true` if `kind` is a NIP-43 relay membership admin command (9030–9032)
+/// or the Buzz workspace-profile admin command (9033).
 pub const fn is_relay_admin_kind(kind: u32) -> bool {
     matches!(
         kind,
-        RELAY_ADMIN_ADD_MEMBER | RELAY_ADMIN_REMOVE_MEMBER | RELAY_ADMIN_CHANGE_ROLE
+        RELAY_ADMIN_ADD_MEMBER
+            | RELAY_ADMIN_REMOVE_MEMBER
+            | RELAY_ADMIN_CHANGE_ROLE
+            | RELAY_ADMIN_SET_WORKSPACE_PROFILE
     )
 }
 
diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs
index 69150aaadb..29701d5753 100644
--- a/crates/buzz-db/src/lib.rs
+++ b/crates/buzz-db/src/lib.rs
@@ -321,6 +321,49 @@ impl Db {
         .transpose()
     }
 
+    /// Returns the community's workspace icon (NIP-11 `icon`), if set.
+    ///
+    /// Set by relay admins/owners via the kind:9033 command; the value is
+    /// validated and size-capped at that write path.
+    pub async fn get_community_icon(&self, community_id: CommunityId) -> Result<Option<String>> {
+        let row = sqlx::query(
+            r#"
+            SELECT icon
+            FROM communities
+            WHERE id = $1
+            "#,
+        )
+        .bind(community_id.as_uuid())
+        .fetch_optional(&self.pool)
+        .await?;
+
+        Ok(row
+            .map(|row| row.try_get::<Option<String>, _>("icon"))
+            .transpose()?
+            .flatten()
+            .filter(|icon| !icon.is_empty()))
+    }
+
+    /// Sets or clears (`None`) the community's workspace icon.
+    pub async fn set_community_icon(
+        &self,
+        community_id: CommunityId,
+        icon: Option<&str>,
+    ) -> Result<()> {
+        sqlx::query(
+            r#"
+            UPDATE communities
+            SET icon = $2
+            WHERE id = $1
+            "#,
+        )
+        .bind(community_id.as_uuid())
+        .bind(icon)
+        .execute(&self.pool)
+        .await?;
+        Ok(())
+    }
+
     /// Ensure a configured community host exists and return its row.
     ///
     /// This is the startup/config seeding path for N=1 deployments. Migrations
diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs
index 74d85aa902..7caf822c25 100644
--- a/crates/buzz-db/src/migration.rs
+++ b/crates/buzz-db/src/migration.rs
@@ -471,7 +471,7 @@ mod tests {
         let mut migrations: Vec<_> = MIGRATOR.iter().collect();
         migrations.sort_by_key(|migration| migration.version);
 
-        assert_eq!(migrations.len(), 2);
+        assert_eq!(migrations.len(), 3);
         assert_eq!(migrations[0].version, 1);
         assert_eq!(&*migrations[0].description, "initial schema");
         assert!(migrations[0]
@@ -506,6 +506,15 @@ mod tests {
             .as_str()
             .contains("CREATE TABLE git_repo_names"));
         assert!(!migrations[0].sql.as_str().contains("git_repo_names"));
+
+        // Same additive-migration rule for the per-community workspace icon
+        // (NIP-11 `icon`): its own version, never folded into 0001.
+        assert_eq!(migrations[2].version, 3);
+        assert!(migrations[2]
+            .sql
+            .as_str()
+            .contains("ALTER TABLE communities ADD COLUMN icon"));
+        assert!(!migrations[0].sql.as_str().contains("icon"));
     }
 
     #[test]
diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs
index 5c0c188a78..3b074de21a 100644
--- a/crates/buzz-relay/src/handlers/ingest.rs
+++ b/crates/buzz-relay/src/handlers/ingest.rs
@@ -32,7 +32,7 @@ use buzz_core::kind::{
     KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2,
     KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF,
     KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE,
-    RELAY_ADMIN_REMOVE_MEMBER,
+    RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE,
 };
 use buzz_core::tenant::TenantContext;
 use buzz_core::verification::verify_event;
@@ -188,10 +188,12 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
         KIND_NIP29_PUT_USER | KIND_NIP29_REMOVE_USER | KIND_NIP29_DELETE_GROUP => {
             Ok(Scope::AdminChannels)
         }
-        // NIP-43: relay membership admin commands (9030–9032)
+        // NIP-43: relay membership admin commands (9030–9032) + Buzz
+        // workspace-profile command (9033)
         k if k == RELAY_ADMIN_ADD_MEMBER
             || k == RELAY_ADMIN_REMOVE_MEMBER
-            || k == RELAY_ADMIN_CHANGE_ROLE =>
+            || k == RELAY_ADMIN_CHANGE_ROLE
+            || k == RELAY_ADMIN_SET_WORKSPACE_PROFILE =>
         {
             Ok(Scope::AdminUsers)
         }
@@ -367,6 +369,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
             | RELAY_ADMIN_ADD_MEMBER
             | RELAY_ADMIN_REMOVE_MEMBER
             | RELAY_ADMIN_CHANGE_ROLE
+            | RELAY_ADMIN_SET_WORKSPACE_PROFILE
             | KIND_NIP43_LEAVE_REQUEST
             // NIP-IA: identity archive/unarchive requests drive relay-global
             // archive state (8002/8003/13535) and are audited as global request
diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs
index fe44e74c74..840d9dcfe5 100644
--- a/crates/buzz-relay/src/handlers/relay_admin.rs
+++ b/crates/buzz-relay/src/handlers/relay_admin.rs
@@ -10,13 +10,17 @@
 //! | 9030 | Add member      | admin or owner       |
 //! | 9031 | Remove member   | admin or owner       |
 //! | 9032 | Change role     | owner only           |
+//! | 9033 | Set workspace profile (icon) | admin or owner |
 
 use std::sync::Arc;
 
 use nostr::Event;
 use tracing::{info, warn};
 
-use buzz_core::kind::{RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER};
+use buzz_core::kind::{
+    RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
+    RELAY_ADMIN_SET_WORKSPACE_PROFILE,
+};
 use buzz_core::tenant::TenantContext;
 use buzz_db::relay_members::RemoveResult;
 
@@ -52,7 +56,45 @@ fn extract_tag_value(event: &Event, name: &str) -> Option<String> {
     None
 }
 
-/// Validate and execute a relay admin command (kinds 9030–9032).
+/// Maximum accepted workspace icon https URL length.
+const MAX_WORKSPACE_ICON_URL_LEN: usize = 2048;
+
+/// Maximum accepted workspace icon data-URL length (~96 KB of base64 ≈ 72 KB
+/// image — generous for a 128px icon).
+const MAX_WORKSPACE_ICON_DATA_URL_LEN: usize = 98_304;
+
+/// Validate a workspace icon: empty (clear), an http(s) URL, or an inline
+/// `data:image/*` URL (what the desktop publishes — it renders across
+/// workspaces without cross-relay media fetches).
+fn validate_workspace_icon(icon: &str) -> Result<(), String> {
+    if icon.is_empty() {
+        return Ok(());
+    }
+    if icon.chars().any(|c| c.is_control() || c.is_whitespace()) {
+        return Err("icon contains invalid characters".to_string());
+    }
+    if icon.starts_with("data:image/") {
+        if icon.len() > MAX_WORKSPACE_ICON_DATA_URL_LEN {
+            return Err(format!(
+                "icon data URL too long: {} bytes (max {MAX_WORKSPACE_ICON_DATA_URL_LEN})",
+                icon.len()
+            ));
+        }
+        return Ok(());
+    }
+    if !icon.starts_with("https://") && !icon.starts_with("http://") {
+        return Err("icon must be an http(s) URL or data:image/* URL".to_string());
+    }
+    if icon.len() > MAX_WORKSPACE_ICON_URL_LEN {
+        return Err(format!(
+            "icon URL too long: {} bytes (max {MAX_WORKSPACE_ICON_URL_LEN})",
+            icon.len()
+        ));
+    }
+    Ok(())
+}
+
+/// Validate and execute a relay admin command (kinds 9030–9033).
 ///
 /// The handler:
 /// 1. Extracts the target pubkey from the `["p", ...]` tag.
@@ -88,10 +130,6 @@ pub async fn handle_relay_admin_event(
         }
     }
 
-    let target_hex = extract_p_tag_hex(event)
-        .ok_or_else(|| "missing or invalid p tag".to_string())?
-        .to_ascii_lowercase();
-
     let sender_member = state
         .db
         .get_relay_member(tenant.community(), &sender_hex)
@@ -103,6 +141,34 @@ pub async fn handle_relay_admin_event(
         .map(|m| m.role.as_str())
         .unwrap_or("");
 
+    // kind:9033 — Set workspace profile (icon). Handled before p-tag
+    // extraction: it targets the relay itself, not a member pubkey.
+    if kind == RELAY_ADMIN_SET_WORKSPACE_PROFILE {
+        if sender_role != "admin" && sender_role != "owner" {
+            return Err("actor not authorized: must be admin or owner".to_string());
+        }
+
+        // Empty or missing icon tag clears the workspace icon.
+        let icon = extract_tag_value(event, "icon").unwrap_or_default();
+        validate_workspace_icon(&icon)?;
+
+        state
+            .db
+            .set_community_icon(
+                tenant.community(),
+                (!icon.is_empty()).then_some(icon.as_str()),
+            )
+            .await
+            .map_err(|e| format!("failed to store workspace icon: {e}"))?;
+
+        info!(sender = %sender_hex, icon_len = icon.len(), "workspace profile updated");
+        return Ok(());
+    }
+
+    let target_hex = extract_p_tag_hex(event)
+        .ok_or_else(|| "missing or invalid p tag".to_string())?
+        .to_ascii_lowercase();
+
     match kind {
         // kind:9030 — Add relay member
         k if k == RELAY_ADMIN_ADD_MEMBER => {
@@ -364,4 +430,39 @@ mod tests {
         let event = make_test_event(9030, vec![vec!["role", "admin"]]);
         assert_eq!(extract_tag_value(&event, "p"), None);
     }
+
+    #[test]
+    fn workspace_icon_empty_ok() {
+        assert!(validate_workspace_icon("").is_ok());
+    }
+
+    #[test]
+    fn workspace_icon_https_ok() {
+        assert!(validate_workspace_icon("https://example.com/icon.png").is_ok());
+    }
+
+    #[test]
+    fn workspace_icon_data_url_ok() {
+        assert!(validate_workspace_icon("data:image/webp;base64,UklGRg==").is_ok());
+    }
+
+    #[test]
+    fn workspace_icon_rejects_non_url() {
+        assert!(validate_workspace_icon("javascript:alert(1)").is_err());
+        assert!(validate_workspace_icon("data:text/html;base64,PGI+").is_err());
+    }
+
+    #[test]
+    fn workspace_icon_rejects_whitespace_and_control() {
+        assert!(validate_workspace_icon("https://example.com/a b.png").is_err());
+        assert!(validate_workspace_icon("https://example.com/a\nb.png").is_err());
+    }
+
+    #[test]
+    fn workspace_icon_rejects_oversized() {
+        let long_url = format!("https://example.com/{}.png", "a".repeat(2048));
+        assert!(validate_workspace_icon(&long_url).is_err());
+        let long_data = format!("data:image/png;base64,{}", "A".repeat(98_304));
+        assert!(validate_workspace_icon(&long_data).is_err());
+    }
 }
diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs
index ca859e0330..03e0065efb 100644
--- a/crates/buzz-relay/src/nip11.rs
+++ b/crates/buzz-relay/src/nip11.rs
@@ -27,6 +27,10 @@ pub struct RelayInfo {
     pub name: String,
     /// Human-readable relay description.
     pub description: String,
+    /// Workspace icon URL (NIP-11 `icon`), per-community, set by relay
+    /// admins/owners via the kind:9033 command. Omitted when no icon is set.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub icon: Option<String>,
     /// Relay operator's public key (hex), if published.
     pub pubkey: Option<String>,
     /// Contact address for the relay operator.
@@ -114,6 +118,10 @@ impl RelayInfo {
     /// unconditionally) requires clients to verify those events against
     /// `self`. Pass `Some` whenever the relay has a stable signing key.
     ///
+    /// `icon` is the community's workspace icon (see
+    /// [`workspace_icon_for_host`]) — a host-scoped scalar, pre-fetched by
+    /// the caller so `build` itself stays static-input.
+    ///
     /// `advertise_nip43` controls whether NIP-43 (relay membership) is added
     /// to `supported_nips`. Set `true` only when the relay actually emits and
     /// gates on NIP-43 events — i.e. has a stable key AND enforces
@@ -121,6 +129,7 @@ impl RelayInfo {
     /// programmer error to advertise NIP-43 without a `relay_self`.
     pub fn build(
         relay_self: Option<&str>,
+        icon: Option<&str>,
         advertise_nip43: bool,
         max_message_length: usize,
     ) -> Self {
@@ -137,6 +146,7 @@ impl RelayInfo {
         Self {
             name: "Buzz Relay".to_string(),
             description: "Buzz — private team communication relay".to_string(),
+            icon: icon.filter(|s| !s.is_empty()).map(|s| s.to_string()),
             pubkey: None,
             contact: None,
             supported_nips,
@@ -152,13 +162,51 @@ impl RelayInfo {
 /// Axum handler that returns the NIP-11 relay information document as JSON.
 pub async fn relay_info_handler(
     axum::extract::State(state): axum::extract::State<std::sync::Arc<crate::state::AppState>>,
+    headers: axum::http::HeaderMap,
 ) -> axum::response::Json<RelayInfo> {
-    let (relay_self, advertise_nip43) = nip11_facts(&state);
-    axum::response::Json(RelayInfo::build(
+    let raw_host = headers
+        .get(axum::http::header::HOST)
+        .and_then(|v| v.to_str().ok())
+        .unwrap_or("");
+    axum::response::Json(nip11_document(&state, raw_host).await)
+}
+
+/// Builds the served NIP-11 document for a request arriving on `raw_host`.
+///
+/// Centralised so the content-negotiated root handler and the dedicated
+/// `/info` endpoint can't drift apart. Every input to `RelayInfo::build`
+/// stays a pre-derived scalar: [`nip11_facts`] (config + keypair) plus the
+/// host-scoped workspace icon.
+pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &str) -> RelayInfo {
+    let (relay_self, advertise_nip43) = nip11_facts(state);
+    let icon = workspace_icon_for_host(state, raw_host).await;
+    RelayInfo::build(
         relay_self.as_deref(),
+        icon.as_deref(),
         advertise_nip43,
         state.config.max_frame_bytes,
-    ))
+    )
+}
+
+/// Fetches the workspace icon for the community bound to `raw_host`, as the
+/// host-scoped scalar consumed by [`RelayInfo::build`].
+///
+/// The icon is per-community state (`communities.icon`, set by relay
+/// admins/owners via the kind:9033 command) served in the standard NIP-11
+/// `icon` field. The lookup is scoped through
+/// [`crate::tenant::bind_community`] — never an unscoped query. Fails open to
+/// `None` (no `icon` field): NIP-11 is intentionally served to unmapped hosts
+/// too, and an icon lookup failure must not break that.
+async fn workspace_icon_for_host(state: &crate::state::AppState, raw_host: &str) -> Option<String> {
+    let tenant = crate::tenant::bind_community(&state.db, raw_host)
+        .await
+        .ok()?;
+    state
+        .db
+        .get_community_icon(tenant.community())
+        .await
+        .ok()
+        .flatten()
 }
 
 /// Derives the two NIP-11 facts that depend on runtime config:
@@ -185,9 +233,13 @@ pub(crate) fn nip11_facts(state: &crate::state::AppState) -> (Option<String>, bo
 ///
 /// The conformance obligation: `RelayInfo::build` "must not grow unscoped
 /// DB/search/audit inputs", so an unauthenticated NIP-11 read can never become
-/// an enumeration oracle for other communities. Today `build` takes only static
+/// an enumeration oracle for *other* communities. `build` takes only static
 /// and scalar inputs — the per-deployment facts arrive pre-derived through
-/// [`nip11_facts`], which reads config and the relay keypair only.
+/// [`nip11_facts`] (config + relay keypair), and the one host-scoped fact
+/// (the workspace `icon`) arrives as a scalar from
+/// [`workspace_icon_for_host`], whose DB lookup is scoped through
+/// [`crate::tenant::bind_community`] and can therefore only ever surface the
+/// requesting host's own community state.
 ///
 /// This const binds `RelayInfo::build` to its **exact** allowed signature. The
 /// moment someone adds a `&Db`, `&AppState`, a search handle, an audit handle,
@@ -196,8 +248,12 @@ pub(crate) fn nip11_facts(state: &crate::state::AppState) -> (Option<String>, bo
 /// hard build break, the same way a deny-lint would. If you must change this
 /// signature, you are changing the conformance contract: update the conformance
 /// doc and prove the new input is host-scoped, not unscoped, first.
-const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn(Option<&str>, bool, usize) -> RelayInfo =
-    RelayInfo::build;
+const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn(
+    Option<&str>,
+    Option<&str>,
+    bool,
+    usize,
+) -> RelayInfo = RelayInfo::build;
 
 #[cfg(test)]
 mod tests {
@@ -227,10 +283,42 @@ mod tests {
 
     #[test]
     fn build_advertises_buzz_repository_url() {
-        let info = RelayInfo::build(None, false, DEFAULT_MAX_FRAME_BYTES);
+        let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES);
         assert_eq!(info.software, "https://github.com/block/buzz");
     }
 
+    /// NIP-WP → NIP-11 mirror: a set workspace icon is served in the standard
+    /// `icon` field; no icon (or a cleared, empty icon) omits the field
+    /// entirely so the JSON matches pre-icon documents byte-for-byte.
+    #[test]
+    fn icon_is_mirrored_and_empty_or_absent_is_omitted() {
+        let info = RelayInfo::build(
+            None,
+            Some("data:image/webp;base64,UklGRg=="),
+            false,
+            DEFAULT_MAX_FRAME_BYTES,
+        );
+        assert_eq!(
+            info.icon.as_deref(),
+            Some("data:image/webp;base64,UklGRg==")
+        );
+        let json = serde_json::to_value(&info).expect("serialize");
+        assert_eq!(
+            json.get("icon").and_then(|v| v.as_str()),
+            Some("data:image/webp;base64,UklGRg==")
+        );
+
+        for icon in [None, Some("")] {
+            let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES);
+            assert!(info.icon.is_none());
+            let json = serde_json::to_value(&info).expect("serialize");
+            assert!(
+                json.get("icon").is_none(),
+                "unset/cleared icon must omit the `icon` field, not serialize null/empty"
+            );
+        }
+    }
+
     #[test]
     fn auth_required_is_advertised_true() {
         // REQ, EVENT, and COUNT all unconditionally require
@@ -241,7 +329,7 @@ mod tests {
 
     #[test]
     fn max_message_length_uses_configured_frame_limit() {
-        let info = RelayInfo::build(None, false, 262_144);
+        let info = RelayInfo::build(None, None, false, 262_144);
         let limitation = info.limitation.expect("limitation");
         assert_eq!(limitation.max_message_length, Some(262_144));
     }
@@ -272,7 +360,7 @@ mod tests {
     /// Open relay, ephemeral key — both `self` and NIP-43 are absent.
     #[test]
     fn build_open_relay_ephemeral_key_omits_self_and_nip43() {
-        let info = RelayInfo::build(None, false, DEFAULT_MAX_FRAME_BYTES);
+        let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES);
         assert!(info.relay_self.is_none());
         assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
     }
@@ -285,7 +373,7 @@ mod tests {
     #[test]
     fn build_open_relay_stable_key_advertises_self_but_not_nip43() {
         let pk = "0000000000000000000000000000000000000000000000000000000000000001";
-        let info = RelayInfo::build(Some(pk), false, DEFAULT_MAX_FRAME_BYTES);
+        let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES);
         assert_eq!(info.relay_self.as_deref(), Some(pk));
         assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
     }
@@ -294,7 +382,7 @@ mod tests {
     #[test]
     fn build_membership_relay_advertises_self_and_nip43() {
         let pk = "0000000000000000000000000000000000000000000000000000000000000001";
-        let info = RelayInfo::build(Some(pk), true, DEFAULT_MAX_FRAME_BYTES);
+        let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES);
         assert_eq!(info.relay_self.as_deref(), Some(pk));
         assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
     }
@@ -305,6 +393,6 @@ mod tests {
     #[test]
     #[should_panic(expected = "advertise_nip43=true requires relay_self=Some")]
     fn build_nip43_without_self_panics_in_debug() {
-        let _ = RelayInfo::build(None, true, DEFAULT_MAX_FRAME_BYTES);
+        let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES);
     }
 }
diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs
index 442531bcdd..fc9e1ec38e 100644
--- a/crates/buzz-relay/src/router.rs
+++ b/crates/buzz-relay/src/router.rs
@@ -21,7 +21,7 @@ use crate::api;
 use crate::audio;
 use crate::connection::handle_connection;
 use crate::metrics::track_metrics;
-use crate::nip11::{nip11_facts, relay_info_handler, RelayInfo};
+use crate::nip11::{nip11_document, relay_info_handler};
 use crate::state::AppState;
 
 /// Build the axum [`Router`] with all relay routes, middleware, and CORS configuration.
@@ -149,27 +149,22 @@ async fn nip11_or_ws_handler(
         .and_then(|v| v.to_str().ok())
         .unwrap_or("");
 
-    let (relay_self, advertise_nip43) = nip11_facts(&state);
+    let raw_host = headers
+        .get(axum::http::header::HOST)
+        .and_then(|v| v.to_str().ok())
+        .unwrap_or("");
 
     if accept.contains("application/nostr+json") {
-        let info = RelayInfo::build(
-            relay_self.as_deref(),
-            advertise_nip43,
-            state.config.max_frame_bytes,
-        );
-        return Json(info).into_response();
+        return Json(nip11_document(&state, raw_host).await).into_response();
     }
 
     // Row zero: bind the connection to its community from the request host
     // BEFORE the WebSocket upgrade, so no frame is ever read on an unbound
     // connection. The host is the authoritative selector; an unmapped host or a
     // lookup failure fails closed with a generic rejection — never a default
-    // tenant. NIP-11 above is intentionally host-agnostic (static facts only),
-    // so it is served before binding and cannot leak which hosts are mapped.
-    let raw_host = headers
-        .get(axum::http::header::HOST)
-        .and_then(|v| v.to_str().ok())
-        .unwrap_or("");
+    // tenant. NIP-11 above is served before binding and stays fail-open: an
+    // unmapped host still gets the document (with host-scoped fields like
+    // `icon` simply absent), so the doc cannot leak which hosts are mapped.
     let tenant = match crate::tenant::bind_community(&state.db, raw_host).await {
         Ok(ctx) => ctx,
         Err(_) => {
@@ -199,12 +194,7 @@ async fn nip11_or_ws_handler(
                 }
             }
             // Not a WS request and not asking for nostr+json — serve NIP-11 as fallback.
-            let info = RelayInfo::build(
-                relay_self.as_deref(),
-                advertise_nip43,
-                state.config.max_frame_bytes,
-            );
-            Json(info).into_response()
+            Json(nip11_document(&state, raw_host).await).into_response()
         }
     }
 }
diff --git a/crates/buzz-test-client/tests/conformance_multitenant.rs b/crates/buzz-test-client/tests/conformance_multitenant.rs
index 423aa42df3..15002142e4 100644
--- a/crates/buzz-test-client/tests/conformance_multitenant.rs
+++ b/crates/buzz-test-client/tests/conformance_multitenant.rs
@@ -454,14 +454,17 @@ mod nip11_relay_info {
     /// input; this test proves the *observable wire behavior* — that the served
     /// document carries nothing that distinguishes one community from another.
     ///
-    /// Because `RelayInfo::build` is genuinely static-input today, host A's and
-    /// host B's NIP-11 bodies are byte-identical, and that identity *is* the
-    /// proof: no field varies by community, so an unauthenticated reader cannot
-    /// use the document to probe whether (or how) community B is configured.
-    /// The moment a per-community value leaks into the doc, the two bodies
-    /// diverge and this assertion fails — that is the mutate-bite this row
-    /// guards (seed a community-distinguishing field into the served doc → the
-    /// A≡B assertion goes red).
+    /// One field is deliberately host-scoped: `icon` (the NIP-WP workspace
+    /// icon, per-community state served in the standard NIP-11 field, fetched
+    /// through `bind_community` so it can only ever be the requesting host's
+    /// own community state — intentionally public presentation, exactly what
+    /// upstream NIP-11 `icon` is). So the
+    /// oracle proof is: the documents are identical *modulo each community's
+    /// own `icon`*, and an unmapped host's document carries no `icon` at all.
+    /// The moment any *other* per-community value leaks into the doc, the
+    /// icon-stripped bodies diverge and this assertion fails — that is the
+    /// mutate-bite this row guards (seed a community-distinguishing field into
+    /// the served doc → the A≡B assertion goes red).
     #[tokio::test]
     #[ignore]
     async fn nip11_is_not_a_cross_community_enumeration_oracle() {
@@ -481,9 +484,9 @@ mod nip11_relay_info {
 
         // Both bodies must be valid NIP-11 JSON — a relay-info object, not an
         // error page or a host echo.
-        let json_a: serde_json::Value =
+        let mut json_a: serde_json::Value =
             serde_json::from_str(&body_a).expect("host A NIP-11 is valid JSON");
-        let json_b: serde_json::Value =
+        let mut json_b: serde_json::Value =
             serde_json::from_str(&body_b).expect("host B NIP-11 is valid JSON");
         assert!(
             json_a.get("supported_nips").is_some(),
@@ -494,14 +497,23 @@ mod nip11_relay_info {
             "host B NIP-11 must be a relay-info document (has supported_nips)"
         );
 
-        // The enumeration-oracle obligation: no field of the served document
-        // varies by community. Identical bodies are the proof that the doc
-        // cannot be used to distinguish or probe another tenant.
+        // `icon` is the one deliberately host-scoped field (the NIP-WP
+        // workspace icon; each host sees only its own community's icon).
+        // Strip it before the equality proof — every OTHER field must still
+        // be community-agnostic.
+        json_a.as_object_mut().map(|o| o.remove("icon"));
+        json_b.as_object_mut().map(|o| o.remove("icon"));
+
+        // The enumeration-oracle obligation: no other field of the served
+        // document varies by community. Identical icon-stripped bodies are the
+        // proof that the doc cannot be used to distinguish or probe another
+        // tenant.
         assert_eq!(
             json_a, json_b,
-            "NIP-11 from host A and host B must be identical: any community-\
-             distinguishing field would make the unauthenticated relay-info \
-             document an enumeration oracle for other tenants"
+            "NIP-11 from host A and host B must be identical apart from each \
+             community's own `icon`: any other community-distinguishing field \
+             would make the unauthenticated relay-info document an enumeration \
+             oracle for other tenants"
         );
 
         // An *unmapped* host must get the SAME document too — not a 404. NIP-11
@@ -523,11 +535,18 @@ mod nip11_relay_info {
         );
         let json_unknown: serde_json::Value =
             serde_json::from_str(&body_unknown).expect("unmapped-host NIP-11 is valid JSON");
+        assert!(
+            json_unknown.get("icon").is_none(),
+            "an unmapped host binds to no community, so its NIP-11 document must \
+             carry no `icon` — leaking any community's icon to an unmapped host \
+             would cross the tenant boundary"
+        );
         assert_eq!(
             json_a, json_unknown,
-            "NIP-11 served to an unmapped host must be byte-identical to a mapped \
-             host's document: the relay-info doc carries no host-derived field, \
-             so it cannot reveal whether a given host is configured"
+            "NIP-11 served to an unmapped host must match a mapped host's \
+             icon-stripped document: apart from the host's own `icon`, the \
+             relay-info doc carries no host-derived field, so it cannot reveal \
+             whether a given host is configured"
         );
     }
 }
diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs
index 7c0e4b8327..e0ab14a439 100644
--- a/desktop/src-tauri/src/commands/workspace.rs
+++ b/desktop/src-tauri/src/commands/workspace.rs
@@ -1,5 +1,5 @@
 use nostr::Keys;
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
 use tauri::{AppHandle, Emitter, State};
 
 use crate::app_state::AppState;
@@ -9,6 +9,43 @@ use crate::managed_agents::{
 };
 use crate::relay;
 
+#[derive(Deserialize)]
+struct RelayInfoIcon {
+    #[serde(default)]
+    icon: Option<String>,
+}
+
+/// Fetch a relay's workspace icon from its NIP-11 relay information document.
+///
+/// Works for any workspace (active or not) with a plain unauthenticated HTTP
+/// GET — no WebSocket session needed. Returns `None` when the relay has no
+/// icon set, is unreachable, or serves a malformed document: the rail falls
+/// back to initials in all three cases.
+#[tauri::command]
+pub async fn fetch_workspace_icon(
+    relay_url: String,
+    state: State<'_, AppState>,
+) -> Result<Option<String>, String> {
+    let http_url = relay::relay_http_base_url(&relay_url);
+    let Ok(response) = state
+        .http_client
+        .get(&http_url)
+        .header("Accept", "application/nostr+json")
+        .send()
+        .await
+    else {
+        return Ok(None);
+    };
+    if !response.status().is_success() {
+        return Ok(None);
+    }
+    let doc = response
+        .json::<RelayInfoIcon>()
+        .await
+        .unwrap_or(RelayInfoIcon { icon: None });
+    Ok(doc.icon.filter(|icon| !icon.is_empty()))
+}
+
 #[derive(Serialize)]
 pub struct ActiveWorkspaceInfo {
     relay_url: String,
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index f811eba262..53b41c256f 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -607,6 +607,7 @@ pub fn run() {
             apply_workspace,
             validate_repos_dir,
             get_active_workspace,
+            fetch_workspace_icon,
             set_prevent_sleep_active,
             get_agent_memory,
             relay_reconnect_hook,
diff --git a/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx b/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx
index 49efc2bdc6..41fa1a111b 100644
--- a/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx
+++ b/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx
@@ -37,6 +37,7 @@ import {
 } from "@/shared/ui/dropdown-menu";
 import { Input } from "@/shared/ui/input";
 import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader";
+import { WorkspaceIconSettingsCard } from "@/features/workspaces/ui/WorkspaceIconSettingsCard";
 import { VirtualizedList } from "@/shared/ui/VirtualizedList";
 
 type AssignableRelayRole = Exclude<RelayMemberRole, "owner">;
@@ -365,6 +366,7 @@ export function RelayMembersSettingsCard({
       />
 
       <div className="space-y-6">
+        <WorkspaceIconSettingsCard />
         <form className="space-y-1.5" onSubmit={handleAddMember}>
           <label className="text-sm font-medium" htmlFor="relay-member-pubkey">
             Invite a person
diff --git a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
index 8997b1808d..7f6f3bdcc4 100644
--- a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
+++ b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
@@ -1,6 +1,7 @@
 import { Plus } from "lucide-react";
 
 import type { Workspace } from "@/features/workspaces/types";
+import { useWorkspaceIcons } from "@/features/workspaces/useWorkspaceIcons";
 import {
   useWorkspaceUnread,
   type WorkspaceUnreadState,
@@ -54,11 +55,13 @@ function WorkspaceButton({
   workspace,
   isActive,
   unread,
+  iconUrl,
   onSwitch,
 }: {
   workspace: Workspace;
   isActive: boolean;
   unread: WorkspaceUnreadState;
+  iconUrl: string | null;
   onSwitch: () => void;
 }) {
   const { mentionCount, showBadge, pending, badgeLabel } =
@@ -81,14 +84,24 @@ function WorkspaceButton({
         >
           <span
             className={cn(
-              "flex h-9 w-9 items-center justify-center rounded-2xl text-xs font-semibold transition-all",
+              "flex h-9 w-9 items-center justify-center overflow-hidden rounded-2xl text-xs font-semibold transition-all",
               isActive
                 ? "rounded-xl bg-primary text-primary-foreground"
                 : "bg-sidebar-accent/60 text-sidebar-foreground/80 hover:rounded-xl hover:bg-primary/80 hover:text-primary-foreground",
               pending && "opacity-60",
             )}
           >
-            {workspaceInitials(workspace.name) || "🐝"}
+            {iconUrl ? (
+              <img
+                alt=""
+                className="h-full w-full object-cover"
+                data-testid={`workspace-rail-icon-${workspace.id}`}
+                draggable={false}
+                src={iconUrl}
+              />
+            ) : (
+              workspaceInitials(workspace.name) || "🐝"
+            )}
           </span>
           {showBadge ? (
             <span
@@ -119,6 +132,7 @@ export function WorkspaceRail({
   onAddWorkspace,
 }: WorkspaceRailProps) {
   const unreadByWorkspace = useWorkspaceUnread(workspaces, activeWorkspaceId);
+  const iconsByWorkspace = useWorkspaceIcons(workspaces);
   const isFullscreen = useIsFullscreen();
   if (workspaces.length <= 1) {
     return null;
@@ -142,6 +156,7 @@ export function WorkspaceRail({
       {workspaces.map((workspace) => (
         <WorkspaceButton
           key={workspace.id}
+          iconUrl={iconsByWorkspace[workspace.id] ?? null}
           isActive={workspace.id === activeWorkspaceId}
           onSwitch={() => onSwitchWorkspace(workspace.id)}
           unread={
diff --git a/desktop/src/features/workspaces/lib/downscaleIcon.ts b/desktop/src/features/workspaces/lib/downscaleIcon.ts
new file mode 100644
index 0000000000..391911a457
--- /dev/null
+++ b/desktop/src/features/workspaces/lib/downscaleIcon.ts
@@ -0,0 +1,37 @@
+/**
+ * Downscale an image file to a small square data-URL for use as a workspace
+ * icon. The result is inlined into the kind:9033 command (and the NIP-11
+ * document the relay serves) so it renders
+ * across workspaces without cross-relay media fetches; the relay caps icon
+ * data-URLs at 96 KB, and 128px WebP/PNG output stays far under that.
+ */
+
+const ICON_SIZE = 128;
+
+export async function downscaleIconToDataUrl(file: File): Promise<string> {
+  const bitmap = await createImageBitmap(file);
+  try {
+    const side = Math.min(bitmap.width, bitmap.height);
+    const sx = (bitmap.width - side) / 2;
+    const sy = (bitmap.height - side) / 2;
+
+    const canvas = document.createElement("canvas");
+    canvas.width = ICON_SIZE;
+    canvas.height = ICON_SIZE;
+    const ctx = canvas.getContext("2d");
+    if (!ctx) {
+      throw new Error("Could not process that image.");
+    }
+    ctx.imageSmoothingQuality = "high";
+    ctx.drawImage(bitmap, sx, sy, side, side, 0, 0, ICON_SIZE, ICON_SIZE);
+
+    // WebP keeps transparency and compresses well; fall back to PNG when the
+    // WebView cannot encode WebP.
+    const webp = canvas.toDataURL("image/webp", 0.85);
+    return webp.startsWith("data:image/webp")
+      ? webp
+      : canvas.toDataURL("image/png");
+  } finally {
+    bitmap.close();
+  }
+}
diff --git a/desktop/src/features/workspaces/ui/WorkspaceIconSettingsCard.tsx b/desktop/src/features/workspaces/ui/WorkspaceIconSettingsCard.tsx
new file mode 100644
index 0000000000..f2ca3053c5
--- /dev/null
+++ b/desktop/src/features/workspaces/ui/WorkspaceIconSettingsCard.tsx
@@ -0,0 +1,137 @@
+import { ImagePlus, Trash2 } from "lucide-react";
+import * as React from "react";
+import { toast } from "sonner";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+import { downscaleIconToDataUrl } from "@/features/workspaces/lib/downscaleIcon";
+import {
+  useActiveWorkspaceIcon,
+  workspaceIconQueryKey,
+} from "@/features/workspaces/useWorkspaceIcons";
+import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
+import { workspaceInitials } from "@/features/sidebar/ui/WorkspaceRail";
+import { setWorkspaceIcon } from "@/shared/api/workspaceProfile";
+import { Button } from "@/shared/ui/button";
+
+const ICON_IMAGE_TYPES = ["image/gif", "image/jpeg", "image/png", "image/webp"];
+
+/**
+ * Admin-only workspace icon editor, rendered inside the Relay Access
+ * settings section. Publishes a kind:9033 command; the relay stores
+ * the icon per community and serves it in NIP-11, which every member's
+ * rail reads.
+ */
+export function WorkspaceIconSettingsCard() {
+  const { activeWorkspace } = useWorkspaces();
+  const relayUrl = activeWorkspace?.relayUrl;
+  const iconQuery = useActiveWorkspaceIcon(relayUrl);
+  const queryClient = useQueryClient();
+  const inputRef = React.useRef<HTMLInputElement | null>(null);
+
+  const mutation = useMutation({
+    mutationFn: (icon: string) => setWorkspaceIcon(icon),
+    onSuccess: async (_data, icon) => {
+      if (relayUrl) {
+        queryClient.setQueryData(workspaceIconQueryKey(relayUrl), icon || null);
+        await queryClient.invalidateQueries({
+          queryKey: workspaceIconQueryKey(relayUrl),
+        });
+      }
+    },
+  });
+
+  async function handleFile(file: File) {
+    if (!ICON_IMAGE_TYPES.includes(file.type)) {
+      toast.error("Choose a PNG, JPG, GIF, or WebP image.");
+      return;
+    }
+    try {
+      const dataUrl = await downscaleIconToDataUrl(file);
+      await mutation.mutateAsync(dataUrl);
+      toast.success("Workspace icon updated");
+    } catch (error) {
+      toast.error(
+        error instanceof Error
+          ? error.message
+          : "Failed to update the workspace icon.",
+      );
+    }
+  }
+
+  async function handleClear() {
+    try {
+      await mutation.mutateAsync("");
+      toast.success("Workspace icon removed");
+    } catch (error) {
+      toast.error(
+        error instanceof Error
+          ? error.message
+          : "Failed to remove the workspace icon.",
+      );
+    }
+  }
+
+  const icon = iconQuery.data ?? null;
+  const initials = activeWorkspace
+    ? workspaceInitials(activeWorkspace.name)
+    : "";
+
+  return (
+    <div className="space-y-1.5" data-testid="workspace-icon-settings">
+      <span className="text-sm font-medium">Workspace icon</span>
+      <div className="flex items-center gap-3">
+        <span className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-sidebar-accent/60 text-sm font-semibold text-sidebar-foreground/80">
+          {icon ? (
+            <img
+              alt="Workspace icon"
+              className="h-full w-full object-cover"
+              data-testid="workspace-icon-preview"
+              src={icon}
+            />
+          ) : (
+            initials || "🐝"
+          )}
+        </span>
+        <div className="flex items-center gap-2">
+          <Button
+            data-testid="workspace-icon-upload"
+            disabled={mutation.isPending}
+            onClick={() => inputRef.current?.click()}
+            type="button"
+            variant="outline"
+          >
+            <ImagePlus className="h-4 w-4" />
+            {icon ? "Replace" : "Upload"}
+          </Button>
+          {icon ? (
+            <Button
+              data-testid="workspace-icon-remove"
+              disabled={mutation.isPending}
+              onClick={() => void handleClear()}
+              type="button"
+              variant="ghost"
+            >
+              <Trash2 className="h-4 w-4" />
+              Remove
+            </Button>
+          ) : null}
+        </div>
+        <input
+          accept={ICON_IMAGE_TYPES.join(",")}
+          className="hidden"
+          onChange={(event) => {
+            const file = event.target.files?.[0];
+            event.target.value = "";
+            if (file) void handleFile(file);
+          }}
+          ref={inputRef}
+          type="file"
+        />
+      </div>
+      <p className="text-xs text-muted-foreground">
+        Shown to every member in the workspace rail and switcher. Square images
+        work best.
+      </p>
+    </div>
+  );
+}
diff --git a/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx b/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx
index a2655ea04a..af8d8bdcd4 100644
--- a/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx
+++ b/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx
@@ -23,11 +23,13 @@ import {
 } from "@/shared/ui/sidebar";
 import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
 import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
+import { cn } from "@/shared/lib/cn";
 import type { ConnectionState } from "@/shared/api/relayClientShared";
 import {
   isRelayConnectionDegraded,
   useRelayConnection,
 } from "@/shared/api/useRelayConnection";
+import { useActiveWorkspaceIcon } from "@/features/workspaces/useWorkspaceIcons";
 import { EditWorkspaceDialog } from "./EditWorkspaceDialog";
 
 const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
@@ -52,7 +54,28 @@ type WorkspaceSwitcherProps = {
   onRemoveWorkspace: (id: string) => void;
 };
 
-function WorkspaceEmojiIcon({ className }: { className: string }) {
+function WorkspaceEmojiIcon({
+  className,
+  iconUrl,
+}: {
+  className: string;
+  iconUrl?: string | null;
+}) {
+  if (iconUrl) {
+    return (
+      <span
+        aria-hidden="true"
+        className={cn(className, "h-5 overflow-hidden rounded-md")}
+      >
+        <img
+          alt=""
+          className="h-full w-full object-cover"
+          draggable={false}
+          src={iconUrl}
+        />
+      </span>
+    );
+  }
   return (
     <span aria-hidden="true" className={className}>
       <span className="-translate-y-px leading-normal">🐝</span>
@@ -76,6 +99,8 @@ export function WorkspaceSwitcher({
   const connectionState = useRelayConnection();
   const degraded = isRelayConnectionDegraded(connectionState);
   const connectionLabel = CONNECTION_STATE_LABEL[connectionState];
+  const activeIconQuery = useActiveWorkspaceIcon(activeWorkspace?.relayUrl);
+  const activeIcon = activeIconQuery.data ?? null;
   const isProfileVariant = variant === "profile";
 
   function clearProfileMenuHoverTimer() {
@@ -143,6 +168,7 @@ export function WorkspaceSwitcher({
               ? "flex w-5 shrink-0 items-center justify-center rounded-md border border-sidebar-border/70 bg-sidebar-accent/40 text-2xs"
               : "flex w-5 shrink-0 items-center justify-center text-xs"
           }
+          iconUrl={activeIcon}
         />
       )}
       <span
diff --git a/desktop/src/features/workspaces/useWorkspaceIcons.ts b/desktop/src/features/workspaces/useWorkspaceIcons.ts
new file mode 100644
index 0000000000..a96f1b1462
--- /dev/null
+++ b/desktop/src/features/workspaces/useWorkspaceIcons.ts
@@ -0,0 +1,68 @@
+import { useQueries, useQuery } from "@tanstack/react-query";
+
+import { fetchWorkspaceIcon } from "@/shared/api/workspaceProfile";
+
+import type { Workspace } from "./types";
+import {
+  loadCachedWorkspaceIcon,
+  saveCachedWorkspaceIcon,
+} from "./workspaceIconCache";
+
+export const workspaceIconQueryKey = (relayUrl: string) =>
+  ["workspaceIcon", relayUrl] as const;
+
+const ICON_STALE_MS = 5 * 60_000;
+
+async function fetchIconForWorkspace(
+  workspace: Workspace,
+): Promise<string | null> {
+  const icon = await fetchWorkspaceIcon(workspace.relayUrl);
+  saveCachedWorkspaceIcon(workspace.relayUrl, icon);
+  return icon;
+}
+
+function iconQueryOptions(workspace: Workspace) {
+  return {
+    queryKey: workspaceIconQueryKey(workspace.relayUrl),
+    queryFn: () => fetchIconForWorkspace(workspace),
+    // Cached icon renders immediately; the fetch still runs and replaces it.
+    placeholderData: loadCachedWorkspaceIcon(workspace.relayUrl),
+    staleTime: ICON_STALE_MS,
+    retry: 1,
+  };
+}
+
+/**
+ * Workspace icons for the rail, keyed by workspace id. Each icon is read
+ * from its relay's NIP-11 document over plain HTTP — active and inactive
+ * workspaces alike. Falls back to the localStorage cache (then null →
+ * initials) when a relay is unreachable.
+ */
+export function useWorkspaceIcons(
+  workspaces: Workspace[],
+): Record<string, string | null> {
+  const results = useQueries({
+    queries: workspaces.map((workspace) => iconQueryOptions(workspace)),
+  });
+
+  const icons: Record<string, string | null> = {};
+  workspaces.forEach((workspace, index) => {
+    icons[workspace.id] =
+      results[index]?.data ?? loadCachedWorkspaceIcon(workspace.relayUrl);
+  });
+  return icons;
+}
+
+/** Icon of the ACTIVE workspace, for settings preview. */
+export function useActiveWorkspaceIcon(relayUrl: string | undefined) {
+  return useQuery({
+    queryKey: workspaceIconQueryKey(relayUrl ?? ""),
+    queryFn: async () => {
+      const icon = await fetchWorkspaceIcon(relayUrl ?? "");
+      if (relayUrl) saveCachedWorkspaceIcon(relayUrl, icon);
+      return icon;
+    },
+    enabled: relayUrl !== undefined,
+    staleTime: ICON_STALE_MS,
+  });
+}
diff --git a/desktop/src/features/workspaces/workspaceIconCache.ts b/desktop/src/features/workspaces/workspaceIconCache.ts
new file mode 100644
index 0000000000..df61a32263
--- /dev/null
+++ b/desktop/src/features/workspaces/workspaceIconCache.ts
@@ -0,0 +1,41 @@
+/**
+ * Local cache of workspace icons keyed by relay URL, so the rail renders
+ * icons instantly on boot and for unreachable relays. Values are small
+ * data-URLs (or http URLs) from the relay's NIP-11 `icon` field.
+ */
+
+const ICON_CACHE_KEY = "buzz-workspace-icons";
+
+function loadCache(): Record<string, string> {
+  try {
+    const raw = localStorage.getItem(ICON_CACHE_KEY);
+    const parsed: unknown = raw ? JSON.parse(raw) : null;
+    if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+      return parsed as Record<string, string>;
+    }
+  } catch {
+    // Corrupt cache — fall through to empty.
+  }
+  return {};
+}
+
+export function loadCachedWorkspaceIcon(relayUrl: string): string | null {
+  return loadCache()[relayUrl] ?? null;
+}
+
+export function saveCachedWorkspaceIcon(
+  relayUrl: string,
+  icon: string | null,
+): void {
+  const cache = loadCache();
+  if (icon) {
+    cache[relayUrl] = icon;
+  } else {
+    delete cache[relayUrl];
+  }
+  try {
+    localStorage.setItem(ICON_CACHE_KEY, JSON.stringify(cache));
+  } catch {
+    // Quota exceeded — the icon still renders from the in-memory query.
+  }
+}
diff --git a/desktop/src/shared/api/workspaceProfile.ts b/desktop/src/shared/api/workspaceProfile.ts
new file mode 100644
index 0000000000..f317ceda53
--- /dev/null
+++ b/desktop/src/shared/api/workspaceProfile.ts
@@ -0,0 +1,51 @@
+/**
+ * Workspace icon, Buzz extension to NIP-43 + standard NIP-11 `icon`.
+ *
+ * An admin/owner publishes a kind:9033 command carrying the icon in an
+ * `["icon", value]` tag; the relay validates the sender's relay role and
+ * stores the icon per community, serving it in the standard `icon` field of
+ * its NIP-11 relay information document. Every member's client reads NIP-11,
+ * so the whole workspace sees the same icon.
+ *
+ * The icon value is a small `data:image/*` URL (downscaled client-side
+ * before publish) so it renders for INACTIVE workspaces straight from the
+ * document — no cross-relay media fetch behind another relay's auth wall.
+ */
+
+import { relayClient } from "@/shared/api/relayClient";
+import { invokeTauri, signRelayEvent } from "@/shared/api/tauri";
+
+/** Buzz: admin command to set the workspace profile (icon). */
+export const KIND_SET_WORKSPACE_PROFILE = 9033;
+
+/**
+ * Fetch a workspace's icon from its relay's NIP-11 document (plain
+ * unauthenticated HTTP via the Tauri backend — works for inactive
+ * workspaces too). Unreachable relay or no icon → null.
+ */
+export async function fetchWorkspaceIcon(
+  relayUrl: string,
+): Promise<string | null> {
+  const icon = await invokeTauri<string | null>("fetch_workspace_icon", {
+    relayUrl,
+  });
+  return icon || null;
+}
+
+/**
+ * Publish a kind:9033 command setting (or clearing, with "") the workspace
+ * icon on the active relay. Requires relay admin/owner role — the relay
+ * rejects the command otherwise.
+ */
+export async function setWorkspaceIcon(icon: string): Promise<void> {
+  const event = await signRelayEvent({
+    kind: KIND_SET_WORKSPACE_PROFILE,
+    content: "",
+    tags: [["icon", icon]],
+  });
+  await relayClient.publishEvent(
+    event,
+    "Timed out while updating the workspace icon.",
+    "Failed to update the workspace icon.",
+  );
+}
diff --git a/docs/multi-tenant-conformance.md b/docs/multi-tenant-conformance.md
index 36e19aed51..3cd56066eb 100644
--- a/docs/multi-tenant-conformance.md
+++ b/docs/multi-tenant-conformance.md
@@ -40,7 +40,7 @@ Conformance obligations:
 | Surface | Today's observable behavior | Tenant source | Community-global vs operator-global | Required DB/index/RLS scope | Auth/fan-out/search effects | Single-community compatibility check | Open decision/test |
 |---|---|---|---|---|---|---|---|
 | Row zero: host binding | A user connects to one relay URL and all state they can observe belongs to that relay. | `resolve_host(connection.host)` before handler entry. | Community-global selector; operator only manages the host map. | `communities(host, id, signing_key, …)`; every scoped table references immutable `community_id`. | All auth, event, REST, media, git, search, pub/sub, and workflow paths consume `TenantContext`; host/token mismatch rejects generically. | One host maps to the default community; no client-visible protocol field changes. | Add model/prose gate that `ctx.community` is derived from host, not supplied by the client. |
-| NIP-11 relay info and relay `self` | `GET /`/`/info` returns one relay info document; `RelayInfo::build` advertises static NIPs and a stable relay signing pubkey when configured. | Host-derived community for community-specific facts; no DB lookup from unauthenticated global state unless explicitly through `TenantContext`. | NIP-11 is community-global. Operator-global software/version may be shared; relay `self` for group/system/audit signing is per-community. | `communities.signing_key` or equivalent per-community signing material; no platform-global `self` for tenant-observable system events. | Unauthenticated reads must not become enumeration oracles for other communities. NIP-43 advertisement reflects membership enforcement for that community only. | One community returns the same JSON except for values already configured today. | Signature/static-input lint remains: `RelayInfo::build` must not grow unscoped DB/search/audit inputs. |
+| NIP-11 relay info and relay `self` | `GET /`/`/info` returns one relay info document; `RelayInfo::build` advertises static NIPs, a stable relay signing pubkey when configured, and the community's workspace icon (NIP-WP). | Host-derived community for community-specific facts; the workspace `icon` is pre-fetched as a scalar via `bind_community` (fail-open to absent on unmapped host); no other DB lookup from unauthenticated global state unless explicitly through `TenantContext`. | NIP-11 is community-global. Operator-global software/version may be shared; relay `self` for group/system/audit signing is per-community; `icon` is per-community intentionally-public presentation. | `communities.signing_key` or equivalent per-community signing material; no platform-global `self` for tenant-observable system events. | Unauthenticated reads must not become enumeration oracles for other communities: apart from the requesting host's own `icon`, no field varies by community, and an unmapped host's document carries no `icon`. NIP-43 advertisement reflects membership enforcement for that community only. | One community returns the same JSON except for values already configured today. | Signature/static-input lint remains: `RelayInfo::build` must not grow unscoped DB/search/audit inputs — host-scoped scalars (like `icon`) are passed in pre-derived. |
 | API tokens and NIP-98 replay | API/NIP-98 clients authenticate REST/media/git; API tokens may carry scopes and channel IDs; NIP-98 replay uses an in-process seen-set today. | Host-derived community plus token's stamped community; stamps must agree. | Community-global token namespace; operator-global only for deployment health/secrets. | `api_tokens` gains `community_id`; token hash uniqueness and lookup are `(community_id, token_hash)` or the token cryptographically embeds community and lookup verifies both. Channel claims must reference channels in the same community. | Replay seen-set key is `(community_id, event_id)` in shared HA storage or equivalent sticky routing; NIP-98 `u` URL host must match `req.community`. | Existing single-community tokens continue to authorize the same scopes/channels after backfill to default community. | HA gate: Redis/shared seen-set with atomic insert-if-absent and TTL ≥ replay window, or documented single-replica/sticky alternative. |
 | Relay membership, pubkey allowlist, archived identities | `relay_members`, `pubkey_allowlist`, and `archived_identities` are relay-global gates over pubkeys. | Host-derived community for tenant access; operator context only for platform administration. | Community-global membership/allowlist/archive by default. Operator-global only for explicit platform ops tables that are never tenant-observable. | Add `community_id` to these tables; primary/unique keys become `(community_id, pubkey)` and indexes include `community_id`. | Membership errors remain generic. NIP-OA owner checks test owner membership in the same community. Identity archive requests cannot hide/archive a key in another community. | One default community preserves today's closed/open relay behavior and admin CLI semantics after commands target the default community. | Decide any future operator-global super-admin surface separately; do not reuse tenant membership tables for it. |
 | Users, profiles, NIP-05, and user search | Kind:0 updates sync a `users` row; NIP-05 handles are unique; `/api/users/search` searches display name/NIP-05/pubkey. | Channel-less events use `req.community`; NIP-05 domain is the connected community host. | Community-global. Same pubkey can have one profile per community; users repost kind:0 in each community they join. | `users` gains `community_id`; keys/uniques are `(community_id, pubkey)`, `(community_id, lower(nip05_handle))`, and `(community_id, okta_user_id)` where applicable. Profile event replacement is scoped by `(community_id, pubkey)`. | Search and batch profile reads include `community_id`; NIP-05 lookup only resolves handles for the requested host/community. No cross-community profile inheritance. | Existing users backfill into the default community; profile APIs and CLI output stay unchanged. | Add tests for same pubkey with different profile/NIP-05 in two communities and for NIP-05 same local part on two hosts. |
diff --git a/docs/nips/NIP-WP.md b/docs/nips/NIP-WP.md
new file mode 100644
index 0000000000..ebaf612de9
--- /dev/null
+++ b/docs/nips/NIP-WP.md
@@ -0,0 +1,94 @@
+NIP-WP
+======
+
+Workspace Profile
+-----------------
+
+`draft` `optional` `relay`
+
+**Depends on**: NIP-01 (basic event format), NIP-11 (relay information document), NIP-42 (Authentication of Clients to Relays), NIP-43 (Relay Access Metadata and Requests)
+
+## Abstract
+
+This NIP defines how a relay-scoped workspace icon is set and read. An admin or owner sets it once with a user-signed command (`kind:9033`, accepted only from relay admins/owners); the relay stores it as per-relay state and serves it in the standard `icon` field of its NIP-11 relay information document, where every client — member or not, Buzz or third-party — reads it.
+
+The write path mirrors NIP-43's admin command shape (`kind:9030`–`9032`): user intent is validated against the relay's access-control state, then the relay updates derived state. The read path is plain NIP-11 — no new event kind is needed to consume the icon.
+
+## Motivation
+
+In Buzz the relay *is* the workspace ([VISION.md](../../VISION.md)). A client connected to several relays needs a way to tell them apart that every member sees identically — initials derived from a locally-configured workspace name differ per device and say nothing about the workspace itself.
+
+Upstream Nostr already standardizes the *read* side of this: NIP-11 defines a first-class `icon` field on the relay information document, fetched with an unauthenticated `GET` + `Accept: application/nostr+json`. This NIP adopts that read path unchanged, so any NIP-11-aware client renders the workspace icon with zero Buzz-specific code.
+
+What upstream does not provide is an in-protocol, role-gated **write** path suited to this deployment model:
+
+- **NIP-86 (Relay Management API)** defines a `changerelayicon` method, but it is a separate JSON-RPC/HTTP surface with its own auth model, distinct from the NIP-42/NIP-43 role state Buzz relays already enforce. Buzz's admin surface is Nostr events (kinds 9030–9032); the icon write follows the same shape rather than introducing a second management protocol for one field.
+- **NIP-29 group metadata** (`kind:39000` `picture`) is per-group state; the workspace icon is per-relay.
+
+Hence one added command kind (`9033`), validated exactly like the neighboring 9030–9032 membership commands, feeding the standard NIP-11 `icon`.
+
+## Terminology
+
+This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as defined in RFC 2119.
+
+- **actor**: The pubkey that signed a `kind:9033` command.
+- **workspace icon**: The image identifying the workspace, carried as an `https` URL or an inline `data:image/*` URL.
+
+## Kinds
+
+| Kind | Name | Signer | Purpose |
+|------|------|--------|---------|
+| `9033` | Set Workspace Profile | admin / owner | Command: set or clear the workspace icon |
+
+## Event Format
+
+### `kind:9033` Set Workspace Profile
+
+A command signed by a relay admin or owner. The icon value is carried in an `icon` tag; content is empty.
+
+```jsonc
+{
+  "kind": 9033,
+  "pubkey": "<admin-or-owner-pubkey-hex>",
+  "content": "",
+  "tags": [
+    ["icon", "data:image/webp;base64,..."]
+  ]
+}
+```
+
+- exactly one `icon` tag. An empty value (or an absent tag) clears the icon.
+- the value MUST be an `https` URL, an `http` URL, or a `data:image/*` URL. Inline data URLs are RECOMMENDED for small icons (≤128px): they render on clients connected to *other* relays without a cross-origin media fetch behind another relay's auth wall.
+
+The `content` field is empty and carries no meaning. Relays MUST NOT parse semantics from `content`.
+
+## Relay Processing Algorithm
+
+When a relay receives a `kind:9033` command it MUST, before applying it:
+
+1. Verify the event signature and NIP-42/NIP-98 authentication as usual.
+2. Verify the actor holds the `admin` or `owner` role in the relay's authoritative access-control state (the same state that backs NIP-43). Reject otherwise.
+3. Validate the `icon` value: empty (clear), or an `http(s)`/`data:image/*` URL containing no whitespace or control characters, within the relay's size limits. Relays SHOULD cap plain URLs (2048 bytes RECOMMENDED) and inline data URLs (96 KiB RECOMMENDED) and MUST reject non-image `data:` URLs.
+
+On acceptance the relay stores the value as its current workspace icon (per relay — in a multi-tenant deployment, per community) and serves it in the `icon` field of its NIP-11 relay information document. A cleared icon omits the field. Last accepted command wins.
+
+## Client Behavior
+
+1. Fetch the relay's NIP-11 document (`GET` on the relay's HTTP endpoint with `Accept: application/nostr+json`).
+2. If the document has a non-empty `icon`, render it wherever the workspace is identified (workspace rail, switcher, settings). Otherwise fall back to a local placeholder (e.g. name initials).
+
+NIP-11 is unauthenticated, so a client can read icons for workspaces it is not currently connected to (e.g. inactive workspaces in a rail) with a plain HTTP fetch. Clients MAY cache the icon locally (keyed by relay URL) to render workspaces whose relays are currently unreachable; the cache is presentation-only and is replaced by the next fetched document.
+
+Only admins/owners can change the icon. Clients SHOULD hide the icon editor from non-admins, but the relay-side role check in §Relay Processing is the enforcement.
+
+## Security Considerations
+
+The icon is intentionally public presentation state: NIP-11 is an unauthenticated document, and serving the icon there means anyone who can reach the relay host can read it. Admins MUST NOT put non-public information in the icon. In a multi-tenant deployment the icon is scoped to the community resolved from the request host — a request can only ever observe the icon of the community it is already addressing, and an unmapped host receives a document with no `icon` field.
+
+Icon values are rendered as images by every member's client, so the relay MUST validate them at the write path: scheme allow-list (`http(s)` / `data:image/*` only — never `javascript:` or non-image `data:` types), no whitespace or control characters, and size caps. Clients render the value in an `<img>`-equivalent sink only, never as HTML.
+
+## Relation to Other NIPs
+
+- **NIP-11 (Relay Information Document)**: Supplies the standard `icon` field and the unauthenticated read path this NIP feeds. Buzz adds nothing to the read side.
+- **NIP-43 (Relay Access Metadata and Requests)**: Supplies the role state (`admin` / `owner`) that authorizes `kind:9033`, and the admin-command shape (`9030`–`9032`) it extends.
+- **NIP-86 (Relay Management API)**: Standardizes `changerelayicon` over a separate JSON-RPC management surface; this NIP achieves the same mutation in-protocol, gated by the NIP-43 role state the relay already enforces (see §Motivation).
diff --git a/migrations/0003_community_icon.sql b/migrations/0003_community_icon.sql
new file mode 100644
index 0000000000..671372e6f5
--- /dev/null
+++ b/migrations/0003_community_icon.sql
@@ -0,0 +1,12 @@
+-- ── Per-community workspace icon (NIP-11 `icon`) ─────────────────────────────
+-- Set by relay admins/owners via the kind:9033 command; served to clients in
+-- the standard NIP-11 relay information document `icon` field, bound to the
+-- community resolved from the request Host (same row-zero seam as WS/NIP-05).
+--
+-- Additive migration: previously applied files must not change checksum.
+-- `communities` is an operator-global registry table (no community_id column
+-- by design); this adds a per-row presentation attribute, not tenant data in
+-- a shared table. TEXT holds either an http(s) URL or a small data:image/*
+-- URL — validated and size-capped at the 9033 write path, not here.
+
+ALTER TABLE communities ADD COLUMN icon TEXT;
