diff --git a/python_payload/apps/__init__.py b/python_payload/apps/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/python_payload/apps/demo_cap_touch/__init__.py b/python_payload/apps/demo_cap_touch/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..27e712415b092c31e48d0302fd63e361514f643f
--- /dev/null
+++ b/python_payload/apps/demo_cap_touch/__init__.py
@@ -0,0 +1,3 @@
+from .main import CapTouchDemo
+
+App = CapTouchDemo
diff --git a/python_payload/apps/demo_cap_touch/flow3r.toml b/python_payload/apps/demo_cap_touch/flow3r.toml
new file mode 100644
index 0000000000000000000000000000000000000000..468449d8cf832ef30c23c9027e11b8236c748ab3
--- /dev/null
+++ b/python_payload/apps/demo_cap_touch/flow3r.toml
@@ -0,0 +1,11 @@
+[app]
+name = "Captouch Demo"
+menu = "Apps"
+
+[entry]
+class = "App"
+
+[metadata]
+author = "Flow3r Badge Authors"
+license = "LGPL-3.0-only"
+url = "https://git.flow3r.garden/flow3r/flow3r-firmware"
diff --git a/python_payload/apps/cap_touch_demo.py b/python_payload/apps/demo_cap_touch/main.py
similarity index 98%
rename from python_payload/apps/cap_touch_demo.py
rename to python_payload/apps/demo_cap_touch/main.py
index 1e7a80c86c490736cadd9b2caae0825f2275a24c..6bd490c5eb7b89a8f4353fc4e4b454b2483e518a 100644
--- a/python_payload/apps/cap_touch_demo.py
+++ b/python_payload/apps/demo_cap_touch/main.py
@@ -84,6 +84,3 @@ class CapTouchDemo(application.Application):
     #    log.info("Performing captouch autocalibration")
     #    captouch.calibration_request()
     #    self.last_calib = 50
-
-
-app = CapTouchDemo("cap touch")
diff --git a/python_payload/apps/harmonic_demo.py b/python_payload/apps/demo_harmonic/__init__.py
similarity index 98%
rename from python_payload/apps/harmonic_demo.py
rename to python_payload/apps/demo_harmonic/__init__.py
index 4f5c3ac541fb90e9ba88820bf888c8d11d06dd5f..573ae3c144778c60e2574189bf93aa6f27dffaf1 100644
--- a/python_payload/apps/harmonic_demo.py
+++ b/python_payload/apps/demo_harmonic/__init__.py
@@ -90,6 +90,3 @@ class HarmonicApp(Application):
                     self.synths[k].stop()
                     self.synths[k + 5].stop()
                     self.synths[k + 10].stop()
-
-
-app = HarmonicApp("harmonic")
diff --git a/python_payload/apps/demo_harmonic/flow3r.toml b/python_payload/apps/demo_harmonic/flow3r.toml
new file mode 100644
index 0000000000000000000000000000000000000000..a7ff1ffe73c2c1fa1e1fb752aef56a93bf0fe601
--- /dev/null
+++ b/python_payload/apps/demo_harmonic/flow3r.toml
@@ -0,0 +1,11 @@
+[app]
+name = "Harmonic"
+menu = "Music"
+
+[entry]
+class = "HarmonicApp"
+
+[metadata]
+author = "Flow3r Badge Authors"
+license = "LGPL-3.0-only"
+url = "https://git.flow3r.garden/flow3r/flow3r-firmware"
diff --git a/python_payload/apps/melodic_demo.py b/python_payload/apps/demo_melodic/__init__.py
similarity index 98%
rename from python_payload/apps/melodic_demo.py
rename to python_payload/apps/demo_melodic/__init__.py
index 2619e8c3a80c49ab6e1097fa7c9c79e651455ba0..26e3fc8435ee00f7ce67d436cbe9873a543fea1f 100644
--- a/python_payload/apps/melodic_demo.py
+++ b/python_payload/apps/demo_melodic/__init__.py
@@ -96,11 +96,9 @@ class MelodicApp(Application):
         ctx.fill()
 
     def on_enter(self, vm: Optional[ViewManager]) -> None:
+        super().on_enter(vm)
         foreground()
 
     def think(self, ins: InputState, delta_ms: int) -> None:
         super().think(ins, delta_ms)
         run(ins)
-
-
-app = MelodicApp("melodic")
diff --git a/python_payload/apps/demo_melodic/flow3r.toml b/python_payload/apps/demo_melodic/flow3r.toml
new file mode 100644
index 0000000000000000000000000000000000000000..6b80e9ed0ab28fde1e241071f27a51d123242e34
--- /dev/null
+++ b/python_payload/apps/demo_melodic/flow3r.toml
@@ -0,0 +1,11 @@
+[app]
+name = "Melodic"
+menu = "Music"
+
+[entry]
+class = "MelodicApp"
+
+[metadata]
+author = "Flow3r Badge Authors"
+license = "LGPL-3.0-only"
+url = "https://git.flow3r.garden/flow3r/flow3r-firmware"
diff --git a/python_payload/apps/scroll_demo.py b/python_payload/apps/demo_scroll/__init__.py
similarity index 100%
rename from python_payload/apps/scroll_demo.py
rename to python_payload/apps/demo_scroll/__init__.py
diff --git a/python_payload/apps/demo_scroll/flow3r.toml b/python_payload/apps/demo_scroll/flow3r.toml
new file mode 100644
index 0000000000000000000000000000000000000000..4fd9d7c0707bcdab12a00edbd471abdd7f2c6a84
--- /dev/null
+++ b/python_payload/apps/demo_scroll/flow3r.toml
@@ -0,0 +1,11 @@
+[app]
+name = "Scroll Demo"
+menu = "Apps"
+
+[entry]
+class = "ScrollDemo"
+
+[metadata]
+author = "Flow3r Badge Authors"
+license = "LGPL-3.0-only"
+url = "https://git.flow3r.garden/flow3r/flow3r-firmware"
diff --git a/python_payload/apps/demo_worms4.py b/python_payload/apps/demo_worms/__init__.py
similarity index 99%
rename from python_payload/apps/demo_worms4.py
rename to python_payload/apps/demo_worms/__init__.py
index 5aa6157684998aefe56ce84d9b5830ab2051e9a3..9a462e817be74a807d2d40cec4528782af33ea69 100644
--- a/python_payload/apps/demo_worms4.py
+++ b/python_payload/apps/demo_worms/__init__.py
@@ -147,6 +147,3 @@ class Worm:
             self.direction = -math.atan2(dy, dx)
             self.mutate()
         self._lastdist = dist
-
-
-app = AppWorms("worms")
diff --git a/python_payload/apps/demo_worms/flow3r.toml b/python_payload/apps/demo_worms/flow3r.toml
new file mode 100644
index 0000000000000000000000000000000000000000..f12d18716d9f28b319aae932986a419cb967d93a
--- /dev/null
+++ b/python_payload/apps/demo_worms/flow3r.toml
@@ -0,0 +1,11 @@
+[app]
+name = "Worms"
+menu = "Apps"
+
+[entry]
+class = "AppWorms"
+
+[metadata]
+author = "Flow3r Badge Authors"
+license = "LGPL-3.0-only"
+url = "https://git.flow3r.garden/flow3r/flow3r-firmware"
diff --git a/python_payload/apps/nick.py b/python_payload/apps/nick/__init__.py
similarity index 99%
rename from python_payload/apps/nick.py
rename to python_payload/apps/nick/__init__.py
index 6555410cee447eb483cc3b555c329df8499382a3..fae347cc10444473e886d9f323c7bea134318f37 100644
--- a/python_payload/apps/nick.py
+++ b/python_payload/apps/nick/__init__.py
@@ -91,6 +91,3 @@ class NickApp(Application):
         self._led += delta_ms / 45
         if self._led >= 40:
             self._led = 0
-
-
-app = NickApp("nick")
diff --git a/python_payload/apps/nick/flow3r.toml b/python_payload/apps/nick/flow3r.toml
new file mode 100644
index 0000000000000000000000000000000000000000..f2ce65ed67512c11fe7effd7fc879d84dab42e34
--- /dev/null
+++ b/python_payload/apps/nick/flow3r.toml
@@ -0,0 +1,11 @@
+[app]
+name = "Nick"
+menu = "Badge"
+
+[entry]
+class = "NickApp"
+
+[metadata]
+author = "Flow3r Badge Authors"
+license = "LGPL-3.0-only"
+url = "https://git.flow3r.garden/flow3r/flow3r-firmware"
diff --git a/python_payload/main.py b/python_payload/main.py
index 56366dac0df718c23fccfd9743a8ca876c7d7780..4ba77cbffb0d025915727e2d298e821aba6563c0 100644
--- a/python_payload/main.py
+++ b/python_payload/main.py
@@ -18,6 +18,7 @@ settings.load_all()
 
 from st3m.ui.view import View, ViewManager, ViewTransitionBlend
 from st3m.ui.menu import (
+    MenuItem,
     MenuItemBack,
     MenuItemForeground,
     MenuItemNoop,
@@ -44,47 +45,21 @@ leds.set_rgb(0, 255, 0, 0)
 
 vm = ViewManager(ViewTransitionBlend())
 
+from st3m.application import discover_bundles
 
-# Preload all applications.
-# TODO(q3k): only load these on demand.
-from apps.demo_worms4 import app as worms
-from apps.harmonic_demo import app as harmonic
-from apps.melodic_demo import app as melodic
-from apps.nick import app as nick
-from apps.cap_touch_demo import app as captouch_demo
-from apps.scroll_demo import app as scroll_demo
+bundles = discover_bundles("/flash/sys/apps")
 
 # Build menu structure
 
 menu_settings = settings.build_menu()
 
-menu_music = SimpleMenu(
-    [
-        MenuItemBack(),
-        MenuItemForeground("Harmonic", harmonic),
-        MenuItemForeground("Melodic", melodic),
-        # MenuItemNoop("TinySynth"),
-        # MenuItemNoop("CrazySynth"),
-        MenuItemNoop("NOOP Sequencer"),
-    ],
-)
-
-menu_apps = SimpleMenu(
-    [
-        MenuItemBack(),
-        MenuItemForeground("captouch", captouch_demo),
-        MenuItemForeground("worms", worms),
-        MenuItemForeground("cap scroll", scroll_demo),
-    ],
-)
 
+def _make_bundle_menu(kind: str) -> SimpleMenu:
+    entries: List[MenuItem] = [MenuItemBack()]
+    for bundle in bundles:
+        entries += bundle.menu_entries(kind)
+    return SimpleMenu(entries)
 
-menu_badge = SimpleMenu(
-    [
-        MenuItemBack(),
-        MenuItemForeground("nick", nick),
-    ],
-)
 
 menu_system = SimpleMenu(
     [
@@ -98,9 +73,9 @@ menu_system = SimpleMenu(
 
 menu_main = SunMenu(
     [
-        MenuItemForeground("Badge", menu_badge),
-        MenuItemForeground("Music", menu_music),
-        MenuItemForeground("Apps", menu_apps),
+        MenuItemForeground("Badge", _make_bundle_menu("Badge")),
+        MenuItemForeground("Music", _make_bundle_menu("Music")),
+        MenuItemForeground("Apps", _make_bundle_menu("Apps")),
         MenuItemForeground("System", menu_system),
     ],
 )
diff --git a/python_payload/st3m/application.py b/python_payload/st3m/application.py
index 7f71101ae42f5c7209b2cc8ea4ed218e43a3f6c8..a938af858555b4361cd642c7e1dd011364db86ca 100644
--- a/python_payload/st3m/application.py
+++ b/python_payload/st3m/application.py
@@ -1,6 +1,21 @@
-from st3m.ui.view import ViewWithInputState, ViewTransitionSwipeRight, ViewManager
+from st3m.ui.view import (
+    ViewWithInputState,
+    ViewTransitionSwipeRight,
+    ViewTransitionSwipeLeft,
+    ViewManager,
+)
+from st3m.ui.menu import MenuItem
 from st3m.input import InputState
-from st3m.goose import Optional
+from st3m.goose import Optional, List, Enum
+from st3m.logging import Log
+
+import toml
+import os
+import os.path
+import stat
+import sys
+
+log = Log(__name__)
 
 
 class Application(ViewWithInputState):
@@ -23,3 +38,223 @@ class Application(ViewWithInputState):
             if self._view_manager is not None:
                 self.on_exit()
                 self._view_manager.pop(ViewTransitionSwipeRight())
+
+
+class BundleLoadException(BaseException):
+    MSG = "failed to load"
+
+    def __init__(self, msg: Optional[str] = None) -> None:
+        res = self.MSG
+        if msg is not None:
+            res += ": " + msg
+        super().__init__(res)
+
+
+class BundleMetadataNotFound(BundleLoadException):
+    MSG = "flow3r.toml not found"
+
+
+class BundleMetadataCorrupt(BundleLoadException):
+    MSG = "flow3r.toml corrupt"
+
+
+class BundleMetadataBroken(BundleLoadException):
+    MSG = "flow3r.toml broken"
+
+
+class BundleMetadata:
+    """
+    Collects data from a flow3r.toml-defined 'bundle', eg. a redistribuable app.
+
+    A flow3r.toml file contains the following sections:
+
+       [app]
+       # Required, displayed in the menu.
+       name = "Name of the application"
+       # One of "Apps", "Badge", "Music". Picks which menu the bundle's class
+       # will be loadable from.
+       menu = "Apps"
+
+       [entry]
+       # Required for app to actually load. Defines the name of the class that
+       # will be imported from the __init__.py next to flow3r.toml. The class
+       # must inherit from st3m.application.Application.
+       class = "DemoApp"
+
+       # Optional, but recommended. Might end up getting displayed somewhere in
+       # a distribution web page or in system menus.
+       [metadata]
+       author = "Hans Acker"
+       # A SPDX-compatible license identifier.
+       license = "..."
+       url = "https://example.com/demoapp"
+
+    This data is used to discover bundles and load them as applications.
+    """
+
+    __slots__ = ["path", "name", "menu", "_t"]
+
+    def __init__(self, path: str) -> None:
+        self.path = path.rstrip("/")
+        try:
+            f = open(self.path + "/flow3r.toml")
+        except Exception:
+            raise BundleMetadataNotFound()
+
+        try:
+            t = toml.load(f)
+        except Exception as e:
+            raise BundleMetadataCorrupt(str(e))
+
+        if "app" not in t or type(t["app"]) != dict:
+            raise BundleMetadataBroken("missing app section")
+
+        app = t["app"]
+        if "name" not in app or type(app["name"]) != str:
+            raise BundleMetadataBroken("missing app.name key")
+        self.name = app["name"]
+        if "menu" not in app or type(app["menu"]) != str:
+            raise BundleMetadataBroken("missing app.menu key")
+        self.menu = app["menu"]
+        if self.menu not in ["Apps", "Music", "Badge"]:
+            raise BundleMetadataBroken("app.menu must be either Apps, Music or Badge")
+
+        self._t = t
+
+    @staticmethod
+    def _sys_path_set(v: List[str]) -> None:
+        # Can't just assign to sys.path in Micropython.
+        sys.path.clear()
+        for el in v:
+            sys.path.append(el)
+
+    def _load_class(self, class_entry: str) -> Application:
+        # Micropython doesn't have a good importlib-like API for doing dynamic
+        # imports of modules at custom paths. That means we have to, for now,
+        # resort to good ol' sys.path manipulation.
+        #
+        # TODO(q3k): extend micropython to make this less messy
+        old_sys_path = sys.path[:]
+
+        log.info(f"Loading {self.name} via class entry {class_entry}...")
+        containing_path = os.path.dirname(self.path)
+        package_name = os.path.basename(self.path)
+
+        if sys.path[0].endswith("python_payload"):
+            # We are in the simulator. Hack around to get this to work.
+            prefix = "/flash/sys"
+            assert containing_path.startswith(prefix)
+            containing_path = containing_path.replace(prefix, sys.path[0])
+
+        new_sys_path = old_sys_path + [containing_path]
+        self._sys_path_set(new_sys_path)
+        try:
+            m = __import__(package_name)
+            self._sys_path_set(old_sys_path)
+            log.info(f"Loaded {self.name} module: {m}")
+            klass = getattr(m, class_entry)
+            log.info(f"Loaded {self.name} class: {klass}")
+            inst = klass(package_name)
+            log.info(f"Instantiated {self.name} class: {inst}")
+            return inst  # type: ignore
+        except Exception as e:
+            self._sys_path_set(old_sys_path)
+            raise BundleLoadException(f"load error: {e}")
+
+    def load(self) -> Application:
+        """
+        Return Application loaded form this bundle.
+
+        Raises a BundleMetadataException if something goes wrong.
+        """
+        entry = self._t.get("entry", None)
+        if entry is None:
+            raise BundleMetadataBroken("missing entry section")
+        if "class" in entry and type(entry["class"]) == str:
+            class_entry = entry["class"]
+            return self._load_class(class_entry)
+
+        raise BundleMetadataBroken("no valid entry method specified")
+
+    def menu_entries(self, kind: str) -> List["MenuItemAppLaunch"]:
+        """
+        Returns MenuItemAppLauch entries for this bundle for a given menu kind.
+
+        Kind is one of 'Apps', 'Badge', 'Music'.
+        """
+        if self.menu != kind:
+            return []
+        return [MenuItemAppLaunch(self)]
+
+    def __repr__(self) -> str:
+        return f"<BundleMetadata: {self.name} at {self.path}>"
+
+
+class MenuItemAppLaunch(MenuItem):
+    """
+    A MenuItem which launches an app from a BundleMetadata.
+
+    The underlying app class is imported and instantiated on first use.
+    """
+
+    def __init__(self, bundle: BundleMetadata):
+        self._bundle = bundle
+        self._instance: Optional[Application] = None
+
+    def press(self, vm: Optional[ViewManager]) -> None:
+        if vm is None:
+            log.warning(f"Could not launch {self.label()} as no ViewManager is present")
+            return
+
+        if self._instance is None:
+            try:
+                self._instance = self._bundle.load()
+            except BundleLoadException as e:
+                log.error(f"Could not load {self.label()}: {e}")
+                return
+        assert self._instance is not None
+        vm.push(self._instance, ViewTransitionSwipeLeft())
+
+    def label(self) -> str:
+        return self._bundle.name
+
+
+def discover_bundles(path: str) -> List[BundleMetadata]:
+    """
+    Discover valid bundles (directories containing flow3r.toml) inside a given
+    path.
+
+    Only direct descendents will be checks - this function doesn't check
+    directories recursively.
+
+    Invalid bundles will be skipped and an error will be logged.
+    """
+    path = path.rstrip("/")
+    try:
+        l = os.listdir(path)
+    except Exception as e:
+        log.warning(f"Could not discover bundles in {path}: {e}")
+        l = []
+
+    bundles = []
+    for d in l:
+        dirpath = path + "/" + d
+        st = os.stat(dirpath)
+        if not stat.S_ISDIR(st[0]):
+            continue
+
+        tomlpath = dirpath + "/flow3r.toml"
+        try:
+            st = os.stat(tomlpath)
+            if not stat.S_ISREG(st[0]):
+                continue
+        except Exception:
+            continue
+
+        try:
+            b = BundleMetadata(dirpath)
+        except BundleLoadException as e:
+            log.error(f"Failed to bundle from {dirpath}: {e}")
+            continue
+        bundles.append(b)
+    return bundles