diff --git a/UNLICENSE b/UNLICENSE
--- a/UNLICENSE
+++ b/UNLICENSE
@@ -0,0 +1,24 @@
+This is free and unencumbered software released into the public domain.
+
+Anyone is free to copy, modify, publish, use, compile, sell, or
+distribute this software, either in source code form or as a compiled
+binary, for any purpose, commercial or non-commercial, and by any
+means.
+
+In jurisdictions that recognize copyright laws, the author or authors
+of this software dedicate any and all copyright interest in the
+software to the public domain. We make this dedication for the benefit
+of the public at large and to the detriment of our heirs and
+successors. We intend this dedication to be an overt act of
+relinquishment in perpetuity of all present and future rights to this
+software under copyright law.
+
+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 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.
+
+For more information, please refer to <http://unlicense.org/>
diff --git a/etcd.cabal b/etcd.cabal
--- a/etcd.cabal
+++ b/etcd.cabal
@@ -1,12 +1,12 @@
 name:                etcd
-version:             0.1.0.1
+version:             0.1.0.2
 license:             OtherLicense
 license-file:        UNLICENSE
 synopsis:            Client for etcd, a highly-available key value store
-description:         See https://github.com/coreos/etcd
+description:         See <https://coreos.com/using-coreos/etcd/>
 author:              Tomas Carnecky
 maintainer:          tomas.carnecky@gmail.com
-category:            Data
+category:            Network
 build-type:          Simple
 cabal-version:       >=1.10
 
@@ -14,27 +14,30 @@
 library
   hs-source-dirs:    src
 
-  exposed-modules:   Data.Etcd
+  exposed-modules:   Network.Etcd
 
   build-depends:     aeson         >= 0
   build-depends:     base          >= 4.0     && < 5
   build-depends:     bytestring    >= 0
   build-depends:     http-conduit  >= 0
+  build-depends:     time          >= 0
 
   default-language:  Haskell2010
   ghc-options:       -Wall
 
 
 
-executable etcd-client
-  hs-source-dirs:    src
+test-suite test
+  type:              exitcode-stdio-1.0
 
-  main-is:           Main.hs
+  hs-source-dirs:    test
+  main-is:           Test.hs
 
-  build-depends:     aeson         >= 0
-  build-depends:     base          >= 4.0     && < 5
-  build-depends:     bytestring    >= 0
-  build-depends:     http-conduit  >= 0
+  build-depends:     MonadRandom
+  build-depends:     base
+  build-depends:     etcd
+  build-depends:     hspec
+  build-depends:     mtl
 
   default-language:  Haskell2010
-  ghc-options:       -Wall
+  ghc-options:       -threaded -with-rtsopts=-N
diff --git a/src/Data/Etcd.hs b/src/Data/Etcd.hs
deleted file mode 100644
--- a/src/Data/Etcd.hs
+++ /dev/null
@@ -1,179 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-
-module Data.Etcd where
-
-
-import           Data.Aeson hiding (Error)
-import           Data.ByteString.Char8 (pack)
-
-import           Control.Applicative
-import           Control.Exception
-import           Control.Monad
-
-import           Network.HTTP.Conduit hiding (Response, path)
-
-
-data Client = Client
-    { leaderUrl :: String
-    , machines :: [ String ]
-    }
-
-
-
--- | The version prefix used in URLs. The current client supports v2.
-versionPrefix :: String
-versionPrefix = "v2"
-
-
-buildUrl :: Client -> String -> String
-buildUrl c p = leaderUrl c ++ "/" ++ versionPrefix ++ "/" ++ p
-
-
-------------------------------------------------------------------------------
--- | Each response comes with an "action" field, which describes what kind of
--- action was performed.
-data Action = GET | SET | DELETE | CREATE | EXPIRE | CAS | CAD
-    deriving (Show, Eq, Ord)
-
-
-instance FromJSON Action where
-    parseJSON (String "get")              = return GET
-    parseJSON (String "set")              = return SET
-    parseJSON (String "delete")           = return DELETE
-    parseJSON (String "create")           = return CREATE
-    parseJSON (String "expire")           = return EXPIRE
-    parseJSON (String "compareAndSwap")   = return CAS
-    parseJSON (String "compareAndDelete") = return CAD
-    parseJSON _                           = fail "Action"
-
-
-
-------------------------------------------------------------------------------
--- | The server responds with this object to all successful requests.
-data Response = Response
-    { _resAction   :: Action
-    , _resNode     :: Node
-    , _resPrevNode :: Maybe Node
-    } deriving (Show, Eq, Ord)
-
-
-instance FromJSON Response where
-    parseJSON (Object o) = Response
-        <$> o .:  "action"
-        <*> o .:  "node"
-        <*> o .:? "prevNode"
-
-    parseJSON _ = fail "Response"
-
-
-
-------------------------------------------------------------------------------
--- | The server sometimes responds to errors with this error object.
-data Error = Error
-    deriving (Show, Eq, Ord)
-
-instance FromJSON Error where
-    parseJSON _ = return Error
-
-
-
-data Node = Node
-    { _nodeKey           :: String
-    , _nodeValue         :: Maybe String
-    , _nodeCreatedIndex  :: Int
-    , _nodeModifiedIndex :: Int
-    , _nodeNodes         :: Maybe [Node]
-    } deriving (Show, Eq, Ord)
-
-
-instance FromJSON Node where
-    parseJSON (Object o) = Node
-        <$> o .:  "key"
-        <*> o .:? "value"
-        <*> o .:  "createdIndex"
-        <*> o .:  "modifiedIndex"
-        <*> o .:? "nodes"
-
-    parseJSON _ = fail "Response"
-
-
-
-{-|---------------------------------------------------------------------------
-
-Low-level HTTP interface
-
-The functions here are used internally when sending requests to etcd. If the
-server is running, the result is 'Either Error Response'. These functions may
-throw an exception if the server is unreachable or not responding.
-
--}
-
-
--- A type synonym for a http response.
-type HR = Either Error Response
-
-
-httpGET :: String -> IO HR
-httpGET url = do
-    req  <- acceptJSON <$> parseUrl url
-    body <- responseBody <$> (withManager $ httpLbs req)
-    return $ maybe (Left Error) Right $ decode body
-
-  where
-    acceptHeader   = ("Accept","application/json")
-    acceptJSON req = req { requestHeaders = acceptHeader : requestHeaders req }
-
-
-httpPOST :: String -> [(String, String)] -> IO HR
-httpPOST url params = do
-    req' <- parseUrl url
-    let req = urlEncodedBody (map (\(k,v) -> (pack k, pack v)) params) $ req'
-
-    void $ withManager $ httpLbs req
-    return $ Left Error
-
-
-------------------------------------------------------------------------------
--- | Run a low-level HTTP request. Catch any exceptions and convert them into
--- a 'Left Error'.
-runRequest :: IO HR -> IO HR
-runRequest a = catch a (ignoreExceptionWith (return $ Left Error))
-
-ignoreExceptionWith :: a -> SomeException -> a
-ignoreExceptionWith a _ = a
-
-
-
-{-|---------------------------------------------------------------------------
-
-Public API
-
--}
-
-
--- | Create a new client and initialize it with a list of seed machines. The
--- list must be non-empty.
-createClient :: [ String ] -> IO Client
-createClient seed = return $ Client (head seed) seed
-
-
-listKeys :: Client -> String -> IO [ Node ]
-listKeys client path = do
-    hr <- runRequest $ httpGET $ buildUrl client $ "keys/" ++ path
-    case hr of
-        Left _ -> return []
-        Right res -> return $ [ _resNode res ]
-
-
-getKey :: Client -> String -> IO (Maybe Node)
-getKey client path = do
-    hr <- runRequest $ httpGET $ buildUrl client $ "keys/" ++ path
-    case hr of
-        Left _ -> return Nothing
-        Right res -> return $ Just $ _resNode res
-
-
-putKey :: Client -> String -> String -> IO ()
-putKey client path value = do
-    void $ httpPOST (buildUrl client $ "keys/" ++ path) [("value", value)]
-    return ()
diff --git a/src/Main.hs b/src/Main.hs
deleted file mode 100644
--- a/src/Main.hs
+++ /dev/null
@@ -1,11 +0,0 @@
-module Main where
-
-import Data.Etcd
-
-
-main = do
-    print "Starting etcd client..."
-    client <- createClient [ "http://127.0.0.1:4001" ]
-    keys <- listKeys client "/_etcd/machines"
-    print keys
-    return ()
diff --git a/src/Network/Etcd.hs b/src/Network/Etcd.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/Etcd.hs
@@ -0,0 +1,364 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{-|
+
+This module contains an implementation of the etcd client.
+
+-}
+
+module Network.Etcd
+    ( Client
+    , createClient
+
+      -- * Types
+    , Node(..)
+    , Index
+    , Key
+    , Value
+    , TTL
+
+      -- * Low-level key operations
+    , get
+    , set
+    , create
+
+      -- * Directory operations
+    , createDirectory
+    , listDirectoryContents
+    , removeDirectory
+    , removeDirectoryRecursive
+    ) where
+
+
+import           Data.Aeson hiding (Value, Error)
+import           Data.ByteString.Char8 (pack)
+import           Data.Time.Clock
+import           Data.Time.LocalTime
+import           Data.List
+
+import           Control.Applicative
+import           Control.Exception
+import           Control.Monad
+
+import           Network.HTTP.Conduit hiding (Response, path)
+
+
+-- | The 'Client' holds all data required to make requests to the etcd
+-- cluster. You should use 'createClient' to initialize a new client.
+data Client = Client
+    { leaderUrl :: String
+      -- ^ The URL to the leader. HTTP requests are sent to this server.
+    }
+
+
+
+-- | The version prefix used in URLs. The current client supports v2.
+versionPrefix :: String
+versionPrefix = "v2"
+
+
+-- | The URL to the given key.
+keyUrl :: Client -> Key -> String
+keyUrl client key = leaderUrl client ++ "/" ++ versionPrefix ++ "/keys/" ++ key
+
+
+------------------------------------------------------------------------------
+-- | Each response comes with an "action" field, which describes what kind of
+-- action was performed.
+data Action = GET | SET | DELETE | CREATE | EXPIRE | CAS | CAD
+    deriving (Show, Eq, Ord)
+
+
+instance FromJSON Action where
+    parseJSON (String "get")              = return GET
+    parseJSON (String "set")              = return SET
+    parseJSON (String "delete")           = return DELETE
+    parseJSON (String "create")           = return CREATE
+    parseJSON (String "expire")           = return EXPIRE
+    parseJSON (String "compareAndSwap")   = return CAS
+    parseJSON (String "compareAndDelete") = return CAD
+    parseJSON _                           = fail "Action"
+
+
+
+------------------------------------------------------------------------------
+-- | The server responds with this object to all successful requests.
+data Response = Response
+    { _resAction   :: Action
+    , _resNode     :: Node
+    , _resPrevNode :: Maybe Node
+    } deriving (Show, Eq, Ord)
+
+
+instance FromJSON Response where
+    parseJSON (Object o) = Response
+        <$> o .:  "action"
+        <*> o .:  "node"
+        <*> o .:? "prevNode"
+
+    parseJSON _ = fail "Response"
+
+
+
+------------------------------------------------------------------------------
+-- | The server sometimes responds to errors with this error object.
+data Error = Error
+    deriving (Show, Eq, Ord)
+
+instance FromJSON Error where
+    parseJSON _ = return Error
+
+
+-- | The etcd index is a unique, monotonically-incrementing integer created for
+-- each change to etcd. See etcd documentation for more details.
+type Index = Int
+
+
+-- | Keys are strings, formatted like filesystem paths (ie. slash-delimited
+-- list of path components).
+type Key = String
+
+
+-- | Values attached to leaf nodes are strings. If you want to store
+-- structured data in the values, you'll need to encode it into a string.
+type Value = String
+
+
+-- | TTL is specified in seconds. The server accepts negative values, but they
+-- don't make much sense.
+type TTL = Int
+
+
+-- | The 'Node' corresponds to the node object as returned by the etcd API.
+--
+-- There are two types of nodes in etcd. One is a leaf node which holds
+-- a value, the other is a directory node which holds zero or more child nodes.
+-- A directory node can not hold a value, the two types are exclusive.
+--
+-- On the wire, the two are not really distinguished, except that the JSON
+-- objects have different fields.
+--
+-- A node may be set to expire after a number of seconds. This is indicated by
+-- the two fields 'ttl' and 'expiration'.
+
+data Node = Node
+    { _nodeKey           :: Key
+      -- ^ The key of the node. It always starts with a slash character (0x47).
+
+    , _nodeCreatedIndex  :: Index
+      -- ^ A unique index, reflects the point in the etcd state machine at
+      -- which the given key was created.
+
+    , _nodeModifiedIndex :: Index
+      -- ^ Like '_nodeCreatedIndex', but reflects when the node was laste
+      -- changed.
+
+    , _nodeDir           :: Bool
+      -- ^ 'True' if this node is a directory.
+
+    , _nodeValue         :: Maybe Value
+      -- ^ The value is only present on leaf nodes. If the node is
+      -- a directory, then this field is 'Nothing'.
+
+    , _nodeNodes         :: Maybe [Node]
+      -- ^ If this node is a directory, then these are its children. The list
+      -- may be empty.
+
+    , _nodeTTL           :: Maybe TTL
+      -- ^ If the node has TTL set, this is the number of seconds how long the
+      -- node will exist.
+
+    , _nodeExpiration    :: Maybe UTCTime
+      -- ^ If TTL is set, then this is the time when it expires.
+
+    } deriving (Show, Eq, Ord)
+
+
+instance FromJSON Node where
+    parseJSON (Object o) = Node
+        <$> o .:  "key"
+        <*> o .:  "createdIndex"
+        <*> o .:  "modifiedIndex"
+        <*> o .:? "dir" .!= False
+        <*> o .:? "value"
+        <*> o .:? "nodes"
+        <*> o .:? "ttl"
+        <*> (fmap zonedTimeToUTC <$> (o .:? "expiration"))
+
+    parseJSON _ = fail "Response"
+
+
+
+{-|---------------------------------------------------------------------------
+
+Low-level HTTP interface
+
+The functions here are used internally when sending requests to etcd. If the
+server is running, the result is 'Either Error Response'. These functions may
+throw an exception if the server is unreachable or not responding.
+
+-}
+
+
+-- A type synonym for a http response.
+type HR = Either Error Response
+
+
+httpGET :: String -> IO HR
+httpGET url = do
+    req  <- acceptJSON <$> parseUrl url
+    body <- responseBody <$> (withManager $ httpLbs req)
+    return $ maybe (Left Error) Right $ decode body
+
+  where
+    acceptHeader   = ("Accept","application/json")
+    acceptJSON req = req { requestHeaders = acceptHeader : requestHeaders req }
+
+
+httpPUT :: String -> [(String, String)] -> IO HR
+httpPUT url params = do
+    req' <- parseUrl url
+    let req = urlEncodedBody (map (\(k,v) -> (pack k, pack v)) params) $ req'
+
+    body <- responseBody <$> (withManager $ httpLbs $ req { method = "PUT" })
+    return $ maybe (Left Error) Right $ decode body
+
+httpPOST :: String -> [(String, String)] -> IO HR
+httpPOST url params = do
+    req' <- parseUrl url
+    let req = urlEncodedBody (map (\(k,v) -> (pack k, pack v)) params) $ req'
+
+    body <- responseBody <$> (withManager $ httpLbs $ req { method = "POST" })
+    return $ maybe (Left Error) Right $ decode body
+
+
+-- | Issue a DELETE request to the given url. Since DELETE requests don't have
+-- a body, the params are appended to the URL as a query string.
+httpDELETE :: String -> [(String, String)] -> IO HR
+httpDELETE url params = do
+    req  <- parseUrl $ url ++ (asQueryParams params)
+    body <- responseBody <$> (withManager $ httpLbs $ req { method = "DELETE" })
+    return $ maybe (Left Error) Right $ decode body
+
+  where
+    asQueryParams [] = ""
+    asQueryParams xs = "?" ++ intercalate "&" (map (\(k,v) -> k ++ "=" ++ v) xs)
+
+
+------------------------------------------------------------------------------
+-- | Run a low-level HTTP request. Catch any exceptions and convert them into
+-- a 'Left Error'.
+runRequest :: IO HR -> IO HR
+runRequest a = catch a (ignoreExceptionWith (return $ Left Error))
+
+ignoreExceptionWith :: a -> SomeException -> a
+ignoreExceptionWith a _ = a
+
+
+-- | Encode an optional TTL into a param pair.
+ttlParam :: Maybe TTL -> [(String,String)]
+ttlParam Nothing    = []
+ttlParam (Just ttl) = [("ttl",show ttl)]
+
+
+
+{-----------------------------------------------------------------------------
+
+Public API
+
+-}
+
+
+-- | Create a new client and initialize it with a list of seed machines. The
+-- list must be non-empty.
+createClient :: [ String ] -> IO Client
+createClient seed = return $ Client (head seed)
+
+
+
+{-----------------------------------------------------------------------------
+
+Low-level key operations
+
+-}
+
+
+-- | Get the node at the given key.
+get :: Client -> Key -> IO (Maybe Node)
+get client key = do
+    hr <- runRequest $ httpGET $ keyUrl client key
+    case hr of
+        Left _ -> return Nothing
+        Right res -> return $ Just $ _resNode res
+
+
+-- | Set the value at the given key.
+set :: Client -> Key -> Value -> Maybe TTL -> IO (Maybe Node)
+set client key value mbTTL = do
+    hr <- runRequest $ httpPUT (keyUrl client key) params
+    case hr of
+        Left _ -> return Nothing
+        Right res -> return $ Just $ _resNode res
+
+  where
+    params = [("value",value)] ++ ttlParam mbTTL
+
+
+-- | Create a value in the given key. The key must be a directory.
+create :: Client -> Key -> Value -> Maybe TTL -> IO Node
+create client key value mbTTL = do
+    hr <- runRequest $ httpPOST (keyUrl client key) params
+    case hr of
+        Left _ -> error "Unexpected error"
+        Right res -> return $ _resNode res
+
+  where
+    params = [("value",value)] ++ ttlParam mbTTL
+
+
+
+{-----------------------------------------------------------------------------
+
+Directories are non-leaf nodes which contain zero or more child nodes. When
+manipulating directories one must include dir=true in the request params.
+
+-}
+
+dirParam :: [(String,String)]
+dirParam = [("dir","true")]
+
+recursiveParam :: [(String,String)]
+recursiveParam = [("recursive","true")]
+
+
+-- | Create a directory at the given key.
+createDirectory :: Client -> Key -> Maybe TTL -> IO ()
+createDirectory client key mbTTL =
+    void $ runRequest $ httpPUT (keyUrl client key) $ dirParam ++ ttlParam mbTTL
+
+
+-- | List all nodes within the given directory.
+listDirectoryContents :: Client -> Key -> IO [Node]
+listDirectoryContents client key = do
+    hr <- runRequest $ httpGET $ keyUrl client key
+    case hr of
+        Left _ -> return []
+        Right res -> do
+            let node = _resNode res
+            case _nodeNodes node of
+                Nothing -> return []
+                Just children -> return children
+
+
+-- | Remove the directory at the given key. The directory MUST be empty,
+-- otherwise the removal fails. If you don't care about the keys within, you
+-- can use 'removeDirectoryRecursive'.
+removeDirectory :: Client -> Key -> IO ()
+removeDirectory client key =
+    void $ runRequest $ httpDELETE (keyUrl client key) dirParam
+
+
+-- | Remove the directory at the given key, including all its children.
+removeDirectoryRecursive :: Client -> Key -> IO ()
+removeDirectoryRecursive client key =
+    void $ runRequest $ httpDELETE (keyUrl client key) $ dirParam ++ recursiveParam
diff --git a/test/Test.hs b/test/Test.hs
new file mode 100644
--- /dev/null
+++ b/test/Test.hs
@@ -0,0 +1,114 @@
+module Main where
+
+import           Test.Hspec
+
+import           Data.Char
+import           Data.Maybe
+
+import           Control.Applicative
+import           Control.Concurrent
+import           Control.Monad
+import           Control.Monad.Random (getRandomR, evalRandIO)
+import           Control.Monad.Trans
+
+import           Network.Etcd
+
+
+main :: IO ()
+main = hspec spec
+
+
+randomKey :: IO String
+randomKey = do
+    rnd <- evalRandIO (sequence $ repeat $ getRandomR (0, 61))
+    return $ take 13 $ map alnum rnd
+  where
+    alnum :: Int -> Char
+    alnum x
+        | x < 26    = chr ((x     ) + 65)
+        | x < 52    = chr ((x - 26) + 97)
+        | x < 62    = chr ((x - 52) + 48)
+        | otherwise = error $ "Out of range: " ++ show x
+
+
+setup :: IO (Client, String)
+setup = do
+    client <- createClient [ "http://127.0.0.1:4001" ]
+    key    <- randomKey
+
+    return (client, key)
+
+
+expectNode :: Maybe Node -> IO Node
+expectNode = maybe (error "Expected Node") return
+
+shouldBeLeaf :: Node -> Value -> Expectation
+shouldBeLeaf node value = _nodeValue node `shouldBe` (Just value)
+
+shouldBeDirectory :: Node -> Expectation
+shouldBeDirectory node = _nodeDir node `shouldBe` True
+
+
+spec :: Spec
+spec = parallel $ do
+
+    describe "Key Space Operations" $ do
+        it "Setting the value of a key" $ do
+            (client, key) <- setup
+            node <- expectNode =<< set client key "value" Nothing
+            node `shouldBeLeaf` "value"
+
+        it "Using key TTL" $ do
+            (client, key) <- setup
+            set client key "value" (Just 1)
+            node <- expectNode =<< get client key
+            node `shouldBeLeaf` "value"
+
+            threadDelay $ 2 * 1000 * 1000
+            node <- get client key
+            node `shouldSatisfy` isNothing
+
+        it "Changing the value of a key" $ do
+            (client, key) <- setup
+            set client key "value" Nothing
+            node <- expectNode =<< set client key "value2" Nothing
+            node `shouldBeLeaf` "value2"
+
+        it "Get the value of a key" $ do
+            (client, _) <- setup
+            void $ fromJust <$> get client "/_etcd/machines"
+
+        it "Creating directories" $ do
+            (client, key) <- setup
+            createDirectory client key Nothing
+            node <- expectNode =<< get client key
+            shouldBeDirectory node
+
+        it "Deleting a directory" $ do
+            (client, key) <- setup
+            createDirectory client key Nothing
+            removeDirectory client key
+
+        it "Listing a directory" $ do
+            (client, key) <- setup
+            createDirectory client key Nothing
+            set client (key ++ "/one") "value" Nothing
+            nodes <- listDirectoryContents client key
+            length nodes `shouldBe` 1
+            _nodeValue (head nodes) `shouldBe` (Just "value")
+
+        it "Deleting a directory recursively" $ do
+            (client, key) <- setup
+            createDirectory client key Nothing
+            set client (key ++ "/one") "value" Nothing
+            removeDirectoryRecursive client key
+
+        it "Atomically Creating In-Order Keys" $ do
+            (client, key) <- setup
+            createDirectory client key Nothing
+            node <- create client key "value" Nothing
+            node `shouldBeLeaf` "value"
+
+            nodes <- listDirectoryContents client key
+            length nodes `shouldBe` 1
+            _nodeValue (head nodes) `shouldBe` (Just "value")
