diff --git a/desktop/src-tauri/src/commands/engrams.rs b/desktop/src-tauri/src/commands/engrams.rs
index 13e9ecb96d..74de129492 100644
--- a/desktop/src-tauri/src/commands/engrams.rs
+++ b/desktop/src-tauri/src/commands/engrams.rs
@@ -9,9 +9,12 @@
 //! Why this shape:
 //! - Owner secret key never leaves Rust. The desktop signs in as the owner
 //!   and decrypts via `agent ↔ owner` NIP-44 conversation key.
-//! - Owner gating is enforced here: the requested agent pubkey MUST exist
-//!   in the local `managed_agents` store. If not, the command refuses.
-//!   The UI hides the section anyway, but defense in depth.
+//! - Owner gating is enforced here: the request is accepted if the agent is
+//!   in the local `managed_agents` store OR the agent's live `kind:0`
+//!   cryptographically declares the viewer as its NIP-OA owner. Either way
+//!   the engrams are NIP-44 encrypted to the viewer's own pubkey, so the
+//!   encryption is the real boundary; this gate just decides whether to try.
+//!   The UI hides the section for non-owners anyway, but defense in depth.
 //! - One call returns everything because the orphans view requires the
 //!   full set anyway. Lazy/per-node decrypt is deferred to IXI-60.
 
@@ -25,6 +28,7 @@ use tauri::{AppHandle, State};
 use buzz_core_pkg::engram::{self, extract_refs, select_head, validate_and_decrypt, Body};
 use buzz_core_pkg::kind::KIND_AGENT_ENGRAM;
 
+use crate::commands::identity_archive::{extract_oa_owner, fetch_kind0};
 use crate::{app_state::AppState, managed_agents::load_managed_agents, relay::query_relay};
 
 /// Hard cap on engrams returned per (agent, owner) pair. Matches the CLI
@@ -67,6 +71,26 @@ pub struct AgentMemoryListing {
     pub fetched_at: u64,
 }
 
+/// Does `kind0` cryptographically declare `viewer_pubkey` as the NIP-OA owner
+/// of the agent it belongs to?
+///
+/// This is the remote-owner gate predicate for [`get_agent_memory`]. It runs
+/// the live `kind:0` through [`extract_oa_owner`] (which verifies the auth tag
+/// against the agent's own pubkey via `nip_oa::verify_auth_tag`) and compares
+/// the recovered owner to the viewer, case-insensitively. Returns `false` for
+/// a missing `kind:0`, a `kind:0` without a verifiable auth tag, or an owner
+/// that doesn't match the viewer — so a forged/mismatched declaration never
+/// opens the gate. Pure over its inputs so the auth branch can be unit-tested
+/// without the relay/Tauri machinery.
+fn kind0_declares_viewer_owner(kind0: Option<&nostr::Event>, viewer_pubkey: &str) -> bool {
+    match kind0 {
+        Some(kind0) => extract_oa_owner(kind0)
+            .map(|(owner_hex, _tag)| owner_hex.eq_ignore_ascii_case(viewer_pubkey))
+            .unwrap_or(false),
+        None => false,
+    }
+}
+
 /// `get_agent_memory` — owner-gated single-payload engram listing.
 ///
 /// Returns the full decrypted set for the (agent, owner) pair where
@@ -85,29 +109,53 @@ pub async fn get_agent_memory(
     state: State<'_, AppState>,
 ) -> Result<AgentMemoryListing, String> {
     // ── Owner gating ────────────────────────────────────────────────────
-    // The viewer (this desktop's identity) is the prospective owner. We
-    // accept the request only if the agent appears in our managed_agents
-    // store — that's the local source of truth for "agents I own". The UI
-    // must already have hidden the section for non-owners; this is just
-    // defense in depth.
+    // The viewer (this desktop's identity) is the prospective owner. The
+    // relay query below is `#p`-tagged for the viewer's OWN pubkey and every
+    // engram is NIP-44 encrypted to that pubkey, so the viewer decrypts with
+    // their own key — the agent's seckey is never needed. Encryption + the
+    // relay's server-side `#p` scoping are the real boundary; this gate only
+    // decides whether we bother attempting (and avoids a needless roundtrip
+    // for agents the viewer plainly doesn't own).
+    //
+    // We accept the request on either of two owner signals, mirroring the UI's
+    // `viewerIsOwner = isCurrentUserOwner || isOwner`:
+    //   1. The agent is in this desktop's `managed_agents` store — local
+    //      fast-path, no roundtrip. Covers locally-run agents (and older
+    //      agents that never advertised an owner).
+    //   2. The agent's live `kind:0` cryptographically declares the viewer as
+    //      its NIP-OA owner (verified via `extract_oa_owner`). This is the
+    //      remote-owner case: the owner runs the agent on another desktop, so
+    //      holds no local seckey, but legitimately owns it.
     //
-    // Why `managed_agents` rather than a NIP-OA `kind:0` lookup (the gate
-    // the archive command uses): the question this surface needs to answer
-    // is "do I have the seckey to decrypt this agent's engrams?", not
-    // "does this agent's `kind:0` declare me as its OA owner?". The former
-    // is what `managed_agents` records — it can't lie, you either have the
-    // key locally or you don't. The latter is forgeable (a malicious agent
-    // can put any pubkey in their `auth` tag) and would also require a
-    // relay roundtrip on every panel open. Keep this gate; don't swap it
-    // back to `resolve_oa_owner` "for consistency" — they're answering
-    // different questions.
+    // (Historical note: this gate used to refuse anything not in
+    // `managed_agents`, on the theory that key-custody was the only safe
+    // proxy for ownership. That conflated "do I hold the seckey?" with "am I
+    // the owner?" — the two diverge exactly for a remote-owned agent, which
+    // wrongly locked legitimate owners out of their own memory. The declared-
+    // owner path is cleared (PR #917 author signed off); decryption still
+    // does the real guarding.)
     let agent = PublicKey::from_hex(&agent_pubkey)
         .map_err(|e| format!("agent pubkey must be 64-hex: {e}"))?;
 
+    let viewer_pubkey = {
+        let keys = state.keys.lock().map_err(|e| e.to_string())?;
+        keys.public_key().to_hex()
+    };
+
     let managed = load_managed_agents(&app)?;
-    if !managed.iter().any(|m| m.pubkey == agent_pubkey) {
+    let is_managed = managed.iter().any(|m| m.pubkey == agent_pubkey);
+    let is_declared_owner = if is_managed {
+        false // already authorized; skip the relay roundtrip
+    } else {
+        // Verify the agent's live `kind:0` declares the viewer as owner.
+        let kind0 = fetch_kind0(&state, &agent_pubkey).await?;
+        kind0_declares_viewer_owner(kind0.as_ref(), &viewer_pubkey)
+    };
+
+    if !is_managed && !is_declared_owner {
         return Err(format!(
-            "not the owner of agent {agent_pubkey} (no managed-agent record)"
+            "not the owner of agent {agent_pubkey} (no managed-agent record \
+             and no verified NIP-OA owner declaration)"
         ));
     }
 
@@ -225,3 +273,100 @@ pub async fn get_agent_memory(
         fetched_at,
     })
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use nostr::{EventBuilder, Keys, Kind, Tag};
+
+    /// Build a `kind:0` carrying a valid NIP-OA `auth` tag declaring `owner`
+    /// as the owner of `agent`. Mirrors the helper in `identity_archive`'s
+    /// tests — same `compute_auth_tag` → `parse_auth_tag` bridge.
+    fn kind0_with_auth(agent: &Keys, owner: &Keys) -> nostr::Event {
+        let agent_hex = agent.public_key().to_hex();
+        let agent_compat = nostr::PublicKey::from_hex(&agent_hex).unwrap();
+        let owner_compat_secret =
+            nostr::SecretKey::from_slice(owner.secret_key().as_secret_bytes()).unwrap();
+        let owner_compat_keys = nostr::Keys::new(owner_compat_secret);
+        let tag_json =
+            buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_compat_keys, &agent_compat, "")
+                .expect("compute_auth_tag");
+        let compat_tag = buzz_sdk_pkg::nip_oa::parse_auth_tag(&tag_json).unwrap();
+        let tag = Tag::parse(compat_tag.as_slice()).unwrap();
+        EventBuilder::new(Kind::Metadata, "{}")
+            .tags([tag])
+            .sign_with_keys(agent)
+            .unwrap()
+    }
+
+    /// The declared-owner gate branch opens when a verified `kind:0` names the
+    /// viewer as the agent's NIP-OA owner. This is the remote-owner case that
+    /// the old key-custody gate wrongly refused (PR #917 migration).
+    #[test]
+    fn declared_owner_gate_opens_for_verified_owner_kind0() {
+        let owner = Keys::generate();
+        let agent = Keys::generate();
+        let kind0 = kind0_with_auth(&agent, &owner);
+
+        assert!(kind0_declares_viewer_owner(
+            Some(&kind0),
+            &owner.public_key().to_hex(),
+        ));
+    }
+
+    /// Owner match is case-insensitive — a verified declaration still opens the
+    /// gate when the viewer pubkey is supplied in a different hex case.
+    #[test]
+    fn declared_owner_gate_is_case_insensitive() {
+        let owner = Keys::generate();
+        let agent = Keys::generate();
+        let kind0 = kind0_with_auth(&agent, &owner);
+
+        assert!(kind0_declares_viewer_owner(
+            Some(&kind0),
+            &owner.public_key().to_hex().to_uppercase(),
+        ));
+    }
+
+    /// A verified declaration for a *different* owner must NOT open the gate for
+    /// some other viewer — the recovered owner has to equal the viewer.
+    #[test]
+    fn declared_owner_gate_refuses_non_owner_viewer() {
+        let owner = Keys::generate();
+        let agent = Keys::generate();
+        let stranger = Keys::generate();
+        let kind0 = kind0_with_auth(&agent, &owner);
+
+        assert!(!kind0_declares_viewer_owner(
+            Some(&kind0),
+            &stranger.public_key().to_hex(),
+        ));
+    }
+
+    /// A `kind:0` with no auth tag carries no owner claim, so the gate stays
+    /// shut even for the agent's own would-be owner.
+    #[test]
+    fn declared_owner_gate_refuses_kind0_without_auth_tag() {
+        let agent = Keys::generate();
+        let viewer = Keys::generate();
+        let kind0 = EventBuilder::new(Kind::Metadata, "{}")
+            .sign_with_keys(&agent)
+            .unwrap();
+
+        assert!(!kind0_declares_viewer_owner(
+            Some(&kind0),
+            &viewer.public_key().to_hex(),
+        ));
+    }
+
+    /// No live `kind:0` at all (relay returned nothing) means no declared
+    /// owner — the gate can only open via the local managed-agent fast path.
+    #[test]
+    fn declared_owner_gate_refuses_missing_kind0() {
+        let viewer = Keys::generate();
+        assert!(!kind0_declares_viewer_owner(
+            None,
+            &viewer.public_key().to_hex(),
+        ));
+    }
+}
diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs
index 64459061b0..8da9b5d317 100644
--- a/desktop/src-tauri/src/commands/identity_archive.rs
+++ b/desktop/src-tauri/src/commands/identity_archive.rs
@@ -29,7 +29,7 @@ use crate::{
 /// preimage subject is the *target* pubkey, not the request signer). The
 /// `buzz-sdk` lives on nostr 0.36; the desktop is on 0.37, so we bridge
 /// via hex round-trip exactly like `relay::build_profile_event` does.
-fn extract_oa_owner(target_kind0: &nostr::Event) -> Option<(String, [String; 4])> {
+pub(crate) fn extract_oa_owner(target_kind0: &nostr::Event) -> Option<(String, [String; 4])> {
     let target_hex = target_kind0.pubkey.to_hex();
     let target_compat = nostr::PublicKey::from_hex(&target_hex).ok()?;
 
@@ -55,7 +55,10 @@ fn extract_oa_owner(target_kind0: &nostr::Event) -> Option<(String, [String; 4])
     None
 }
 
-async fn fetch_kind0(state: &AppState, pubkey: &str) -> Result<Option<nostr::Event>, String> {
+pub(crate) async fn fetch_kind0(
+    state: &AppState,
+    pubkey: &str,
+) -> Result<Option<nostr::Event>, String> {
     let events = query_relay(
         state,
         &[serde_json::json!({
diff --git a/desktop/src/features/agent-memory/hooks.ts b/desktop/src/features/agent-memory/hooks.ts
index 0c57ba6dda..fb6d4edcd5 100644
--- a/desktop/src/features/agent-memory/hooks.ts
+++ b/desktop/src/features/agent-memory/hooks.ts
@@ -12,25 +12,26 @@ export const agentMemoryQueryKey = (agentPubkey: string) =>
   ["agent-memory", agentPubkey.toLowerCase()] as const;
 
 /**
- * Synchronous gate: does this desktop manage the agent? Used by the profile
- * panel to hide the Memory section entirely for non-owners.
+ * Synchronous gate: does this desktop manage the agent (i.e. hold its
+ * seckey locally)? This is the *local fast-path* owner signal — one of two
+ * inputs to `viewerIsOwner`. The other is the declared NIP-OA owner signal
+ * (`isCurrentUserOwner`, computed in the profile panel from the agent's
+ * `kind:0`). Callers OR the two: `viewerIsOwner = isCurrentUserOwner || isOwner`.
  *
  * Returns `boolean | undefined`:
  *   - `undefined` is the *loading* state (managed-agent list still resolving).
  *     Callers should defer rendering, not show an error.
  *   - `true` / `false` once the list is known.
  *
- * Why `managed_agents` (not NIP-OA `kind:0` via `useOaOwnerQuery`)?
- * The archive button gates on `useOaOwnerQuery` because publishing a
- * `kind:9035` requires verifying NIP-OA cryptographically — the action is
- * *signing as the OA owner*. The memory viewer's question is different:
- * "do I have the seckey to decrypt this agent's engrams?" `managed_agents`
- * answers exactly that — it's the local source of truth for "agents whose
- * keys this desktop holds." NIP-OA on its own is weaker for this surface:
- * a malicious agent can forge an `auth` tag in their `kind:0` pointing at
- * any pubkey, but only the desktop that actually holds the seckey can
- * decrypt. Don't "fix" this back to `useOaOwnerQuery` — it would replace
- * a precise predicate with a weaker one and add a relay roundtrip.
+ * Why not gate the Memory section on this alone? It answers "do I hold the
+ * seckey?", which used to stand in for "am I the owner?". Those two diverge
+ * exactly for a remote-owned agent — the owner runs it on another desktop,
+ * holds no local seckey, but legitimately owns it. Engrams are NIP-44
+ * encrypted to the *owner's* pubkey and decrypted with the owner's own key
+ * (never the agent's seckey), so a declared owner can read their own memory
+ * regardless of where the agent runs. The encryption is the real boundary;
+ * this predicate is just the no-roundtrip half of the owner check. The
+ * declared-owner half (`isCurrentUserOwner`) covers the remote case.
  *
  * Lowercase compare because pubkeys can arrive from either side in mixed
  * case via Nostr libs; the underlying store stores them as-given.
@@ -49,8 +50,8 @@ export function useIsManagedAgent(
  * Fetch + decrypt the engram listing for one agent. Owner-gated at the
  * Rust layer; if the viewer isn't the agent's owner the underlying call
  * returns an `Err` (we surface it as `query.isError`). The UI must hide
- * the section for non-owners — see {@link useIsManagedAgent} — but this
- * hook is robust to a misuse there.
+ * the section for non-owners (the caller passes `viewerIsOwner` down to
+ * {@link MemorySection}), but this hook is robust to a misuse there.
  *
  * `staleTime: 30s`: engrams change rarely (each write is a deliberate
  * agent action). 30s keeps profile re-opens snappy without going so far
diff --git a/desktop/src/features/agent-memory/ui/MemorySection.tsx b/desktop/src/features/agent-memory/ui/MemorySection.tsx
index a35b262c37..d6e3880152 100644
--- a/desktop/src/features/agent-memory/ui/MemorySection.tsx
+++ b/desktop/src/features/agent-memory/ui/MemorySection.tsx
@@ -1,10 +1,7 @@
 import * as React from "react";
 import { AlertTriangle, ChevronDown, RefreshCw } from "lucide-react";
 
-import {
-  useAgentMemoryGraph,
-  useIsManagedAgent,
-} from "@/features/agent-memory/hooks";
+import { useAgentMemoryGraph } from "@/features/agent-memory/hooks";
 import type { MemoryTreeNode } from "@/features/agent-memory/lib/buildMemoryGraph";
 import type { EngramEntry } from "@/shared/api/tauriEngrams";
 import { cn } from "@/shared/lib/cn";
@@ -23,9 +20,11 @@ const MEMORY_DANGLING_REF_TOOLTIP =
 /**
  * Memory section — IXI-7 phase 1 read-only viewer.
  *
- * Owner-gated: returns `null` for non-owners (and while the managed-agent
- * list is still loading, to avoid a one-frame flash of the section before
- * we know the viewer's owner status).
+ * Owner-gated by the caller: the parent passes `viewerIsOwner` (the
+ * `isCurrentUserOwner || isOwner` signal computed in the profile panel from
+ * the agent's declared NIP-OA owner OR local key custody). We return `null`
+ * for non-owners. Engrams decrypt with the viewer's OWN key, so a declared
+ * owner whose agent runs elsewhere sees this section just the same.
  *
  * The whole thing is contained: skeleton/error/empty live *inside* this
  * section so the rest of the profile panel stays interactive while we
@@ -42,35 +41,35 @@ const MEMORY_DANGLING_REF_TOOLTIP =
  */
 export function MemorySection({
   agentPubkey,
+  viewerIsOwner,
 }: {
   agentPubkey: string;
+  viewerIsOwner: boolean;
 }): React.ReactElement | null {
-  const isOwner = useIsManagedAgent(agentPubkey);
-
-  // Hide entirely for non-owners. Defer (`null`) while the managed-agent
-  // list is still loading rather than render a placeholder we'll yank.
-  if (isOwner !== true) return null;
+  // Hide entirely for non-owners.
+  if (!viewerIsOwner) return null;
 
   return <MemorySectionForOwner agentPubkey={agentPubkey} />;
 }
 
 export function MemoryRefreshButton({
   agentPubkey,
+  viewerIsOwner,
   className,
   iconClassName,
   variant = "ghost",
 }: {
   agentPubkey: string;
+  viewerIsOwner: boolean;
   className?: string;
   iconClassName?: string;
   variant?: ButtonProps["variant"];
 }): React.ReactElement | null {
-  const isOwner = useIsManagedAgent(agentPubkey);
   const { query } = useAgentMemoryGraph(agentPubkey, {
-    enabled: isOwner === true,
+    enabled: viewerIsOwner,
   });
 
-  if (isOwner !== true || !query.data) return null;
+  if (!viewerIsOwner || !query.data) return null;
 
   return (
     <Button
diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts
index ee8483dc86..6f3370de09 100644
--- a/desktop/src/features/agents/observerRelayStore.ts
+++ b/desktop/src/features/agents/observerRelayStore.ts
@@ -40,7 +40,40 @@ const snapshotByAgent = new Map<string, ObserverSnapshot>();
 
 // Normalized pubkeys of agents we are actively managing. Only events whose
 // "agent" tag matches an entry here will be decrypted (defense-in-depth).
+//
+// This set is the *union* of every active subscriber's contribution. Multiple
+// callers of `useManagedAgentObserverBridge` (e.g. the channel screen and the
+// profile panel) can be mounted at once, each tracking a different agent list.
+// We key each subscriber's contribution in `knownAgentsBySubscription` and
+// recompute the union, so co-mounted callers no longer clobber each other.
 const knownAgentPubkeys = new Set<string>();
+const knownAgentsBySubscription = new Map<string, Set<string>>();
+
+function recomputeKnownAgentPubkeys() {
+  knownAgentPubkeys.clear();
+  for (const subscriptionAgents of knownAgentsBySubscription.values()) {
+    for (const pubkey of subscriptionAgents) {
+      knownAgentPubkeys.add(pubkey);
+    }
+  }
+}
+
+function registerKnownAgents(
+  subscriptionId: string,
+  pubkeys: readonly string[],
+) {
+  knownAgentsBySubscription.set(
+    subscriptionId,
+    new Set(pubkeys.map((pubkey) => normalizePubkey(pubkey))),
+  );
+  recomputeKnownAgentPubkeys();
+}
+
+function unregisterKnownAgents(subscriptionId: string) {
+  if (knownAgentsBySubscription.delete(subscriptionId)) {
+    recomputeKnownAgentPubkeys();
+  }
+}
 
 let connectionState: ConnectionState = "idle";
 let errorMessage: string | null = null;
@@ -275,6 +308,7 @@ export function getAgentTranscript(
 export function useManagedAgentObserverBridge(
   agents: readonly Pick<ManagedAgent, "pubkey" | "status">[],
 ) {
+  const subscriptionId = React.useId();
   const hasActiveAgent = React.useMemo(
     () =>
       agents.some(
@@ -283,13 +317,20 @@ export function useManagedAgentObserverBridge(
     [agents],
   );
 
-  // Keep the trusted-pubkey set in sync with the current managed agent list.
+  const agentPubkeys = React.useMemo(
+    () => agents.map((agent) => agent.pubkey),
+    [agents],
+  );
+
+  // Keep this subscriber's slice of the trusted-pubkey set in sync with its
+  // own agent list. The store recomputes the union across all subscribers, so
+  // a co-mounted caller no longer wipes out this caller's agents.
   React.useEffect(() => {
-    knownAgentPubkeys.clear();
-    for (const agent of agents) {
-      knownAgentPubkeys.add(normalizePubkey(agent.pubkey));
-    }
-  }, [agents]);
+    registerKnownAgents(subscriptionId, agentPubkeys);
+    return () => {
+      unregisterKnownAgents(subscriptionId);
+    };
+  }, [subscriptionId, agentPubkeys]);
 
   React.useEffect(() => {
     if (!hasActiveAgent) {
@@ -309,6 +350,7 @@ export function resetAgentObserverStore() {
   transcriptByAgent.clear();
   snapshotByAgent.clear();
   knownAgentPubkeys.clear();
+  knownAgentsBySubscription.clear();
   connectionState = "idle";
   errorMessage = null;
   notifyListeners();
diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx
index d7a147a9fd..560190d719 100644
--- a/desktop/src/features/channels/ui/MembersSidebar.tsx
+++ b/desktop/src/features/channels/ui/MembersSidebar.tsx
@@ -573,6 +573,13 @@ export function MembersSidebar({
   }
 
   function renderMemberCard(member: ChannelMember, memberIsBot: boolean) {
+    const memberProfile =
+      memberProfilesQuery.data?.profiles[member.pubkey.toLowerCase()];
+    const viewerIsOwner = Boolean(
+      memberProfile?.ownerPubkey &&
+        currentPubkey &&
+        memberProfile.ownerPubkey.toLowerCase() === currentPubkey.toLowerCase(),
+    );
     return (
       <div className="content-visibility-auto" key={member.pubkey}>
         <MembersSidebarMemberCard
@@ -609,10 +616,8 @@ export function MembersSidebar({
           presenceStatus={
             memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null
           }
-          profileAvatarUrl={
-            memberProfilesQuery.data?.profiles[member.pubkey.toLowerCase()]
-              ?.avatarUrl ?? null
-          }
+          profileAvatarUrl={memberProfile?.avatarUrl ?? null}
+          viewerIsOwner={viewerIsOwner}
         />
       </div>
     );
diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx
index 9574f451b3..d7429bce0c 100644
--- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx
+++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx
@@ -52,6 +52,7 @@ type MembersSidebarMemberCardProps = {
   onViewActivity?: (pubkey: string) => void;
   presenceStatus?: PresenceStatus | null;
   profileAvatarUrl?: string | null;
+  viewerIsOwner: boolean;
 };
 
 const MEMBER_ROW_INSET_DIVIDER_CLASS =
@@ -111,12 +112,13 @@ export function MembersSidebarMemberCard({
   onViewActivity,
   presenceStatus,
   profileAvatarUrl,
+  viewerIsOwner,
 }: MembersSidebarMemberCardProps) {
   const roleLabel = formatRoleLabel(member, memberIsBot);
   const disabled = isActionPending || isArchived;
   const canViewActivity =
     memberIsBot &&
-    managedAgent?.backend.type === "local" &&
+    (viewerIsOwner || managedAgent?.backend.type === "local") &&
     Boolean(onViewActivity);
   const hasActions = memberIsBot
     ? Boolean(managedAgent) || canRemoveMember || canViewActivity
diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx
index 48136fe288..335202c224 100644
--- a/desktop/src/features/profile/ui/UserProfilePanel.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx
@@ -187,7 +187,22 @@ export function UserProfilePanel({
     (agent) => agent.pubkey.toLowerCase() === pubkeyLower,
   );
   const isBot = Boolean(relayAgent || managedAgent);
+  // Does THIS desktop hold the agent's seckey? Gates edit (which needs the key)
+  // and grants owner access when the agent is managed locally.
   const isOwner = useIsManagedAgent(isBot ? pubkey : null);
+  // Is the viewer the agent's declared owner (NIP-OA `ownerPubkey == me`)? This
+  // is the right signal for viewing owner-scoped data (activity feed, memory):
+  // the relay routes and the client decrypts those frames with the owner's OWN
+  // key, so the agent's seckey is never needed. Computed here (before the gates
+  // that consume it) so visibility keys off declared ownership, not key custody.
+  const isCurrentUserOwner =
+    currentPubkey !== undefined &&
+    ownerPubkey !== null &&
+    ownerPubkey.toLowerCase() === currentPubkey.toLowerCase();
+  // The viewer may see owner-scoped data if they declared-own the agent OR they
+  // manage it locally (older agents may not advertise an owner pubkey). Every
+  // real boundary is server-side, so this only controls what UI we paint.
+  const viewerIsOwner = isCurrentUserOwner || isOwner === true;
 
   // Populate the active-turns store for this agent so useActiveAgentTurns works
   // even if the Agents page hasn't been visited yet.
@@ -198,15 +213,36 @@ export function UserProfilePanel({
         : [],
     [managedAgent],
   );
+  // The observer bridge subscribes on the OWNER's own pubkey and decrypts the
+  // agent's telemetry with the owner's key — no agent seckey needed. It only
+  // decrypts frames whose agent pubkey is "known", and only subscribes when an
+  // agent is running/deployed. For a remote agent we own but don't manage
+  // locally, `managedAgent` is undefined, so we seed the bridge from the relay
+  // agent (treated as "deployed") when the viewer is the declared owner. This
+  // mirrors what the composer-area ingress already does in ChannelScreen.
+  const observerBridgeAgents = React.useMemo(() => {
+    if (managedAgent) {
+      return [{ pubkey: managedAgent.pubkey, status: managedAgent.status }];
+    }
+    if (viewerIsOwner && relayAgent) {
+      return [
+        {
+          pubkey: relayAgent.pubkey,
+          status: "deployed" as ManagedAgent["status"],
+        },
+      ];
+    }
+    return [];
+  }, [managedAgent, relayAgent, viewerIsOwner]);
   useActiveAgentTurnsBridge(bridgeAgents);
-  useManagedAgentObserverBridge(bridgeAgents);
+  useManagedAgentObserverBridge(observerBridgeAgents);
   const canEditAgent = isOwner === true && managedAgent !== undefined;
   const memoryQuery = useAgentMemoryQuery(pubkey, {
-    enabled: isOwner === true,
+    enabled: viewerIsOwner,
   });
   const isSelf =
     currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase();
-  const canViewActivity = isOwner === true && Boolean(onOpenAgentSession);
+  const canViewActivity = viewerIsOwner && Boolean(onOpenAgentSession);
   const isFollowing =
     !isSelf &&
     (contactListQuery.data?.contacts.some(
@@ -282,10 +318,6 @@ export function UserProfilePanel({
     ownerProfileQuery.data,
     ownerPubkey,
   ]);
-  const isCurrentUserOwner =
-    currentPubkey !== undefined &&
-    ownerPubkey !== null &&
-    ownerPubkey.toLowerCase() === currentPubkey.toLowerCase();
   const ownerDisplayName = ownerHandle
     ? isCurrentUserOwner || (!ownerPubkey && isOwner === true)
       ? `${ownerHandle} (you)`
@@ -317,8 +349,12 @@ export function UserProfilePanel({
 
   const headerActions = (
     <div className="ml-auto flex shrink-0 items-center gap-2">
-      {view === "memories" && isOwner === true ? (
-        <MemoryRefreshButton agentPubkey={pubkey} variant="outline" />
+      {view === "memories" && viewerIsOwner ? (
+        <MemoryRefreshButton
+          agentPubkey={pubkey}
+          variant="outline"
+          viewerIsOwner={viewerIsOwner}
+        />
       ) : null}
       <Button
         aria-label="Close profile"
@@ -355,7 +391,7 @@ export function UserProfilePanel({
           handleOpenActivity={handleOpenActivity}
           isBot={isBot}
           isFollowing={isFollowing}
-          isOwner={isOwner}
+          isOwner={viewerIsOwner}
           isSelf={isSelf}
           managedAgent={managedAgent}
           memoriesLoading={memoryQuery.isLoading}
@@ -383,7 +419,7 @@ export function UserProfilePanel({
       ) : null}
 
       {view === "memories" ? (
-        <MemoryFocusedView agentPubkey={pubkey} isOwner={isOwner} />
+        <MemoryFocusedView agentPubkey={pubkey} viewerIsOwner={viewerIsOwner} />
       ) : null}
 
       {view === "channels" ? (
diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx
index b00a986733..42a3739c30 100644
--- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx
@@ -898,18 +898,18 @@ function ProfileIngressRow({
 
 export function MemoryFocusedView({
   agentPubkey,
-  isOwner,
+  viewerIsOwner,
 }: {
   agentPubkey: string;
-  isOwner: boolean | undefined;
+  viewerIsOwner: boolean | undefined;
 }) {
-  if (isOwner !== true) {
+  if (viewerIsOwner !== true) {
     return null;
   }
 
   return (
     <div className="pt-4">
-      <MemorySection agentPubkey={agentPubkey} />
+      <MemorySection agentPubkey={agentPubkey} viewerIsOwner={viewerIsOwner} />
     </div>
   );
 }
diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx
index 070f7da754..6d1f508d1a 100644
--- a/desktop/src/features/profile/ui/UserProfilePopover.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx
@@ -6,6 +6,8 @@ import {
   useRelayAgentsQuery,
   useManagedAgentsQuery,
 } from "@/features/agents/hooks";
+import { useIsManagedAgent } from "@/features/agent-memory/hooks";
+import { useIdentityQuery } from "@/shared/api/hooks";
 import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
 import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
 import { usePresenceQuery } from "@/features/presence/hooks";
@@ -91,8 +93,23 @@ export function UserProfilePopover({
   const managedAgent = managedAgentsQuery.data?.find(
     (a) => a.pubkey === pubkey,
   );
-  const canViewActivity = role === "bot" && Boolean(onOpenAgentSession);
   const profile = profileQuery.data;
+  // Owner signal mirrors UserProfilePanel: a declared NIP-OA owner whose agent
+  // runs elsewhere holds no local seckey, so key custody (`isOwner`) alone
+  // wrongly hides the affordance from them — and gating on bot-ness alone shows
+  // it to every viewer. Combine declared ownership with local management, same
+  // shape as the pane/sidebar/memory fixes. Every real boundary is server-side;
+  // this only decides whether to paint the "View activity log" button.
+  const isOwner = useIsManagedAgent(role === "bot" ? pubkey : null);
+  const ownerPubkey = profile?.ownerPubkey ?? null;
+  const currentPubkey = useIdentityQuery().data?.pubkey;
+  const isCurrentUserOwner =
+    currentPubkey !== undefined &&
+    ownerPubkey !== null &&
+    ownerPubkey.toLowerCase() === currentPubkey.toLowerCase();
+  const viewerIsOwner = isCurrentUserOwner || isOwner === true;
+  const canViewActivity =
+    role === "bot" && viewerIsOwner && Boolean(onOpenAgentSession);
   const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()];
   const userStatus = userStatusQuery.data?.[pubkey.toLowerCase()];
   const activeTurns = useActiveAgentTurns(role === "bot" ? pubkey : null);
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index a4f457d749..4ae193e2ea 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -720,6 +720,12 @@ const OUTSIDER_PUBKEY =
   "df8e91b86fda13a9a67896df77232f7bdab2ba9c3e165378e1ba3d24c13a328e";
 const PROFILE_ONLY_AGENT_PUBKEY =
   "8f83d6b7f3d74f7d933ae3a54dd8c6cc85c7f98e531c16e5a827b953441a8d67";
+// A relay-classified bot agent whose declared NIP-OA owner is the mock viewer,
+// but which is NOT locally managed. This is the fixture that exercises the
+// sidebar's owner-gate path (`viewerIsOwner`), distinct from the local-managed
+// path that `mira` (profile-only) and managed-agent fixtures cover.
+const OWNED_RELAY_AGENT_PUBKEY =
+  "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00";
 const MOCK_IDENTITY_PUBKEY = DEFAULT_MOCK_IDENTITY.pubkey;
 
 const mockDisplayNames = new Map<string, string>([
@@ -728,6 +734,7 @@ const mockDisplayNames = new Map<string, string>([
   [BOB_PUBKEY, "bob"],
   [CHARLIE_PUBKEY, "charlie"],
   [PROFILE_ONLY_AGENT_PUBKEY, "mira"],
+  [OWNED_RELAY_AGENT_PUBKEY, "nadia"],
   [OUTSIDER_PUBKEY, "outsider"],
   [DEFAULT_REAL_IDENTITY.pubkey, DEFAULT_REAL_IDENTITY.username],
 ]);
@@ -735,6 +742,7 @@ const mockAgentPubkeys = new Set([
   ALICE_PUBKEY,
   CHARLIE_PUBKEY,
   PROFILE_ONLY_AGENT_PUBKEY,
+  OWNED_RELAY_AGENT_PUBKEY,
 ]);
 
 function isoMinutesAgo(minutesAgo: number): string {
@@ -1429,6 +1437,7 @@ const mockChannels: MockChannel[] = [
     members: [
       createMockMember(MOCK_IDENTITY_PUBKEY, "owner", 1000),
       createMockMember(CHARLIE_PUBKEY, "bot", 800),
+      createMockMember(OWNED_RELAY_AGENT_PUBKEY, "member", 600),
     ],
   }),
   createMockChannel({
@@ -1633,6 +1642,16 @@ const defaultMockRelayAgents: RawRelayAgent[] = [
     respond_to: "anyone",
     respond_to_allowlist: [],
   },
+  {
+    pubkey: OWNED_RELAY_AGENT_PUBKEY,
+    name: "nadia",
+    agent_type: "goose",
+    channels: ["agents"],
+    channel_ids: ["94a444a4-c0a3-5966-ab05-530c6ddc2301"],
+    capabilities: ["search", "summaries"],
+    status: "online",
+    respond_to: "anyone",
+  },
 ];
 let mockRelayAgents: RawRelayAgent[] = defaultMockRelayAgents.map((agent) => ({
   ...agent,
@@ -1886,6 +1905,18 @@ const mockProfiles = new Map<string, RawProfile>([
       is_agent: true,
     },
   ],
+  [
+    OWNED_RELAY_AGENT_PUBKEY,
+    {
+      pubkey: OWNED_RELAY_AGENT_PUBKEY,
+      display_name: "nadia",
+      avatar_url: null,
+      about: null,
+      nip05_handle: null,
+      owner_pubkey: MOCK_IDENTITY_PUBKEY,
+      is_agent: true,
+    },
+  ],
 ]);
 const mockPresence = new Map<string, PresenceStatus>([
   [MOCK_IDENTITY_PUBKEY, "offline"],
@@ -1894,6 +1925,7 @@ const mockPresence = new Map<string, PresenceStatus>([
   [BOB_PUBKEY, "away"],
   [CHARLIE_PUBKEY, "online"],
   [PROFILE_ONLY_AGENT_PUBKEY, "online"],
+  [OWNED_RELAY_AGENT_PUBKEY, "online"],
   [OUTSIDER_PUBKEY, "offline"],
 ]);
 const mockFeedOverrides: RawHomeFeedResponse["feed"] = {
diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts
index db57609306..46d4fb05b1 100644
--- a/desktop/tests/e2e/channels.spec.ts
+++ b/desktop/tests/e2e/channels.spec.ts
@@ -8,6 +8,12 @@ import {
 } from "../helpers/bridge";
 
 const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8);
+// Relay-only agent owned by the mock viewer (see e2eBridge.ts
+// OWNED_RELAY_AGENT_PUBKEY). Classified as a bot via mockRelayAgents and
+// owned-by-viewer via its mockProfiles owner_pubkey, so the sidebar
+// view-activity gate (memberIsBot && viewerIsOwner) opens for it.
+const OWNED_RELAY_AGENT_PUBKEY =
+  "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00";
 
 async function openChannelManagement(
   page: import("@playwright/test").Page,
@@ -951,6 +957,25 @@ test("shows and clears activity indicators for active channel agents", async ({
   );
 });
 
+test("members sidebar exposes view-activity for a viewer-owned relay agent", async ({
+  page,
+}) => {
+  await page.goto("/");
+
+  await openMembersSidebar(page, "agents");
+
+  // nadia is a relay-only agent (no local managed record) owned by the mock
+  // viewer. The view-activity affordance must open via the declared-owner
+  // path, not the local-managed path.
+  await expect(
+    page.getByTestId(`sidebar-member-${OWNED_RELAY_AGENT_PUBKEY}`),
+  ).toBeVisible();
+  await openMemberMenu(page, OWNED_RELAY_AGENT_PUBKEY);
+  await expect(
+    page.getByTestId(`sidebar-view-activity-${OWNED_RELAY_AGENT_PUBKEY}`),
+  ).toBeVisible();
+});
+
 test("typing indicator shows avatars and maintains stable name order", async ({
   page,
 }) => {
