diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,4 @@
+# 0.1.0.0
+
+First version
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Jean-Charles Quillet
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,78 @@
+# Systranything
+
+[![nix][status-nix-png]][status-nix] 
+[![haskell-ci][status-haskell-ci-png]][status-haskell-ci]
+
+`systranything` lets you put anything in your system tray. It is supported by 
+all destop environment that implements the [StatusNotifierHost 
+specification][status-notifier-host] from [freedesktop.org][freedesktop] 
+([KDE][kde], [XFCE][xfce], ...).
+
+It uses a YAML file which describes the icon to put in the system tray along a 
+context menu and callbacks to be executed in a shell.
+
+The menu can contain labels, separators, submenus, checkboxes and radiobuttons. 
+Scroll events can be triggered on the main icon.
+
+Among other things, I use it to change my VPN settings. The icon shows its 
+current status:
+
+![demo](./demo.gif)
+
+What you can do with it:
+
+- a custom launcher menu
+- a volume icon
+- a menu to turn on or off your VPN
+- a menu to toggle dual monitor setups
+- anything that requires a status icon and scriptable actions
+
+See [the example file](example.yaml) to get started.
+
+Run it with:
+
+```bash
+$ systranything -f ./example.yaml
+```
+
+It has a verbose mode which can be turned on with `-v`. It writes on `stdout` 
+the commands executed along their outputs.
+
+# Hacking
+
+The project can be built with [nix][nix].
+
+Install with:
+
+```bash
+$ nix profile install
+```
+
+Build with:
+
+```bash
+$ nix build
+```
+
+The binary is then created in `./result/bin/systranything`
+
+Hack with:
+
+```bash
+$ nix develop
+```
+
+You will be dropped in a shell with all the needed tools in scope: `cabal` to 
+build the project and `haskell-language-server` for a better developer 
+experience.
+
+[freedesktop]: https://www.freedesktop.org/wiki/
+[kde]: https://kde.org/
+[nix]: https://nixos.org/
+[polybar]: https://polybar.github.io/
+[status-notifier-host]: https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/StatusNotifierHost/
+[status-nix-png]: https://github.com/jecaro/systranything/workflows/nix/badge.svg
+[status-haskell-ci-png]: https://github.com/jecaro/systranything/workflows/Haskell-CI/badge.svg
+[xfce]: https://www.xfce.org/
+[status-nix]: https://github.com/jecaro/systranything/actions/workflows/nix.yml
+[status-haskell-ci]: https://github.com/jecaro/systranything/actions/workflows/haskell-ci.yml
diff --git a/cabal.project b/cabal.project
new file mode 100644
--- /dev/null
+++ b/cabal.project
@@ -0,0 +1,2 @@
+packages: .
+max-backjumps: 5000
diff --git a/demo.gif b/demo.gif
new file mode 100644
Binary files /dev/null and b/demo.gif differ
diff --git a/systranything.cabal b/systranything.cabal
new file mode 100644
--- /dev/null
+++ b/systranything.cabal
@@ -0,0 +1,86 @@
+cabal-version: 2.2
+
+name: systranything
+version: 0.1.0.0
+synopsis: Let you put anything in the system tray
+homepage: https://github.com/jecaro/systranything
+author: Jean-Charles Quillet
+maintainer: jeancharles.quillet@gmail.com
+license: MIT
+license-file: LICENSE
+category: Desktop
+build-type: Simple
+description:
+  `systranything` creates a system tray menu based on a YAML file. The YAML
+  contains the specification of the menu items with shell commands to execute
+  when the items are clicked.
+tested-with:
+  GHC == 9.2.8,
+  GHC == 9.4.5,
+  GHC == 9.6.2
+extra-source-files:
+  cabal.project
+extra-doc-files:
+  CHANGELOG.md
+  README.md
+  demo.gif
+
+source-repository head
+  type: git
+  location: https://github.com/jecaro/systranything.git
+
+executable systranything
+  main-is:
+    Main.hs
+  hs-source-dirs:
+    systranything
+  other-modules:
+    Model.Checkbox
+    Model.Command
+    Model.Common
+    Model.Indicator
+    Model.Internal
+    Model.Item
+    Model.Label
+    Model.RadioGroup
+    Model.RadioButton
+    Model.Root
+    Model.SubMenu
+    Options
+  build-depends:
+    aeson >= 2.1.2 && < 2.2,
+    base >= 4.16 && < 5,
+    bytestring >= 0.11.4 && < 0.12,
+    extra >= 1.7.14 && < 1.8,
+    gi-ayatana-appindicator3 >= 0.1.0 && < 0.2,
+    gi-gdk >= 3.0.28 && < 3.1,
+    gi-glib >= 2.0.29 && < 2.1,
+    gi-gobject >= 2.0.30 && < 2.1,
+    gi-gtk >= 3.0.39 && < 3.1,
+    optparse-applicative >= 0.17.1 && < 0.18,
+    text >= 2.0.2 && < 2.1,
+    typed-process >= 0.2.11 && < 0.3,
+    unliftio >= 0.2.25 && < 0.3,
+    yaml >= 0.11.11 && < 0.12,
+  default-language:
+    Haskell2010
+  default-extensions:
+    DeriveGeneric
+    DerivingStrategies
+    OverloadedLabels
+    OverloadedStrings
+    RecordWildCards
+    ScopedTypeVariables
+    StrictData
+    TupleSections
+  ghc-options:
+    -Wall
+    -Wcompat
+    -Widentities
+    -Wincomplete-record-updates
+    -Wincomplete-uni-patterns
+    -Wpartial-fields
+    -Wredundant-constraints
+    -Wunused-packages
+    -threaded
+    -with-rtsopts=-N
diff --git a/systranything/Main.hs b/systranything/Main.hs
new file mode 100644
--- /dev/null
+++ b/systranything/Main.hs
@@ -0,0 +1,39 @@
+module Main (main) where
+
+import Control.Exception (Exception (..), SomeException)
+import Control.Monad (void)
+import Data.Yaml (decodeFileThrow, prettyPrintParseException)
+import qualified GI.GLib as GLib
+import qualified GI.Gtk as Gtk
+import Model.Indicator (newIndicator)
+import Model.Item (populate)
+import Model.Root (Root (..))
+import Options (Options (..), parseArgs)
+import System.Exit (exitFailure)
+import System.IO (hPutStr, stderr)
+import UnliftIO (handleAny)
+
+main :: IO ()
+main = do
+  MkOptions {..} <- parseArgs
+
+  handleAny exceptions $ do
+    MkRoot {..} <- decodeFileThrow opFilename
+
+    void . Gtk.init $ Nothing
+
+    mbMenu <- traverse (populate opVerbose) roMenu
+    indicator <- newIndicator opVerbose roIndicator mbMenu
+
+    -- Make sure the indicator is not garbage collected. That's mandatory for
+    -- the case when there is no periodic update to keep the indicator alive.
+    Gtk.withManagedPtr indicator . const $
+      GLib.mainLoopRun =<< GLib.mainLoopNew Nothing False
+
+exceptions :: SomeException -> IO ()
+exceptions e = hPutStr stderr message >> exitFailure
+  where
+    message
+      | Just parseException <- fromException e =
+          prettyPrintParseException parseException <> "\n"
+      | otherwise = "Unknown exception: " <> show e
diff --git a/systranything/Model/Checkbox.hs b/systranything/Model/Checkbox.hs
new file mode 100644
--- /dev/null
+++ b/systranything/Model/Checkbox.hs
@@ -0,0 +1,42 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+module Model.Checkbox (Checkbox (..), newItem) where
+
+import Control.Monad (void)
+import Data.Aeson.TH (deriveJSON)
+import Data.Text (Text)
+import qualified Data.Text as T
+import Foreign.C (CULong)
+import GHC.Generics (Generic)
+import qualified GI.GObject as GObject
+import qualified GI.Gtk as Gtk
+import Model.Common (runCommand)
+import Model.Internal (options)
+
+data Checkbox = MkCheckbox
+  { chLabel :: Text,
+    chOnClick :: Text,
+    chOnGetStatus :: Text
+  }
+  deriving stock (Generic, Show)
+
+$(deriveJSON options ''Checkbox)
+
+newItem :: Bool -> Checkbox -> IO (Gtk.CheckMenuItem, IO ())
+newItem verbose MkCheckbox {..} = do
+  item <- Gtk.checkMenuItemNewWithLabel chLabel
+
+  signalId <- runCommandOnToggle verbose chOnClick item
+
+  pure (item, updateAction item signalId)
+  where
+    updateAction item signalId = do
+      mbOutput <- runCommand verbose chOnGetStatus
+      let active = maybe False (not . T.null) mbOutput
+      GObject.signalHandlerBlock item signalId
+      Gtk.checkMenuItemSetActive item active
+      GObject.signalHandlerUnblock item signalId
+
+runCommandOnToggle :: Bool -> Text -> Gtk.CheckMenuItem -> IO CULong
+runCommandOnToggle verbose command item =
+  Gtk.on item #toggled . void $ runCommand verbose command
diff --git a/systranything/Model/Command.hs b/systranything/Model/Command.hs
new file mode 100644
--- /dev/null
+++ b/systranything/Model/Command.hs
@@ -0,0 +1,41 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+module Model.Command (Command (..), periodicallyUpdateIcon) where
+
+import Control.Monad (void)
+import Data.Aeson.TH (deriveJSON)
+import Data.List.NonEmpty (NonEmpty (..))
+import Data.List.NonEmpty.Extra ((!?))
+import Data.Maybe (fromMaybe)
+import Data.Text (Text)
+import qualified Data.Text as T
+import Data.Word (Word32)
+import GHC.Generics (Generic)
+import qualified GI.AyatanaAppIndicator3 as AI
+import qualified GI.GLib as GLib
+import Model.Common (runCommand)
+import Model.Internal (options)
+import Text.Read (readMaybe)
+
+data Command = MkCommand
+  { coOnTimeout :: Text,
+    coPoolingInterval :: Word32,
+    coIcons :: [Text]
+  }
+  deriving stock (Generic, Show)
+
+$(deriveJSON options ''Command)
+
+periodicallyUpdateIcon :: Bool -> AI.Indicator -> Text -> Command -> IO ()
+periodicallyUpdateIcon verbose indicator icon MkCommand {..} =
+  void . GLib.timeoutAddSeconds GLib.PRIORITY_DEFAULT coPoolingInterval $ do
+    mbOutput <- runCommand verbose coOnTimeout
+
+    let newIcon = fromMaybe icon $ fromIndex =<< toInt =<< mbOutput
+
+    AI.indicatorSetIconFull indicator newIcon Nothing
+
+    pure True
+  where
+    fromIndex = (!?) (icon :| coIcons)
+    toInt = readMaybe . T.unpack
diff --git a/systranything/Model/Common.hs b/systranything/Model/Common.hs
new file mode 100644
--- /dev/null
+++ b/systranything/Model/Common.hs
@@ -0,0 +1,34 @@
+module Model.Common (runCommand) where
+
+import Control.Monad (when)
+import Data.ByteString.Lazy as LBS
+import Data.Text (Text)
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as T
+import qualified Data.Text.IO as T
+import System.Exit (ExitCode (..))
+import System.Process.Typed (readProcess, shell)
+
+runCommand :: Bool -> Text -> IO (Maybe Text)
+runCommand verbose command = do
+  when verbose . T.putStrLn $ "Running command: " <> command
+
+  (exitCode, stdoutBS, stderrBS) <- readProcess . shell $ T.unpack command
+
+  let stdoutTxt = T.decodeUtf8Lenient (LBS.toStrict stdoutBS)
+
+  when (exitCode /= ExitSuccess) $ do
+    T.putStrLn "Command failed: "
+    T.putStrLn command
+
+  when (verbose || exitCode /= ExitSuccess) $ do
+    let stderrTxt = T.decodeUtf8Lenient (LBS.toStrict stderrBS)
+    T.putStrLn "stdout: "
+    T.putStrLn stdoutTxt
+    T.putStrLn "stderr: "
+    T.putStrLn stderrTxt
+
+  pure $
+    if exitCode /= ExitSuccess
+      then Nothing
+      else Just $ T.stripEnd stdoutTxt
diff --git a/systranything/Model/Indicator.hs b/systranything/Model/Indicator.hs
new file mode 100644
--- /dev/null
+++ b/systranything/Model/Indicator.hs
@@ -0,0 +1,64 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+module Model.Indicator (Indicator (..), newIndicator) where
+
+import Control.Monad (void, when)
+import Data.Aeson.TH (deriveJSON)
+import Data.Foldable (traverse_)
+import Data.IORef (newIORef, readIORef, writeIORef)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import qualified GI.AyatanaAppIndicator3 as AI
+import qualified GI.Gdk as Gdk
+import qualified GI.Gtk as Gtk
+import Model.Command (Command, periodicallyUpdateIcon)
+import Model.Common (runCommand)
+import Model.Internal (options)
+
+data Indicator = MkIndicator
+  { inIcon :: Text,
+    inCommand :: Maybe Command,
+    inOnScrollUp :: Maybe Text,
+    inOnScrollDown :: Maybe Text
+  }
+  deriving stock (Generic, Show)
+
+$(deriveJSON options ''Indicator)
+
+newIndicator :: Bool -> Indicator -> Maybe Gtk.Menu -> IO AI.Indicator
+newIndicator verbose indicator@MkIndicator {..} menu = do
+  gtkIndicator <-
+    AI.indicatorNew
+      "systranything"
+      inIcon
+      AI.IndicatorCategorySystemServices
+
+  -- The indicator beeing not active by default means it is hidden
+  AI.indicatorSetStatus gtkIndicator AI.IndicatorStatusActive
+  AI.indicatorSetMenu gtkIndicator menu
+
+  traverse_ (periodicallyUpdateIcon False gtkIndicator inIcon) inCommand
+
+  onScrollEvent verbose gtkIndicator indicator
+
+  pure gtkIndicator
+
+onScrollEvent :: Bool -> AI.Indicator -> Indicator -> IO ()
+onScrollEvent verbose indicator (MkIndicator {..}) = do
+  -- GTK sends one scroll up or down event followed by smooth events. Getting
+  -- the actual direction from smooth requires to access the scroll event
+  -- itself which doesn't seem possible with the current version of
+  -- libayarana-appindicator
+
+  refDirection <- newIORef Gdk.ScrollDirectionDown
+
+  void . Gtk.on indicator #scrollEvent $ \_ direction -> do
+    when (direction /= Gdk.ScrollDirectionSmooth) $ do
+      writeIORef refDirection direction
+
+    lastDirection <- readIORef refDirection
+
+    traverse_ (runCommand verbose) $
+      if lastDirection == Gdk.ScrollDirectionDown
+        then inOnScrollDown
+        else inOnScrollUp
diff --git a/systranything/Model/Internal.hs b/systranything/Model/Internal.hs
new file mode 100644
--- /dev/null
+++ b/systranything/Model/Internal.hs
@@ -0,0 +1,13 @@
+module Model.Internal (options) where
+
+import Data.Aeson (Options(..), SumEncoding(..), camelTo2, defaultOptions)
+
+options :: Options
+options =
+  defaultOptions
+    { fieldLabelModifier = camelTo2 '-' . drop 2,
+      constructorTagModifier = camelTo2 '-' . drop 2,
+      omitNothingFields = True,
+      sumEncoding = ObjectWithSingleField
+    }
+
diff --git a/systranything/Model/Item.hs b/systranything/Model/Item.hs
new file mode 100644
--- /dev/null
+++ b/systranything/Model/Item.hs
@@ -0,0 +1,65 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+module Model.Item (Item (..), populate) where
+
+import Control.Monad (void)
+import Data.Aeson.TH (deriveJSON)
+import Data.Foldable (traverse_)
+import Data.List (singleton)
+import Data.List.NonEmpty (NonEmpty)
+import qualified Data.List.NonEmpty as NE
+import GHC.Generics (Generic)
+import qualified GI.Gtk as Gtk
+import Model.Checkbox (Checkbox (..))
+import qualified Model.Checkbox as Checkbox
+import Model.Internal (options)
+import Model.Label (Label)
+import qualified Model.Label as Label
+import Model.RadioGroup (RadioGroup (..))
+import qualified Model.RadioGroup as RadioGroup
+import {-# SOURCE #-} Model.SubMenu (SubMenu)
+import {-# SOURCE #-} qualified Model.SubMenu as SubMenu
+
+data Item
+  = MkItemLabel Label
+  | MkItemCheckbox Checkbox
+  | MkItemRadioGroup RadioGroup
+  | MkItemSubMenu SubMenu
+  | MkItemSeparator
+  deriving stock (Generic, Show)
+
+$(deriveJSON options ''Item)
+
+populate :: Bool -> NonEmpty Item -> IO Gtk.Menu
+populate verbose items = do
+  menu <- Gtk.menuNew
+  updateStateActions <- traverse (initAppendAndShow menu) items
+  void . Gtk.on menu #show $ sequence_ updateStateActions
+  pure menu
+  where
+    initAppendAndShow :: Gtk.Menu -> Item -> IO (IO ())
+    initAppendAndShow menu item = do
+      (menuItems, updateAction) <- newItem verbose item
+      traverse_ (appendAndShow menu) menuItems
+      pure updateAction
+
+    appendAndShow :: Gtk.Menu -> Gtk.MenuItem -> IO ()
+    appendAndShow menu item = do
+      Gtk.menuShellAppend menu item
+      Gtk.widgetShow item
+
+newItem :: Bool -> Item -> IO ([Gtk.MenuItem], IO ())
+newItem verbose (MkItemLabel label) =
+  (,mempty) . singleton <$> Label.newItem verbose label
+newItem verbose (MkItemCheckbox checkbox) = do
+  (item, updateAction) <- Checkbox.newItem verbose checkbox
+  menuItems <- Gtk.toMenuItem item
+  pure ([menuItems], updateAction)
+newItem verbose (MkItemRadioGroup group) = do
+  (items, updateAction) <- RadioGroup.newItems verbose group
+  menuItems <- traverse Gtk.toMenuItem $ NE.toList items
+  pure (menuItems, updateAction)
+newItem verbose (MkItemSubMenu submenu) =
+  (,mempty) . singleton <$> SubMenu.newItem verbose submenu
+newItem _ MkItemSeparator =
+  (,mempty) . singleton <$> (Gtk.toMenuItem =<< Gtk.separatorMenuItemNew)
diff --git a/systranything/Model/Label.hs b/systranything/Model/Label.hs
new file mode 100644
--- /dev/null
+++ b/systranything/Model/Label.hs
@@ -0,0 +1,25 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+module Model.Label (Label (..), newItem) where
+
+import Control.Monad (void)
+import Data.Aeson.TH (deriveJSON)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import qualified GI.Gtk as Gtk
+import Model.Common (runCommand)
+import Model.Internal (options)
+
+data Label = MkLabel
+  { laLabel :: Text,
+    laOnClick :: Text
+  }
+  deriving stock (Generic, Show)
+
+$(deriveJSON options ''Label)
+
+newItem :: Bool -> Label -> IO Gtk.MenuItem
+newItem verbose MkLabel {..} = do
+  item <- Gtk.menuItemNewWithLabel laLabel
+  void . Gtk.on item #activate . void $ runCommand verbose laOnClick
+  pure item
diff --git a/systranything/Model/RadioButton.hs b/systranything/Model/RadioButton.hs
new file mode 100644
--- /dev/null
+++ b/systranything/Model/RadioButton.hs
@@ -0,0 +1,23 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+module Model.RadioButton (RadioButton (..), newItem) where
+
+import Data.Aeson.TH (deriveJSON)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import qualified GI.Gtk as Gtk
+import Model.Internal (options)
+
+data RadioButton = MkRadioButton
+  { raLabel :: Text,
+    raOnClick :: Text
+  }
+  deriving stock (Generic, Show)
+
+$(deriveJSON options ''RadioButton)
+
+newItem :: Text -> RadioButton -> IO Gtk.RadioMenuItem
+newItem selected MkRadioButton {..} = do
+  menuItem <- Gtk.radioMenuItemNewWithLabel ([] :: [Gtk.RadioMenuItem]) raLabel
+  Gtk.checkMenuItemSetActive menuItem (selected == raLabel)
+  pure menuItem
diff --git a/systranything/Model/RadioGroup.hs b/systranything/Model/RadioGroup.hs
new file mode 100644
--- /dev/null
+++ b/systranything/Model/RadioGroup.hs
@@ -0,0 +1,59 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+module Model.RadioGroup (RadioGroup (..), newItems) where
+
+import Control.Monad (void, when)
+import Data.Aeson.TH (deriveJSON)
+import Data.Foldable (traverse_)
+import Data.List.NonEmpty (NonEmpty (..))
+import qualified Data.List.NonEmpty as NE
+import Data.Maybe (fromMaybe)
+import Data.Text (Text)
+import Foreign.C (CULong)
+import GHC.Generics (Generic)
+import qualified GI.GObject as GI
+import qualified GI.Gtk as Gtk
+import Model.Common (runCommand)
+import Model.Internal (options)
+import Model.RadioButton (RadioButton (..))
+import qualified Model.RadioButton as RadioButton
+
+data RadioGroup = MkRadioGroup
+  { raButtons :: NonEmpty RadioButton,
+    raDefault :: Text,
+    raOnGetStatus :: Text
+  }
+  deriving stock (Generic, Show)
+
+$(deriveJSON options ''RadioGroup)
+
+newItems :: Bool -> RadioGroup -> IO (NonEmpty Gtk.RadioMenuItem, IO ())
+newItems verbose MkRadioGroup {..} = do
+  items@(x :| xs) <- traverse (RadioButton.newItem raDefault) raButtons
+
+  traverse_ (`Gtk.radioMenuItemJoinGroup` Just x) xs
+
+  signalIds <- traverse connectOnToggled $ NE.zip items raButtons
+
+  pure (items, updateAction $ NE.zip items signalIds)
+  where
+    connectOnToggled (item, MkRadioButton {..}) = do
+      checkMenuItem <- Gtk.toCheckMenuItem item
+      runCommandOnToggleActive verbose raOnClick checkMenuItem
+
+    updateAction itemsAndSignalIds = do
+      mbOutput <- runCommand verbose raOnGetStatus
+      let selected = fromMaybe raDefault mbOutput
+      traverse_ (updateActive selected) itemsAndSignalIds
+
+    updateActive selected (item, signalId) = do
+      label <- Gtk.menuItemGetLabel item
+      GI.signalHandlerBlock item signalId
+      Gtk.setCheckMenuItemActive item $ label == selected
+      GI.signalHandlerUnblock item signalId
+
+runCommandOnToggleActive :: Bool -> Text -> Gtk.CheckMenuItem -> IO CULong
+runCommandOnToggleActive verbose command item =
+  Gtk.on item #toggled $ do
+    isActive <- Gtk.checkMenuItemGetActive item
+    when isActive . void $ runCommand verbose command
diff --git a/systranything/Model/Root.hs b/systranything/Model/Root.hs
new file mode 100644
--- /dev/null
+++ b/systranything/Model/Root.hs
@@ -0,0 +1,18 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+module Model.Root (Root (..)) where
+
+import Data.Aeson.TH (deriveJSON)
+import Data.List.NonEmpty (NonEmpty)
+import GHC.Generics (Generic)
+import Model.Indicator (Indicator)
+import Model.Internal (options)
+import Model.Item (Item)
+
+data Root = MkRoot
+  { roIndicator :: Indicator,
+    roMenu :: Maybe (NonEmpty Item)
+  }
+  deriving stock (Generic, Show)
+
+$(deriveJSON options ''Root)
diff --git a/systranything/Model/SubMenu.hs b/systranything/Model/SubMenu.hs
new file mode 100644
--- /dev/null
+++ b/systranything/Model/SubMenu.hs
@@ -0,0 +1,26 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+module Model.SubMenu (SubMenu (..), newItem) where
+
+import Data.Aeson.TH (deriveJSON)
+import Data.List.NonEmpty (NonEmpty)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import qualified GI.Gtk as Gtk
+import Model.Internal (options)
+import Model.Item (Item, populate)
+
+data SubMenu = MkSubMenu
+  { suLabel :: Text,
+    suItems :: NonEmpty Item
+  }
+  deriving stock (Generic, Show)
+
+$(deriveJSON options ''SubMenu)
+
+newItem :: Bool -> SubMenu -> IO Gtk.MenuItem
+newItem verbose (MkSubMenu {..}) = do
+  subMenu <- populate verbose suItems
+  item <- Gtk.menuItemNewWithLabel suLabel
+  Gtk.menuItemSetSubmenu item $ Just subMenu
+  pure item
diff --git a/systranything/Model/SubMenu.hs-boot b/systranything/Model/SubMenu.hs-boot
new file mode 100644
--- /dev/null
+++ b/systranything/Model/SubMenu.hs-boot
@@ -0,0 +1,14 @@
+module Model.SubMenu where
+
+import Data.Aeson (FromJSON, ToJSON)
+import qualified GI.Gtk as Gtk
+
+data SubMenu
+
+instance Show SubMenu
+
+instance FromJSON SubMenu
+
+instance ToJSON SubMenu
+
+newItem :: Bool -> SubMenu -> IO Gtk.MenuItem
diff --git a/systranything/Options.hs b/systranything/Options.hs
new file mode 100644
--- /dev/null
+++ b/systranything/Options.hs
@@ -0,0 +1,40 @@
+module Options (Options (..), parseArgs) where
+
+import Control.Applicative ((<**>))
+import Options.Applicative
+  ( Parser,
+    execParser,
+    fullDesc,
+    help,
+    helper,
+    info,
+    long,
+    metavar,
+    short,
+    strOption,
+    switch,
+  )
+
+data Options = MkOptions
+  { opVerbose :: Bool,
+    opFilename :: FilePath
+  }
+  deriving (Show)
+
+parseArgs :: IO Options
+parseArgs = execParser (info (parser <**> helper) fullDesc)
+
+parser :: Parser Options
+parser =
+  MkOptions
+    <$> switch
+      ( long "verbose"
+          <> short 'v'
+          <> help "Enable verbose mode"
+      )
+    <*> strOption
+      ( long "filename"
+          <> short 'f'
+          <> metavar "FILENAME"
+          <> help "Path to the configuration file"
+      )
