diff --git a/Documentation/how-to-build.rst b/Documentation/how-to-build.rst
index da17ca9829536730a7b2134a5650f38d0c604d13..144c2e1f757b66fca5aff56255e2f851ec0b7ef7 100644
--- a/Documentation/how-to-build.rst
+++ b/Documentation/how-to-build.rst
@@ -68,6 +68,8 @@ firmware features:
 - ``-Ddebug_prints=true``: Print more verbose debugging log messages
 - ``-Dble_trace=true``: Enable BLE tracing.  This will output lots of status
   info related to BLE.
+- ``-Ddebug_core1=true``: Enable the core 1 SWD lines which are exposed on the
+  SAO connector.  Only use this if you have a debugger which is modified for core 1.
 
 .. warning::
 
diff --git a/epicardium/api/caller.c b/epicardium/api/caller.c
index f1ba675fa98b03d0ee6411e2a0271829174d809d..f2f5643abb62e6497c19e7e831ccd1ad1a60525f 100644
--- a/epicardium/api/caller.c
+++ b/epicardium/api/caller.c
@@ -53,3 +53,66 @@ void *_api_call_transact(void *buffer)
 
 	return API_CALL_MEM->buffer;
 }
+
+__attribute__((noreturn)) void epic_exit(int ret)
+{
+	/*
+	 * Call __epic_exit() and then jump to the reset routine/
+	 */
+	void *buffer;
+
+	buffer         = _api_call_start(API_SYSTEM_EXIT, sizeof(int));
+	*(int *)buffer = ret;
+	_api_call_transact(buffer);
+
+	API_CALL_MEM->reset_stub();
+
+	/* unreachable */
+	while (1)
+		;
+}
+
+int epic_exec(char *name)
+{
+	/*
+	 * Call __epic_exec().  If it succeeds, jump to the reset routine.
+	 * Otherwise, return the error code.
+	 */
+	void *buffer;
+
+	buffer           = _api_call_start(API_SYSTEM_EXEC, sizeof(char *));
+	*(char **)buffer = name;
+	int ret          = *(int *)_api_call_transact(buffer);
+
+	if (ret < 0) {
+		return ret;
+	}
+
+	API_CALL_MEM->reset_stub();
+
+	/* unreachable */
+	while (1)
+		;
+}
+
+int api_fetch_args(char *buf, size_t cnt)
+{
+	if (API_CALL_MEM->id != 0) {
+		/*
+		 * When any call happened before the args are fetched, they are
+		 * overwritten and no longer accessible.
+		 */
+		return (-1);
+	}
+
+	if (API_CALL_MEM->buffer[0x20] == '\0') {
+		return 0;
+	}
+
+	int i;
+	for (i = 0; i < cnt && API_CALL_MEM->buffer[i + 0x20] != '\0'; i++) {
+		buf[i] = API_CALL_MEM->buffer[i + 0x20];
+	}
+
+	return i - 1;
+}
diff --git a/epicardium/api/caller.h b/epicardium/api/caller.h
index d9192c85e133502c005d2e868eebbf39598dd7ff..93bd3f3d71fb88cc81481c939af6b18fb6e496b0 100644
--- a/epicardium/api/caller.h
+++ b/epicardium/api/caller.h
@@ -27,3 +27,12 @@ void *_api_call_start(api_id_t id, uintptr_t size);
  *   - Pointer to a buffer containing the return value
  */
 void *_api_call_transact(void *buffer);
+
+/*
+ * Fetch arguments from the API buffer.  This function will only work properly
+ * directly after startup of core 1.  If api_fetch_args() is called after other
+ * calls have already happened, it will return -1.
+ *
+ * Otherwise it will return the length of data which was read.
+ */
+int api_fetch_args(char *buf, size_t cnt);
diff --git a/epicardium/api/common.h b/epicardium/api/common.h
index c884f37c48aaa7c6b26b64d5961f99df7a07c6f2..ff0c9f3abf4da6822ca89c890c3a6bb55fab7ce6 100644
--- a/epicardium/api/common.h
+++ b/epicardium/api/common.h
@@ -8,7 +8,8 @@
  * Semaphore used for API synchronization.
  * TODO: Replace this with a LDREX/STREX based implementation
  */
-#define _API_SEMAPHORE    0
+#define _API_SEMAPHORE        0
+#define _CONTROL_SEMAPHORE    1
 
 /* Type of API IDs */
 typedef uint32_t api_id_t;
@@ -19,6 +20,13 @@ typedef uint32_t api_id_t;
 
 /* Layout of the shared memory for API calls */
 struct api_call_mem {
+	/*
+	 * Reset stub.  The reset stub is a small function provided by
+	 * epicardium that should be called by a payload when receiving the
+	 * reset interrupt.
+	 */
+	void (*reset_stub)();
+
 	/*
 	 * Flag for synchronization of API calls.  When this flag
 	 * is set, the caller has issued a call and is waiting for
diff --git a/epicardium/api/control.c b/epicardium/api/control.c
new file mode 100644
index 0000000000000000000000000000000000000000..daa1ff52ea81cb897e8c22efc1c024d6cc4d96ec
--- /dev/null
+++ b/epicardium/api/control.c
@@ -0,0 +1,233 @@
+#include "epicardium.h"
+#include "api/dispatcher.h"
+#include "api/interrupt-sender.h"
+#include "modules/log.h"
+
+#include "card10.h"
+
+#include "max32665.h"
+#include "sema.h"
+#include "tmr.h"
+
+static void __core1_init(void);
+
+struct core1_info {
+	/* Location of core1's interrupt vector table */
+	volatile uintptr_t ivt_addr;
+	/* Whether core 1 is ready for a new IVT */
+	volatile bool ready;
+};
+
+/*
+ * Information passing structure for controlling core 1.
+ */
+static volatile struct core1_info core1_info = {
+	.ivt_addr = 0x00,
+	.ready    = false,
+};
+
+/*
+ * Minimal IVT needed for initial startup.  This IVT only contains the initial
+ * stack pointer and reset-handler and is used to startup core 1.  Afterwards,
+ * the payload's IVT is loaded into VTOR and used from then on.
+ */
+static uintptr_t core1_initial_ivt[] = {
+	/* Initial Stack Pointer */
+	0x20080000,
+	/* Reset Handler */
+	(uintptr_t)__core1_reset,
+};
+
+/*
+ * Reset Handler
+ *
+ * Calls __core1_init() to reset & prepare the core for loading a new payload.
+ */
+__attribute__((naked)) void __core1_reset(void)
+{
+	__asm volatile("mov	sp, %0\n\t"
+		       : /* No Outputs */
+		       : "r"(core1_initial_ivt[0]));
+	__core1_init();
+}
+
+/*
+ * Init core 1.  This function will reset the core and wait for a new IVT
+ * address from Epicardium.  Once this address is received, it will start
+ * execution with the supplied reset handler.
+ */
+void __core1_init(void)
+{
+	/*
+	 * Clear any pending API interrupts.
+	 */
+	TMR_IntClear(MXC_TMR5);
+
+	/*
+	 * Reset Interrupts
+	 *
+	 * To ensure proper operation of the new payload, disable all interrupts
+	 * and clear all pending ones.
+	 */
+	for (int i = 0; i < MXC_IRQ_EXT_COUNT; i++) {
+		NVIC_DisableIRQ(i);
+		NVIC_ClearPendingIRQ(i);
+		NVIC_SetPriority(i, 0);
+	}
+
+	/*
+	 * Check whether we catched the core during an interrupt.  If this is
+	 * the case, try returning from the exception handler first and call
+	 * __core1_reset() again in thread context.
+	 */
+	if ((SCB->ICSR & SCB_ICSR_VECTACTIVE_Msk) != 0) {
+		/*
+		 * Construct an exception frame so the CPU will jump back to our
+		 * __core1_reset() function once we exit from the exception
+		 * handler.
+		 *
+		 * To exit the exception, a special "EXC_RETURN" value is loaded
+		 * into the link register and then branched to.
+		 */
+		__asm volatile(
+			"ldr	r0, =0x41000000\n\t"
+			"ldr	r1, =0\n\t"
+			"push	{ r0 }\n\t" /* xPSR */
+			"push	{ %0 }\n\t" /* PC */
+			"push	{ %0 }\n\t" /* LR */
+			"push	{ r1 }\n\t" /* R12 */
+			"push	{ r1 }\n\t" /* R3 */
+			"push	{ r1 }\n\t" /* R2 */
+			"push	{ r1 }\n\t" /* R1 */
+			"push	{ r1 }\n\t" /* R0 */
+
+			"ldr	lr, =0xFFFFFFF9\n\t"
+			"bx	lr\n\t"
+			: /* No Outputs */
+			: "r"((uintptr_t)__core1_reset)
+			: "pc", "lr");
+
+		/* unreachable */
+		while (1)
+			;
+	}
+
+	/* Wait for the IVT address */
+	while (1) {
+		while (SEMA_GetSema(_CONTROL_SEMAPHORE) == E_BUSY) {
+		}
+
+		__DMB();
+		__ISB();
+
+		/*
+		 * The IVT address is reset to 0 by Epicardium before execution
+		 * gets here.  Once a new address has been set, core 1 can use
+		 * the new IVT.
+		 */
+		if (core1_info.ivt_addr != 0x00) {
+			break;
+		}
+
+		/* Signal that we are ready for an IVT address */
+		core1_info.ready = true;
+
+		SEMA_FreeSema(_CONTROL_SEMAPHORE);
+
+		__WFE();
+	}
+
+	uintptr_t *ivt      = (uintptr_t *)core1_info.ivt_addr;
+	core1_info.ivt_addr = 0x00;
+
+	SEMA_FreeSema(_CONTROL_SEMAPHORE);
+
+	/*
+	 * Reset the call-flag before entering the payload so API calls behave
+	 * properly.  This is necessary because epic_exec() will set the flag
+	 * to "returning" on exit.
+	 */
+	API_CALL_MEM->call_flag = _API_FLAG_IDLE;
+
+	/*
+	 * Set the IVT
+	 */
+	SCB->VTOR = (uintptr_t)ivt;
+
+	/*
+	 * Clear any pending API interrupts.
+	 */
+	TMR_IntClear(MXC_TMR5);
+	NVIC_ClearPendingIRQ(TMR5_IRQn);
+
+	/*
+	 * Jump to payload's reset handler
+	 */
+	__asm volatile(
+		"ldr r0, %0\n\t"
+		"blx r0\n\r"
+		: /* No Outputs */
+		: "m"(*(ivt + 1))
+		: "r0");
+}
+
+void core1_boot(void)
+{
+	/*
+	 * Boot using the initial IVT.  This will place core 1 into a loop,
+	 * waiting for a payload.
+	 */
+	core1_start(&core1_initial_ivt);
+}
+
+void core1_reset(void)
+{
+	/* Signal core 1 that we intend to load a new payload. */
+	api_interrupt_trigger(EPIC_INT_RESET);
+
+	/* Wait for the core to accept */
+	while (1) {
+		while (SEMA_GetSema(_CONTROL_SEMAPHORE) == E_BUSY) {
+		}
+
+		/*
+		 * core 1 will set the ready flag once it is spinning in the
+		 * above loop, waiting for a new IVT.
+		 */
+		if (core1_info.ready) {
+			break;
+		}
+
+		SEMA_FreeSema(_CONTROL_SEMAPHORE);
+
+		for (int i = 0; i < 10000; i++) {
+		}
+	}
+
+	/*
+	 * TODO: If the other core does not respond within a certain grace
+	 * period, we need to force it into our desired state by overwriting
+	 * all of its memory.  Yes, I don't like this method either ...
+	 */
+}
+
+void core1_load(void *ivt, char *args)
+{
+	/* If the core is currently in an API call, reset it. */
+	API_CALL_MEM->call_flag = _API_FLAG_IDLE;
+	API_CALL_MEM->id        = 0;
+	API_CALL_MEM->int_id    = (-1);
+
+	api_prepare_args(args);
+
+	core1_info.ivt_addr = (uintptr_t)ivt;
+	core1_info.ready    = false;
+
+	__DMB();
+	__ISB();
+
+	SEMA_FreeSema(_CONTROL_SEMAPHORE);
+
+	__SEV();
+	__WFE();
+}
diff --git a/epicardium/api/dispatcher.c b/epicardium/api/dispatcher.c
index 4675c8d4f53f1e90b2d0809cb5979589e7a58af1..4ffac4220a9aa0ed473a5e7cf911cb092a7135d0 100644
--- a/epicardium/api/dispatcher.c
+++ b/epicardium/api/dispatcher.c
@@ -1,14 +1,26 @@
-#include <stdlib.h>
-#include "sema.h"
 #include "api/dispatcher.h"
+
 #include "max32665.h"
+#include "sema.h"
+
+#include <stdlib.h>
+#include <string.h>
+
+/* This function is defined by the generated dispatcher code */
+void __api_dispatch_call(api_id_t id, void *buffer);
+
+static volatile bool event_ready = false;
 
 int api_dispatcher_init()
 {
 	int ret;
 
-	ret                     = SEMA_Init(NULL);
-	API_CALL_MEM->call_flag = _API_FLAG_IDLE;
+	ret = SEMA_Init(NULL);
+	SEMA_FreeSema(_API_SEMAPHORE);
+	API_CALL_MEM->reset_stub = __core1_reset;
+	API_CALL_MEM->call_flag  = _API_FLAG_IDLE;
+	API_CALL_MEM->id         = 0;
+	API_CALL_MEM->int_id     = (-1);
 
 	/*
 	 * Enable TX events for both cores.
@@ -20,8 +32,6 @@ int api_dispatcher_init()
 	return ret;
 }
 
-static bool event_ready = false;
-
 bool api_dispatcher_poll_once()
 {
 	if (event_ready) {
@@ -68,3 +78,15 @@ api_id_t api_dispatcher_exec()
 
 	return id;
 }
+
+void api_prepare_args(char *args)
+{
+	/*
+	 * The args are stored with an offset of 0x20 to make sure they won't
+	 * collide with any integer return value of API calls like epic_exec().
+	 */
+	API_CALL_MEM->id = 0;
+	for (int i = 0; i <= strlen(args); i++) {
+		API_CALL_MEM->buffer[i + 0x20] = args[i];
+	}
+}
diff --git a/epicardium/api/dispatcher.h b/epicardium/api/dispatcher.h
index a67aa0c40df25968f51809da9c4401b5c3ef1a7a..2299f54df34f5ac0d13e187de1bb712ae2af1b34 100644
--- a/epicardium/api/dispatcher.h
+++ b/epicardium/api/dispatcher.h
@@ -22,5 +22,25 @@ bool api_dispatcher_poll();
  */
 api_id_t api_dispatcher_exec();
 
-/* This function is defined by the generated dispatcher code */
-void __api_dispatch_call(api_id_t id, void *buffer);
+/*
+ * Fill the API buffer with data for l0dable/pycardium startup.
+ *
+ * The data is a NULL-terminated string.
+ */
+void api_prepare_args(char *args);
+
+/*********************************************************************
+ *                         core 1 control                            *
+ *********************************************************************/
+
+/* Startup core1 into a state where it is ready to receive a payload. */
+void core1_boot(void);
+
+/* Reset core 1 into a state where it can accept a new payload */
+void core1_reset(void);
+
+/* Load a payload into core 1 */
+void core1_load(void *ivt, char *args);
+
+/* core 1 reset stub.  See epicardium/api/control.c for details. */
+void __core1_reset(void);
diff --git a/epicardium/api/genapi.py b/epicardium/api/genapi.py
index 5fd546a6f2bfcaea5d50fc2d5fd890e460454f0a..c6cefc927bd7a1bac3729208302f91a9c57811d5 100644
--- a/epicardium/api/genapi.py
+++ b/epicardium/api/genapi.py
@@ -231,6 +231,9 @@ void __dispatch_isr(api_int_id_t id)
             f_client.write(tmp.format(**isr))
 
         tmp = """\
+        case (-1):
+                /* Ignore a spurious interrupt */
+                break;
         default:
                 epic_isr_default_handler(id);
                 break;
diff --git a/epicardium/api/interrupt-receiver.c b/epicardium/api/interrupt-receiver.c
index c212a402d0a564375bddc193fd7ccf7c50507a81..f9856423fd1a51211752dcd810b608fb549f9fcd 100644
--- a/epicardium/api/interrupt-receiver.c
+++ b/epicardium/api/interrupt-receiver.c
@@ -10,5 +10,12 @@ void TMR5_IRQHandler(void)
 {
 	TMR_IntClear(MXC_TMR5);
 	__dispatch_isr(API_CALL_MEM->int_id);
-	API_CALL_MEM->int_id = 0;
+	API_CALL_MEM->int_id = (-1);
+}
+
+/* Reset Handler */
+void __epic_isr_reset(void)
+{
+	API_CALL_MEM->int_id = (-1);
+	API_CALL_MEM->reset_stub();
 }
diff --git a/epicardium/api/interrupt-sender.c b/epicardium/api/interrupt-sender.c
index cabd3bcb2b96f6f79c65a0a9722bd05860cf860e..d531846d89fcdfaf634d1d0fa355d057a9125db3 100644
--- a/epicardium/api/interrupt-sender.c
+++ b/epicardium/api/interrupt-sender.c
@@ -11,8 +11,9 @@ int api_interrupt_trigger(api_int_id_t id)
 	}
 
 	if (int_enabled[id]) {
-		while (API_CALL_MEM->int_id)
+		while (API_CALL_MEM->int_id != (-1))
 			;
+
 		API_CALL_MEM->int_id = id;
 		TMR_TO_Start(MXC_TMR5, 1, 0);
 	}
@@ -21,11 +22,14 @@ int api_interrupt_trigger(api_int_id_t id)
 
 void api_interrupt_init(void)
 {
-	API_CALL_MEM->int_id = 0;
+	API_CALL_MEM->int_id = (-1);
 
 	for (int i = 0; i < EPIC_INT_NUM; i++) {
 		int_enabled[i] = false;
 	}
+
+	/* Reset interrupt is always enabled */
+	int_enabled[EPIC_INT_RESET] = true;
 }
 
 int epic_interrupt_enable(api_int_id_t int_id)
@@ -40,7 +44,7 @@ int epic_interrupt_enable(api_int_id_t int_id)
 
 int epic_interrupt_disable(api_int_id_t int_id)
 {
-	if (int_id >= EPIC_INT_NUM) {
+	if (int_id >= EPIC_INT_NUM || int_id == EPIC_INT_RESET) {
 		return -EINVAL;
 	}
 
diff --git a/epicardium/epicardium.h b/epicardium/epicardium.h
index 2c65e376d4f3741592d34e1e64f57cd3c9714db2..c680d55926adf7af5202c7e498873e7ea6d10472 100644
--- a/epicardium/epicardium.h
+++ b/epicardium/epicardium.h
@@ -29,8 +29,8 @@ typedef _Bool bool;
  */
 
 /* clang-format off */
-#define API_SYSTEM_EXIT             0x1 /* TODO */
-#define API_SYSTEM_EXEC             0x2 /* TODO */
+#define API_SYSTEM_EXIT             0x1
+#define API_SYSTEM_EXEC             0x2
 
 #define API_INTERRUPT_ENABLE        0xA
 #define API_INTERRUPT_DISABLE       0xB
@@ -116,7 +116,7 @@ API(API_INTERRUPT_DISABLE, int epic_interrupt_disable(api_int_id_t int_id));
  */
 
 /* clang-format off */
-/** Reset Handler? **TODO** */
+/** Reset Handler */
 #define EPIC_INT_RESET                  0
 /** ``^C`` interrupt. See :c:func:`epic_isr_ctrl_c` for details.  */
 #define EPIC_INT_CTRL_C                 1
@@ -129,8 +129,59 @@ API(API_INTERRUPT_DISABLE, int epic_interrupt_disable(api_int_id_t int_id));
 #define EPIC_INT_NUM                    4
 /* clang-format on */
 
-API_ISR(EPIC_INT_RESET, epic_isr_reset);
+/*
+ * "Reset Handler*.  This isr is implemented by the API caller and is used to
+ * reset the core for loading a new payload.
+ *
+ * Just listed here for completeness.  You don't need to implement this yourself.
+ */
+API_ISR(EPIC_INT_RESET, __epic_isr_reset);
+
+/**
+ * Core API
+ * ========
+ * The following functions control execution of code on core 1.
+ */
+
+/**
+ * Stop execution of the current payload and return to the menu.
+ *
+ * :param int ret:  Return code.
+ * :return: :c:func:`epic_exit` will never return.
+ */
+void epic_exit(int ret) __attribute__((noreturn));
+
+/*
+ * The actual epic_exit() function is not an API call because it needs special
+ * behavior.  The underlying call is __epic_exit() which returns.  After calling
+ * this API function, epic_exit() will enter the reset handler.
+ */
+API(API_SYSTEM_EXIT, void __epic_exit(int ret));
 
+/**
+ * Stop execution of the current payload and immediately start another payload.
+ *
+ * :param char* name: Name (path) of the new payload to start.  This can either
+ *    be:
+ *
+ *    - A path to an ``.elf`` file (l0dable).
+ *    - A path to a ``.py`` file (will be loaded using Pycardium).
+ *    - A path to a directory (assumed to be a Python module, execution starts
+ *      with ``__init__.py`` in this folder).
+ *
+ * :return: :c:func:`epic_exec` will only return in case loading went wrong.
+ *    The following error codes can be returned:
+ *
+ *    - ``-ENOENT``: File not found.
+ *    - ``-ENOEXEC``: File not a loadable format.
+ */
+int epic_exec(char *name);
+
+/*
+ * Underlying API call for epic_exec().  The function is not an API call itself
+ * because it needs special behavior when loading a new payload.
+ */
+API(API_SYSTEM_EXEC, int __epic_exec(char *name));
 
 /**
  * UART/Serial Interface
diff --git a/epicardium/main.c b/epicardium/main.c
index 56f532a675960a4f86c375356ab795603a8f5c7b..c70ea936532c0bf85ce913186e90ca0241fcf8b3 100644
--- a/epicardium/main.c
+++ b/epicardium/main.c
@@ -6,12 +6,12 @@
 #include "max32665.h"
 #include "uart.h"
 #include "cdcacm.h"
+#include "gpio.h"
 
 #include "card10.h"
 #include "pmic.h"
 #include "leds.h"
 #include "api/dispatcher.h"
-#include "l0der/l0der.h"
 #include "modules/modules.h"
 #include "modules/log.h"
 #include "modules/stream.h"
@@ -30,28 +30,31 @@ TaskHandle_t dispatcher_task_id;
 
 void vBleTask(void *pvParameters);
 
-/*
- * API dispatcher task.  This task will sleep until an API call is issued and
- * then wake up to dispatch it.
- */
-void vApiDispatcher(void *pvParameters)
-{
-	LOG_DEBUG("dispatcher", "Ready.");
-	while (1) {
-		if (api_dispatcher_poll()) {
-			api_dispatcher_exec();
-		}
-		ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
-	}
-}
-
 int main(void)
 {
 	LOG_INFO("startup", "Epicardium startup ...");
 	LOG_INFO("startup", "Version " CARD10_VERSION);
 
-	card10_init();
-	card10_diag();
+	hardware_early_init();
+
+#ifdef CARD10_DEBUG_CORE1
+	LOG_WARN("startup", "Core 1 Debugger Mode");
+	static const gpio_cfg_t swclk = {
+		PORT_0,
+		PIN_7,
+		GPIO_FUNC_ALT3,
+		GPIO_PAD_NONE,
+	};
+	static const gpio_cfg_t swdio = {
+		PORT_0,
+		PIN_6,
+		GPIO_FUNC_ALT3,
+		GPIO_PAD_NONE,
+	};
+
+	GPIO_Config(&swclk);
+	GPIO_Config(&swdio);
+#endif /* CARD10_DEBUG_CORE1 */
 
 	gfx_copy_region_raw(
 		&display_screen, 0, 0, 160, 80, 2, (const void *)(Heart)
@@ -69,6 +72,9 @@ int main(void)
 	api_interrupt_init();
 	stream_init();
 
+	LOG_INFO("startup", "Initializing dispatcher ...");
+	api_dispatcher_init();
+
 	LOG_INFO("startup", "Initializing tasks ...");
 
 	/* Serial */
@@ -123,33 +129,20 @@ int main(void)
 		abort();
 	}
 
-	LOG_INFO("startup", "Initializing dispatcher ...");
-	api_dispatcher_init();
-
 	/* light sensor */
 	LOG_INFO("startup", "starting light sensor ...");
 	epic_light_sensor_run();
 
-	/*
-	 * See if there's a l0dable.elf to run. If not, run pycardium.
-	 * This is temporary until epicardium gets a l0dable API from pycardium.
-	 */
-	const char *l0dable = "l0dable.elf";
-	if (f_stat(l0dable, NULL) == FR_OK) {
-		LOG_INFO("startup", "Running %s ...", l0dable);
-		struct l0dable_info info;
-		int res = l0der_load_path(l0dable, &info);
-		if (res != 0) {
-			LOG_ERR("startup", "l0der failed: %d\n", res);
-		} else {
-			LOG_INFO(
-				"startup", "Starting %s on core1 ...", l0dable
-			);
-			core1_start(info.isr_vector);
-		}
-	} else {
-		LOG_INFO("startup", "Starting pycardium on core1 ...");
-		core1_start((void *)0x10080000);
+	/* Lifecycle */
+	if (xTaskCreate(
+		    vLifecycleTask,
+		    (const char *)"Lifecycle",
+		    configMINIMAL_STACK_SIZE * 4,
+		    NULL,
+		    tskIDLE_PRIORITY + 1,
+		    NULL) != pdPASS) {
+		LOG_CRIT("startup", "Failed to create %s task!", "Lifecycle");
+		abort();
 	}
 
 	LOG_INFO("startup", "Starting FreeRTOS ...");
diff --git a/epicardium/meson.build b/epicardium/meson.build
index c9b128f7289f791bc4c8bbd2040d7b8d10e1d710..220f0bae63c7f84e60aee6002760c8271c7587de 100644
--- a/epicardium/meson.build
+++ b/epicardium/meson.build
@@ -37,8 +37,9 @@ api_dispatcher_lib = static_library(
   'api-dispatcher',
   'api/dispatcher.c',
   'api/interrupt-sender.c',
+  'api/control.c',
   api[1], # Dispatcher
-  dependencies: periphdriver,
+  dependencies: [libcard10, periphdriver],
 )
 
 ##########################################################################
diff --git a/epicardium/modules/dispatcher.c b/epicardium/modules/dispatcher.c
new file mode 100644
index 0000000000000000000000000000000000000000..60f1c1f818dcd8f41ddeb1f4b4a11b2e2400e938
--- /dev/null
+++ b/epicardium/modules/dispatcher.c
@@ -0,0 +1,34 @@
+#include "modules/log.h"
+
+#include "api/dispatcher.h"
+
+#include "FreeRTOS.h"
+#include "task.h"
+#include "semphr.h"
+
+#define TIMEOUT pdMS_TO_TICKS(2000)
+
+static StaticSemaphore_t api_mutex_data;
+SemaphoreHandle_t api_mutex = NULL;
+
+/*
+ * API dispatcher task.  This task will sleep until an API call is issued and
+ * then wake up to dispatch it.
+ */
+void vApiDispatcher(void *pvParameters)
+{
+	api_mutex = xSemaphoreCreateMutexStatic(&api_mutex_data);
+
+	LOG_DEBUG("dispatcher", "Ready.");
+	while (1) {
+		if (api_dispatcher_poll()) {
+			if (xSemaphoreTake(api_mutex, TIMEOUT) != pdTRUE) {
+				LOG_ERR("dispatcher", "API mutex blocked");
+				continue;
+			}
+			api_dispatcher_exec();
+			xSemaphoreGive(api_mutex);
+		}
+		ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
+	}
+}
diff --git a/epicardium/modules/hardware.c b/epicardium/modules/hardware.c
new file mode 100644
index 0000000000000000000000000000000000000000..ee369248a17788907875d346fb5f3b4e9de9dc52
--- /dev/null
+++ b/epicardium/modules/hardware.c
@@ -0,0 +1,40 @@
+#include "modules/modules.h"
+
+#include "card10.h"
+
+/*
+ * Early init is called at the very beginning and is meant for modules which
+ * absolutely need to start as soon as possible.  hardware_early_init() blocks
+ * which means code in here should be fast.
+ */
+int hardware_early_init(void)
+{
+	card10_init();
+	return 0;
+}
+
+/*
+ * hardware_init() is called after the core has been bootstrapped and is meant
+ * for less critical initialization steps.  Modules which initialize here should
+ * be robust against a l0dable using their API before initialization is done.
+ *
+ * Ideally, acquire a lock in hardware_early_init() and release it in
+ * hardware_init() once initialization is done.
+ */
+int hardware_init(void)
+{
+	return 0;
+}
+
+/*
+ * hardware_reset() is called whenever a new l0dable is started.  hardware_reset()
+ * should bring all peripherals back into a known initial state.  This does not
+ * necessarily mean resetting the peripheral entirely but hardware_reset()
+ * should at least bring the API facing part of a peripheral back into the state
+ * a fresh booted l0dable expects.
+ */
+int hardware_reset(void)
+{
+	card10_init();
+	return 0;
+}
diff --git a/epicardium/modules/lifecycle.c b/epicardium/modules/lifecycle.c
new file mode 100644
index 0000000000000000000000000000000000000000..13ba9704e5d068edce4dc85fb3a26c610b662964
--- /dev/null
+++ b/epicardium/modules/lifecycle.c
@@ -0,0 +1,315 @@
+#include "epicardium.h"
+#include "modules/log.h"
+#include "modules/modules.h"
+#include "api/dispatcher.h"
+#include "l0der/l0der.h"
+
+#include "card10.h"
+
+#include "FreeRTOS.h"
+#include "task.h"
+#include "semphr.h"
+
+#include <string.h>
+#include <stdbool.h>
+#include <stdbool.h>
+
+#define PYCARDIUM_IVT (void *)0x10080000
+#define BLOCK_WAIT pdMS_TO_TICKS(1000)
+/*
+ * Loading an empty filename into Pycardium will drop straight into the
+ * interpreter.  This define is used to make it more clear when we intend
+ * to go into the interpreter.
+ */
+#define PYINTERPRETER ""
+
+static TaskHandle_t lifecycle_task = NULL;
+static StaticSemaphore_t core1_mutex_data;
+static SemaphoreHandle_t core1_mutex = NULL;
+
+enum payload_type {
+	PL_INVALID       = 0,
+	PL_PYTHON_SCRIPT = 1,
+	PL_PYTHON_DIR    = 2,
+	PL_PYTHON_INTERP = 3,
+	PL_L0DABLE       = 4,
+};
+
+struct load_info {
+	bool do_reset;
+	enum payload_type type;
+	char name[256];
+};
+static volatile struct load_info async_load = {
+	.do_reset = false,
+	.name     = { 0 },
+	.type     = PL_INVALID,
+};
+
+/* Helpers {{{ */
+
+/*
+ * Check if the payload is a valid file (or module) and if so, return its type.
+ */
+static int load_stat(char *name)
+{
+	size_t name_len = strlen(name);
+
+	if (name_len == 0) {
+		return PL_PYTHON_INTERP;
+	}
+
+	struct epic_stat stat;
+	if (epic_file_stat(name, &stat) < 0) {
+		return -ENOENT;
+	}
+
+	if (stat.type == EPICSTAT_DIR) {
+		/* This might be a python module. */
+		return PL_PYTHON_DIR;
+	}
+
+	if (strcmp(name + name_len - 3, ".py") == 0) {
+		/* A python script */
+		return PL_PYTHON_SCRIPT;
+	} else if (strcmp(name + name_len - 4, ".elf") == 0) {
+		return PL_L0DABLE;
+	}
+
+	return -ENOEXEC;
+}
+
+/*
+ * Actually load a payload into core 1.  Optionally reset the core first.
+ */
+static int do_load(struct load_info *info)
+{
+	if (*info->name == '\0') {
+		LOG_INFO("lifecycle", "Loading Python interpreter ...");
+	} else {
+		LOG_INFO("lifecycle", "Loading \"%s\" ...", info->name);
+	}
+
+	if (xSemaphoreTake(api_mutex, BLOCK_WAIT) != pdTRUE) {
+		LOG_ERR("lifecycle", "API blocked");
+		return -EBUSY;
+	}
+
+	if (info->do_reset) {
+		LOG_DEBUG("lifecycle", "Triggering core 1 reset.");
+		core1_reset();
+
+		api_dispatcher_init();
+	}
+
+	switch (info->type) {
+	case PL_PYTHON_SCRIPT:
+	case PL_PYTHON_DIR:
+	case PL_PYTHON_INTERP:
+		core1_load(PYCARDIUM_IVT, info->name);
+		break;
+	case PL_L0DABLE:
+		/*
+		 * Always reset when loading a l0dable to make sure the memory
+		 * space is absolutely free.
+		 */
+		core1_reset();
+
+		struct l0dable_info l0dable;
+		int res = l0der_load_path(info->name, &l0dable);
+		if (res != 0) {
+			LOG_ERR("lifecycle", "l0der failed: %d\n", res);
+			xSemaphoreGive(api_mutex);
+			return -ENOEXEC;
+		}
+		core1_load(l0dable.isr_vector, "");
+
+		break;
+	default:
+		LOG_ERR("lifecyle",
+			"Attempted to load invalid payload (%s)",
+			info->name);
+		xSemaphoreGive(api_mutex);
+		return -EINVAL;
+	}
+
+	xSemaphoreGive(api_mutex);
+	return 0;
+}
+
+/*
+ * Do a synchroneous load.
+ */
+static int load_sync(char *name, bool reset)
+{
+	int ret = load_stat(name);
+	if (ret < 0) {
+		return ret;
+	}
+
+	struct load_info info = {
+		.name     = { 0 },
+		.type     = ret,
+		.do_reset = reset,
+	};
+
+	strncpy(info.name, name, sizeof(info.name));
+
+	return do_load(&info);
+}
+
+/*
+ * Do an asynchroneous load.  This will return immediately if the payload seems
+ * valid and call the lifecycle task to actually perform the load later.
+ */
+static int load_async(char *name, bool reset)
+{
+	int ret = load_stat(name);
+	if (ret < 0) {
+		return ret;
+	}
+
+	async_load.type     = ret;
+	async_load.do_reset = reset;
+
+	strncpy((char *)async_load.name, name, sizeof(async_load.name));
+
+	if (lifecycle_task != NULL) {
+		xTaskNotifyGive(lifecycle_task);
+	}
+
+	return 0;
+}
+
+/*
+ * Go back to the menu.
+ */
+static void load_menu(bool reset)
+{
+	LOG_INFO("lifecycle", "Into the menu");
+
+	if (xSemaphoreTake(core1_mutex, BLOCK_WAIT) != pdTRUE) {
+		LOG_ERR("lifecycle",
+			"Can't load because mutex is blocked (menu).");
+		return;
+	}
+
+	int ret = load_async("menu.py", reset);
+	if (ret < 0) {
+		/* TODO: Write default menu */
+		LOG_WARN("lifecycle", "No menu script found.");
+		load_async(PYINTERPRETER, reset);
+	}
+
+	xSemaphoreGive(core1_mutex);
+}
+/* Helpers }}} */
+
+/* API {{{ */
+/*
+ * This is NOT the epic_exec() called from Pycardium, but an implementation of
+ * the same call for use in Epicardium.  This function is synchroneous and will
+ * wait until the call returns.
+ */
+int epic_exec(char *name)
+{
+	if (xSemaphoreTake(core1_mutex, BLOCK_WAIT) != pdTRUE) {
+		LOG_ERR("lifecycle",
+			"Can't load because mutex is blocked (epi exec).");
+		return -EBUSY;
+	}
+
+	int ret = load_sync(name, true);
+	xSemaphoreGive(core1_mutex);
+	return ret;
+}
+
+/*
+ * This is the underlying call for epic_exec() from Pycardium.  It is
+ * asynchroneous and will return early to allow Pycardium (or a l0dable) to jump
+ * to the reset handler.
+ *
+ * The lifecycle task will deal with actually loading the new payload.
+ */
+int __epic_exec(char *name)
+{
+	if (xSemaphoreTake(core1_mutex, BLOCK_WAIT) != pdTRUE) {
+		LOG_ERR("lifecycle",
+			"Can't load because mutex is blocked (1 exec).");
+		return -EBUSY;
+	}
+	int ret = load_async(name, false);
+	xSemaphoreGive(core1_mutex);
+	return ret;
+}
+
+/*
+ * This is the underlying call for epic_exit() from Pycardium.  It is
+ * asynchroneous and will return early to allow Pycardium (or a l0dable) to jump
+ * to the reset handler.
+ *
+ * The lifecycle task will deal with actually loading the new payload.
+ */
+void __epic_exit(int ret)
+{
+	if (ret == 0) {
+		LOG_INFO("lifecycle", "Payload returned successfully");
+	} else {
+		LOG_WARN("lifecycle", "Payload returned with %d.", ret);
+	}
+
+	load_menu(false);
+}
+
+/*
+ * This function can be used in Epicardium to jump back to the menu.
+ *
+ * It is asynchroneous and will return immediately.  The lifecycle task will
+ * take care of actually jumping back.
+ */
+void return_to_menu(void)
+{
+	load_menu(true);
+}
+/* API }}} */
+
+void vLifecycleTask(void *pvParameters)
+{
+	lifecycle_task = xTaskGetCurrentTaskHandle();
+	core1_mutex    = xSemaphoreCreateMutexStatic(&core1_mutex_data);
+
+	if (xSemaphoreTake(core1_mutex, 0) != pdTRUE) {
+		LOG_CRIT(
+			"lifecycle", "Failed to acquire mutex after creation."
+		);
+		vTaskDelay(portMAX_DELAY);
+	}
+
+	LOG_INFO("lifecycle", "Booting core 1 ...");
+	core1_boot();
+	vTaskDelay(pdMS_TO_TICKS(10));
+
+	xSemaphoreGive(core1_mutex);
+
+	/* If `main.py` exists, start it.  Otherwise, start `menu.py`. */
+	if (epic_exec("main.py") < 0) {
+		return_to_menu();
+	}
+
+	hardware_init();
+
+	/* When triggered, reset core 1 to menu */
+	while (1) {
+		ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
+
+		if (xSemaphoreTake(core1_mutex, BLOCK_WAIT) != pdTRUE) {
+			LOG_ERR("lifecycle",
+				"Can't load because mutex is blocked (task).");
+			continue;
+		}
+
+		do_load((struct load_info *)&async_load);
+
+		xSemaphoreGive(core1_mutex);
+	}
+}
diff --git a/epicardium/modules/meson.build b/epicardium/modules/meson.build
index b3cf4eb2ee679963768a76a54834208de0a30ad8..d7c9f40b90fe174bc81cba76cd928a210a4c2b75 100644
--- a/epicardium/modules/meson.build
+++ b/epicardium/modules/meson.build
@@ -1,7 +1,10 @@
 module_sources = files(
+  'dispatcher.c',
   'display.c',
   'fileops.c',
+  'hardware.c',
   'leds.c',
+  'lifecycle.c',
   'light_sensor.c',
   'log.c',
   'pmic.c',
diff --git a/epicardium/modules/modules.h b/epicardium/modules/modules.h
index 4a7bc2557aea29d2d79a96e0c00cefc786f917f5..35968547f77fb7180548ec178e3484bff37ca08e 100644
--- a/epicardium/modules/modules.h
+++ b/epicardium/modules/modules.h
@@ -1,12 +1,28 @@
 #ifndef MODULES_H
 #define MODULES_H
 
+#include "FreeRTOS.h"
+#include "semphr.h"
+
 #include <stdint.h>
 
+/* ---------- Dispatcher --------------------------------------------------- */
+void vApiDispatcher(void *pvParameters);
+extern SemaphoreHandle_t api_mutex;
+extern TaskHandle_t dispatcher_task_id;
+
+/* ---------- Hardware Init & Reset ---------------------------------------- */
+int hardware_early_init(void);
+int hardware_init(void);
+int hardware_reset(void);
+
+/* ---------- Lifecycle ---------------------------------------------------- */
+void vLifecycleTask(void *pvParameters);
+void return_to_menu(void);
+
 /* ---------- Serial ------------------------------------------------------- */
 #define SERIAL_READ_BUFFER_SIZE 128
 void vSerialTask(void *pvParameters);
-
 void serial_enqueue_char(char chr);
 
 /* ---------- PMIC --------------------------------------------------------- */
@@ -20,4 +36,5 @@ void ble_uart_write(uint8_t *pValue, uint8_t len);
 
 // Forces an unlock of the display. Only to be used in epicardium
 void disp_forcelock();
+
 #endif /* MODULES_H */
diff --git a/epicardium/modules/pmic.c b/epicardium/modules/pmic.c
index 9e5c9b62412ea6ebe685af5bd55d57f859f077ab..492545bb3b47c33b5494351a6f321d0af376df2c 100644
--- a/epicardium/modules/pmic.c
+++ b/epicardium/modules/pmic.c
@@ -27,19 +27,21 @@ void pmic_interrupt_callback(void *_)
 
 void vPmicTask(void *pvParameters)
 {
-	int count          = 0;
-	portTickType delay = portMAX_DELAY;
-	pmic_task_id       = xTaskGetCurrentTaskHandle();
+	pmic_task_id = xTaskGetCurrentTaskHandle();
 
-	while (1) {
-		ulTaskNotifyTake(pdTRUE, delay);
+	TickType_t button_start_tick = 0;
 
-		if (count == PMIC_PRESS_SLEEP) {
-			LOG_ERR("pmic", "Sleep [[ Unimplemented ]]");
+	while (1) {
+		if (button_start_tick == 0) {
+			ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
+		} else {
+			ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(100));
 		}
 
-		if (count == PMIC_PRESS_POWEROFF) {
-			LOG_INFO("pmic", "Poweroff");
+		TickType_t duration = xTaskGetTickCount() - button_start_tick;
+
+		if (button_start_tick != 0 && duration > pdMS_TO_TICKS(1000)) {
+			LOG_WARN("pmic", "Poweroff");
 			MAX77650_setSFT_RST(0x2);
 		}
 
@@ -47,17 +49,17 @@ void vPmicTask(void *pvParameters)
 
 		if (int_flag & MAX77650_INT_nEN_F) {
 			/* Button was pressed */
-			count = 0;
-			delay = portTICK_PERIOD_MS * 100;
+			button_start_tick = xTaskGetTickCount();
 		}
 		if (int_flag & MAX77650_INT_nEN_R) {
-			/* Button was pressed */
-			if (count < PMIC_PRESS_SLEEP) {
+			/* Button was released */
+			button_start_tick = 0;
+			if (duration < pdMS_TO_TICKS(400)) {
+				return_to_menu();
+			} else {
+				LOG_WARN("pmic", "Resetting ...");
 				card10_reset();
 			}
-
-			count = 0;
-			delay = portMAX_DELAY;
 		}
 
 		/* TODO: Remove when all interrupts are handled */
@@ -68,9 +70,5 @@ void vPmicTask(void *pvParameters)
 				int_flag
 			);
 		}
-
-		if (delay != portMAX_DELAY) {
-			count += 1;
-		}
 	}
 }
diff --git a/l0dables/lib/crt.s b/l0dables/lib/crt.s
index b811c4e6cb0c732b0de7091e596da94fce9bd1ca..57726cb566590e7b6ed90f9b5f71ae095ca55fa7 100644
--- a/l0dables/lib/crt.s
+++ b/l0dables/lib/crt.s
@@ -18,10 +18,10 @@
 		 *
 		 * All of the following (apart from Reset_Handler, which calls main())
 		 * are backed by weak referenced symbols, which you can override just
-         * by defining them in C code.
+		 * by defining them in C code.
 		 */
-		.section .data
-		.align 2
+		.section .text.isr_vector
+		.align 7
 		.globl __isr_vector
 __isr_vector:
 		.long    0                             /* Top of Stack, overriden by l0der at load time */
diff --git a/l0dables/lib/l0dable.ld b/l0dables/lib/l0dable.ld
index 31fbb773de00115fecb9a49d7a449a38cbfc02b6..aa7225cfecc230c6e45017d009771e8e7fdaaf0d 100644
--- a/l0dables/lib/l0dable.ld
+++ b/l0dables/lib/l0dable.ld
@@ -33,6 +33,9 @@ SECTIONS {
 
     .text :
     {
+        /* The vector table needs 128 byte alignment */
+        . = ALIGN(128);
+        KEEP(*(.text.isr_vector))
         *(.text*)
         *(.rodata*)
 
diff --git a/l0dables/lib/meson.build b/l0dables/lib/meson.build
index f2f16295ae51a3d5ba0680ae14c220911dd540d1..d2407514569cdb380817abed63ac20a19384f78f 100644
--- a/l0dables/lib/meson.build
+++ b/l0dables/lib/meson.build
@@ -3,7 +3,7 @@ l0dable_startup_lib = static_library(
   'crt.s',
   'hardware.c',
   dependencies: [api_caller],
-  pic: true,
+  c_args: ['-fpie'],
 )
 
 l0dable_startup = declare_dependency(
diff --git a/meson.build b/meson.build
index 3dd0578c22938b2bafa857aca91a2625f3b2ac0e..f1586ec93aa9aae174a8a26bc95d1cc708e0cea5 100644
--- a/meson.build
+++ b/meson.build
@@ -28,6 +28,13 @@ if get_option('debug_prints')
   )
 endif
 
+if get_option('debug_core1')
+  add_global_arguments(
+    ['-DCARD10_DEBUG_CORE1=1'],
+    language: 'c',
+  )
+endif
+
 add_global_link_arguments(
   '-Wl,--gc-sections',
   '-lm',
diff --git a/meson_options.txt b/meson_options.txt
index bb96608f53ba3363e9e5a3b96dfb6eb6e11086df..0bff7f441774f3f6eb695dd2f29fc308af3a0d44 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -6,6 +6,14 @@ option(
   description: 'Whether to print debug messages on the serial console'
 )
 
+option(
+  'debug_core1',
+  type: 'boolean',
+  value: false,
+
+  description: 'Enable core 1 debugging interface'
+)
+
 option(
   'ble_trace',
   type: 'boolean',
diff --git a/pycardium/main.c b/pycardium/main.c
index 60c86093af0e319a6367cb05957aec40dc8d7eb8..49f03244d24ce38116d01334c0598cacb9c33918 100644
--- a/pycardium/main.c
+++ b/pycardium/main.c
@@ -1,4 +1,5 @@
 #include "epicardium.h"
+#include "api/caller.h"
 #include "mphalport.h"
 #include "card10-version.h"
 
@@ -24,10 +25,19 @@ static const char header[] =
 
 int main(void)
 {
-	epic_uart_write_str(header, sizeof(header));
+	char script_name[128] = { 0 };
+	int cnt = api_fetch_args(script_name, sizeof(script_name));
 
 	pycardium_hal_init();
 
+	epic_uart_write_str(header, sizeof(header));
+
+	if (cnt < 0) {
+		printf("pycardium: Error fetching args: %d\n", cnt);
+	} else if (cnt > 0) {
+		printf("  Loading %s ...\n", script_name);
+	}
+
 	mp_stack_set_top(&__StackTop);
 	mp_stack_set_limit((mp_int_t)&__StackLimit);
 
@@ -35,12 +45,22 @@ int main(void)
 		gc_init(&__HeapBase + 1024 * 10, &__HeapLimit);
 
 		mp_init();
-		pyexec_file_if_exists("main.py");
+
+		if (cnt > 0) {
+			pyexec_file_if_exists(script_name);
+		}
+
 		pyexec_friendly_repl();
+
 		mp_deinit();
 	}
 }
 
+void HardFault_Handler(void)
+{
+	epic_exit(255);
+}
+
 void gc_collect(void)
 {
 	void *sp = (void *)__get_MSP();
diff --git a/pycardium/meson.build b/pycardium/meson.build
index 8dafd3e0afa2341d913823719efffb888bea738e..b43d0fc5cd7668e6db7e0bd3fe8a5301c954cb8a 100644
--- a/pycardium/meson.build
+++ b/pycardium/meson.build
@@ -6,6 +6,7 @@ modsrc = files(
   'modules/interrupt.c',
   'modules/sys_leds.c',
   'modules/light_sensor.c',
+  'modules/os.c',
   'modules/sys_display.c',
   'modules/utime.c',
   'modules/vibra.c',
diff --git a/pycardium/modules/os.c b/pycardium/modules/os.c
new file mode 100644
index 0000000000000000000000000000000000000000..3f84c26eea85efd6208a79f8356e482395bb4216
--- /dev/null
+++ b/pycardium/modules/os.c
@@ -0,0 +1,65 @@
+#include "epicardium.h"
+
+#include "py/obj.h"
+#include "py/runtime.h"
+
+#include <string.h>
+
+static mp_obj_t mp_os_exit(size_t n_args, const mp_obj_t *args)
+{
+	int ret = 0;
+	if (n_args == 1) {
+		ret = mp_obj_get_int(args[0]);
+	}
+
+	epic_exit(ret);
+
+	/* unreachable */
+	return mp_const_none;
+}
+static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(exit_obj, 0, 1, mp_os_exit);
+
+static mp_obj_t mp_os_exec(mp_obj_t name_in)
+{
+	const char *name_ptr;
+	char name_str[256];
+	size_t len, maxlen;
+
+	name_ptr = mp_obj_str_get_data(name_in, &len);
+
+	/*
+	 * The string retrieved from MicroPython is not NULL-terminated so we
+	 * first need to copy it and add a NULL-byte.
+	 */
+	maxlen = len < (sizeof(name_str) - 1) ? len : (sizeof(name_str) - 1);
+	memcpy(name_str, name_ptr, maxlen);
+	name_str[maxlen] = '\0';
+
+	int ret = epic_exec(name_str);
+
+	/*
+	 * If epic_exec() returns, something went wrong.  We can raise an
+	 * exception in all cases.
+	 */
+	mp_raise_OSError(-ret);
+
+	/* unreachable */
+	return mp_const_none;
+}
+static MP_DEFINE_CONST_FUN_OBJ_1(exec_obj, mp_os_exec);
+
+static const mp_rom_map_elem_t os_module_gobals_table[] = {
+	{ MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_os) },
+	{ MP_ROM_QSTR(MP_QSTR_exit), MP_ROM_PTR(&exit_obj) },
+	{ MP_ROM_QSTR(MP_QSTR_exec), MP_ROM_PTR(&exec_obj) },
+};
+static MP_DEFINE_CONST_DICT(os_module_globals, os_module_gobals_table);
+
+const mp_obj_module_t os_module = {
+	.base    = { &mp_type_module },
+	.globals = (mp_obj_dict_t *)&os_module_globals,
+};
+
+/* This is a special macro that will make MicroPython aware of this module */
+/* clang-format off */
+MP_REGISTER_MODULE(MP_QSTR_os, os_module, MODULE_OS_ENABLED);
diff --git a/pycardium/modules/qstrdefs.h b/pycardium/modules/qstrdefs.h
index 1c3990607704dae3ea7ff4cb7d60b8d669b1fa42..f25307d58b4ca4b9f46a040daa9951bfe84f79b6 100644
--- a/pycardium/modules/qstrdefs.h
+++ b/pycardium/modules/qstrdefs.h
@@ -85,3 +85,7 @@ Q(tell)
 Q(TextIOWrapper)
 Q(write)
 
+/* os */
+Q(os)
+Q(exit)
+Q(exec)
diff --git a/pycardium/mpconfigport.h b/pycardium/mpconfigport.h
index 38934682aff55c4a4c8522b5715eaa900ac3c355..5e4d09e907b1f7ee5c7abd191b5a4e7f2b2c1ac7 100644
--- a/pycardium/mpconfigport.h
+++ b/pycardium/mpconfigport.h
@@ -43,6 +43,7 @@
 #define MODULE_INTERRUPT_ENABLED            (1)
 #define MODULE_LEDS_ENABLED                 (1)
 #define MODULE_LIGHT_SENSOR_ENABLED         (1)
+#define MODULE_OS_ENABLED                   (1)
 #define MODULE_UTIME_ENABLED                (1)
 #define MODULE_VIBRA_ENABLED                (1)