diff --git a/epicardium/api/caller.c b/epicardium/api/caller.c
index 7c52d28051250850009871c460ce29561caaaf48..f2f5643abb62e6497c19e7e831ccd1ad1a60525f 100644
--- a/epicardium/api/caller.c
+++ b/epicardium/api/caller.c
@@ -54,6 +54,47 @@ 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) {
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 1ae2dde0ea54abef8bbab248502d5537b47e243c..c70ea936532c0bf85ce913186e90ca0241fcf8b3 100644
--- a/epicardium/main.c
+++ b/epicardium/main.c
@@ -12,7 +12,6 @@
 #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"
@@ -73,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 */
@@ -127,39 +129,22 @@ int main(void)
 		abort();
 	}
 
-	LOG_INFO("startup", "Initializing dispatcher ...");
-	api_dispatcher_init();
-
 	/* light sensor */
 	LOG_INFO("startup", "starting light sensor ...");
 	epic_light_sensor_run();
 
-	core1_boot();
-
-	/*
-	 * 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_load(info.isr_vector, "");
-		}
-	} else {
-		LOG_INFO("startup", "Starting pycardium on core 1 ...");
-		core1_load((void *)0x10080000, "main.py");
+	/* 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();
 	}
 
-	hardware_init();
-
 	LOG_INFO("startup", "Starting FreeRTOS ...");
 	vTaskStartScheduler();
 
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 ba1a4fd47d3827fc1f2d3d85215c089564208e9f..d7c9f40b90fe174bc81cba76cd928a210a4c2b75 100644
--- a/epicardium/modules/meson.build
+++ b/epicardium/modules/meson.build
@@ -4,6 +4,7 @@ module_sources = files(
   '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 df1a9d9b477f043433225c59eec93500e386d0af..35968547f77fb7180548ec178e3484bff37ca08e 100644
--- a/epicardium/modules/modules.h
+++ b/epicardium/modules/modules.h
@@ -16,6 +16,10 @@ 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);