diff --git a/manager/knot_resolver_manager/cli/__init__.py b/manager/knot_resolver_manager/cli/__init__.py
index 881ab37a02bb59385e2d7cac9372d6534bb0f0d3..d5fdd10f2c7c0d583337e0067bc9a3088f13bd8d 100644
--- a/manager/knot_resolver_manager/cli/__init__.py
+++ b/manager/knot_resolver_manager/cli/__init__.py
@@ -1,3 +1,26 @@
-def main():
-    print("Knot Resolver CLI successfully running...")
-    print("... unfortunatelly, it does nothing at the moment")
+from typing import TYPE_CHECKING, cast
+
+from typing_extensions import Literal
+
+from knot_resolver_manager.utils.requests import request
+
+if TYPE_CHECKING:
+    from knot_resolver_manager.cli.__main__ import Args, ConfigArgs
+
+
+def config(args: "Args") -> None:
+    cfg: "ConfigArgs" = cast("ConfigArgs", args.command)
+
+    if not cfg.path.startswith("/"):
+        cfg.path = "/" + cfg.path
+
+    method: Literal["GET", "POST"] = "GET" if cfg.replacement_value is None else "POST"
+    url = f"{args.socket}/v1/config{cfg.path}"
+    response = request(method, url, cfg.replacement_value)
+    print(response)
+
+
+def stop(args: "Args") -> None:
+    url = f"{args.socket}/stop"
+    response = request("POST", url)
+    print(response)
diff --git a/manager/knot_resolver_manager/cli/__main__.py b/manager/knot_resolver_manager/cli/__main__.py
index 56d61c984fd658dd8ff9b305a26b15e31240b211..b6ef92b661760dcc093eaae2a3bce8bac30c8b8e 100644
--- a/manager/knot_resolver_manager/cli/__main__.py
+++ b/manager/knot_resolver_manager/cli/__main__.py
@@ -1,4 +1,80 @@
-from knot_resolver_manager.cli import main
+import argparse
+from abc import ABC
+from typing import Optional
+
+from knot_resolver_manager.cli import config, stop
+from knot_resolver_manager.compat.dataclasses import dataclass
+
+
+class Cmd(ABC):
+    def __init__(self, ns: argparse.Namespace) -> None:
+        pass
+
+    def run(self, args: "Args") -> None:
+        raise NotImplementedError()
+
+
+class ConfigArgs(Cmd):
+    def __init__(self, ns: argparse.Namespace) -> None:
+        super().__init__(ns)
+        self.path: str = str(ns.path)
+        self.replacement_value: Optional[str] = ns.new_value
+        self.delete: bool = ns.delete
+        self.stdin: bool = ns.stdin
+
+    def run(self, args: "Args") -> None:
+        config(args)
+
+
+class StopArgs(Cmd):
+    def run(self, args: "Args") -> None:
+        stop(args)
+
+
+@dataclass
+class Args:
+    socket: str
+    command: Cmd  # union in the future
+
+
+def parse_args() -> Args:
+    # pylint: disable=redefined-outer-name
+
+    parser = argparse.ArgumentParser("kresctl", description="CLI for controlling Knot Resolver")
+    parser.add_argument(
+        "-s",
+        "--socket",
+        action="store",
+        type=str,
+        help="manager API listen address",
+        default="http+unix://%2Fvar%2Frun%2Fknot-resolver%2Fmanager.sock",
+        nargs=1,
+        required=True,
+    )
+    subparsers = parser.add_subparsers()
+
+    config = subparsers.add_parser(
+        "config", help="dynamically change configuration of a running resolver", aliases=["c", "conf"]
+    )
+    config.add_argument("path", type=str, help="which part of config should we work with")
+    config.add_argument(
+        "new_value",
+        type=str,
+        nargs="?",
+        help="optional, what value should we set for the given path (JSON)",
+        default=None,
+    )
+    config.add_argument("-d", "--delete", action="store_true", help="delete part of the config tree", default=False)
+    config.add_argument("--stdin", help="read new config value on stdin", action="store_true", default=False)
+    config.set_defaults(command_type=ConfigArgs)
+
+    stop = subparsers.add_parser("stop", help="shutdown everything")
+    stop.set_defaults(command_type=StopArgs)
+
+    ns = parser.parse_args()
+    return Args(socket=ns.socket[0], command=ns.command_type(ns))  # type: ignore[call-arg]
+
 
 if __name__ == "__main__":
-    main()
+    _args = parse_args()
+    _args.command.run(_args)
diff --git a/manager/knot_resolver_manager/client/__init__.py b/manager/knot_resolver_manager/client/__init__.py
deleted file mode 100644
index 70433c972e9e479e5763dc81c4fabce1e0ae1516..0000000000000000000000000000000000000000
--- a/manager/knot_resolver_manager/client/__init__.py
+++ /dev/null
@@ -1,90 +0,0 @@
-import ipaddress
-import json
-import multiprocessing
-import subprocess
-import time
-import urllib.parse
-from pathlib import Path
-from typing import Dict, List, Union
-
-import requests
-
-from knot_resolver_manager import compat
-from knot_resolver_manager.server import start_server
-from knot_resolver_manager.utils.modeling import ParsedTree
-
-
-class KnotManagerClient:
-    def __init__(self, url: str):
-        self._url = url
-
-    def _create_url(self, path: str) -> str:
-        return urllib.parse.urljoin(self._url, path)
-
-    def stop(self) -> None:
-        response = requests.post(self._create_url("/stop"))
-        print(response.text)
-
-    def set_num_workers(self, n: int) -> None:
-        response = requests.post(self._create_url("/config/server/workers"), data=str(n))
-        print(response.text)
-
-    def set_groupid(self, gid: str) -> None:
-        response = requests.post(self._create_url("/config/server/groupid"), data=f'"{gid}"')
-        print(response.text)
-
-    def set_static_hints(self, hints: Dict[str, List[Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]]) -> None:
-        payload = {name: [str(a) for a in addrs] for name, addrs in hints.items()}
-        response = requests.post(self._create_url("/config/static-hints/hints"), json=payload)
-        print(response.text)
-
-    def set_listen_ip_address(self, ip: Union[ipaddress.IPv4Address, ipaddress.IPv6Address], port: int) -> None:
-        payload = [{"listen": {"ip": str(ip), "port": port}}]
-        response = requests.post(self._create_url("/config/network/interfaces"), json=payload)
-        print(response)
-
-    def wait_for_initialization(self, timeout_sec: float = 5, time_step: float = 0.4) -> None:
-        started = time.time()
-        while True:
-            try:
-                response = requests.get(self._create_url("/"))
-                data = json.loads(response.text)
-                if data["status"] == "RUNNING":
-                    return
-            except BaseException:
-                pass
-
-            if time.time() - started > timeout_sec:
-                raise TimeoutError("The manager did not start in time")
-
-            time.sleep(time_step)
-
-
-def count_running_kresds() -> int:
-    """
-    Inteded use-case is testing... Nothing more
-
-    Looks at running processes in the system and returns the number of kresd instances observed.
-    """
-    cmd = subprocess.run(
-        "ps aux | grep kresd | grep -v grep", shell=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False
-    )
-    return len(str(cmd.stdout, "utf8").strip().split("\n"))
-
-
-class _DefaultSentinel:
-    pass
-
-
-_DEFAULT_SENTINEL = _DefaultSentinel()
-
-
-def start_manager_in_background(
-    initial_config: Union[Path, ParsedTree, _DefaultSentinel] = _DEFAULT_SENTINEL
-) -> multiprocessing.Process:
-    if isinstance(initial_config, _DefaultSentinel):
-        p = multiprocessing.Process(target=compat.asyncio.run, args=(start_server(),))
-    else:
-        p = multiprocessing.Process(target=compat.asyncio.run, args=(start_server(config=initial_config),))
-    p.start()
-    return p
diff --git a/manager/knot_resolver_manager/client/__main__.py b/manager/knot_resolver_manager/client/__main__.py
deleted file mode 100644
index 1c1ba5a5da8d4c989ea36f42be3bf42f3b89b381..0000000000000000000000000000000000000000
--- a/manager/knot_resolver_manager/client/__main__.py
+++ /dev/null
@@ -1,99 +0,0 @@
-import ipaddress
-import sys
-
-import click
-from click.exceptions import ClickException
-
-from knot_resolver_manager.client import KnotManagerClient
-from knot_resolver_manager.datamodel.config_schema import KresConfig
-from knot_resolver_manager.exceptions import KresManagerException
-from knot_resolver_manager.utils.modeling import parse_yaml
-
-BASE_URL = "base_url"
-
-
-@click.group()
-@click.option(
-    "-u",
-    "--url",
-    "base_url",
-    nargs=1,
-    default="http://localhost:5000/",
-    help="Set base URL on which the manager communicates",
-)
-@click.pass_context
-def main(ctx: click.Context, base_url: str) -> None:
-    ctx.ensure_object(dict)
-    ctx.obj[BASE_URL] = base_url
-
-
-@main.command(help="Shutdown the manager and all workers")
-@click.pass_context
-def stop(ctx: click.Context) -> None:
-    client = KnotManagerClient(ctx.obj[BASE_URL])
-    client.stop()
-
-
-@main.command("gen-lua", help="Generate LUA config from a given declarative config")
-@click.argument("config_path", type=str, nargs=1)
-def gen_lua(config_path: str) -> None:
-    try:
-        with open(config_path, "r", encoding="utf8") as f:
-            data = f.read()
-        parsed = parse_yaml(data)
-        config = KresConfig(parsed)
-        lua = config.render_lua()
-        click.echo_via_pager(lua)
-    except KresManagerException as e:
-        ne = ClickException(str(e))
-        ne.exit_code = 1
-        raise ne
-
-
-@main.command(help="Set number of workers")
-@click.argument("instances", type=int, nargs=1)
-@click.pass_context
-def workers(ctx: click.Context, instances: int) -> None:
-    client = KnotManagerClient(ctx.obj[BASE_URL])
-    client.set_num_workers(instances)
-
-
-@main.command(help="Set the manager groupid")
-@click.argument("gid", type=str, nargs=1)
-@click.pass_context
-def groupid(ctx: click.Context, gid: str) -> None:
-    client = KnotManagerClient(ctx.obj[BASE_URL])
-    client.set_groupid(gid)
-
-
-@main.command("one-static-hint", help="Set one inline static-hint hints (replaces old static hints)")
-@click.argument("name", type=str, nargs=1)
-@click.argument("ip", type=str, nargs=1)
-@click.pass_context
-def one_static_hint(ctx: click.Context, name: str, ip: str) -> None:
-    client = KnotManagerClient(ctx.obj[BASE_URL])
-    client.set_static_hints({name: [ipaddress.ip_address(ip)]})
-
-
-@main.command("listen-ip", help="Configure where the resolver should listen (replaces all previous locations)")
-@click.argument("ip", type=str, nargs=1)
-@click.argument("port", type=int, nargs=1)
-@click.pass_context
-def listen_ip(ctx: click.Context, ip: str, port: int) -> None:
-    client = KnotManagerClient(ctx.obj[BASE_URL])
-    client.set_listen_ip_address(ipaddress.ip_address(ip), port)
-
-
-@main.command(help="Wait for manager initialization")
-@click.pass_context
-def wait(ctx: click.Context) -> None:
-    client = KnotManagerClient(ctx.obj[BASE_URL])
-    try:
-        client.wait_for_initialization()
-    except TimeoutError as e:
-        click.echo(f"ERR: {e}")
-        sys.exit(1)
-
-
-if __name__ == "__main__":
-    main()  # pylint: disable=no-value-for-parameter
diff --git a/manager/knot_resolver_manager/config_store.py b/manager/knot_resolver_manager/config_store.py
index 6a8d58e06a1f5183399a422a9ceb220b7eb9d5d5..035196023a2ad4e6b223e5949dd273a8d5aa8157 100644
--- a/manager/knot_resolver_manager/config_store.py
+++ b/manager/knot_resolver_manager/config_store.py
@@ -26,7 +26,9 @@ class ConfigStore:
         err_res = filter(lambda r: r.is_err(), results)
         errs = list(map(lambda r: r.unwrap_err(), err_res))
         if len(errs) > 0:
-            raise KresManagerException("Validation of the new config failed. The reasons are:", *errs)
+            raise KresManagerException(
+                "Validation of the new config failed. The reasons are:\n - " + "\n - ".join(errs)
+            )
 
         async with self._update_lock:
             # update the stored config with the new version
diff --git a/manager/knot_resolver_manager/server.py b/manager/knot_resolver_manager/server.py
index edcbda728972705fab8d0df3027164a50ec14e7b..60c0693f74391e88bbea26d55b3553b8ac231f4b 100644
--- a/manager/knot_resolver_manager/server.py
+++ b/manager/knot_resolver_manager/server.py
@@ -50,10 +50,11 @@ async def error_handler(request: web.Request, handler: Any) -> web.Response:
     try:
         return await handler(request)
     except DataValidationError as e:
-        return web.Response(text=f"validation of configuration failed: {e}", status=HTTPStatus.BAD_REQUEST)
+        return web.Response(text=f"validation of configuration failed:\n{e}", status=HTTPStatus.BAD_REQUEST)
+    except DataParsingError as e:
+        return web.Response(text=f"request processing error:\n{e}", status=HTTPStatus.BAD_REQUEST)
     except KresManagerException as e:
-        logger.error("Request processing failed", exc_info=True)
-        return web.Response(text=f"Request processing failed: {e}", status=HTTPStatus.INTERNAL_SERVER_ERROR)
+        return web.Response(text=f"request processing failed:\n{e}", status=HTTPStatus.INTERNAL_SERVER_ERROR)
 
 
 class Server:
diff --git a/manager/knot_resolver_manager/utils/modeling/base_schema.py b/manager/knot_resolver_manager/utils/modeling/base_schema.py
index 15821920a46576e58dad81431362f3a12afafff1..1a3c5300a9c02547ce414e9000cf39103254c6ba 100644
--- a/manager/knot_resolver_manager/utils/modeling/base_schema.py
+++ b/manager/knot_resolver_manager/utils/modeling/base_schema.py
@@ -553,6 +553,10 @@ class BaseSchema(Serializable):
         if self._LAYER is not None:
             source = self._LAYER(source, object_path=object_path)  # pylint: disable=not-callable
 
+        # prevent failure when user provides a different type than object
+        if isinstance(source, ParsedTree) and not source.is_dict():
+            raise DataValidationError(f"expected object, found '{source.type()}'", object_path)
+
         # assign fields
         used_keys = self._assign_fields(source, object_path)
 
diff --git a/manager/knot_resolver_manager/utils/modeling/parsing.py b/manager/knot_resolver_manager/utils/modeling/parsing.py
index eb26c851ee60a3ade31213d70543bdc65fee3282..fad582c2d8e998c622f7214630c8fd6bd88340d3 100644
--- a/manager/knot_resolver_manager/utils/modeling/parsing.py
+++ b/manager/knot_resolver_manager/utils/modeling/parsing.py
@@ -2,7 +2,7 @@ import base64
 import json
 from enum import Enum, auto
 from hashlib import blake2b
-from typing import Any, Dict, List, Optional, Set, Tuple, Union
+from typing import Any, Dict, List, Optional, Set, Tuple, Type, Union
 
 import yaml
 from typing_extensions import Literal
@@ -44,6 +44,12 @@ class ParsedTree:
         assert isinstance(self._data, dict)
         return self._data[ParsedTree._convert_internal_field_name_to_external(key)]
 
+    def is_dict(self) -> bool:
+        return isinstance(self._data, dict)
+
+    def type(self) -> Type[Any]:
+        return type(self._data)
+
     def __contains__(self, key: str) -> bool:
         assert isinstance(self._data, dict)
         return ParsedTree._convert_internal_field_name_to_external(key) in self._data
@@ -142,7 +148,9 @@ class _Format(Enum):
             "text/vnd.yaml": _Format.YAML,
         }
         if mime_type not in formats:
-            raise DataParsingError("Unsupported MIME type")
+            raise DataParsingError(
+                f"unsupported MIME type '{mime_type}', expected 'application/json' or 'text/vnd.yaml'"
+            )
         return formats[mime_type]
 
 
diff --git a/manager/knot_resolver_manager/utils/requests.py b/manager/knot_resolver_manager/utils/requests.py
new file mode 100644
index 0000000000000000000000000000000000000000..18b72b132c20267a0490d1ba48028ebc766e12e0
--- /dev/null
+++ b/manager/knot_resolver_manager/utils/requests.py
@@ -0,0 +1,77 @@
+import socket
+from http.client import HTTPConnection
+from typing import Any, Optional, Union
+from urllib.error import HTTPError
+from urllib.request import AbstractHTTPHandler, Request, build_opener, install_opener, urlopen
+
+from typing_extensions import Literal
+
+
+class Response:
+    def __init__(self, status: int, body: str) -> None:
+        self.status = status
+        self.body = body
+
+    def __repr__(self) -> str:
+        return f"status: {self.status}\nbody:\n{self.body}"
+
+
+def request(
+    method: Literal["GET", "POST", "HEAD", "PUT", "DELETE"],
+    url: str,
+    body: Optional[str] = None,
+    content_type: str = "application/json",
+) -> Response:
+    req = Request(
+        url,
+        method=method,
+        data=body.encode("utf8") if body is not None else None,
+        headers={"Content-Type": content_type},
+    )
+    # req.add_header("Authorization", _authorization_header)
+
+    try:
+        with urlopen(req) as response:
+            return Response(response.status, response.read().decode("utf8"))
+    except HTTPError as err:
+        return Response(err.code, response.read().decode("utf8"))
+
+
+# Code heavily inspired by requests-unixsocket
+# https://github.com/msabramo/requests-unixsocket/blob/master/requests_unixsocket/adapters.py
+class UnixHTTPConnection(HTTPConnection):
+    def __init__(self, unix_socket_url: str, timeout: Union[int, float] = 60):
+        """Create an HTTP connection to a unix domain socket
+        :param unix_socket_url: A URL with a scheme of 'http+unix' and the
+        netloc is a percent-encoded path to a unix domain socket. E.g.:
+        'http+unix://%2Ftmp%2Fprofilesvc.sock/status/pid'
+        """
+        super().__init__("localhost", timeout=timeout)
+        self.unix_socket_path = unix_socket_url
+        self.timeout = timeout
+        self.sock: Optional[socket.socket] = None
+
+    def __del__(self):  # base class does not have d'tor
+        if self.sock:
+            self.sock.close()
+
+    def connect(self):
+        sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+        sock.settimeout(1)  # there is something weird stored in self.timeout
+        sock.connect(self.unix_socket_path)
+        self.sock = sock
+
+
+class UnixHTTPHandler(AbstractHTTPHandler):
+    def __init__(self) -> None:
+        super().__init__()
+
+        def open_(self: UnixHTTPHandler, req: Any) -> Any:
+            return self.do_open(UnixHTTPConnection, req)
+
+        setattr(UnixHTTPHandler, "http+unix_open", open_)
+        setattr(UnixHTTPHandler, "http+unix_request", AbstractHTTPHandler.do_request_)
+
+
+opener = build_opener(UnixHTTPHandler())
+install_opener(opener)
diff --git a/manager/pyproject.toml b/manager/pyproject.toml
index 41e1afc18cdb8f408113c88a28640e471cf5eb3b..26d65cb40ee3e94826099b5b394d937fa412d456 100644
--- a/manager/pyproject.toml
+++ b/manager/pyproject.toml
@@ -17,9 +17,7 @@ generate-setup-file = true
 python = "^3.6.8"
 aiohttp = "^3.6.12"
 Jinja2 = "^2.11.3"
-click = "^7.1.2"
 PyYAML = "^5.4.1"
-requests = "^2.25.1"
 typing-extensions = ">=3.7.2"
 prometheus-client = "^0.6"
 supervisor = "^4.2.2"
@@ -31,19 +29,14 @@ black = "^20.8b1"
 tox = "^3.21.4"
 tox-pyenv = "^1.1.0"
 poethepoet = "^0.13.0"
-requests = "^2.25.1"
-requests-unixsocket = "^0.2.0"
-click = "^7.1.2"
 toml = "^0.10.2"
 debugpy = "^1.2.1"
 Sphinx = "^4.0.2"
 pylint = "^2.11.1"
 pytest-asyncio = "^0.16.0"
 pytest = "^6.2.5"
-types-requests = "^2.26.3"
 types-PyYAML = "^6.0.1"
 mypy = "^0.930"
-types-click = "^7.1.8"
 types-Jinja2 = "^2.11.9"
 types-dataclasses = "^0.6.4"
 poetry = "^1.1.12"
diff --git a/manager/setup.py b/manager/setup.py
index 762eb07204a13b9c031de34b1664980f26c80454..96ae759705d5d8e43367eb533944d0ba0ea0aec4 100644
--- a/manager/setup.py
+++ b/manager/setup.py
@@ -4,7 +4,6 @@ from setuptools import setup
 packages = \
 ['knot_resolver_manager',
  'knot_resolver_manager.cli',
- 'knot_resolver_manager.client',
  'knot_resolver_manager.compat',
  'knot_resolver_manager.datamodel',
  'knot_resolver_manager.datamodel.types',
@@ -22,9 +21,7 @@ install_requires = \
 ['Jinja2>=2.11.3',
  'PyYAML>=5.4.1',
  'aiohttp>=3.6.12',
- 'click>=7.1.2',
  'prometheus-client>=0.6',
- 'requests>=2.25.1',
  'supervisor>=4.2.2',
  'typing-extensions>=3.7.2']