diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,11 @@
+# Changelog for `home-assistant-client`
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to the
+[Haskell Package Versioning Policy](https://pvp.haskell.org/).
+
+## 0.1.0.0 - 2025-08-10
+
+- Initial version with partial support for the Home Assistant REST API
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,26 @@
+Copyright 2025 Michael B. Gale
+
+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,36 @@
+# home-assistant-client: Haskell client for the Home Assistant API
+
+![GitHub](https://img.shields.io/github/license/mbg/home-assistant-client)
+[![Haskell](https://github.com/mbg/home-assistant-client/actions/workflows/haskell.yml/badge.svg)](https://github.com/mbg/home-assistant-client/actions/workflows/haskell.yml)
+[![Stackage Nightly](https://github.com/mbg/home-assistant-client/actions/workflows/nightly.yml/badge.svg)](https://github.com/mbg/home-assistant-client/actions/workflows/nightly.yml)
+
+A Haskell library for the [Home Assistant API](https://developers.home-assistant.io/docs/api/rest/), allowing you to write Haskell applications which interact with [Home Assistant](https://www.home-assistant.io).
+
+A `ha-client` program that exposes the library functions as CLI commands is also provided.
+
+## Library usage
+
+The Home Assistant API offers fairly general endpoints, for which the `HomeAssistant.Client` module provides client computations. All API endpoints require authorisation with a [long-lived token](https://developers.home-assistant.io/docs/auth_api/#long-lived-access-token). For example, to retrieve a list available services from Home Assistant:
+
+```haskell
+import Network.HTTP.Client
+
+defaultAddress :: String
+defaultAddress = "http://homeassistant.local:8123/"
+
+main :: IO ()
+main = do
+    -- `token` should be the long-lived token
+    httpManager <- defaultManagerSettings
+    address <- parseBaseUrl defaultAddress
+
+    let env = mkHomeAssistantEnv token httpManager address
+    result <- runClientM services env
+    print result
+```
+
+## CLI usage
+
+You must set an environment variable named `HA_TOKEN` to your [long-lived token](https://developers.home-assistant.io/docs/auth_api/#long-lived-access-token). The CLI also inspects the `HA_ADDRESS` environment variable to determine the address of the Home Assistant server. This is optional and will default to `http://homeassistant.local:8123/` if no value is set.
+
+The `ha-client` CLI can be invoked with `--help` to retrieve usage information. All sub-commands also support the `--help` flag to retrieve command-specific help.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,88 @@
+module Main (main) where
+
+--------------------------------------------------------------------------------
+
+import Control.Monad.IO.Class
+
+import qualified Data.Aeson as JSON
+import qualified Data.ByteString.Char8 as C8
+import qualified Data.ByteString.Lazy as LBS
+import Data.Maybe
+
+import System.Environment
+import System.Exit
+
+import Network.HTTP.Client
+import Network.HTTP.Client.TLS
+
+import HomeAssistant.Client
+import HomeAssistant.Common.HomeAssistant
+import HomeAssistant.Common.Notify
+
+import Options
+
+--------------------------------------------------------------------------------
+
+-- | This is the default address that Home Assistant installations can be found at.
+-- Used only if no address is explicitly specified.
+defaultAddress :: String
+defaultAddress = "http://homeassistant.local:8123/"
+
+--------------------------------------------------------------------------------
+
+managerForBaseUrl :: BaseUrl -> IO Manager
+managerForBaseUrl address = newManager $ case baseUrlScheme address of
+    Http -> defaultManagerSettings
+    Https -> tlsManagerSettings
+
+--------------------------------------------------------------------------------
+
+runAndFormat :: JSON.ToJSON v => ClientEnv -> HA v -> IO ()
+runAndFormat env m = runClientM m env >>= \case
+    Left err -> print err
+    Right v -> LBS.putStr (JSON.encode v)
+
+runConfigCmd :: ClientEnv -> ConfigCommand -> IO ()
+runConfigCmd env ShowConfig = runAndFormat env config
+runConfigCmd env CheckConfig = runAndFormat env $ do
+    result <- check
+    case result of
+        JSON.Error err -> liftIO $ do
+            print err
+            exitFailure
+        JSON.Success v -> pure v
+runConfigCmd env ReloadAllConfig = runAndFormat env reloadAll
+
+runServiceCmd :: ClientEnv -> ServiceCommand -> IO ()
+runServiceCmd env ListServices = runAndFormat env services
+
+runNotificationCmd :: ClientEnv -> NotificationCommand -> IO ()
+runNotificationCmd env SendNotification{..} = runAndFormat env $ do
+    let notificationData = OtherNotificationData <$> sendNotificationData
+        payload = setNotificationData notificationData $ notification sendNotificationMessage
+    notify sendNotificationDevice payload
+
+-- | 'main' is the main entry point for this application.
+main :: IO ()
+main = do
+    -- Parse command-line arguments
+    opts <- parseCmdLineArgs
+
+    mToken <- fmap C8.pack <$> lookupEnv "HA_TOKEN"
+    address <- fromMaybe defaultAddress <$> lookupEnv "HA_ADDRESS" >>= parseBaseUrl
+
+    case mToken of
+        Nothing -> do
+            putStrLn "Error: HA_TOKEN must be set"
+            exitFailure
+        Just token -> do
+            httpManager <- managerForBaseUrl address
+
+            let env = mkHomeAssistantEnv token httpManager address
+
+            case clientOptionsCommand opts of
+                ConfigCommand cmd -> runConfigCmd env cmd
+                ServiceCommand cmd -> runServiceCmd env cmd
+                NotificationCommand cmd -> runNotificationCmd env cmd
+
+--------------------------------------------------------------------------------
diff --git a/app/Options.hs b/app/Options.hs
new file mode 100644
--- /dev/null
+++ b/app/Options.hs
@@ -0,0 +1,110 @@
+-- | Contains types representing the command-line configuration options of the CLI
+-- along with parsers to parse command-line arguments into configuration values.
+module Options (
+    ConfigCommand(..),
+    ServiceCommand(..),
+    NotificationCommand(..),
+    ClientCommand(..),
+    ClientOptions(..),
+    parseCmdLineArgs
+) where
+
+--------------------------------------------------------------------------------
+
+import Data.Aeson
+import Data.Text
+import Data.ByteString.Lazy.Char8 qualified as C8
+
+import Options.Applicative
+
+--------------------------------------------------------------------------------
+
+-- | Represents commands related to the Home Assistant configuration.
+data ConfigCommand
+    = ShowConfig
+    | CheckConfig
+    | ReloadAllConfig
+
+showConfigP :: Parser ConfigCommand
+showConfigP = pure ShowConfig
+
+checkConfigP :: Parser ConfigCommand
+checkConfigP = pure ShowConfig
+
+reloadAllConfigP :: Parser ConfigCommand
+reloadAllConfigP = pure ReloadAllConfig
+
+--------------------------------------------------------------------------------
+
+-- | Represents commands related to Home Assistant services.
+data ServiceCommand
+    = ListServices
+
+listServicesP :: Parser ServiceCommand
+listServicesP = pure ListServices
+
+--------------------------------------------------------------------------------
+
+-- | Represents commands related to the notification service.
+data NotificationCommand
+    = SendNotification {
+        -- | The name of the service to send the notification to.
+        sendNotificationDevice :: Text,
+        -- | The message to send.
+        sendNotificationMessage :: Text,
+        -- | Arbitrary data to send.
+        sendNotificationData :: Maybe Value
+    }
+
+sendNotificationP :: Parser NotificationCommand
+sendNotificationP = SendNotification <$>
+    argument str (metavar "DEVICE") <*>
+    argument str (metavar "MESSAGE") <*>
+    optional (option (eitherReader (eitherDecode . C8.pack)) (long "data"))
+
+--------------------------------------------------------------------------------
+
+data ClientCommand
+    = ConfigCommand ConfigCommand
+    | ServiceCommand ServiceCommand
+    | NotificationCommand NotificationCommand
+
+configP :: Parser ClientCommand
+configP = fmap ConfigCommand $ subparser $
+    command "show" (info (showConfigP <**> helper) (progDesc "Show the Home Assistant configuration")) <>
+    command "check" (info (checkConfigP <**> helper) (progDesc "Check the Home Assistant configuration")) <>
+    command "reload-all" (info (reloadAllConfigP <**> helper) (progDesc "Reloads all Home Assistant configurations"))
+
+servicesP :: Parser ClientCommand
+servicesP = fmap ServiceCommand $ subparser $
+    command "list" (info (listServicesP <**> helper) (progDesc "Lists all Home Assistant services"))
+
+notificationP :: Parser ClientCommand
+notificationP = fmap NotificationCommand $ subparser $
+    command "send" (info (sendNotificationP <**> helper) (progDesc "Send a notification"))
+
+clientCommandP :: Parser ClientCommand
+clientCommandP = subparser $
+    command "config" (info (configP <**> helper) (progDesc "Configuration commands")) <>
+    command "services" (info (servicesP <**> helper) (progDesc "Services commands")) <>
+    command "notification" (info (notificationP <**> helper) (progDesc "Notification service commands"))
+
+--------------------------------------------------------------------------------
+
+data ClientOptions = MkClientOptions {
+    clientAddress :: Maybe Text,
+    clientOptionsCommand :: ClientCommand
+}
+
+optionsP :: Parser ClientOptions
+optionsP = MkClientOptions
+ <$> optional (strOption (long "address" <> metavar "URL" <> help "The address of the Home Assistant server"))
+ <*> clientCommandP
+
+opts :: ParserInfo ClientOptions
+opts = info (optionsP <**> helper) idm
+
+parseCmdLineArgs :: IO ClientOptions
+parseCmdLineArgs = execParser opts
+
+--------------------------------------------------------------------------------
diff --git a/home-assistant-client.cabal b/home-assistant-client.cabal
new file mode 100644
--- /dev/null
+++ b/home-assistant-client.cabal
@@ -0,0 +1,144 @@
+cabal-version: 2.2
+
+-- This file has been generated from package.yaml by hpack version 0.38.1.
+--
+-- see: https://github.com/sol/hpack
+
+name:           home-assistant-client
+version:        0.1.0.0
+synopsis:       Client library for the Home Assistant API.
+description:    Please see the README on GitHub at <https://github.com/mbg/home-assistant-client#readme>
+category:       Web
+homepage:       https://github.com/mbg/home-assistant-client#readme
+bug-reports:    https://github.com/mbg/home-assistant-client/issues
+author:         Michael B. Gale
+maintainer:     github@michael-gale.co.uk
+copyright:      2025 Michael B. Gale
+license:        BSD-3-Clause
+license-file:   LICENSE
+build-type:     Simple
+extra-source-files:
+    README.md
+extra-doc-files:
+    CHANGELOG.md
+
+source-repository head
+  type: git
+  location: https://github.com/mbg/home-assistant-client
+
+library
+  exposed-modules:
+      HomeAssistant.Client
+      HomeAssistant.Common.HomeAssistant
+      HomeAssistant.Common.Notify
+      HomeAssistant.Types
+  other-modules:
+      Paths_home_assistant_client
+  autogen-modules:
+      Paths_home_assistant_client
+  hs-source-dirs:
+      src
+  default-extensions:
+      DataKinds
+      DeriveGeneric
+      DerivingVia
+      FlexibleInstances
+      GADTs
+      ImportQualifiedPost
+      KindSignatures
+      LambdaCase
+      OverloadedStrings
+      RecordWildCards
+      TypeOperators
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints
+  build-depends:
+      aeson >=2.2.3 && <2.3
+    , base >=4.7 && <5
+    , bytestring >=0.12.1 && <0.13
+    , containers >=0.6.8 && <0.8
+    , deriving-aeson >=0.2.10 && <0.3
+    , exceptions >=0.10.9 && <0.11
+    , http-client >=0.7.19 && <0.8
+    , http-client-tls >=0.3.6 && <0.4
+    , http-types >=0.12.4 && <0.13
+    , servant >=0.20.3 && <0.21
+    , servant-client >=0.20.3 && <0.21
+    , text >=2.1.1 && <2.2
+  default-language: Haskell2010
+
+executable ha-client
+  main-is: Main.hs
+  other-modules:
+      Options
+      Paths_home_assistant_client
+  autogen-modules:
+      Paths_home_assistant_client
+  hs-source-dirs:
+      app
+  default-extensions:
+      DataKinds
+      DeriveGeneric
+      DerivingVia
+      FlexibleInstances
+      GADTs
+      ImportQualifiedPost
+      KindSignatures
+      LambdaCase
+      OverloadedStrings
+      RecordWildCards
+      TypeOperators
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      aeson >=2.2.3 && <2.3
+    , base >=4.7 && <5
+    , bytestring >=0.12.1 && <0.13
+    , containers >=0.6.8 && <0.8
+    , deriving-aeson >=0.2.10 && <0.3
+    , exceptions >=0.10.9 && <0.11
+    , home-assistant-client
+    , http-client >=0.7.19 && <0.8
+    , http-client-tls >=0.3.6 && <0.4
+    , http-types >=0.12.4 && <0.13
+    , optparse-applicative >=0.18.0 && <0.20
+    , servant >=0.20.3 && <0.21
+    , servant-client >=0.20.3 && <0.21
+    , text >=2.1.1 && <2.2
+  default-language: Haskell2010
+
+test-suite home-assistant-client-test
+  type: exitcode-stdio-1.0
+  main-is: Spec.hs
+  other-modules:
+      Paths_home_assistant_client
+  autogen-modules:
+      Paths_home_assistant_client
+  hs-source-dirs:
+      test
+  default-extensions:
+      DataKinds
+      DeriveGeneric
+      DerivingVia
+      FlexibleInstances
+      GADTs
+      ImportQualifiedPost
+      KindSignatures
+      LambdaCase
+      OverloadedStrings
+      RecordWildCards
+      TypeOperators
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      aeson >=2.2.3 && <2.3
+    , base >=4.7 && <5
+    , bytestring >=0.12.1 && <0.13
+    , containers >=0.6.8 && <0.8
+    , deriving-aeson >=0.2.10 && <0.3
+    , exceptions >=0.10.9 && <0.11
+    , home-assistant-client
+    , http-client >=0.7.19 && <0.8
+    , http-client-tls >=0.3.6 && <0.4
+    , http-types >=0.12.4 && <0.13
+    , servant >=0.20.3 && <0.21
+    , servant-client >=0.20.3 && <0.21
+    , text >=2.1.1 && <2.2
+  default-language: Haskell2010
diff --git a/src/HomeAssistant/Client.hs b/src/HomeAssistant/Client.hs
new file mode 100644
--- /dev/null
+++ b/src/HomeAssistant/Client.hs
@@ -0,0 +1,98 @@
+-- | Client library for the Home Assistant API.
+module HomeAssistant.Client (
+    API,
+    HA,
+    JSONOptions,
+    StateChange,
+    status,
+    config,
+    services,
+    callService,
+    mkHomeAssistantEnv,
+    module Servant.Client
+) where
+
+--------------------------------------------------------------------------------
+
+import Data.Aeson
+import Data.Data
+import Data.Text
+import Data.ByteString.Char8 qualified as C8
+
+import Deriving.Aeson
+
+import Network.HTTP.Client (Manager, requestHeaders)
+import Network.HTTP.Types
+
+import Servant.API
+import Servant.Client
+
+import HomeAssistant.Types
+
+--------------------------------------------------------------------------------
+
+-- | The Home Assistant API as a type.
+type API = "api" :> Endpoints
+type Endpoints
+    = Get '[JSON] Value
+ :<|> "config" :>
+      Get '[JSON] Config
+ :<|> "services" :>
+      Get '[JSON] [ServiceDomain]
+ :<|> "services" :>
+      Capture "domain" Text :>
+      Capture "service" Text :>
+      ReqBody '[JSON] (Maybe Value) :>
+      Post '[JSON] Value
+
+api :: Proxy API
+api = Proxy
+
+type HA = ClientM
+
+--------------------------------------------------------------------------------
+
+status :: HA Value
+config :: HA Config
+services :: HA [ServiceDomain]
+
+--------------------------------------------------------------------------------
+
+-- | Represents information about the state of an entity.
+data StateChange = MkStateChange {
+  stateChangeAttributes :: Value,
+  stateChangeEntityID :: Text,
+  stateChangeLastChanged :: Text,
+  stateChangeState :: Text
+} deriving (Generic, Eq, Show)
+  deriving (FromJSON, ToJSON) via CustomJSON (JSONOptions "stateChange") StateChange
+
+-- | 'callService' @domain service body@ calls @service@ in @domain@ with an
+-- optional @body@. The result depends on the service.
+callService :: Text -> Text -> Maybe Value -> HA Value
+
+--------------------------------------------------------------------------------
+
+status
+  :<|> config
+  :<|> services
+  :<|> callService = client api
+
+--------------------------------------------------------------------------------
+
+authorize :: C8.ByteString -> ClientEnv -> ClientEnv
+authorize token env = env{ makeClientRequest = mkRequest } where
+    mkRequest url req = do
+        -- Construct the request using the default function.
+        baseReq <- defaultMakeClientRequest url req
+        -- Then inject the Authorization header with the HA token.
+        pure baseReq{
+            requestHeaders =
+                (hAuthorization, "Bearer " <> token) : requestHeaders baseReq
+        }
+
+mkHomeAssistantEnv :: C8.ByteString -> Manager -> BaseUrl -> ClientEnv
+mkHomeAssistantEnv token httpManager address =
+    authorize token $ mkClientEnv httpManager address
+
+--------------------------------------------------------------------------------
diff --git a/src/HomeAssistant/Common/HomeAssistant.hs b/src/HomeAssistant/Common/HomeAssistant.hs
new file mode 100644
--- /dev/null
+++ b/src/HomeAssistant/Common/HomeAssistant.hs
@@ -0,0 +1,31 @@
+-- | Implements
+module HomeAssistant.Common.HomeAssistant (
+    check,
+    reloadAll
+) where
+
+--------------------------------------------------------------------------------
+
+import Data.Aeson
+import qualified Data.Text as T
+
+import HomeAssistant.Client ( HA, callService )
+import HomeAssistant.Types ( Config )
+
+--------------------------------------------------------------------------------
+
+-- | The name of the Home Assistant service.
+serviceName :: T.Text
+serviceName = "homeassistant"
+
+--------------------------------------------------------------------------------
+
+-- | 'notify' @device notification@ is a wrapper around `callService` that uses
+-- the notifications service to send @notification@ to @device@.
+check :: HA (Result Config)
+check = fromJSON <$> callService serviceName "check_config" Nothing
+
+reloadAll :: HA Value
+reloadAll = callService serviceName "reload_all" Nothing
+
+--------------------------------------------------------------------------------
diff --git a/src/HomeAssistant/Common/Notify.hs b/src/HomeAssistant/Common/Notify.hs
new file mode 100644
--- /dev/null
+++ b/src/HomeAssistant/Common/Notify.hs
@@ -0,0 +1,138 @@
+-- | Implements
+module HomeAssistant.Common.Notify (
+    NotificationData(..),
+    Notification,
+    notification,
+    setNotificationTitle,
+    setNotificationData,
+    notify
+) where
+
+--------------------------------------------------------------------------------
+
+import Data.Aeson
+import qualified Data.Text as T
+
+import Deriving.Aeson
+
+import HomeAssistant.Client
+
+--------------------------------------------------------------------------------
+
+-- | The name of the notifications service.
+serviceName :: T.Text
+serviceName = "notify"
+
+-- | Enumerates different notification platforms that are supported by HA.
+-- Different platforms have different capabilities and understand different
+-- options in their payloads.
+data NotificationPlatform
+    -- | Generic.
+    = OtherPlatform
+    -- | iOS/macOS
+    | Apple
+    deriving (Eq, Show)
+
+-- |
+-- See https://companion.home-assistant.io/docs/notifications/actionable-notifications.
+data NotificationAction = MkNotificationAction {
+    -- | The text to display on the button.
+    notificationActionTitle :: T.Text,
+    -- | The key data to send to HA when the button is pressed.
+    --
+    -- Can be set to the string @"REPLY"@ to prompt for the string to return to HA.
+    --
+    -- On Android, this must be set to the string @"URI"@ to open the optional URI that can be specified.
+    notificationActionAction :: T.Text,
+    -- | Optional, the URI to open when the button is pressed.
+    notificationActionURI :: Maybe T.Text,
+    -- | Set to @"textInput"@ to prompt for text.
+    notificationActionBehavior :: Maybe T.Text
+} deriving (Generic, Eq, Show)
+  deriving (FromJSON, ToJSON) via CustomJSON (JSONOptions "notificationAction") NotificationAction
+
+data AppleNotificationData = MkAppleNotificationData {
+    -- | A URI to open when the notification is clicked on,
+    -- see https://companion.home-assistant.io/docs/notifications/notifications-basic/#opening-a-url
+    appleNotificationUrl :: T.Text,
+    -- | A list of actions that should be available as buttons when the notification is pressed.
+    appleNotificationAction :: Maybe [NotificationAction]
+} deriving (Generic, Eq, Show)
+  deriving (FromJSON, ToJSON) via CustomJSON (JSONOptions "appleNotification") AppleNotificationData
+
+
+-- | Represents platform-specific data for a notification.
+data NotificationData (platform :: NotificationPlatform) where
+    -- | A general constructor that can be used to pass arbitrary JSON data to
+    -- the notification provider.
+    OtherNotificationData :: Value -> NotificationData 'OtherPlatform
+    AppleNotificationData :: AppleNotificationData -> NotificationData 'Apple
+
+instance Eq (NotificationData platform) where
+    (OtherNotificationData l) == (OtherNotificationData r) = l == r
+    (AppleNotificationData l) == (AppleNotificationData r) = l == r
+
+instance Show (NotificationData platform) where
+    show (OtherNotificationData d) = show d
+    show (AppleNotificationData d) = show d
+
+instance FromJSON (NotificationData OtherPlatform) where
+    parseJSON = fmap OtherNotificationData . parseJSON
+
+instance FromJSON (NotificationData Apple) where
+    parseJSON = fmap AppleNotificationData . parseJSON
+
+instance ToJSON (NotificationData platform) where
+    toJSON (OtherNotificationData v) = toJSON v
+    toJSON (AppleNotificationData v) = toJSON v
+
+data Notification platform = MkNotification {
+    -- | The notification message. This is required.
+    notificationMessage :: T.Text,
+    -- | The title of the notification. Will default to the Home Assistant app name on the
+    -- target device if not set.
+    notificationTitle :: Maybe T.Text,
+    -- | Platform-specific data associated with the notification.
+    --
+    -- See the constructors of `NotificationData` for more information and the documentation
+    -- at https://www.home-assistant.io/integrations/notify/
+    notificationData :: Maybe (NotificationData platform),
+    -- | Platform-specific target for the notification.
+    notificationTarget :: Maybe Value
+} deriving (Generic, Eq, Show)
+  deriving (ToJSON) via CustomJSON (JSONOptions "notification") (Notification platform)
+
+instance FromJSON (Notification OtherPlatform) where
+    parseJSON = withObject "Notification" $ \v ->
+        MkNotification <$> v .: "message" <*> v .: "title" <*> v .: "data" <*> v .: "target"
+
+-- | 'notification' @message@ constructs a `Notification` containing @message@.
+-- Can be used to then change other properties of the resulting `Notification`.
+notification :: T.Text -> Notification platform
+notification message = MkNotification{
+    notificationMessage = message,
+    notificationTitle = Nothing,
+    notificationData = Nothing,
+    notificationTarget = Nothing
+}
+
+-- | 'setNotificationTitle' @title notification@ sets @notification@'s @title@.
+setNotificationTitle ::
+    Maybe T.Text ->
+    Notification platform ->
+    Notification platform
+setNotificationTitle title n = n{ notificationTitle = title }
+
+-- | 'setNotificationData' @data notification@ sets @notification@'s @data@.
+setNotificationData ::
+    Maybe (NotificationData platform) ->
+    Notification platform ->
+    Notification platform
+setNotificationData d n = n{ notificationData = d }
+
+-- | 'notify' @device notification@ is a wrapper around `callService` that uses
+-- the notifications service to send @notification@ to @device@.
+notify :: T.Text -> Notification platform -> HA Value
+notify device = callService serviceName device . Just . toJSON
+
+--------------------------------------------------------------------------------
diff --git a/src/HomeAssistant/Types.hs b/src/HomeAssistant/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/HomeAssistant/Types.hs
@@ -0,0 +1,76 @@
+-- | Types used by Home Assistant.
+module HomeAssistant.Types (
+    JSONOptions,
+    UnitSystem(..),
+    Config(..),
+    Service(..),
+    ServiceDomain(..)
+) where
+
+--------------------------------------------------------------------------------
+
+import Data.Aeson
+import Data.Text
+import Data.Map.Lazy qualified as M
+
+import Deriving.Aeson
+
+import GHC.TypeLits
+
+--------------------------------------------------------------------------------
+
+-- | The custom JSON encoding/decoding options for HA types.
+--
+-- HA does not like fields that are set to @null@ - they must be omitted.
+type JSONOptions (prefix :: Symbol) =
+  '[OmitNothingFields, FieldLabelModifier '[StripPrefix prefix, CamelToSnake]]
+
+--------------------------------------------------------------------------------
+
+-- | Represents units that Home Assistant is configured to use.
+data UnitSystem = MkUnitSystem {
+    -- | Identifies the unit used for distances.
+    unitSystemLength :: Text,
+    -- | Identifies the unit used for mass.
+    unitSystemMass :: Text,
+    -- | Identifies the unit used for temperatures.
+    unitSystemTemperature :: Text,
+    -- | Identifies the unit used for volumes.
+    unitSystemVolume :: Text
+} deriving (Generic, Eq, Show)
+  deriving (FromJSON, ToJSON) via CustomJSON (JSONOptions "unitSystem") UnitSystem
+
+-- | Represents Home Assistant configurations.
+data Config = MkConfig {
+    configComponents :: [Text],
+    configConfigDir :: Text,
+    configLocationName :: Text,
+    configTimeZone :: Text,
+    configElevation :: Int,
+    configLatitude :: Double,
+    configLongitude :: Double,
+    -- | The units that Home Assistant is configured to use.
+    configUnitSystem :: UnitSystem,
+    configVersion :: Text,
+    configWhitelistExternalDirs :: [Text]
+} deriving (Generic, Eq, Show)
+  deriving (FromJSON, ToJSON) via CustomJSON (JSONOptions "config") Config
+
+-- | Represents Home Assistant services.
+data Service = MkService {
+    serviceName :: Text,
+    serviceDescription :: Text,
+    serviceFields :: M.Map Text Value
+} deriving (Generic, Eq, Show)
+  deriving (FromJSON, ToJSON) via CustomJSON (JSONOptions "service") Service
+
+-- | Represents Home Assistant service domains.
+data ServiceDomain = MkServiceDomain {
+    -- | The domain name.
+    sdDomain :: Text,
+    -- | The services in this domain as a mapping from names to services.
+    sdServices :: M.Map Text Service
+} deriving (Generic, Eq, Show)
+  deriving (FromJSON, ToJSON) via CustomJSON (JSONOptions "sd") ServiceDomain
+
+--------------------------------------------------------------------------------
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,2 @@
+main :: IO ()
+main = putStrLn "Test suite not yet implemented"
