diff --git a/tests/test_ur_socket_client.py b/tests/test_ur_socket_client.py
new file mode 100644
index 0000000000000000000000000000000000000000..d713f84344cd13914d2c0829a9143e4a8712fe7e
--- /dev/null
+++ b/tests/test_ur_socket_client.py
@@ -0,0 +1,52 @@
+import socket
+
+import pytest
+
+from ur_py_ctl import URSocketClient
+
+
+@pytest.fixture
+def mock_socket(monkeypatch):
+    def noop(*args, **kwargs):
+        return
+
+    def mock_socket_accept(*args, **kwargs):
+        return socket.socket(), "0.0.0.0"
+
+    def mock_socket_recv(*args, **kwargs):
+        return b"bytes"
+
+    monkeypatch.setattr(socket.socket, "send", noop)
+    monkeypatch.setattr(socket.socket, "bind", noop)
+    monkeypatch.setattr(socket.socket, "accept", mock_socket_accept)
+    monkeypatch.setattr(socket.socket, "connect", noop)
+    monkeypatch.setattr(socket.socket, "recv", mock_socket_recv)
+
+
+def test_ur_socket_client():
+    URSocketClient("192.168.1.2")
+
+
+def test_ur_socket_client_contextmanager(mock_socket):
+    with URSocketClient("192.168.1.2", recv_socket_hostname="127.0.0.1"):
+        pass
+
+
+def test_ur_socket_client_contextmanager_send(mock_socket):
+    with URSocketClient("192.168.1.2", recv_socket_hostname="127.0.0.1") as client:
+        assert client.send_script(b"test") is None
+
+
+def test_ur_socket_client_contextmanager_send_await_resp(mock_socket):
+    with URSocketClient("192.168.1.2", recv_socket_hostname="127.0.0.1") as client:
+        assert client.send_script(b"test", await_response=True) == "bytes"
+
+
+def test_ur_socket_client_contextmanager_send_str(mock_socket):
+    with URSocketClient("192.168.1.2", recv_socket_hostname="127.0.0.1") as client:
+        assert client.send_script("test") is None
+
+
+def test_ur_socket_client_contextmanager_send_str_await_resp(mock_socket):
+    with URSocketClient("192.168.1.2", recv_socket_hostname="127.0.0.1") as client:
+        assert client.send_script("test", await_response=True) == "bytes"
diff --git a/ur_py_ctl/__init__.py b/ur_py_ctl/__init__.py
index 8b1f1358c9e465786378ed974947ac400a79a0b8..df68f7abe6704144c9ba69b58f3fd7a31a235493 100644
--- a/ur_py_ctl/__init__.py
+++ b/ur_py_ctl/__init__.py
@@ -12,6 +12,7 @@ REPO_DIR = HERE.parent
 DATA_DIR = REPO_DIR / "data"
 LOG_DIR = REPO_DIR / "log"
 
+from .ur_socket_client import URSocketClient  # noqa: F401,E402
 from .urscript_commands import Motion  # noqa: F401,E402
 from .urscript_commands import move_to_conf  # noqa: F401,E402
 from .urscript_commands import move_to_pose  # noqa: F401,E402
diff --git a/ur_py_ctl/ur_socket_client.py b/ur_py_ctl/ur_socket_client.py
new file mode 100644
index 0000000000000000000000000000000000000000..057edf092fae70664aac5e5691a8066f4b38a3ae
--- /dev/null
+++ b/ur_py_ctl/ur_socket_client.py
@@ -0,0 +1,112 @@
+import logging
+import socket
+from typing import Union
+
+# TODO: Integration tests with socket server mimicking UR controller.
+
+
+class URSocketClient:
+    """Socket client for communication with UR robots.
+
+    Parameters
+    ----------
+    ur_hostname
+        IP or hostname for robot controller.
+    ur_port
+        One of 30001, 30002 or 30003. See `Remote Control Via TCP/IP
+        <https://www.universal-robots.com/articles/ur/interface-communication/remote-control-via-tcpip/>`_
+    recv_socket_hostname
+        The address the robot controller is to use to send data back to the
+        client. This needs to be an IP that the controller can reach the client
+        (your computer) with or a hostname that resolves correctly for the robot
+        controller.
+    recv_socket_port
+        Any free port on the client (your computer). It's generally advisable to
+        use not use one the reserved ports (1-1024). Arbitrarily defaults to
+        40000.
+    send_socket_timeout
+        Timeout for the send operations on the send port. I.e. how long to wait
+        for UR controller to accept message.
+
+    Usage
+    -----
+    >>> import math
+    >>> import move_to_conf from ur_py_ctl
+    >>> move_cmd = move_to_conf([radians(j) for j in [0, 0, 30, 0, 30]])
+    "movej([0, 0, 30, 0, 30])"
+    >>> script = "def program()\n" + move_cmd + "\nend\n\nprogram()"
+    "def program
+    movej([0, 0, 30, 0, 30])
+    end
+
+    program()"
+    >>> with URClient("robot.local", recv_socket_hostname="laptop.local") as client:
+    ...     client.send_script(script)
+    """
+
+    def __init__(
+        self,
+        ur_hostname: str,
+        ur_port=30002,
+        recv_socket_hostname: str = None,
+        recv_socket_port: int = 40000,
+        send_socket_timeout: int = 2,
+    ):
+        self.send_addr = (ur_hostname, ur_port)
+        self.recv_addr = (recv_socket_hostname, recv_socket_port)
+
+        self.send_sock = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)
+        self.send_sock.settimeout(send_socket_timeout)
+
+        # SO_REUSEADDR might be causing comm problems..
+        # self.send_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+
+        self.recv_sock = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)
+
+        self.recv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+
+    def __enter__(self):
+        self._connect_send_sock()
+        if self._has_recv_addr():
+            self._bind_recv_sock()
+        return self
+
+    def __exit__(self, ex_type, ex_value, ex_traceback) -> bool:
+        self.send_sock.close()
+        self.recv_sock.close()
+
+        return True
+
+    def send_script(
+        self, script: Union[str, bytes], await_response=False
+    ) -> str | None:
+        if isinstance(script, str):
+            script = script.encode(encoding="utf-8")
+
+        self.send_sock.send(script)
+
+        if await_response:
+            if not self._has_recv_addr():
+                raise RuntimeError("Missing address for receiving socket!")
+
+            # Listen for incoming connections
+            self.recv_sock.listen(1)
+
+            logging.debug("Waiting for accept")
+
+            conn, client_address = self.recv_sock.accept()
+
+            logging.debug(f"Received accept from: {client_address}")
+
+            return conn.recv(1024).decode(encoding="utf-8")
+
+        return None
+
+    def _connect_send_sock(self) -> None:
+        self.send_sock.connect(self.send_addr)
+
+    def _bind_recv_sock(self) -> None:
+        self.recv_sock.bind(self.recv_addr)
+
+    def _has_recv_addr(self) -> bool:
+        return self.recv_addr[0] is not None and self.recv_addr[1] is not None