diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for sensu-run
+
+## 0.0.0  -- 2017-05-16
+
+* Initial release
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2016, Mitsutoshi Aoe
+
+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 Mitsutoshi Aoe 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/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/sensu-run.cabal b/sensu-run.cabal
new file mode 100644
--- /dev/null
+++ b/sensu-run.cabal
@@ -0,0 +1,40 @@
+name: sensu-run
+version: 0.0.0
+synopsis: A tool to send command execution results to Sensu
+description:
+  @sensu-run@ is a command line tool to send command execution results to Sensu
+  monitoring server.
+license: BSD3
+license-file: LICENSE
+author: Mitsutoshi Aoe
+maintainer: maoe@foldr.in
+copyright: Copyright (C) 2016-2017 Mitsutoshi Aoe
+homepage: https://github.com/maoe/sensu-run#readme
+bug-reports: https://github.com/maoe/sensu-run/issues
+category: System
+build-type: Simple
+extra-source-files: CHANGELOG.md
+cabal-version: >= 1.10
+tested-with: GHC >= 8.0.1 && < 8.1
+
+executable sensu-run
+  main-is: sensu-run.hs
+  build-depends:
+      aeson >= 0.11 && < 1.3
+    , base >= 4.9 && < 4.10
+    , bytestring >= 0.10 && < 0.11
+    , filepath >= 1.4.1 && < 1.5
+    , http-client >= 0.5.6 && < 0.6
+    , http-types >= 0.9.1 && < 0.10
+    , lens >= 4.15 && < 4.16
+    , network >= 2.2.3 && < 2.7
+    , optparse-applicative >= 0.12 && < 0.14
+    , process >= 1.4 && < 1.7
+    , temporary >= 1.1 && < 1.3
+    , text >= 1.2.2 && < 1.3
+    , time >= 1.5.0.1 && < 1.9
+    , vector >= 0.11 && < 0.13
+    , wreq >= 0.5.0 && < 0.6
+
+  ghc-options: -Wall -threaded
+  default-language: Haskell2010
diff --git a/sensu-run.hs b/sensu-run.hs
new file mode 100644
--- /dev/null
+++ b/sensu-run.hs
@@ -0,0 +1,278 @@
+{-# LANGUAGE ApplicativeDo #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE MultiWayIf #-}
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE PatternSynonyms #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE StrictData #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE ViewPatterns #-}
+module Main where
+import Control.Exception
+import Data.Foldable
+import Data.List.NonEmpty (NonEmpty)
+import Data.Maybe
+import System.Exit
+import System.IO
+import qualified Data.List.NonEmpty as NE
+import qualified System.Timeout as Timeout
+
+import Control.Lens hiding ((.=))
+import Data.Aeson
+import Data.Time
+import Data.Time.Clock.POSIX
+import Network.HTTP.Client (HttpException)
+import Network.Socket
+import System.FilePath ((</>))
+import System.IO.Temp
+import System.Process
+import qualified Data.ByteString.Lazy as BL
+import qualified Data.ByteString.Lazy.Char8 as BL8
+import qualified Data.Text as T
+import qualified Data.Text.Encoding.Error as TE
+import qualified Data.Text.Lazy as TL
+import qualified Data.Text.Lazy.Encoding as TL
+import qualified Network.HTTP.Types.Status as HT
+import qualified Network.Socket.ByteString.Lazy as Socket
+import qualified Network.Wreq as W
+import qualified Options.Applicative as O
+
+main :: IO ()
+main = do
+  issued <- getCurrentTime
+  Options {..} <- O.execParser $ O.info (O.helper <*> options) O.fullDesc
+  withSystemTempFile "sensu-run.XXX" $ \path hdl -> do
+    executed <- getCurrentTime
+    rawStatus <- bracket
+      (startProcess cmdspec hdl)
+      terminateProcess
+      (withTimeout timeout . waitForProcess)
+    exited <- getCurrentTime
+    rawOutput <- BL.readFile path
+    let
+      encoded = encode $ CheckResult
+        { command = cmdspec
+        , output = TL.decodeUtf8With TE.lenientDecode rawOutput
+        , status = case rawStatus of
+          Nothing -> UNKNOWN
+          Just ExitSuccess -> OK
+          Just (ExitFailure {}) -> CRITICAL
+        , duration = diffUTCTime exited executed
+        , ..
+        }
+    if dryRun
+      then BL8.putStrLn encoded
+      else case endpoint of
+        ClientSocketInput port -> sendToClientSocketInput port encoded
+        SensuServer urls -> sendToSensuServer urls encoded
+    case rawStatus of
+      Just ExitSuccess -> exitSuccess
+      Nothing -> do
+        hPutStrLn stderr $ showCmdSpec cmdspec ++ " timed out"
+        exitFailure
+      Just (ExitFailure {}) -> exitFailure
+
+sendToClientSocketInput
+  :: PortNumber -- ^ Listening port of Sensu client socket
+  -> BL8.ByteString -- ^ Payload
+  -> IO ()
+sendToClientSocketInput port payload = bracket open close $ \sock -> do
+  localhost <- inet_addr "127.0.0.1"
+  connect sock $ SockAddrInet port localhost
+  Socket.sendAll sock payload
+  `catch` \(ioe :: IOException) -> do
+    hPutStrLn stderr $
+      "Failed to write results to localhost:3030 (" ++ show ioe ++ ")"
+    exitFailure
+  where
+    open = socket AF_INET Stream defaultProtocol
+
+sendToSensuServer
+  :: NonEmpty String -- ^ Sensu server base URLs
+  -> BL8.ByteString -- ^ Payload
+  -> IO ()
+sendToSensuServer urls payload =
+  foldr go (handleError "no more retry") urls
+    `catch` \(e :: HttpException) -> handleError (show e)
+  where
+    go url retry = do
+      resp <- W.postWith params (url </> "results") payload
+      let status = resp ^. W.responseStatus
+      if
+        | HT.statusIsClientError status -> handleError $ show status
+        | HT.statusIsServerError status -> retry
+        | HT.statusIsSuccessful status -> return ()
+        | otherwise ->
+          fail $ "sendToSensuServer: unexpected status " ++ show status
+    params = W.defaults &
+      W.header "Content-Type" .~ ["application/json"]
+    handleError reason = do
+      hPutStrLn stderr $
+        "Failed to POST results to Sensu server (" ++ reason ++ ")"
+      exitFailure
+
+startProcess :: CmdSpec -> Handle -> IO ProcessHandle
+startProcess cmdspec hdl = do
+  (_, _, _, ph) <- createProcess CreateProcess
+    { cmdspec
+    , cwd = Nothing
+    , env = Nothing
+    , std_in = Inherit
+    , std_out = UseHandle hdl
+    , std_err = UseHandle hdl
+    , close_fds = False
+    , create_group = False
+    , delegate_ctlc = False
+    , detach_console = False
+    , create_new_console = False
+    , new_session = False
+    , child_group = Nothing
+    , child_user = Nothing
+    }
+  return ph
+
+withTimeout :: Maybe NominalDiffTime -> IO a -> IO (Maybe a)
+withTimeout time io = case time of
+  Just n -> Timeout.timeout (round $ n * 10^(6 :: Int))  io
+  Nothing -> Just <$> io
+
+data Options = Options
+  { name :: T.Text
+  , cmdspec :: CmdSpec
+  , source :: Maybe T.Text
+  , ttl :: Maybe NominalDiffTime
+  , timeout :: Maybe NominalDiffTime
+  , handlers :: [T.Text]
+  , endpoint :: Endpoint
+  , dryRun :: Bool
+  }
+
+data Endpoint
+  = ClientSocketInput PortNumber
+  -- ^ Local client socket input
+  | SensuServer (NonEmpty String)
+  -- ^ Sensu server API
+  --
+  -- Multiple servers can be specified. sensu-run retries sequentially until it
+  -- succeeds.
+
+options :: O.Parser Options
+options = do
+  name <- textOption $ mconcat
+    [ O.short 'n'
+    , O.long "name"
+    , O.metavar "NAME"
+    , O.help "The name of the check"
+    ]
+  source <- O.optional $ textOption $ mconcat
+    [ O.long "source"
+    , O.metavar "SOURCE"
+    , O.help $ unlines
+      [ "The check source, used to create a JIT Sensu client for an"
+      , "external resource" ]
+    ]
+  ttl <- durationOption $ mconcat
+    [ O.long "ttl"
+    , O.metavar "SECONDS"
+    , O.help "The time to live in seconds until check results are considered stale"
+    ]
+  timeout <- durationOption $ mconcat
+    [ O.long "timeout"
+    , O.metavar "SECONDS"
+    , O.help "The check executaion duration timeout in seconds"
+    ]
+  handlers <- O.some $ textOption $ mconcat
+    [ O.long "handler"
+    , O.metavar "HANDLER"
+    , O.help "Sensu event handler(s) to use for events created by the check"
+    ]
+  endpoint <- asum
+    [ ClientSocketInput <$> portOption
+    , SensuServer . NE.fromList <$> O.some serverOption
+    ]
+  dryRun <- O.switch $ mconcat
+    [ O.long "dry-run"
+    , O.long "dry"
+    ]
+  cmdspec <- cmdSpecOption
+  return Options {..}
+  where
+    textOption m = T.pack <$> O.strOption m
+    durationOption m =
+      fmap (realToFrac @Double) <$> O.optional (O.option O.auto m)
+    cmdSpecOption = cmdSpec
+      <$> O.optional
+        (O.switch $ mconcat
+          [ O.short 's'
+          , O.long "shell"
+          , O.help "Execute the command using the shell"
+          ])
+      <*> O.some (O.strArgument $ O.metavar "COMMAND")
+      where
+        cmdSpec (fromMaybe False -> isShell) args
+          | isShell = ShellCommand (unwords args)
+          | otherwise = RawCommand (head args) (tail args)
+    portOption = O.option O.auto $ mconcat
+      [ O.long "port"
+      , O.metavar "PORT"
+      , O.help
+        "Send results to the local sensu-client listening on the specified port"
+      , O.showDefault
+      , O.value 3030
+      ]
+    serverOption = O.strOption $ mconcat
+      [ O.long "server"
+      , O.metavar "URL"
+      , O.help "Send results to the specified Sensu server"
+      ]
+
+data CheckResult = CheckResult
+  { name :: T.Text
+  , command :: CmdSpec
+  , status :: ExitCode
+  , source :: Maybe T.Text
+  , issued :: UTCTime
+  , executed :: UTCTime
+  , duration :: NominalDiffTime
+  , output :: TL.Text
+  }
+
+pattern OK :: ExitCode
+pattern OK = ExitSuccess
+
+pattern WARNING :: ExitCode
+pattern WARNING = ExitFailure 1
+
+pattern CRITICAL :: ExitCode
+pattern CRITICAL = ExitFailure 2
+
+pattern UNKNOWN :: ExitCode
+pattern UNKNOWN = ExitFailure 3
+
+instance ToJSON CheckResult where
+  toJSON = object . checkResultKeyValue
+  toEncoding = pairs . mconcat . checkResultKeyValue
+
+checkResultKeyValue :: KeyValue a => CheckResult -> [a]
+checkResultKeyValue CheckResult {..} =
+  addOptional "source" source
+    [ "name" .= name
+    , "command" .= showCmdSpec command
+    , "issued" .= (floor (utcTimeToPOSIXSeconds issued) :: Int)
+    , "executed" .= (floor (utcTimeToPOSIXSeconds executed) :: Int)
+    , "duration" .= (round duration :: Int)
+    , "status" .= statusToInt status
+    , "output" .= output
+    ]
+    where
+      addOptional key val ps = maybe ps (\val' -> key .= val' : ps) val
+      statusToInt ExitSuccess = 0
+      statusToInt (ExitFailure n) = n
+
+showCmdSpec :: CmdSpec -> String
+showCmdSpec = \case
+  ShellCommand cmd -> cmd
+  RawCommand cmd args -> unwords $ cmd:args
