diff --git a/CMakeLists.txt b/CMakeLists.txt
index 2dfc305..fe0a485 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -158,6 +158,7 @@ if (requirements)
add_subdirectory(src/web)
if (NOT MSVC AND NOT WIN32 AND NOT (${CMAKE_SYSTEM_NAME} MATCHES "SunOS"))
add_subdirectory(src/power)
+ add_subdirectory(src/virt)
endif()
endif()
if (SAI_BUILDER)
diff --git a/src/virt/CMakeLists.txt b/src/virt/CMakeLists.txt
new file mode 100644
index 0000000..a74deea
--- /dev/null
+++ b/src/virt/CMakeLists.txt
@@ -0,0 +1,53 @@
+set(SUB "sai-virt")
+set(CPACK_DEBIAN_VIRT_PACKAGE_NAME "sai-virt")
+
+set(SRCS
+ v-sai.c
+ v-ws-server.c
+ ../common/c-utils.c
+ ../common/struct-metadata.c
+)
+
+set(requirements 1)
+require_lws_config(LWS_WITH_CLIENT 1 requirements)
+require_lws_config(LWS_WITH_STRUCT_JSON 1 requirements)
+require_lws_config(LWS_WITH_SECURE_STREAMS 1 requirements)
+
+if (requirements)
+ add_executable(${SUB} ${SRCS})
+add_dependencies(${SUB} sai_git_hash)
+ if (APPLE)
+ set_property(TARGET ${SUB} PROPERTY MACOSX_RPATH YES)
+ endif()
+
+ if (SAI_LWS_INC_PATH)
+ target_include_directories(${SUB} PRIVATE ${SAI_LWS_INC_PATH})
+ endif()
+
+ if (HAS_LIBCAP)
+ find_library(CAP_LIB_PATH NAMES "cap")
+ endif()
+
+ target_link_libraries(${SUB} websockets ${SAI_LWS_LIB_PATH})
+
+ message("LWS_OPENSSL_LIBRARIES ${SUB} '${LWS_OPENSSL_LIBRARIES}'")
+ if (LWS_OPENSSL_LIBRARIES)
+ target_link_libraries(${SUB} ${LWS_OPENSSL_LIBRARIES})
+ endif()
+
+ if (SAI_EXT_PTHREAD_LIBRARIES)
+ target_link_libraries(${SUB} ${SAI_EXT_PTHREAD_LIBRARIES})
+ endif()
+ if (HAS_LIBCAP)
+ target_link_libraries(${SUB} ${CAP_LIB_PATH})
+ endif()
+
+ if (MSVC OR WIN32)
+ target_link_libraries(${SUB} ws2_32.lib userenv.lib psapi.lib iphlpapi.lib)
+ endif()
+
+ install(TARGETS "sai-virt"
+ RUNTIME DESTINATION "${BIN_DIR}" COMPONENT virt)
+
+endif(requirements)
+include(CPack)
diff --git a/src/virt/v-private.h b/src/virt/v-private.h
new file mode 100644
index 0000000..ff914e8
--- /dev/null
+++ b/src/virt/v-private.h
@@ -0,0 +1,54 @@
+/*
+ * Sai virt definitions src/virt/v-private.h
+ *
+ * Copyright (C) 2019 - 2026 Andy Green <andy@warmcat.com>
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation:
+ * version 2.1 of the License.
+ */
+
+#ifndef SAI_VIRT_V_PRIVATE_H
+#define SAI_VIRT_V_PRIVATE_H
+
+#include "../common/include/private.h"
+#include <pthread.h>
+
+struct saiv_server;
+
+/*
+ * Represents the virt process state
+ */
+struct sai_virt {
+ lws_dll2_owner_t sai_server_owner; /* servers we connect to */
+ struct lws_context *context;
+ struct lws_vhost *vhost;
+
+ const char *bind; /* listen socket binding */
+ const char *perms; /* user:group */
+ const char *port; /* port we listen on */
+
+ char hostname[64];
+};
+
+typedef struct saiv_server {
+ lws_dll2_t list;
+ struct lws_ss_handle *ss;
+ const char *url;
+ const char *name;
+} saiv_server_t;
+
+LWS_SS_USER_TYPEDEF
+ char payload[200];
+ size_t size;
+ size_t pos;
+ struct lws_buflist *bl_tx;
+} saiv_server_link_t;
+
+extern struct sai_virt virt;
+extern const lws_ss_info_t ssi_saiv_server_link_t;
+
+int saiv_config(struct sai_virt *virt, const char *d);
+
+#endif
diff --git a/src/virt/v-sai.c b/src/virt/v-sai.c
new file mode 100644
index 0000000..18c65b5
--- /dev/null
+++ b/src/virt/v-sai.c
@@ -0,0 +1,116 @@
+/*
+ * sai-virt
+ *
+ * Copyright (C) 2019 - 2026 Andy Green <andy@warmcat.com>
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation:
+ * version 2.1 of the License.
+ */
+
+#include <libwebsockets.h>
+#include <string.h>
+#include <signal.h>
+#include <unistd.h>
+
+#include "v-private.h"
+
+struct sai_virt virt;
+static int interrupted;
+
+static const char * const default_ss_policy =
+ "{"
+ "\"retry\": ["
+ "{\"default\": {"
+ "\"backoff\": [1000, 2000, 3000, 5000, 10000],"
+ "\"conceal\": 99999,"
+ "\"jitterpc\": 20,"
+ "\"svalidping\": 15,"
+ "\"svalidhup\": 30"
+ "}}"
+ "],"
+ "\"s\": ["
+ "{\"sai_power_client\": {"
+ "\"endpoint\": \"${url}\","
+ "\"protocol\": \"ws\","
+ "\"ws_subprotocol\": \"com-warmcat-sai-builder\","
+ "\"http_url\": \"\","
+ "\"retry\": \"default\","
+ "\"metadata\": ["
+ "{\"url\": \"\"}"
+ "]"
+ "}}"
+ "]}"
+;
+
+static void sigint_handler(int sig)
+{
+ interrupted = 1;
+}
+
+int main(int argc, const char **argv)
+{
+ struct lws_context_creation_info info;
+ int logs = LLL_USER | LLL_ERR | LLL_WARN | LLL_NOTICE;
+ const char *p;
+
+ if ((p = lws_cmdline_option(argc, argv, "-d")))
+ logs = atoi(p);
+
+ lws_set_log_level(logs, NULL);
+
+ lwsl_user("Sai Virt - Copyright (C) 2019-2026 Andy Green <andy@warmcat.com>\n");
+
+ if (gethostname(virt.hostname, sizeof(virt.hostname) - 1))
+ lws_strncpy(virt.hostname, "unknown", sizeof(virt.hostname));
+
+ memset(&info, 0, sizeof info);
+ info.port = CONTEXT_PORT_NO_LISTEN;
+ info.options = LWS_SERVER_OPTION_DO_SSL_GLOBAL_INIT |
+ LWS_SERVER_OPTION_VALIDATE_UTF8;
+
+ signal(SIGINT, sigint_handler);
+
+ info.pss_policies_json = default_ss_policy;
+
+ virt.context = lws_create_context(&info);
+ if (!virt.context) {
+ lwsl_err("lws init failed\n");
+ return 1;
+ }
+
+ virt.vhost = lws_create_vhost(virt.context, &info);
+ if (!virt.vhost) {
+ lwsl_err("lws init failed\n");
+ return 1;
+ }
+
+ /* We create the server link manually for testing skeleton */
+ saiv_server_t *srv = malloc(sizeof(*srv));
+ if (srv) {
+ memset(srv, 0, sizeof(*srv));
+ srv->url = "warmcat.com"; /* example */
+ if (lws_ss_create(virt.context, 0, &ssi_saiv_server_link_t,
+ srv, &srv->ss, NULL, NULL)) {
+ lwsl_err("%s: failed to create ss\n", __func__);
+ free(srv);
+ } else {
+ lws_dll2_add_tail(&srv->list, &virt.sai_server_owner);
+ }
+ }
+
+ while (!lws_service(virt.context, 0) && !interrupted)
+ ;
+
+ lws_start_foreach_dll_safe(struct lws_dll2 *, d, d1, virt.sai_server_owner.head) {
+ saiv_server_t *s = lws_container_of(d, saiv_server_t, list);
+ lws_ss_destroy(&s->ss);
+ lws_dll2_remove(d);
+ free(s);
+ } lws_end_foreach_dll_safe(d, d1);
+
+ lws_context_destroy(virt.context);
+
+ return 0;
+}
diff --git a/src/virt/v-ws-server.c b/src/virt/v-ws-server.c
new file mode 100644
index 0000000..afc7276
--- /dev/null
+++ b/src/virt/v-ws-server.c
@@ -0,0 +1,128 @@
+/*
+ * sai-virt - src/virt/v-ws-server.c
+ *
+ * Copyright (C) 2019 - 2026 Andy Green <andy@warmcat.com>
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation:
+ * version 2.1 of the License.
+ */
+
+#include <libwebsockets.h>
+#include <string.h>
+
+#include "v-private.h"
+
+/*
+ * When we connect, we masquerade as a builder/pcon and send our platforms
+ */
+static lws_ss_state_return_t
+saiv_server_tx(void *userobj, lws_ss_tx_ordinal_t ord, uint8_t *buf,
+ size_t *len, int *flags)
+{
+ saiv_server_link_t *g = (saiv_server_link_t *)userobj;
+
+ return sai_ss_tx_from_buflist_helper(g->ss, &g->bl_tx, buf, len, flags);
+}
+
+static lws_ss_state_return_t
+saiv_server_rx(void *userobj, const uint8_t *buf, size_t len, int flags)
+{
+ struct lejp_ctx ctx;
+ lws_struct_args_t a;
+ int m;
+
+ /*
+ * We receive JSON from sai-server, usually com.warmcat.sai.power.pending_tasks
+ */
+ memset(&a, 0, sizeof(a));
+ a.map_st[0] = lsm_schema_pending_tasks;
+ a.map_entries_st[0] = LWS_ARRAY_SIZE(lsm_schema_pending_tasks);
+ a.ac_block_size = 512;
+
+ lws_struct_json_init_parse(&ctx, NULL, &a);
+ m = lejp_parse(&ctx, (uint8_t *)buf, (int)len);
+ if (m < 0) {
+ lwsl_err("%s: JSON decode failed '%s'\n", __func__, lejp_error_to_string(m));
+ return LWSSSSRET_OK;
+ }
+
+ if (!a.dest) {
+ lwsac_free(&a.ac);
+ return LWSSSSRET_OK;
+ }
+
+ if (a.top_schema_index == 0) {
+ sai_platform_pending_tasks_t *pt = (sai_platform_pending_tasks_t *)a.dest;
+ lwsl_notice("%s: Pending tasks for pcons: %s\n", __func__, pt->pcons);
+
+ lws_start_foreach_dll(struct lws_dll2 *, p, pt->tasks.head) {
+ sai_platform_pending_task_t *t = lws_container_of(p, sai_platform_pending_task_t, list);
+ lwsl_notice(" - %s: %u pending\n", t->plat, t->pending);
+ } lws_end_foreach_dll(p);
+ }
+
+ lwsac_free(&a.ac);
+ return LWSSSSRET_OK;
+}
+
+static lws_ss_state_return_t
+saiv_server_state(void *userobj, void *sh, lws_ss_constate_t state,
+ lws_ss_tx_ordinal_t ack)
+{
+ saiv_server_link_t *g = (saiv_server_link_t *)userobj;
+ sai_builder_registration_t r;
+ struct lwsac *ac = NULL;
+
+ switch (state) {
+ case LWSSSCS_CREATING:
+ lwsl_notice("%s: CREATING\n", __func__);
+ /* We'd set metadata url here from the config, skipped for skeleton */
+ break;
+
+ case LWSSSCS_CONNECTED:
+ lwsl_notice("%s: Connected to sai-server\n", __func__);
+
+ memset(&r, 0, sizeof(r));
+ lws_strncpy(r.builder_name, virt.hostname, sizeof(r.builder_name));
+ lws_strncpy(r.power_controller_name, virt.hostname, sizeof(r.power_controller_name));
+
+ /* We can spawn mac-m1, windows-10, etc. (Mocked for now) */
+ const char *plats[] = {"windows-x86_64", "mac-m1"};
+ for (size_t i = 0; i < LWS_ARRAY_SIZE(plats); i++) {
+ sai_builder_platform_t *bp = lwsac_use_zero(&ac, sizeof(*bp), 512);
+ if (bp) {
+ lws_strncpy(bp->name, plats[i], sizeof(bp->name));
+ lws_dll2_add_tail(&bp->list, &r.platforms_owner);
+ }
+ }
+
+ sai_ss_serialize_queue_helper(g->ss, &g->bl_tx,
+ lsm_schema_builder_registration,
+ LWS_ARRAY_SIZE(lsm_schema_builder_registration),
+ &r);
+ lwsac_free(&ac);
+ break;
+
+ case LWSSSCS_DISCONNECTED:
+ lwsl_notice("%s: Disconnected\n", __func__);
+ lws_buflist_destroy_all_segments(&g->bl_tx);
+ break;
+
+ default:
+ break;
+ }
+
+ return LWSSSSRET_OK;
+}
+
+const lws_ss_info_t ssi_saiv_server_link_t = {
+ .handle_offset = offsetof(saiv_server_link_t, ss),
+ .opaque_user_data_offset = offsetof(saiv_server_link_t, opaque_data),
+ .rx = saiv_server_rx,
+ .tx = saiv_server_tx,
+ .state = saiv_server_state,
+ .user_alloc = sizeof(saiv_server_link_t),
+ .streamtype = "sai_power_client"
+};