diff --git a/python_payload/st3m/application.py b/python_payload/st3m/application.py
index 8f6a86e2f162cbae66eedf0c1a9d1650a56e8249..c8a777ab7d97083afad1829f57b54548188ae85b 100644
--- a/python_payload/st3m/application.py
+++ b/python_payload/st3m/application.py
@@ -6,7 +6,7 @@ from st3m.ui.view import (
 )
 from st3m.ui.menu import MenuItem
 from st3m.input import InputState
-from st3m.goose import Optional, List, Enum
+from st3m.goose import Optional, List, Enum, Dict
 from st3m.logging import Log
 
 import toml
@@ -97,7 +97,7 @@ class BundleMetadata:
     This data is used to discover bundles and load them as applications.
     """
 
-    __slots__ = ["path", "name", "menu", "_t"]
+    __slots__ = ["path", "name", "menu", "_t", "version"]
 
     def __init__(self, path: str) -> None:
         self.path = path.rstrip("/")
@@ -126,6 +126,11 @@ class BundleMetadata:
         if self.menu not in ["Apps", "Music", "Badge", "Hidden"]:
             raise BundleMetadataBroken("app.menu must be either Apps, Music or Badge")
 
+        version = 0
+        if t.get("metadata") is not None:
+            version = t["metadata"].get("version", 0)
+        self.version = version
+
         self._t = t
 
     @staticmethod
@@ -193,8 +198,16 @@ class BundleMetadata:
             return []
         return [MenuItemAppLaunch(self)]
 
+    @property
+    def source(self) -> str:
+        return os.path.dirname(self.path)
+
+    @property
+    def id(self) -> str:
+        return os.path.basename(self.path)
+
     def __repr__(self) -> str:
-        return f"<BundleMetadata: {self.name} at {self.path}>"
+        return f"<BundleMetadata: {self.id} at {self.path}>"
 
 
 class MenuItemAppLaunch(MenuItem):
@@ -226,6 +239,80 @@ class MenuItemAppLaunch(MenuItem):
         return self._bundle.name
 
 
+class BundleManager:
+    """
+    The BundleManager maintains information about BundleMetadata at different
+    locations in the badge filesystem.
+
+    It also manages updating/reloading bundles.
+    """
+
+    def __init__(self) -> None:
+        self.bundles: Dict[str, BundleMetadata] = {}
+
+    @staticmethod
+    def _source_trumps(a: str, b: str) -> bool:
+        prios = {
+            "/flash/sys/apps": 200,
+            "/sd/apps": 120,
+            "/flash/apps": 100,
+        }
+        prio_a = prios.get(a, 0)
+        prio_b = prios.get(b, 0)
+        return prio_a > prio_b
+
+    def _discover_at(self, path: str) -> None:
+        path = path.rstrip("/")
+        try:
+            l = os.listdir(path)
+        except Exception as e:
+            log.warning(f"Could not discover bundles in {path}: {e}")
+            l = []
+
+        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
+
+            id_ = b.id
+            if id_ not in self.bundles:
+                self.bundles[id_] = b
+                continue
+            ex = self.bundles[id_]
+
+            # Do we have a newer version?
+            if b.version > ex.version:
+                self.bundles[id_] = b
+                continue
+            # Do we have a higher priority source?
+            if self._source_trumps(b.source, ex.source):
+                self.bundles[id_] = b
+                continue
+            log.warning(
+                f"Ignoring {id_} at {b.source} as it already exists at {ex.source}"
+            )
+
+    def update(self) -> None:
+        self._discover_at("/flash/sys/apps")
+        self._discover_at("/flash/apps")
+        self._discover_at("/sd/apps")
+
+
 def discover_bundles(path: str) -> List[BundleMetadata]:
     """
     Discover valid bundles (directories containing flow3r.toml) inside a given
diff --git a/python_payload/st3m/run.py b/python_payload/st3m/run.py
index fafa9b8d6dcf3f7ee9d2c95477655785393564ea..ef1378d9e61b1d8ee6cbe831afeec36bf2af6808 100644
--- a/python_payload/st3m/run.py
+++ b/python_payload/st3m/run.py
@@ -11,7 +11,7 @@ from st3m.ui.menu import (
 from st3m.ui.elements import overlays
 from st3m.ui.view import View, ViewManager, ViewTransitionBlend
 from st3m.ui.elements.menus import SimpleMenu, SunMenu
-from st3m.application import discover_bundles, BundleMetadata, MenuItemAppLaunch
+from st3m.application import BundleManager, BundleMetadata, MenuItemAppLaunch
 from st3m.about import About
 from st3m import settings, logging, processors, wifi
 
@@ -52,9 +52,11 @@ def run_responder(r: Responder) -> None:
     reactor.run()
 
 
-def _make_bundle_menu(bundles: List[BundleMetadata], kind: str) -> SimpleMenu:
+def _make_bundle_menu(mgr: BundleManager, kind: str) -> SimpleMenu:
     entries: List[MenuItem] = [MenuItemBack()]
-    for bundle in bundles:
+    ids = sorted(mgr.bundles.keys())
+    for id in ids:
+        bundle = mgr.bundles[id]
         entries += bundle.menu_entries(kind)
     return SimpleMenu(entries)
 
@@ -127,7 +129,8 @@ def run_main() -> None:
     audio.set_volume_dB(-10)
     leds.set_rgb(0, 255, 0, 0)
     leds.update()
-    bundles = discover_bundles("/flash/sys/apps")
+    bundles = BundleManager()
+    bundles.update()
 
     settings.load_all()
     menu_settings = settings.build_menu()
@@ -152,8 +155,7 @@ def run_main() -> None:
         ],
     )
     if override_main_app is not None:
-        requested = [b for b in bundles if b.name == override_main_app]
-        print([b.name for b in bundles])
+        requested = [b for b in bundles.bundles.values() if b.name == override_main_app]
         if len(requested) > 1:
             raise Exception(f"More than one bundle named {override_main_app}")
         if len(requested) == 0: