diff --git a/Makefile.tools b/Makefile.tools
index 71033d6388cf..51a5af6af77e 100644
--- a/Makefile.tools
+++ b/Makefile.tools
@@ -14,7 +14,8 @@ client_bluetoothctl_SOURCES = client/main.c \
 					client/admin.h client/admin.c \
 					client/player.h client/player.c \
 					client/mgmt.h client/mgmt.c \
-					client/assistant.h client/assistant.c
+					client/assistant.h client/assistant.c \
+					client/hci.h client/hci.c
 client_bluetoothctl_LDADD = lib/libbluetooth-internal.la \
 			gdbus/libgdbus-internal.la src/libshared-glib.la \
 			$(GLIB_LIBS) $(DBUS_LIBS) -lreadline
diff --git a/client/hci.c b/client/hci.c
new file mode 100644
index 000000000000..69fc4ba91707
--- /dev/null
+++ b/client/hci.c
@@ -0,0 +1,333 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ *
+ *  BlueZ - Bluetooth protocol stack for Linux
+ *
+ *  Copyright (C) 2024 Intel Corporation
+ *
+ *
+ */
+
+#ifdef HAVE_CONFIG_H
+#include <config.h>
+#endif
+
+#include <stdlib.h>
+#include <string.h>
+
+#include <glib.h>
+
+#include "src/shared/util.h"
+#include "src/shared/queue.h"
+#include "src/shared/shell.h"
+#include "src/shared/hci.h"
+#include "monitor/bt.h"
+#include "hci.h"
+
+static struct bt_hci *hci;
+static struct queue *events;
+
+struct hci_event {
+	uint8_t event;
+	unsigned int id;
+};
+
+static void hci_open(int argc, char *argv[])
+{
+	long index;
+	char *endptr = NULL;
+
+	if (hci) {
+		bt_shell_printf("HCI channel already open\n");
+		return bt_shell_noninteractive_quit(EXIT_FAILURE);
+	}
+
+	index = strtol(argv[1], &endptr, 0);
+	if (!endptr || *endptr != '\0' || index < 0 || index > UINT16_MAX) {
+		bt_shell_printf("Invalid index: %s\n", argv[1]);
+		return bt_shell_noninteractive_quit(EXIT_FAILURE);
+	}
+
+	if (!strcasecmp(argv[2], "raw")) {
+		hci = bt_hci_new_raw_device(index);
+		if (!hci) {
+			bt_shell_printf("Unable to open raw channel\n");
+			return bt_shell_noninteractive_quit(EXIT_FAILURE);
+		}
+	} else if (!strcasecmp(argv[2], "user")) {
+		hci = bt_hci_new_user_channel(index);
+		if (!hci) {
+			bt_shell_printf("Unable to open user channel\n");
+			return bt_shell_noninteractive_quit(EXIT_FAILURE);
+		}
+	} else {
+		bt_shell_printf("Invalid channel: %s\n", argv[2]);
+		return bt_shell_noninteractive_quit(EXIT_FAILURE);
+	}
+
+	bt_shell_printf("HCI index %ld %s channel opened\n", index, argv[2]);
+
+	return bt_shell_noninteractive_quit(EXIT_SUCCESS);
+}
+
+static uint8_t *str2bytearray(char *arg, size_t *val_len)
+{
+	uint8_t value[UINT8_MAX];
+	char *entry;
+	unsigned int i;
+
+	for (i = 0; (entry = strsep(&arg, " \t")) != NULL; i++) {
+		long val;
+		char *endptr = NULL;
+
+		if (*entry == '\0')
+			continue;
+
+		if (i >= G_N_ELEMENTS(value)) {
+			bt_shell_printf("Too much data\n");
+			return NULL;
+		}
+
+		val = strtol(entry, &endptr, 0);
+		if (!endptr || *endptr != '\0' || val > UINT8_MAX) {
+			bt_shell_printf("Invalid value at index %d\n", i);
+			return NULL;
+		}
+
+		value[i] = val;
+	}
+
+	*val_len = i;
+
+	return util_memdup(value, i);
+}
+
+static void hci_cmd_complete(const void *data, uint8_t size, void *user_data)
+{
+	bt_shell_printf("HCI Command complete:\n");
+	bt_shell_hexdump(data, size);
+
+	return bt_shell_noninteractive_quit(EXIT_SUCCESS);
+}
+
+static void hci_cmd(int argc, char *argv[])
+{
+	long opcode;
+	struct iovec iov = {};
+	char *endptr = NULL;
+	unsigned int ret;
+
+	if (!hci) {
+		bt_shell_printf("HCI channel not open\n");
+		return bt_shell_noninteractive_quit(EXIT_FAILURE);
+	}
+
+	opcode = strtol(argv[1], &endptr, 0);
+	if (!endptr || *endptr != '\0' || opcode < 0 || opcode > UINT16_MAX) {
+		bt_shell_printf("Invalid opcode: %s\n", argv[1]);
+		return bt_shell_noninteractive_quit(EXIT_FAILURE);
+	}
+
+	if (argc > 2) {
+		iov.iov_base = str2bytearray(argv[2], &iov.iov_len);
+		if (!iov.iov_base) {
+			bt_shell_printf("Invalid parameters: %s\n", argv[2]);
+			return bt_shell_noninteractive_quit(EXIT_FAILURE);
+		}
+	}
+
+	ret = bt_hci_send(hci, opcode, iov.iov_base, iov.iov_len,
+				hci_cmd_complete, NULL, NULL);
+
+	free(iov.iov_base);
+
+	if (!ret)
+		return bt_shell_noninteractive_quit(EXIT_FAILURE);
+}
+
+static void hci_send(int argc, char *argv[])
+{
+	uint8_t type;
+	long handle;
+	struct iovec iov = {};
+	char *endptr = NULL;
+	bool ret;
+
+	if (!hci) {
+		bt_shell_printf("HCI channel not open\n");
+		return bt_shell_noninteractive_quit(EXIT_FAILURE);
+	}
+
+	if (!strcasecmp(argv[1], "acl")) {
+		type = BT_H4_ACL_PKT;
+	} else if (!strcasecmp(argv[1], "sco")) {
+		type = BT_H4_SCO_PKT;
+	} else if (!strcasecmp(argv[1], "iso")) {
+		type = BT_H4_ISO_PKT;
+	} else {
+		bt_shell_printf("Invalid type: %s\n", argv[1]);
+		return bt_shell_noninteractive_quit(EXIT_FAILURE);
+	}
+
+	handle = strtol(argv[2], &endptr, 0);
+	if (!endptr || *endptr != '\0' || handle < 0 || handle > UINT16_MAX) {
+		bt_shell_printf("Invalid handle: %s\n", argv[2]);
+		return bt_shell_noninteractive_quit(EXIT_FAILURE);
+	}
+
+	if (argc > 3) {
+		iov.iov_base = str2bytearray(argv[3], &iov.iov_len);
+		if (!iov.iov_base) {
+			bt_shell_printf("Invalid data: %s\n", argv[3]);
+			return bt_shell_noninteractive_quit(EXIT_FAILURE);
+		}
+	}
+
+	ret = bt_hci_send_data(hci, type, handle, iov.iov_base, iov.iov_len);
+
+	free(iov.iov_base);
+
+	return bt_shell_noninteractive_quit(ret ? EXIT_SUCCESS : EXIT_FAILURE);
+}
+
+static bool match_event(const void *data, const void *match_data)
+{
+	const struct hci_event *evt = data;
+	uint8_t event = PTR_TO_UINT(match_data);
+
+	return evt->event == event;
+}
+
+static void hci_evt_received(const void *data, uint8_t size, void *user_data)
+{
+	struct hci_event *evt = user_data;
+
+	bt_shell_printf("HCI Event 0x%02x received:\n", evt->event);
+	bt_shell_hexdump(data, size);
+}
+
+static void hci_register(int argc, char *argv[])
+{
+	struct hci_event *evt;
+	long event;
+	char *endptr = NULL;
+
+	if (!hci) {
+		bt_shell_printf("HCI channel not open\n");
+		return bt_shell_noninteractive_quit(EXIT_FAILURE);
+	}
+
+	event = strtol(argv[1], &endptr, 0);
+	if (!endptr || *endptr != '\0' || event < 0 || event > UINT8_MAX) {
+		bt_shell_printf("Invalid event: %s\n", argv[1]);
+		return bt_shell_noninteractive_quit(EXIT_FAILURE);
+	}
+
+	if (!events)
+		events = queue_new();
+
+	evt = queue_find(events, match_event, UINT_TO_PTR(event));
+	if (evt) {
+		bt_shell_printf("Event already registered\n");
+		return bt_shell_noninteractive_quit(EXIT_FAILURE);
+	}
+
+	evt = new0(struct hci_event, 1);
+	evt->event = event;
+	evt->id = bt_hci_register(hci, event, hci_evt_received, evt, NULL);
+
+	if (!evt->id) {
+		free(evt);
+		return bt_shell_noninteractive_quit(EXIT_FAILURE);
+	}
+
+	bt_shell_printf("HCI Event 0x%02x registered\n", (uint8_t)event);
+
+	queue_push_tail(events, evt);
+
+	return bt_shell_noninteractive_quit(EXIT_SUCCESS);
+}
+
+static void hci_unregister(int argc, char *argv[])
+{
+	struct hci_event *evt;
+	long event;
+	char *endptr = NULL;
+
+	if (!hci) {
+		bt_shell_printf("HCI channel not open\n");
+		return bt_shell_noninteractive_quit(EXIT_FAILURE);
+	}
+
+	event = strtol(argv[1], &endptr, 0);
+	if (!endptr || *endptr != '\0' || event < 0 || event > UINT8_MAX) {
+		bt_shell_printf("Invalid event: %s\n", argv[1]);
+		return bt_shell_noninteractive_quit(EXIT_FAILURE);
+	}
+
+	evt = queue_find(events, match_event, UINT_TO_PTR(event));
+	if (!evt) {
+		bt_shell_printf("Event not registered\n");
+		return bt_shell_noninteractive_quit(EXIT_FAILURE);
+	}
+
+	bt_hci_unregister(hci, evt->id);
+	queue_remove(events, evt);
+	free(evt);
+
+	bt_shell_printf("HCI Event 0x%02x unregistered\n", (uint8_t)event);
+
+	return bt_shell_noninteractive_quit(EXIT_SUCCESS);
+}
+
+static void hci_close(int argc, char *argv[])
+{
+	if (!hci) {
+		bt_shell_printf("HCI channel not open\n");
+		return bt_shell_noninteractive_quit(EXIT_FAILURE);
+	}
+
+	bt_hci_unref(hci);
+	hci = NULL;
+
+	bt_shell_printf("HCI channel closed\n");
+
+	return bt_shell_noninteractive_quit(EXIT_SUCCESS);
+}
+
+static const struct bt_shell_menu hci_menu = {
+	.name = "hci",
+	.desc = "HCI Submenu",
+	.entries = {
+	{ "open",        "<index> <chan=raw,user>", hci_open,
+					"Open HCI channel" },
+	{ "cmd",         "<opcode> [parameters...]", hci_cmd,
+					"Send HCI command" },
+	{ "send",        "<type=acl,sco,iso> <handle> [data...]", hci_send,
+					"Send HCI data" },
+	{ "register",    "<event>", hci_register,
+					"Register HCI event handler" },
+	{ "unregister",  "<event>", hci_unregister,
+					"Unregister HCI event handler" },
+	{ "close",         NULL, hci_close, "Close HCI channel" },
+	{} },
+};
+
+void hci_add_submenu(void)
+{
+	bt_shell_add_submenu(&hci_menu);
+}
+
+void hci_remove_submenu(void)
+{
+	if (!hci)
+		return;
+
+	if (events) {
+		queue_destroy(events, free);
+		events = NULL;
+	}
+
+	bt_hci_unref(hci);
+	hci = NULL;
+}
diff --git a/client/hci.h b/client/hci.h
new file mode 100644
index 000000000000..5efd89802452
--- /dev/null
+++ b/client/hci.h
@@ -0,0 +1,12 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ *
+ *  BlueZ - Bluetooth protocol stack for Linux
+ *
+ *  Copyright (C) 2024 Intel Corporation
+ *
+ *
+ */
+
+void hci_add_submenu(void);
+void hci_remove_submenu(void);
diff --git a/client/main.c b/client/main.c
index 9258b44655dd..c4fc49427021 100644
--- a/client/main.c
+++ b/client/main.c
@@ -37,6 +37,7 @@
 #include "player.h"
 #include "mgmt.h"
 #include "assistant.h"
+#include "hci.h"
 
 /* String display constants */
 #define COLORED_NEW	COLOR_GREEN "NEW" COLOR_OFF
@@ -3207,6 +3208,7 @@ int main(int argc, char *argv[])
 	player_add_submenu();
 	mgmt_add_submenu();
 	assistant_add_submenu();
+	hci_add_submenu();
 
 	client = g_dbus_client_new(dbus_conn, "org.bluez", "/org/bluez");
 
@@ -3229,6 +3231,7 @@ int main(int argc, char *argv[])
 	player_remove_submenu();
 	mgmt_remove_submenu();
 	assistant_remove_submenu();
+	hci_remove_submenu();
 
 	g_dbus_client_unref(client);
 
