diff --git a/declearn/communication3/__init__.py b/declearn/communication3/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..9629fa22621b73b2bc4b232f9f79aa169cbad676
--- /dev/null
+++ b/declearn/communication3/__init__.py
@@ -0,0 +1,20 @@
+# coding: utf-8
+
+# Copyright 2023 Inria (Institut National de Recherche en Informatique
+# et Automatique)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""DecLearn v3 experimental network communication API."""
+
+from . import server
diff --git a/declearn/communication3/server/__init__.py b/declearn/communication3/server/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..70c7fe3a8d303a1bb0427750ffed4f1c6177acc6
--- /dev/null
+++ b/declearn/communication3/server/__init__.py
@@ -0,0 +1,20 @@
+# coding: utf-8
+
+# Copyright 2023 Inria (Institut National de Recherche en Informatique
+# et Automatique)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""DecLearn v3 experimental network server endpoint code."""
+
+from ._peers import PeerPolicy, PeersManager
diff --git a/declearn/communication3/server/_peers.py b/declearn/communication3/server/_peers.py
new file mode 100644
index 0000000000000000000000000000000000000000..73eda459e9bafb62cc647b0ef2b84f00960d9698
--- /dev/null
+++ b/declearn/communication3/server/_peers.py
@@ -0,0 +1,179 @@
+# coding: utf-8
+
+# Copyright 2023 Inria (Institut National de Recherche en Informatique
+# et Automatique)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Component to manage a network of peers for a communication server."""
+
+import copy
+import dataclasses
+import secrets
+
+from typing import Dict, Optional, Set, Tuple
+
+
+__all__ = [
+    "PeersManager",
+    "PeerPolicy",
+]
+
+
+DEFAULT_TOKEN_NBYTES = 64  # Default number of bytes in user auth tokens.
+
+
+def create_secret_token() -> str:
+    """Create a random secret string token."""
+    return secrets.token_hex(nbytes=DEFAULT_TOKEN_NBYTES)
+
+
+MOCK_TOKEN = create_secret_token()
+
+
+class InvalidPeerPolicyError(Exception):
+    """Custom exception to document invalid PeerPolicy instances."""
+
+
+@dataclasses.dataclass
+class PeerPolicy:
+    """Dataclass defining the visibility and reachability policy of a peer."""
+
+    authorized: Set[str] = dataclasses.field(default_factory=set)
+    blocked: Set[str] = dataclasses.field(default_factory=set)
+    visible: bool = True
+    # future:
+    #  - distinguish visibility / reachability?
+    #  - add web-of-trust features? (visible by peers of peers)
+
+    def __post_init__(self) -> None:
+        self.check_validity()
+
+    def check_validity(self) -> None:
+        """Raise if this policy is unvalid due to inner contradictions."""
+        if not self.blocked.isdisjoint(self.authorized):
+            raise InvalidPeerPolicyError(
+                "Policy documents some peers as both authorized and blocked."
+            )
+
+
+@dataclasses.dataclass
+class Peer:
+    """Proxy for a client in a communication network of peers."""
+
+    name: str
+    token: str = dataclasses.field(default_factory=create_secret_token)
+    policy: PeerPolicy = dataclasses.field(default_factory=PeerPolicy)
+
+    def is_visible_by(
+        self,
+        peer: str,
+    ) -> bool:
+        """Check whether this peer is visible by a given peer."""
+        if self.policy.visible:
+            return peer not in self.policy.blocked
+        return peer in self.policy.authorized
+
+
+class PeersManager:
+    """Component to manage a network of peers for a communication server."""
+
+    def __init__(
+        self,
+    ) -> None:
+        self._peers = {}  # type: Dict[str, Peer]
+
+    def add_peer(
+        self,
+        name: str,
+        policy: Optional[PeerPolicy] = None,
+    ) -> Tuple[str, str]:
+        """Add a new peer to the network, and return its name and token."""
+        if name in self._peers:
+            count = sum(
+                other.rsplit(".", 1)[0] == name for other in self._peers
+            )
+            name = f"{name}.{count}"
+        self._peers[name] = peer = Peer(name, policy=policy or PeerPolicy())
+        return name, peer.token
+
+    def authenticate_peer(
+        self,
+        name: str,
+        token: str,
+    ) -> bool:
+        """Authenticate a peer based on their secret token."""
+        if name in self._peers:
+            return secrets.compare_digest(token, self._peers[name].token)
+        # Perform a comparison, to avoid peer name leakage via a time attack.
+        secrets.compare_digest(token, MOCK_TOKEN)
+        return False
+
+    def _get_peer(
+        self,
+        name: str,
+    ) -> Peer:
+        """Gather a peer by name, or raise a KeyError."""
+        peer = self._peers.get(name, None)
+        if peer is None:
+            raise KeyError(f"Invalid peer name: {name}.")
+        return peer
+
+    def remove_peer(
+        self,
+        name: str,
+    ) -> None:
+        """Remove a peer from the network."""
+        self._get_peer(name)  # raise if the peer does not exist
+        self._peers.pop(name)
+
+    def get_peer_policy(
+        self,
+        name: str,
+    ) -> PeerPolicy:
+        """Access the visibility policy of a peer."""
+        peer = self._get_peer(name)
+        return copy.deepcopy(peer.policy)
+
+    def update_peer_policy(
+        self,
+        name: str,
+        policy: PeerPolicy,
+    ) -> None:
+        """Update the visibility policy of a peer."""
+        peer = self._get_peer(name)
+        peer.policy = copy.deepcopy(policy)
+
+    def get_peer_topology(
+        self,
+        name: str,
+    ) -> Set[str]:  # future: Topology, with more details
+        """Return the network topology from the POV of a given peer."""
+        peer = self._get_peer(name)
+        return {
+            name
+            for name, other in self._peers.items()
+            if (other is not peer) and other.is_visible_by(peer.name)
+        }
+
+    def get_full_topology(
+        self,
+    ) -> Dict[str, Dict[str, bool]]:
+        """Return the network's adjacency matrix, as a nested dict."""
+        matrix = {}
+        for name, peer in self._peers.items():
+            matrix[name] = {
+                other.name: (other is peer) or other.is_visible_by(peer.name)
+                for other in self._peers.values()
+            }
+        return matrix
diff --git a/test/communication3/server/test_peers.py b/test/communication3/server/test_peers.py
new file mode 100644
index 0000000000000000000000000000000000000000..77f526206fcdf17ad840fada3202b3244517352f
--- /dev/null
+++ b/test/communication3/server/test_peers.py
@@ -0,0 +1,110 @@
+# coding: utf-8
+
+# Copyright 2023 Inria (Institut National de Recherche en Informatique
+# et Automatique)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Unit tests for the PeersManager communication server component."""
+
+
+from declearn.communication3.server import PeersManager, PeerPolicy
+
+
+class TestPeersManager:
+    """Unit tests for the PeersManager communication server component."""
+
+    def test_add_peer_to_empty_manager(self) -> None:
+        """Test that a peer may be added to an empty manager."""
+        manager = PeersManager()
+        name, token = manager.add_peer("toto")
+        assert name == "toto"
+        assert isinstance(token, str)
+
+    def test_add_peers_with_same_name(self) -> None:
+        """Test that two peers may be created from the same initial name."""
+        manager = PeersManager()
+        name_a, token_a = manager.add_peer("toto")
+        name_b, token_b = manager.add_peer("toto")
+        assert name_a != name_b
+        assert name_a == "toto"
+        assert name_b.startswith(name_a)
+        assert token_a != token_b
+
+    def test_authenticate_peer_with_proper_credentials(self) -> None:
+        """Test that peer authentication works with proper credentials."""
+        manager = PeersManager()
+        name, token = manager.add_peer("toto")
+        assert manager.authenticate_peer(name, token)
+
+    def test_authenticate_peer_with_wrong_token(self) -> None:
+        """Test that peer authentication works with wrongful token."""
+        manager = PeersManager()
+        name, _ = manager.add_peer("toto")
+        assert not manager.authenticate_peer(name, "mock-token")
+
+    def test_authenticate_peer_with_wrong_name(self) -> None:
+        """Test that peer authentication works with non-existent name."""
+        manager = PeersManager()
+        assert not manager.authenticate_peer("toto", "mock-token")
+
+    def test_remove_peer(self) -> None:
+        """Test that peer removal works."""
+        # Set up a manager with a peer.
+        manager = PeersManager()
+        name, token = manager.add_peer("toto")
+        assert manager.authenticate_peer(name, token)
+        # Verify that the peer can be removed.
+        manager.remove_peer(name)
+        assert not manager.authenticate_peer(name, token)
+
+    def test_set_get_update_peer_policy(self) -> None:
+        """Test that a peer's policy may be set, accessed and updated."""
+        # Set up a peer with a base policy.
+        manager = PeersManager()
+        base_policy = PeerPolicy(authorized={"foo"}, blocked={"bar"})
+        name, _ = manager.add_peer("toto", policy=base_policy)
+        # Test that the policy can be accessed and has been properly set.
+        assert manager.get_peer_policy(name) == base_policy
+        # Test that this policy can be updated.
+        new_policy = PeerPolicy(authorized={"bar"}, blocked={"foo"})
+        manager.update_peer_policy(name, new_policy)
+        assert manager.get_peer_policy(name) == new_policy
+
+    def test_get_peer_topology(self) -> None:
+        """That that peer-policy-based partial topology access works."""
+        # Set up a manager with three peers, one of which is hidden.
+        manager = PeersManager()
+        manager.add_peer("foo", policy=PeerPolicy(visible=True))
+        manager.add_peer("bar", policy=PeerPolicy(visible=False))
+        manager.add_peer("buz", policy=PeerPolicy(visible=True))
+        # Verify that visible topologies match expectations.
+        assert manager.get_peer_topology("foo") == {"buz"}
+        assert manager.get_peer_topology("bar") == {"foo", "buz"}
+        assert manager.get_peer_topology("buz") == {"foo"}
+
+    def test_get_full_topology(self) -> None:
+        """That that peer-policy-based global topology access works."""
+        # Set up a manager with three peers, one of which is hidden to others.
+        manager = PeersManager()
+        manager.add_peer("foo", policy=PeerPolicy(visible=True))
+        manager.add_peer("bar", policy=PeerPolicy(visible=False))
+        manager.add_peer("buz", policy=PeerPolicy(visible=True))
+        # Verify that the global topology matches expectations.
+        expected = {
+            "foo": {"foo": True, "bar": False, "buz": True},
+            "bar": {"foo": True, "bar": True, "buz": True},
+            "buz": {"foo": True, "bar": False, "buz": True},
+        }
+        topology = manager.get_full_topology()
+        assert topology == expected