diff --git a/sdkconfig b/sdkconfig
index 8be62b2ebe574e602da8ba8065af74fdeca819b4..abd9da2dd8deb5ed752d36406b37685a209d3ff9 100644
--- a/sdkconfig
+++ b/sdkconfig
@@ -796,8 +796,11 @@ CONFIG_FREERTOS_TIMER_TASK_PRIORITY=1
 CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=2048
 CONFIG_FREERTOS_TIMER_QUEUE_LENGTH=10
 CONFIG_FREERTOS_QUEUE_REGISTRY_SIZE=0
-# CONFIG_FREERTOS_USE_TRACE_FACILITY is not set
-# CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS is not set
+CONFIG_FREERTOS_USE_TRACE_FACILITY=y
+CONFIG_FREERTOS_USE_STATS_FORMATTING_FUNCTIONS=y
+CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y
+CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y
+CONFIG_FREERTOS_RUN_TIME_STATS_USING_ESP_TIMER=y
 # CONFIG_FREERTOS_USE_TICKLESS_IDLE is not set
 CONFIG_FREERTOS_CHECK_MUTEX_GIVEN_BY_OWNER=y
 # CONFIG_FREERTOS_CHECK_PORT_CRITICAL_COMPLIANCE is not set
diff --git a/usermodule/micropython.cmake b/usermodule/micropython.cmake
index d647bb687cbbf8476d7b54b94bad12a65477a656..275176c306b822af5987e83aa34ff93ce0a74fc8 100644
--- a/usermodule/micropython.cmake
+++ b/usermodule/micropython.cmake
@@ -7,6 +7,7 @@ add_library(usermod_badge23 INTERFACE)
 target_sources(usermod_badge23 INTERFACE
     ${CMAKE_CURRENT_LIST_DIR}/mp_hardware.c
     ${CMAKE_CURRENT_LIST_DIR}/mp_synth.c
+    ${CMAKE_CURRENT_LIST_DIR}/mp_kernel.c
 )
 
 target_include_directories(usermod_badge23 INTERFACE
diff --git a/usermodule/mp_kernel.c b/usermodule/mp_kernel.c
new file mode 100644
index 0000000000000000000000000000000000000000..b76ce986207b1a811a43686be2f9b80527ae9a9e
--- /dev/null
+++ b/usermodule/mp_kernel.c
@@ -0,0 +1,199 @@
+/// kernel is a micropython C module which allows access to the badge's
+/// 'kernel', ie. FreeRTOS/ESP-IDF/... This is a low-level API intended to be
+/// used for use by badge developers.
+
+#include <stdio.h>
+#include <string.h>
+#include "py/runtime.h"
+#include "py/obj.h"
+
+#if ( configUSE_TRACE_FACILITY != 1 )
+#error config_USE_TRACE_FACILITY must be set
+#endif
+
+/// task object which represents a snapshot of a FreeRTOS task at a given time.
+///
+/// Properties:
+///  - number: The FreeRTOS task number
+///  - stack_left: High water mark of stack usage by task, ie. highest ever
+///                recorded use of stack. The units seem arbitrary.
+///  - run_time: Number of times this task has been recorded to been scheduled
+///              on a core. The units are arbitrary and should only be used
+///              comparatively against other task runtimes, and the global total
+///              runtime value from scheduler_stats.
+///  - state: one of kernel.{RUNNING,READY,BLOCKED,SUSPENDED,DELETED,INVALID}
+///  - core_affinity: bitmask of where this task is allowed to be scheduled. Bit
+///                   0 is core 0, bit 1 is core 1. The value 0b11 (3) means the
+///                   task is allowed to run on any core.
+typedef struct _task_obj_t {
+    mp_obj_base_t base;
+
+    char name[configMAX_TASK_NAME_LEN];
+    uint32_t number;
+    uint16_t stack_left;
+    uint32_t run_time;
+    eTaskState state;
+    uint32_t core_affinity;
+} task_obj_t;
+
+const mp_obj_type_t task_type;
+
+STATIC void task_print(const mp_print_t *print, mp_obj_t self_in, mp_print_kind_t kind) {
+    (void)kind;
+    task_obj_t *self = MP_OBJ_TO_PTR(self_in);
+    mp_print_str(print, "Task(name=");
+    if (self->name != NULL) {
+        mp_print_str(print, self->name);
+    } else {
+        mp_print_str(print, "NULL");
+    }
+    mp_print_str(print, ",state=");
+    switch (self->state) {
+    case eRunning:
+        mp_print_str(print, "RUNNING");
+        break;
+    case eReady:
+        mp_print_str(print, "READY");
+        break;
+    case eBlocked:
+        mp_print_str(print, "BLOCKED");
+        break;
+    case eSuspended:
+        mp_print_str(print, "SUSPENDED");
+        break;
+    case eDeleted:
+        mp_print_str(print, "DELETED");
+        break;
+    case eInvalid:
+        mp_print_str(print, "INVALID");
+        break;
+    default:
+        mp_print_str(print, "???");
+        break;
+    }
+    mp_printf(print, ",number=%d,stack_left=%d,run_time=%d,core_affinity=%d)", self->number, self->stack_left, self->run_time, self->core_affinity);
+}
+
+STATIC void task_attr(mp_obj_t self_in, qstr attr, mp_obj_t *dest) {
+    task_obj_t *self = MP_OBJ_TO_PTR(self_in);
+    if (dest[0] != MP_OBJ_NULL) {
+        return;
+    }
+    switch (attr) {
+    case MP_QSTR_name: dest[0] = mp_obj_new_str(self->name, strlen(self->name)); break;
+    case MP_QSTR_state: dest[0] = MP_OBJ_NEW_SMALL_INT(self->state); break;
+    case MP_QSTR_number: dest[0] = mp_obj_new_int_from_uint(self->number); break;
+    case MP_QSTR_stack_left: dest[0] = mp_obj_new_int_from_uint(self->stack_left); break;
+    case MP_QSTR_run_time: dest[0] = mp_obj_new_int_from_uint(self->run_time); break;
+    case MP_QSTR_core_affinity: dest[0] = mp_obj_new_int_from_uint(self->core_affinity); break;
+    }
+}
+
+MP_DEFINE_CONST_OBJ_TYPE(
+    task_type,
+    MP_QSTR_task,
+    MP_TYPE_FLAG_NONE,
+    print, task_print,
+    attr, task_attr
+);
+
+/// snapshot of the FreeRTOS scheduler state. Will not update dynamically,
+/// instead needs to be re-created by calling scheduler_snapsot() again.
+///
+/// Properties:
+///  - tasks: list of tasks
+///  - total_runtime: number of times that the task scheduling measurement has
+///                   been performed.
+typedef struct _scheduler_snapshot_obj_t {
+    mp_obj_base_t base;
+
+    mp_obj_t tasks;
+    uint32_t total_runtime;
+} scheduler_snapshot_obj_t;
+
+const mp_obj_type_t scheduler_snapshot_type;
+
+STATIC void scheduler_snapshot_print(const mp_print_t *print, mp_obj_t self_in, mp_print_kind_t kind) {
+    (void)kind;
+    scheduler_snapshot_obj_t *self = MP_OBJ_TO_PTR(self_in);
+    mp_printf(print, "SchedulerSnapshot(tasks=[...], total_runtime=%d)", self->total_runtime);
+}
+
+STATIC void scheduler_snapshot_attr(mp_obj_t self_in, qstr attr, mp_obj_t *dest) {
+    scheduler_snapshot_obj_t *self = MP_OBJ_TO_PTR(self_in);
+    if (dest[0] != MP_OBJ_NULL) {
+        return;
+    }
+    switch (attr) {
+    case MP_QSTR_total_runtime: dest[0] = mp_obj_new_int_from_uint(self->total_runtime); break;
+    case MP_QSTR_tasks: dest[0] = self->tasks; break;
+    }
+}
+
+MP_DEFINE_CONST_OBJ_TYPE(
+    scheduler_snapshot_type,
+    MP_QSTR_scheduler_snapshot,
+    MP_TYPE_FLAG_NONE,
+    print, scheduler_snapshot_print,
+    attr, scheduler_snapshot_attr
+);
+
+STATIC mp_obj_t mp_scheduler_snapshot(void) {
+    mp_obj_t tasks_out = mp_obj_new_list(0, NULL);
+
+    UBaseType_t ntasks = uxTaskGetNumberOfTasks();
+    TaskStatus_t *tasks = calloc(sizeof(TaskStatus_t), ntasks);
+    if (tasks == NULL) {
+        mp_raise_msg(&mp_type_MemoryError, "out of memory");
+        return mp_const_none;
+    }
+    uint32_t total_runtime;
+    UBaseType_t ntasks_returned = uxTaskGetSystemState(tasks, ntasks, &total_runtime);
+    for (UBaseType_t i = 0; i < ntasks_returned; i++) {
+        task_obj_t *task = m_new_obj(task_obj_t);
+        task->base.type = &task_type;
+        strncpy(task->name, tasks[i].pcTaskName, configMAX_TASK_NAME_LEN-1);
+        task->number = tasks[i].xTaskNumber;
+        task->stack_left = tasks[i].usStackHighWaterMark;
+        task->run_time = tasks[i].ulRunTimeCounter;
+        task->state = tasks[i].eCurrentState;
+        task->core_affinity = 0b11;
+        switch (tasks[i].xCoreID) {
+        case 0:
+            task->core_affinity = 1;
+            break;
+        case 1:
+            task->core_affinity = 2;
+            break;
+        }
+        mp_obj_list_append(tasks_out, MP_OBJ_FROM_PTR(task));
+    }
+
+    scheduler_snapshot_obj_t *snap = m_new_obj(scheduler_snapshot_obj_t);
+    snap->base.type = &scheduler_snapshot_type;
+    snap->tasks = tasks_out;
+    snap->total_runtime = total_runtime;
+    return snap;
+}
+
+STATIC MP_DEFINE_CONST_FUN_OBJ_0(mp_scheduler_snapshot_obj, mp_scheduler_snapshot);
+
+STATIC const mp_rom_map_elem_t globals_table[] = {
+    { MP_ROM_QSTR(MP_QSTR_scheduler_snapshot), MP_ROM_PTR(&mp_scheduler_snapshot_obj) },
+
+    { MP_ROM_QSTR(MP_QSTR_RUNNING), MP_ROM_INT(eRunning) },
+    { MP_ROM_QSTR(MP_QSTR_READY), MP_ROM_INT(eReady) },
+    { MP_ROM_QSTR(MP_QSTR_BLOCKED), MP_ROM_INT(eBlocked) },
+    { MP_ROM_QSTR(MP_QSTR_SUSPENDED), MP_ROM_INT(eSuspended) },
+    { MP_ROM_QSTR(MP_QSTR_DELETED), MP_ROM_INT(eDeleted) },
+    { MP_ROM_QSTR(MP_QSTR_INVALID), MP_ROM_INT(eInvalid) },
+};
+
+STATIC MP_DEFINE_CONST_DICT(globals, globals_table);
+
+const mp_obj_module_t mp_module_kernel_user_cmodule = {
+    .base = { &mp_type_module },
+    .globals = (mp_obj_dict_t*)&globals,
+};
+
+MP_REGISTER_MODULE(MP_QSTR_kernel, mp_module_kernel_user_cmodule);
\ No newline at end of file