diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for lvar
+
+## 0.1.0.0 -- 2021-04-26
+
+* First version. Released on an unsuspecting world.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,29 @@
+BSD 3-Clause License
+
+Copyright (c) 2021, Sridhar Ratnakumar
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,5 @@
+# `Data.LVar`
+
+[`TMVar`](https://hackage.haskell.org/package/stm) with change notification.
+
+Used in [Ema](https://ema.srid.ca/).
diff --git a/lvar.cabal b/lvar.cabal
new file mode 100644
--- /dev/null
+++ b/lvar.cabal
@@ -0,0 +1,53 @@
+cabal-version:      2.4
+name:               lvar
+version:            0.1.0.0
+license:            BSD-3-Clause
+copyright:          2021 Sridhar Ratnakumar
+maintainer:         srid@srid.ca
+author:             Sridhar Ratnakumar
+category:           Concurrency
+synopsis:  TMVar that can be listened to
+bug-reports: https://github.com/srid/lvar/issues
+homepage: https://github.com/srid/lvar
+description:
+  LVar wraps a TMVar to allow multiple threads to listen to the underlying changes.
+
+extra-source-files:
+  CHANGELOG.md
+  LICENSE
+  README.md
+
+library
+  -- Modules included in this executable, other than Main.
+  -- other-modules:
+
+  -- LANGUAGE extensions used by modules in this package.
+  -- other-extensions:
+  build-depends:
+    , base                   >=4.13.0.0 && <=4.17.0.0
+    , containers
+    , stm
+    , relude
+  mixins:
+    base hiding (Prelude),
+    relude (Relude as Prelude),
+    relude
+  ghc-options:
+    -Wall -Wincomplete-record-updates -Wincomplete-uni-patterns
+  default-extensions:
+    FlexibleContexts
+    FlexibleInstances
+    KindSignatures
+    LambdaCase
+    MultiParamTypeClasses
+    MultiWayIf
+    OverloadedStrings
+    ScopedTypeVariables
+    TupleSections
+    ViewPatterns
+
+  exposed-modules:
+    Data.LVar
+
+  hs-source-dirs:     src
+  default-language:   Haskell2010
diff --git a/src/Data/LVar.hs b/src/Data/LVar.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/LVar.hs
@@ -0,0 +1,142 @@
+{-# LANGUAGE DeriveAnyClass #-}
+
+-- | @LVar@ is like @Control.Concurrent.STM.TMVar@ but with a capability for
+-- listening to its changes.
+module Data.LVar
+  ( -- * Types
+    LVar,
+    ListenerId,
+
+    -- * Creating a LVar
+    new,
+    empty,
+
+    -- * Modifying a LVar
+    get,
+    set,
+    modify,
+
+    -- * Listening to a LVar
+    addListener,
+    listenNext,
+    removeListener,
+  )
+where
+
+import Control.Exception (throw)
+import qualified Data.Map.Strict as Map
+import Prelude hiding (empty, get, modify)
+
+-- A mutable variable (like @TMVar@), changes to which can be listened to from
+-- multiple threads.
+data LVar a = LVar
+  { -- | A value that changes over time
+    lvarCurrent :: TMVar a,
+    -- | Subscribers listening on changes to the value
+    lvarListeners :: TMVar (Map ListenerId (TMVar ()))
+  }
+
+type ListenerId = Int
+
+-- | Create a new @LVar@ with the given initial value
+new :: forall a m. MonadIO m => a -> m (LVar a)
+new val = do
+  LVar <$> newTMVarIO val <*> newTMVarIO mempty
+
+-- | Like @new@, but there is no initial value. A @get@ will block until an
+-- initial value is set using @set@ or @modify@
+empty :: MonadIO m => m (LVar a)
+empty =
+  LVar <$> newEmptyTMVarIO <*> newTMVarIO mempty
+
+-- | Get the value of the @LVar@
+get :: MonadIO m => LVar a -> m a
+get v =
+  atomically $ readTMVar $ lvarCurrent v
+
+-- | Set the @LVar@ value; active listeners are automatically notifed.
+set :: MonadIO m => LVar a -> a -> m ()
+set v val = do
+  atomically $ do
+    let var = lvarCurrent v
+    isEmptyTMVar var >>= \case
+      True -> putTMVar var val
+      False -> void $ swapTMVar var val
+    notifyListeners v
+
+-- | Modify the @LVar@ value; active listeners are automatically notified.
+modify :: MonadIO m => LVar a -> (a -> a) -> m ()
+modify v f = do
+  atomically $ do
+    curr <- readTMVar (lvarCurrent v)
+    void $ swapTMVar (lvarCurrent v) (f curr)
+    notifyListeners v
+
+notifyListeners :: LVar a -> STM ()
+notifyListeners v' = do
+  subs <- readTMVar $ lvarListeners v'
+  forM_ (Map.elems subs) $ \subVar -> do
+    tryPutTMVar subVar ()
+
+data ListenerDead = ListenerDead
+  deriving (Exception, Show)
+
+-- | Create a listener for changes to the @LVar@, as they are set by @set@ or
+-- @modify@ from this time onwards.
+--
+-- You must call @listenNext@ to get the next updated value (or current value if
+-- there is one).
+--
+-- Returns a @ListenerId@ that can be used to stop listening later (via
+-- @removeListener@)
+addListener ::
+  MonadIO m =>
+  LVar a ->
+  m ListenerId
+addListener v = do
+  atomically $ do
+    subs <- readTMVar $ lvarListeners v
+    let nextIdx = maybe 1 (succ . fst) $ Map.lookupMax subs
+    notify <-
+      tryReadTMVar (lvarCurrent v) >>= \case
+        Nothing -> newEmptyTMVar
+        -- As a value is already available, send that as first notification.
+        --
+        -- NOTE: Creating a TMVar that is "full" ensures that we send a current
+        -- (which is not empty) value on @listenNext@).
+        Just _ -> newTMVar ()
+    void $ swapTMVar (lvarListeners v) $ Map.insert nextIdx notify subs
+    pure nextIdx
+
+-- | Listen for the next value update (since the last @listenNext@ or
+-- @addListener@). Unless the @LVar@ was empty when @addListener@ was invoked,
+-- the first invocation of @listenNext@ will return the current value even if
+-- there wasn't an update.  Therefore, the *first* call to @listenNext@ will
+-- *always* return immediately, unless the @LVar@ is empty.
+--
+-- Call this in a loop to listen on a series of updates.
+--
+-- Throws @ListenerDead@ if called with a @ListenerId@ that got already removed
+-- by @removeListener@.
+listenNext :: MonadIO m => LVar a -> ListenerId -> m a
+listenNext v idx = do
+  atomically $ do
+    lookupListener v idx >>= \case
+      Nothing ->
+        -- FIXME: can we avoid this by design?
+        throw ListenerDead
+      Just listenVar -> do
+        takeTMVar listenVar
+        readTMVar (lvarCurrent v)
+  where
+    lookupListener :: LVar a -> ListenerId -> STM (Maybe (TMVar ()))
+    lookupListener v' lId = do
+      Map.lookup lId <$> readTMVar (lvarListeners v')
+
+-- | Stop listening to the @LVar@
+removeListener :: MonadIO m => LVar a -> ListenerId -> m ()
+removeListener v lId = do
+  atomically $ do
+    subs <- readTMVar $ lvarListeners v
+    whenJust (Map.lookup lId subs) $ \_sub -> do
+      void $ swapTMVar (lvarListeners v) $ Map.delete lId subs
