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
+=======
+
+Initial release. REST and Discovery APIs support.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2013, Matvey Aksenov
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * 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.
+
+    * Neither the name of Matvey Aksenov nor the names of other
+      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
+OWNER 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,4 @@
+libjenkins
+==========
+[![Build Status](https://drone.io/github.com/supki/libjenkins/status.png)](https://drone.io/github.com/supki/libjenkins/latest)
+[![Build Status](https://secure.travis-ci.org/supki/libjenkins.png?branch=master)](http://travis-ci.org/supki/libjenkins)
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/examples/Discover.hs b/examples/Discover.hs
new file mode 100644
--- /dev/null
+++ b/examples/Discover.hs
@@ -0,0 +1,29 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | Discover Jenkins server on the network
+module Main where
+
+import           Data.Monoid ((<>), mempty) -- base
+import           Data.Text (Text)           -- text
+import qualified Data.Text as T             -- text
+import qualified Data.Text.IO as T          -- text
+import           Jenkins.Discover           -- libjenkins
+import           System.Exit (exitFailure)  -- base
+
+
+main :: IO ()
+main = do
+  -- send discover broadcast with 100ms timeout
+  discoveries <- discover 100000
+  case discoveries of
+    -- no Jenkins responded
+    [] -> exitFailure
+    -- pretty print responses
+    _  -> mapM_ (T.putStrLn . pretty) discoveries
+
+-- | Pretty print Jenkins discovery responses
+pretty :: Discover -> Text
+pretty x = T.unwords $
+  ["Jenkins", version x] ++ maybe mempty (return . between "(" ")") (server_id x) ++ ["at", url x]
+ where
+  between l r t = l <> t <> r
+
diff --git a/examples/Jobs.hs b/examples/Jobs.hs
new file mode 100644
--- /dev/null
+++ b/examples/Jobs.hs
@@ -0,0 +1,73 @@
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+-- | Show jobs status
+module Main where
+
+import           Control.Lens                      -- lens
+import           Control.Lens.Aeson                -- lens-aeson
+import qualified Data.ByteString.Char8 as B        -- bytestring
+import           Data.Text (Text)                  -- text
+import qualified Data.Text.IO as T                 -- text
+import           Jenkins.REST hiding (render)      -- libjenkins
+import           Options.Applicative               -- optparse-applicative
+import           System.Console.ANSI               -- ansi-terminal
+import           System.Exit (exitFailure)         -- base
+
+{-# ANN module ("HLint: Use camelCase" :: String) #-}
+
+
+-- | Job name and status
+data Job = Job
+  { name  :: Text
+  , color :: Color
+  }
+
+
+main :: IO ()
+main = do
+  -- more useful help on error
+  opts <- customExecParser (prefs showHelpOnError) options
+  -- get all jobs (colored)
+  jobs <- colorized_jobs opts
+  case jobs of
+    -- render them
+    Right js -> mapM_ render js
+    -- something bad happened, show it!
+    Left  e  -> do
+      print e
+      exitFailure
+
+-- get jobs names from jenkins "root" API
+colorized_jobs :: Settings -> IO (Either Disconnect [Job])
+colorized_jobs settings = runJenkins settings $ do
+  res <- get (json -?- "tree" -=- "jobs[name]")
+  let jobs = res ^.. key "jobs"._Array.each.key "name"._String
+  concurrentlys (map colorize jobs)
+
+-- get jobs colors as they appear on web UI
+colorize :: Text -> Jenkins Job
+colorize name = do
+  res <- get (job name `as` json -?- "tree" -=- "color")
+  return . Job name $ case res ^? key "color" of
+    -- but sane
+    Just "red"  -> Red
+    Just "blue" -> Green
+    _           -> Yellow
+
+-- render colored job (assumes ANSI terminal)
+render :: Job -> IO ()
+render Job { name, color } = do
+  setSGR [SetColor Foreground Dull color]
+  T.putStrLn name
+  setSGR []
+
+
+-- | Quite a trivial jenkins settings parser
+options :: ParserInfo Settings
+options = info (helper <*> parser) fullDesc
+ where
+  parser = Settings
+    <$> (Host <$> strOption (long "host"))
+    <*> (Port <$> option (long "port"))
+    <*> (User . B.pack <$> strOption (long "user"))
+    <*> (APIToken . B.pack <$> strOption (long "token" <> long "password"))
diff --git a/examples/Rename.hs b/examples/Rename.hs
new file mode 100644
--- /dev/null
+++ b/examples/Rename.hs
@@ -0,0 +1,81 @@
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+-- | Rename jobs matching supplied pattern
+module Main where
+
+import           Control.Lens                      -- lens
+import           Control.Lens.Aeson                -- lens-aeson
+import           Control.Monad (when)              -- base
+import qualified Data.ByteString.Char8 as B        -- bytestring
+import           Data.Foldable (for_)              -- base
+import           Data.Function (fix)               -- base
+import           Data.Text (Text)                  -- text
+import qualified Data.Text as T                    -- text
+import qualified Data.Text.IO as T                 -- text
+import           Jenkins.REST                      -- libjenkins
+import           Options.Applicative               -- optparse-applicative
+import           System.Exit (exitFailure)         -- base
+
+{-# ANN module ("HLint: Use camelCase" :: String) #-}
+
+
+main :: IO ()
+main = do
+  -- more useful help on error
+  opts <- customExecParser (prefs showHelpOnError) options
+  res  <- rename opts
+  case res of
+    Right () -> T.putStrLn "Done."
+    -- something bad happened, show it!
+    Left  e  -> do
+      print e
+      exitFailure
+
+-- | Prompt to rename all jobs matching pattern
+rename :: Options -> IO (Either Disconnect ())
+rename (Options { settings, old, new }) = runJenkins settings $ do
+  -- get jobs names from jenkins "root" API
+  res <- get (json -?- "tree" -=- "jobs[name]")
+  let jobs = res ^.. key "jobs"._Array.each.key "name"._String
+  for_ jobs rename_job
+ where
+  rename_job :: Text -> Jenkins ()
+  rename_job name = when (old `T.isInfixOf` name) $ do
+    let name' = (old `T.replace` new) name
+    -- prompt for every matching job
+    yes <- prompt $ T.unwords ["Rename", name, "to", name', "? [y/n]"]
+    when yes $
+      -- if user agrees then voodoo comes
+      post_ (job name -/- "doRename" -?- "newName" -=- name')
+
+  -- asks user until she enters 'y' or 'n'
+  prompt message = io . fix $ \loop -> do
+    T.putStrLn message
+    res <- T.getLine
+    case T.toUpper res of
+      "Y" -> return True
+      "N" -> return False
+      _   -> loop
+
+
+-- | Program options
+data Options = Options
+  { settings :: Settings
+  , old      :: Text
+  , new      :: Text
+  }
+
+-- | Quite a trivial options parser
+options :: ParserInfo Options
+options = info (helper <*> parser) fullDesc
+ where
+  parser = Options
+    <$> parse_settings
+    <*> nullOption (reader (return . T.pack) <> long "old")
+    <*> nullOption (reader (return . T.pack) <> long "new")
+
+  parse_settings = Settings
+    <$> (Host <$> strOption (long "host"))
+    <*> (Port <$> option (long "port"))
+    <*> (User . B.pack <$> strOption (long "user"))
+    <*> (APIToken . B.pack <$> strOption (long "token" <> long "password"))
diff --git a/libjenkins.cabal b/libjenkins.cabal
new file mode 100644
--- /dev/null
+++ b/libjenkins.cabal
@@ -0,0 +1,63 @@
+name:                libjenkins
+version:             0.1.0.0
+synopsis:            Jenkins API interface
+description:         Jenkins API interface. It supports REST and Discovery APIs
+license:             BSD3
+license-file:        LICENSE
+author:              Matvey Aksenov
+maintainer:          matvey.aksenov@gmail.com
+category:            Network
+build-type:          Simple
+extra-source-files:
+  README.md
+  CHANGELOG.md
+  examples/Jobs.hs
+  examples/Rename.hs
+  examples/Discover.hs
+cabal-version:       >= 1.10
+
+library
+  default-language:  Haskell2010
+  hs-source-dirs:    src
+  exposed-modules:
+    Jenkins.Discover
+    Jenkins.REST
+    Jenkins.REST.Internal
+    Jenkins.REST.Lens
+    Jenkins.REST.Method
+  build-depends:
+      async         >= 2.0
+    , base          >= 4.5 && < 5
+    , bytestring    >= 0.9
+    , conduit       >= 1.0
+    , data-default  >= 0.5
+    , free          >= 4.1
+    , http-conduit  >= 1.9
+    , http-types    >= 0.8
+    , lens          >= 3.9
+    , monad-control >= 0.3
+    , network       >= 2.4
+    , text          >= 0.11
+    , transformers  >= 0.3
+    , xml-conduit   >= 1.1
+    , xml-lens      >= 0.1
+
+test-suite doctests
+  default-language:  Haskell2010
+  type:              exitcode-stdio-1.0
+  build-depends:
+      base == 4.*
+    , directory
+    , doctest
+    , filepath
+  hs-source-dirs:    tests
+  main-is:           Doctests.hs
+
+source-repository head
+  type:     git
+  location: https://github.com/supki/libjenkins
+
+source-repository this
+  type:     git
+  location: https://github.com/supki/libjenkins
+  tag:      0.1.0.0
diff --git a/src/Jenkins/Discover.hs b/src/Jenkins/Discover.hs
new file mode 100644
--- /dev/null
+++ b/src/Jenkins/Discover.hs
@@ -0,0 +1,95 @@
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ViewPatterns #-}
+-- | Discover Jenkins on the network
+module Jenkins.Discover
+  ( Discover(..), discover
+  ) where
+
+import           Control.Applicative (Applicative(..), (<$>))
+import           Control.Lens
+import           Data.ByteString (ByteString)
+import qualified Data.ByteString as B
+import qualified Data.ByteString.Lazy as BL
+import           Data.Maybe (mapMaybe)
+import           Data.Text (Text)
+import           Network.BSD
+import           Network.Socket
+import           Network.Socket.ByteString as B
+import           System.Timeout (timeout)
+import qualified Text.XML as X
+import qualified Text.XML.Lens as X
+
+{-# ANN module ("HLint: ignore Use camelCase" :: String) #-}
+
+
+-- | Jenkins information
+data Discover = Discover
+  { version   :: Text
+  , url       :: Text
+  , server_id :: Maybe Text
+  } deriving (Show, Read)
+
+
+-- | Discover Jenkins on the network
+discover
+  :: Int           -- ^ timeout
+  -> IO [Discover]
+discover t = do
+  (b, addr) <- broadcastSocket
+  B.sendTo b (B.pack [0, 0, 0, 0]) addr -- does not matter what to send here
+
+  msgs <- while (timeout t (readAnswer b))
+
+  close b
+  return (mapMaybe parse msgs)
+ where
+  while :: IO (Maybe a) -> IO [a]
+  while io = go where
+    go = do
+      mr <- io
+      case mr of
+        Nothing -> return []
+        Just r  -> (r :) <$> go
+
+broadcastSocket :: IO (Socket, SockAddr)
+broadcastSocket = do
+  s <- getProtocolNumber "udp" >>= socket AF_INET Datagram
+  setSocketOption s Broadcast 1
+  return (s, SockAddrInet port (-1) {- 255.255.255.255 -})
+ where
+  port = 33848
+
+readAnswer :: Socket -> IO ByteString
+readAnswer s = fst <$> B.recvFrom s 4096
+
+
+-- | Parse Jenkins discovery response XML
+--
+-- The \"Scheme\" is as follows:
+--
+-- @
+-- <hudson>
+--   <version>...</version>
+--   <url>...</url>
+--   <server-id>...</server-id>
+-- </hudson>
+-- @
+parse :: ByteString -> Maybe Discover
+parse (X.parseLBS X.def . fromStrict -> bs) = case bs of
+  Left  _      -> Nothing
+  Right parsed ->
+    let v = parsed^?deeper.X.el "version".content
+        u = parsed^?deeper.X.el "url".content
+        s = parsed^?deeper.X.el "server-id".content
+    in Discover <$> v <*> u <*> pure s
+ where
+  content = X.nodes.traverse.X._Content
+  deeper  = X.root.X.nodes.traverse.X._Element
+
+fromStrict :: ByteString -> BL.ByteString
+#if defined(__GLASGOW_HASKELL__) && (__GLASGOW_HASKELL__ >= 706)
+fromStrict = BL.fromStrict
+#else
+fromStrict = BL.fromChunks . pure
+#endif
diff --git a/src/Jenkins/REST.hs b/src/Jenkins/REST.hs
new file mode 100644
--- /dev/null
+++ b/src/Jenkins/REST.hs
@@ -0,0 +1,201 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DeriveDataTypeable #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE TemplateHaskell #-}
+-- | Jenkins REST API interface
+module Jenkins.REST
+  ( -- * Types
+    Jenkins, Disconnect(..)
+  , Settings(..), Host(..), Port(..), User(..), APIToken(..), Password
+  , Request
+    -- * Jenkins queries construction
+  , get, post, post_, concurrently, io, disconnect, with
+    -- ** Jenkins method construction
+  , module Jenkins.REST.Method
+    -- ** Little helpers
+  , concurrentlys, concurrentlys_, postXML, reload, restart
+    -- * Jenkins queries execution
+  , runJenkins, runJenkinsP
+    -- ** Lenses and prisms
+  , jenkins_host, jenkins_port, jenkins_user, jenkins_api_token, jenkins_password
+  , _Host, _Port, _User, _APIToken, _Password
+    -- * Usable @http-conduit@ 'Request' type API
+  , module Jenkins.REST.Lens
+  ) where
+
+import           Control.Applicative ((<$))
+import           Control.Exception (handle)
+import           Control.Lens
+import           Control.Monad.IO.Class (MonadIO(..))
+import           Control.Monad.Trans.Maybe (MaybeT(..))
+import           Control.Monad.Trans.Reader (ReaderT, runReaderT)
+import qualified Data.ByteString as B
+import qualified Data.ByteString.Lazy as BL
+import           Data.Data (Data, Typeable)
+import           Data.Default (Default(..))
+import           Data.Monoid (mempty)
+import           Data.String (IsString)
+import           GHC.Generics (Generic)
+import           Network.HTTP.Conduit (Request, HttpException, withManager, applyBasicAuth, parseUrl)
+import           Text.XML (Document, renderLBS)
+
+import           Jenkins.REST.Internal
+import qualified Jenkins.REST.Lens
+import           Jenkins.REST.Lens as L
+import           Jenkins.REST.Method
+
+{-# ANN module ("HLint: Use const" :: String) #-}
+
+
+-- | @GET@ query
+get :: Method Complete f -> Jenkins BL.ByteString
+get m = liftJ $ Get m id
+{-# INLINE get #-}
+
+-- | @POST@ query (with payload)
+post :: (forall f. Method Complete f) -> BL.ByteString -> Jenkins ()
+post m body = liftJ $ Post m body (\_ -> ())
+{-# INLINE post #-}
+
+-- | @POST@ query (without payload)
+post_ :: (forall f. Method Complete f) -> Jenkins ()
+post_ m = post m mempty
+{-# INLINE post_ #-}
+
+-- | Do both queries 'concurrently'
+concurrently :: Jenkins a -> Jenkins b -> Jenkins (a, b)
+concurrently ja jb = liftJ $ Conc ja jb (,)
+{-# INLINE concurrently #-}
+
+-- | Lift arbitrary 'IO' action
+io :: IO a -> Jenkins a
+io = liftIO
+{-# INLINE io #-}
+
+-- | Disconnect from Jenkins
+--
+-- Following queries won't be executed
+disconnect :: Jenkins a
+disconnect = liftJ Dcon
+{-# INLINE disconnect #-}
+
+-- | Make custom local changes to 'Request'
+with :: (forall m. Request m -> Request m) -> Jenkins a -> Jenkins a
+with f j = liftJ $ With f j id
+{-# INLINE with #-}
+
+
+-- * Convenience
+
+-- | @POST@ job's @config.xml@ (in @xml-conduit@ format)
+postXML :: (forall f. Method Complete f) -> Document -> Jenkins ()
+postXML m =
+  with (requestHeaders <>~ [("Content-Type", "text/xml")]) . post m . renderLBS def
+{-# INLINE postXML #-}
+
+-- | Do a list of queries 'concurrently'
+concurrentlys :: [Jenkins a] -> Jenkins [a]
+concurrentlys = foldr go (return [])
+ where
+  go x xs = do
+    (y, ys) <- concurrently x xs
+    return (y : ys)
+{-# INLINE concurrentlys #-}
+
+-- | Do a list of queries 'concurrently' ignoring their results
+concurrentlys_ :: [Jenkins a] -> Jenkins ()
+concurrentlys_ = foldr (\x xs -> () <$ concurrently x xs) (return ())
+{-# INLINE concurrentlys_ #-}
+
+-- | Reload jenkins configuration from disk
+reload :: Jenkins a
+reload = do
+  post_ "reload"
+  disconnect
+{-# INLINE reload #-}
+
+-- | Restart jenkins
+restart :: Jenkins a
+restart = do
+  post_ "restart"
+  disconnect
+{-# INLINE restart #-}
+
+
+-- | Jenkins settings
+--
+-- '_jenkins_api_token' might as well be the user's password, Jenkins
+-- does not make any distinction between these concepts
+data Settings = Settings
+  { _jenkins_host      :: Host
+  , _jenkins_port      :: Port
+  , _jenkins_user      :: User
+  , _jenkins_api_token :: APIToken
+  } deriving (Show, Eq, Typeable, Data, Generic)
+
+instance Default Settings where
+  def = Settings
+    { _jenkins_host      = "http://example.com"
+    , _jenkins_port      = 80
+    , _jenkins_user      = "anonymous"
+    , _jenkins_api_token = "secret"
+    }
+
+-- | Jenkins host address
+newtype Host = Host { unHost :: String }
+  deriving (Show, Eq, Typeable, Data, Generic, IsString)
+
+-- | Jenkins port
+newtype Port = Port { unPort :: Int }
+  deriving (Show, Eq, Typeable, Data, Generic, Num)
+
+-- | Jenkins user
+newtype User = User { unUser :: B.ByteString }
+  deriving (Show, Eq, Typeable, Data, Generic, IsString)
+
+-- | Jenkins user API token
+newtype APIToken = APIToken { unAPIToken :: B.ByteString }
+  deriving (Show, Eq, Typeable, Data, Generic, IsString)
+
+-- | Jenkins user password
+type Password = APIToken
+
+makeLenses ''Settings
+
+jenkins_password :: Lens' Settings Password
+jenkins_password = jenkins_api_token
+{-# INLINE jenkins_password #-}
+
+makePrisms ''Host
+makePrisms ''Port
+makePrisms ''User
+makePrisms ''APIToken
+
+_Password :: Prism' Password B.ByteString
+_Password = _APIToken
+{-# INLINE _Password #-}
+
+
+data Disconnect =
+    Disconnect
+  | JenkinsException HttpException
+    deriving (Show, Typeable, Generic)
+
+-- | Communicate with Jenkins REST API
+--
+-- Catches 'HttpException's thrown by @http-conduit@, but
+-- does not catch exceptions thrown by embedded 'IO' actions
+runJenkins :: Settings -> Jenkins a -> IO (Either Disconnect a)
+runJenkins (Settings (Host h) (Port p) (User u) (APIToken t)) jenk =
+  handle (return . Left . JenkinsException) $
+    withManager $ \manager -> do
+      req <- liftIO $ parseUrl h
+      let req' = req
+            & L.port            .~ p
+            & L.responseTimeout .~ Just (20 * 1000000)
+      res <- runReaderT (runMaybeT (runJenkinsIO manager jenk)) (applyBasicAuth u t req')
+      return $ maybe (Left Disconnect) Right res
diff --git a/src/Jenkins/REST/Internal.hs b/src/Jenkins/REST/Internal.hs
new file mode 100644
--- /dev/null
+++ b/src/Jenkins/REST/Internal.hs
@@ -0,0 +1,114 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE NoMonoLocalBinds #-}
+{-# LANGUAGE NoMonomorphismRestriction #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE TypeFamilies #-}
+module Jenkins.REST.Internal where
+
+import           Control.Applicative (Applicative(..))
+import           Control.Concurrent.Async (concurrently)
+import           Control.Exception (toException)
+import           Control.Lens
+import           Control.Monad (join)
+import           Control.Monad.Free.Church (F, iterM, liftF)
+import           Control.Monad.IO.Class (MonadIO(..))
+import           Control.Monad.Trans.Class (lift)
+import           Control.Monad.Trans.Control (MonadTransControl(..))
+import           Control.Monad.Trans.Reader (ReaderT, ask, local)
+import           Control.Monad.Trans.Maybe (MaybeT, mapMaybeT)
+import qualified Data.ByteString.Lazy as BL
+import           Data.Conduit (ResourceT)
+import           Network.HTTP.Conduit
+import           Network.HTTP.Types (Status(..))
+
+import           Jenkins.REST.Lens as L
+import           Jenkins.REST.Method
+
+
+-- | Jenkins REST API composable queries
+newtype Jenkins a = Jenkins { unJenkins :: F JenkinsF a }
+  deriving (Functor, Applicative, Monad)
+
+instance MonadIO Jenkins where
+  liftIO = liftJ . IO
+  {-# INLINE liftIO #-}
+
+-- | 'JenkinsF' terms
+data JenkinsF a where
+  Get  :: Method Complete f -> (BL.ByteString -> a) -> JenkinsF a
+  Post :: (forall f. Method Complete f) -> BL.ByteString -> (BL.ByteString -> a) -> JenkinsF a
+  Conc :: Jenkins a -> Jenkins b -> (a -> b -> c) -> JenkinsF c
+  IO   :: IO a -> JenkinsF a
+  With :: (forall m. Request m -> Request m) -> Jenkins b -> (b -> a) -> JenkinsF a
+  Dcon :: JenkinsF a
+
+instance Functor JenkinsF where
+  fmap f (Get  m g)      = Get  m      (f . g)
+  fmap f (Post m body g) = Post m body (f . g)
+  fmap f (Conc m n g)    = Conc m n    (\a b -> f (g a b))
+  fmap f (IO a)          = IO (fmap f a)
+  fmap f (With h j g)    = With h j    (f . g)
+  fmap _ Dcon            = Dcon
+  {-# INLINE fmap #-}
+
+-- | Lift 'JenkinsF' query to the 'Jenkins' query language
+liftJ :: JenkinsF a -> Jenkins a
+liftJ = Jenkins . liftF
+{-# INLINE liftJ #-}
+
+
+runJenkinsIO
+  :: Manager
+  -> Jenkins a
+  -> MaybeT (ReaderT (Request (ResourceT IO)) (ResourceT IO)) a
+runJenkinsIO manager = runJenkinsP (jenkinsIO manager)
+{-# INLINE runJenkinsIO #-}
+
+-- | Generic Jenkins REST API queries interpreter
+--
+-- Particularly useful for testing (with @m ≡ 'Identity'@)
+runJenkinsP :: Monad m => (JenkinsF (m a) -> m a) -> Jenkins a -> m a
+runJenkinsP go = iterM go . unJenkins
+{-# INLINE runJenkinsP #-}
+
+jenkinsIO
+  :: Manager
+  -> JenkinsF (MaybeT (ReaderT (Request (ResourceT IO)) (ResourceT IO)) a)
+  -> MaybeT (ReaderT (Request (ResourceT IO)) (ResourceT IO)) a
+jenkinsIO manager = go where
+  go (Get m next) = do
+    req <- lift ask
+    let req' = req
+          & L.path   %~ (`slash` render m)
+          & L.method .~ "GET"
+    bs <- lift . lift $ httpLbs req' manager
+    next (responseBody bs)
+  go (Post m body next) = do
+    req <- lift ask
+    let req' = req
+          & L.path          %~ (`slash` render m)
+          & L.method        .~ "POST"
+          & L.requestBody   .~ RequestBodyLBS body
+          & L.redirectCount .~ 0
+          & L.checkStatus   .~ \s@(Status st _) hs cookie_jar ->
+            if 200 <= st && st < 400
+                then Nothing
+                else Just . toException $ StatusCodeException s hs cookie_jar
+    res <- lift . lift $ httpLbs req' manager
+    next (responseBody res)
+  go (Conc jenka jenkb next) = do
+    (a, b) <- liftWith $ \run' -> liftWith $ \run'' -> liftWith $ \run''' ->
+      let run :: Jenkins t -> IO (StT ResourceT (StT (ReaderT (Request (ResourceT IO))) (StT MaybeT t)))
+          run = run''' . run'' . run' . runJenkinsIO manager
+      in concurrently (run jenka) (run jenkb)
+    c <- restoreT . restoreT . restoreT $ return a
+    d <- restoreT . restoreT . restoreT $ return b
+    next c d
+  go (IO action) = join (liftIO action)
+  go (With f jenk next) = do
+    res <- mapMaybeT (local f) (runJenkinsIO manager jenk)
+    next res
+  go Dcon = fail "disconnect"
diff --git a/src/Jenkins/REST/Lens.hs b/src/Jenkins/REST/Lens.hs
new file mode 100644
--- /dev/null
+++ b/src/Jenkins/REST/Lens.hs
@@ -0,0 +1,56 @@
+-- | Lenses for 'Network.HTTP.Conduit' types
+module Jenkins.REST.Lens
+  ( -- * 'Request' lenses
+    method, secure
+  , host, port
+  , path, queryString, requestBody, requestHeaders
+  , redirectCount, checkStatus, responseTimeout
+  ) where
+
+import           Control.Applicative ((<$>))
+import           Control.Exception (SomeException)
+import           Control.Lens
+import           Data.ByteString (ByteString)
+import qualified Network.HTTP.Conduit as H
+import qualified Network.HTTP.Types as H
+
+
+-- | HTTP request method, eg GET, POST.
+method :: Lens' (H.Request m) H.Method
+method f req = (\m' -> req { H.method = m' }) <$> f (H.method req)
+
+-- | Whether to use HTTPS (ie, SSL).
+secure :: Lens' (H.Request m) Bool
+secure f req = (\s' -> req { H.secure = s' }) <$> f (H.secure req)
+
+host :: Lens' (H.Request m) ByteString
+host f req = (\h' -> req { H.host = h' }) <$> f (H.host req)
+
+port :: Lens' (H.Request m) Int
+port f req = (\p' -> req { H.port = p' }) <$> f (H.port req)
+
+-- | Everything from the host to the query string.
+path :: Lens' (H.Request m) ByteString
+path f req = (\p' -> req { H.path = p' }) <$> f (H.path req)
+
+queryString :: Lens' (H.Request m) ByteString
+queryString f req = (\qs' -> req { H.queryString = qs' }) <$> f (H.queryString req)
+
+requestBody :: Lens' (H.Request m) (H.RequestBody m)
+requestBody f req = (\rb' -> req { H.requestBody = rb' }) <$> f (H.requestBody req)
+
+requestHeaders :: Lens' (H.Request m) H.RequestHeaders
+requestHeaders f req = (\rh' -> req { H.requestHeaders = rh' }) <$> f (H.requestHeaders req)
+
+-- | How many redirects to follow when getting a resource. 0 means follow no redirects. Default value: 10.
+redirectCount :: Lens' (H.Request m) Int
+redirectCount f req = (\rc' -> req { H.redirectCount = rc' }) <$> f (H.redirectCount req)
+
+-- | Check the status code. Note that this will run after all redirects are performed.
+-- Default: return a StatusCodeException on non-2XX responses.
+checkStatus :: Lens' (H.Request m) (H.Status -> H.ResponseHeaders -> H.CookieJar -> Maybe SomeException)
+checkStatus f req = (\cs' -> req { H.checkStatus = cs' }) <$> f (H.checkStatus req)
+
+-- | Number of microseconds to wait for a response. If Nothing, will wait indefinitely. Default: 5 seconds.
+responseTimeout :: Lens' (H.Request m) (Maybe Int)
+responseTimeout f req = (\rt' -> req { H.responseTimeout = rt' }) <$> f (H.responseTimeout req)
diff --git a/src/Jenkins/REST/Method.hs b/src/Jenkins/REST/Method.hs
new file mode 100644
--- /dev/null
+++ b/src/Jenkins/REST/Method.hs
@@ -0,0 +1,278 @@
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DeriveDataTypeable #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE KindSignatures #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE StandaloneDeriving #-}
+{-# LANGUAGE UndecidableInstances #-}
+-- | Jenkins REST API method construction
+module Jenkins.REST.Method
+  ( -- * Types
+    Method, Type(..), Format, As
+    -- * Method construction
+  , text, int, (-?-), (-/-), (-=-), (-&-), query
+  , as, JSONy(..), XMLy(..), Pythony(..)
+    -- ** Helpers
+  , job, build, view, queue
+    -- * Rendering
+  , render, slash
+  ) where
+
+import           Data.ByteString (ByteString)
+#if defined(__GLASGOW_HASKELL__) && (__GLASGOW_HASKELL__ < 706)
+import           Data.ByteString.Char8 ()
+#endif
+import qualified Data.ByteString as B
+import           Data.Data (Data, Typeable)
+import           Data.Monoid (Monoid(..), (<>))
+import           Data.String (IsString(..))
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as T
+import           Data.Text (Text)
+import           GHC.Generics (Generic)
+import           Network.URI (escapeURIChar, isUnreserved)
+
+-- $setup
+-- >>> :set -XOverloadedStrings
+
+
+infix  1 :~?, -?-
+infix  3 :~@, `as`
+infix  7 :~=, -=-
+infixr 5 :~/, -/-, :~&, -&-
+-- | Jenkins RESTFul API method encoding
+data Method :: Type -> Format -> * where
+  Empty :: Method t f
+  Text  :: Text -> Method Complete f
+  (:~/)  :: Method Complete f -> Method Complete f -> Method Complete f
+  (:~@)  :: Method Complete f -> As f -> Method Complete f
+  (:~=)  :: Text -> Maybe Text -> Method Query f
+  (:~&)  :: Method Query f -> Method Query f -> Method Query f
+  (:~?)  :: Method Complete f -> Method Query f -> Method Complete f
+
+deriving instance Show (As f) => Show (Method t f)
+
+-- | Only to support number literals
+instance Num (Method Complete f) where
+  (+)         = error "Method.(+): not supposed to be used"
+  (*)         = error "Method.(*): not supposed to be used"
+  abs         = error "Method.abs: not supposed to be used"
+  signum      = error "Method.signum: not supposed to be used"
+  fromInteger = fromString . show
+
+instance IsString (Method Complete f) where
+  fromString = Text . T.pack
+
+instance IsString (Method Query f) where
+  fromString str = T.pack str :~= Nothing
+
+-- | Method types
+data Type = Query | Complete
+  deriving (Show, Eq, Typeable, Data, Generic)
+
+-- | Response formats
+data Format = JSON | XML | Python
+  deriving (Show, Eq, Typeable, Data, Generic)
+
+-- | Response format singleton type
+data As :: Format -> * where
+  AsJSON   :: As JSON
+  AsXML    :: As XML
+  AsPython :: As Python
+
+deriving instance Show (As f)
+deriving instance Eq (As f)
+
+-- | Convert 'Text' to 'Method'
+text :: Text -> Method Complete f
+text = Text
+
+-- | Convert 'Integer' to 'Method'
+int :: Integer -> Method Complete f
+int = fromInteger
+
+-- | Append 2 paths
+(-/-) :: Method Complete f -> Method Complete f -> Method Complete f
+(-/-) = (:~/)
+
+-- | Append 2 queries
+(-&-) :: Method Query f -> Method Query f -> Method Query f
+(-&-) = (:~&)
+
+-- | Make a query
+(-=-) :: Text -> Text -> Method Query f
+x -=- y = x :~= Just y
+
+-- | Choose response format
+as :: Method Complete f -> As f -> Method Complete f
+as = (:~@)
+
+-- | JSON response format
+class JSONy t where
+  json :: t JSON
+
+instance JSONy As where
+  json = AsJSON
+
+instance t ~ Complete => JSONy (Method t) where
+  json = "" `as` json
+
+-- | XML response format
+class XMLy t where
+  xml :: t XML
+
+instance XMLy As where
+  xml = AsXML
+
+instance t ~ Complete => XMLy (Method t) where
+  xml = "" `as` xml
+
+-- | Python response format
+class Pythony t where
+  python :: t Python
+
+instance Pythony As where
+  python = AsPython
+
+instance t ~ Complete => Pythony (Method t) where
+  python = "" `as` python
+
+-- | Append path and query
+(-?-) :: Method Complete f -> Method Query f -> Method Complete f
+(-?-) = (:~?)
+
+-- | list-to-query combinator
+--
+-- >>> render (query [("foo", Nothing), ("bar", Just "baz"), ("quux", Nothing)])
+-- "foo&bar=baz&quux"
+--
+-- >>> render (query [])
+-- ""
+query :: [(Text, Maybe Text)] -> Method Query f
+query [] = Empty
+query xs = foldr1 (:~&) (map (uncurry (:~=)) xs)
+
+
+-- | Render 'Method' to something that can be sent over the wire
+--
+-- >>> render ("" `as` xml)
+-- "api/xml"
+--
+-- >>> render xml
+-- "api/xml"
+--
+-- >>> render ("job" -/- 7 `as` xml)
+-- "job/7/api/xml"
+--
+-- >>> render ("job" -/- 7 `as` xml)
+-- "job/7/api/xml"
+--
+-- >>> render ("job" -/- 7 `as` json)
+-- "job/7/api/json"
+--
+-- >>> render (text "restart")
+-- "restart"
+--
+-- >>> render ("job" -?- "name" -=- "foo" -&- "title" -=- "bar")
+-- "job?name=foo&title=bar"
+--
+-- >>> render ("job" -?- "name" -&- "title" -=- "bar")
+-- "job?name&title=bar"
+--
+-- >>> render ("job" -/- 7 `as` json -?- "name" -&- "title" -=- "bar")
+-- "job/7/api/json?name&title=bar"
+--
+-- >>> render ("job" -/- "ДМИТРИЙ" `as` xml)
+-- "job/%D0%94%D0%9C%D0%98%D0%A2%D0%A0%D0%98%D0%99/api/xml"
+render :: Method t f -> ByteString
+render Empty            = ""
+render (Text s)         = renderText s
+render (x :~/ y)        = render x `slash` render y
+render (x :~@ f)        =
+  let prefix  = render x
+      postfix = renderFormat f
+  in if B.null prefix then  "api" `slash` postfix else prefix `slash` "api" `slash` postfix
+render (x :~= Just y)   = renderText x `equals` renderText y
+render (x :~= Nothing)  = renderText x
+render (x :~& y)        = render x `ampersand` render y
+render (x :~? y)        = render x `question` render y
+
+renderFormat :: IsString s => As f -> s
+renderFormat AsJSON   = "json"
+renderFormat AsXML    = "xml"
+renderFormat AsPython = "python"
+
+-- | Render unicode text as query string
+--
+-- >>> renderText "foo-bar-baz"
+-- "foo-bar-baz"
+--
+-- >>> renderText "foo bar baz"
+-- "foo%20bar%20baz"
+--
+-- >>> renderText "ДМИТРИЙ МАЛИКОВ"
+-- "%D0%94%D0%9C%D0%98%D0%A2%D0%A0%D0%98%D0%99%20%D0%9C%D0%90%D0%9B%D0%98%D0%9A%D0%9E%D0%92"
+renderText :: Text -> ByteString
+renderText = T.encodeUtf8 . T.concatMap (T.pack . escapeURIChar isUnreserved)
+
+-- | Insert \"\/\" between two 'String'-like things and concat them.
+slash :: (IsString m, Monoid m) => m -> m -> m
+slash = insert "/"
+
+-- | Insert \"=\" between two 'String'-like things and concat them.
+equals :: (IsString m, Monoid m) => m -> m -> m
+equals = insert "="
+
+-- | Insert \"&\" between two 'String'-like things and concat them.
+ampersand :: (IsString m, Monoid m) => m -> m -> m
+ampersand = insert "&"
+
+-- | Insert \"?\" between two 'String'-like things and concat them.
+question :: (IsString m, Monoid m) => m -> m -> m
+question = insert "?"
+
+-- | Insert 'String'-like thing between two 'String'-like things and concat them.
+--
+-- >>> "foo" `slash` "bar"
+-- "foo/bar"
+--
+-- >>> "" `ampersand` "foo"
+-- "&foo"
+--
+-- >>> "foo" `question` ""
+-- "foo?"
+insert :: (IsString m, Monoid m) => m -> m -> m -> m
+insert t x y = x <> t <> y
+
+
+-- | Job API method
+--
+-- >>> render (job "name" `as` json)
+-- "job/name/api/json"
+job :: Text -> Method Complete f
+job name = "job" -/- text name
+
+-- | Job build API method
+--
+-- >>> render (build "name" 4 `as` json)
+-- "job/name/4/api/json"
+build :: Integral a => Text -> a -> Method Complete f
+build name num = "job" -/- text name -/- int (toInteger num)
+
+-- | View API method
+--
+-- >>> render (view "name" `as` xml)
+-- "view/name/api/xml"
+view :: Text -> Method Complete f
+view name = "view" -/- text name
+
+-- | Queue API method
+--
+-- >>> render (queue `as` python)
+-- "queue/api/python"
+queue :: Method Complete f
+queue = "queue"
diff --git a/tests/Doctests.hs b/tests/Doctests.hs
new file mode 100644
--- /dev/null
+++ b/tests/Doctests.hs
@@ -0,0 +1,28 @@
+module Main where
+
+import Control.Applicative ((<$>))
+import Control.Monad (forM, liftM)
+import Data.List (isSuffixOf)
+import System.Directory (getDirectoryContents, doesDirectoryExist)
+import System.FilePath ((</>))
+import Test.DocTest (doctest)
+
+
+main :: IO ()
+main = sources "src" >>= doctest
+
+sources :: FilePath -> IO [FilePath]
+sources root = do
+  files <- contents root
+  liftM concat . forM files $ \file -> do
+    let path = root </> file
+    is_dir <- doesDirectoryExist path
+    case is_dir of
+      True              -> sources path
+      _ | isSource path -> return [path]
+        | otherwise     -> return []
+ where
+  isSource path = any (`isSuffixOf` path) [".hs", ".lhs"]
+
+contents :: FilePath -> IO [FilePath]
+contents dir = filter (`notElem` [".", ".."]) <$> getDirectoryContents dir
