diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright Alex Mingoia (c) 2021
+
+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 Alex Mingoia 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,22 @@
+# Twain
+
+[![Hackage](https://img.shields.io/hackage/v/twain.svg?style=flat)](http://hackage.haskell.org/package/twain)
+![BSD3 License](http://img.shields.io/badge/license-BSD3-brightgreen.svg)
+
+Twain is a tiny web application framework for [WAI](http://hackage.haskell.org/package/wai).
+
+- Simple routing with path captures.
+- Parameter parsing of cookies, path, query, and body.
+- Compose responses from an app environment using a reader-like monad.
+- Helpers for redirects, headers, status codes.
+- Routes decompose into WAI middleware.
+
+```haskell
+import Web.Twain
+
+main :: IO ()
+main = do
+  twain 8080 "my app" $ do
+    get "/" $ do
+      send $ html "Hello, World!"
+```
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/changelog.md b/changelog.md
new file mode 100644
--- /dev/null
+++ b/changelog.md
@@ -0,0 +1,5 @@
+# Change Log
+
+## 1.0.0.0 [2021-05-11]
+
+- Initial release.
diff --git a/src/Web/Twain.hs b/src/Web/Twain.hs
new file mode 100644
--- /dev/null
+++ b/src/Web/Twain.hs
@@ -0,0 +1,324 @@
+module Web.Twain
+  ( -- * Twain to WAI
+    twain,
+    twain',
+    twainApp,
+
+    -- * Middleware and Routes.
+    middleware,
+    get,
+    put,
+    patch,
+    post,
+    delete,
+    notFound,
+    onException,
+    addRoute,
+
+    -- * Request and Parameters.
+    env,
+    param,
+    param',
+    paramMaybe,
+    params,
+    file,
+    files,
+    header,
+    headers,
+    request,
+
+    -- * Responses.
+    send,
+    next,
+    redirect301,
+    redirect302,
+    redirect303,
+    text,
+    html,
+    json,
+    xml,
+    raw,
+    status,
+    withHeader,
+    withCookie,
+    withCookie',
+    expireCookie,
+    module Web.Twain.Types,
+    module Network.HTTP.Types,
+  )
+where
+
+import Control.Exception (SomeException)
+import Data.Aeson (ToJSON)
+import qualified Data.Aeson as JSON
+import Data.ByteString.Char8 as Char8
+import Data.ByteString.Lazy as BL
+import qualified Data.CaseInsensitive as CI
+import Data.Either.Combinators (rightToMaybe)
+import qualified Data.List as L
+import Data.Text as T
+import Data.Text.Encoding
+import Data.Time
+import Network.HTTP.Types
+import Network.Wai (Application, Middleware, Request, Response, mapResponseHeaders, mapResponseStatus, requestHeaders, responseLBS)
+import Network.Wai.Handler.Warp (Port, Settings, defaultSettings, runEnv, runSettings, setOnExceptionResponse, setPort)
+import Network.Wai.Parse (File, FileInfo, defaultParseRequestBodyOptions)
+import System.Environment (lookupEnv)
+import Web.Cookie
+import Web.Twain.Internal
+import Web.Twain.Types
+
+-- | Run a Twain app on `Port` using the given environment.
+--
+-- If a PORT environment variable is set, it will take precendence.
+--
+-- > twain 8080 "My App" $ do
+-- >   middleware logger
+-- >   get "/" $ do
+-- >     appTitle <- env
+-- >     send $ text ("Hello from " <> appTitle)
+-- >   get "/greetings/:name"
+-- >     name <- param "name"
+-- >     send $ text ("Hello, " <> name)
+-- >   notFound $ do
+-- >     send $ status status404 $ text "Not Found"
+twain :: Port -> e -> TwainM e () -> IO ()
+twain port env m = do
+  mp <- lookupEnv "PORT"
+  let p = maybe port read mp
+      st = exec m env
+      app = composeMiddleware $ middlewares st
+      handler = onExceptionResponse st
+      settings' = setOnExceptionResponse handler $ setPort p defaultSettings
+  runSettings settings' app
+
+-- | Run a Twain app passing Warp `Settings`.
+twain' :: Settings -> e -> TwainM e () -> IO ()
+twain' settings env m = do
+  let st = exec m env
+      app = composeMiddleware $ middlewares st
+      settings' = setOnExceptionResponse (onExceptionResponse st) settings
+  runSettings settings' app
+
+-- | Create a WAI `Application` from a Twain app and environment.
+twainApp :: e -> TwainM e () -> Application
+twainApp env m = composeMiddleware $ middlewares $ exec m env
+
+-- | Use the given middleware. The first declared is the outermost middleware
+-- (it has first access to request and last action on response).
+middleware :: Middleware -> TwainM e ()
+middleware m = modify (\st -> st {middlewares = m : middlewares st})
+
+get :: PathPattern -> RouteM e a -> TwainM e ()
+get = addRoute (Just "GET")
+
+put :: PathPattern -> RouteM e a -> TwainM e ()
+put = addRoute (Just "PUT")
+
+patch :: PathPattern -> RouteM e a -> TwainM e ()
+patch = addRoute (Just "PATCH")
+
+post :: PathPattern -> RouteM e a -> TwainM e ()
+post = addRoute (Just "POST")
+
+delete :: PathPattern -> RouteM e a -> TwainM e ()
+delete = addRoute (Just "DELETE")
+
+-- | Add a route if nothing else is found. This matches any request, so it
+-- should go last.
+notFound :: RouteM e a -> TwainM e ()
+notFound = addRoute Nothing (MatchPath (const (Just [])))
+
+-- | Render a `Response` on exceptions.
+onException :: (SomeException -> Response) -> TwainM e ()
+onException handler = modify $ \st -> st {onExceptionResponse = handler}
+
+-- | Add a route matching `Method` (optional) and `PathPattern`.
+addRoute :: Maybe Method -> PathPattern -> RouteM e a -> TwainM e ()
+addRoute method pat route =
+  modify $ \st ->
+    let m = routeMiddleware method pat route (environment st)
+     in st {middlewares = m : middlewares st}
+
+-- | Get the app environment.
+env :: RouteM e e
+env = RouteM $ \st -> return $ Right (reqEnv st, st)
+
+-- | Get a parameter. Looks in query, path, cookie, and body (in that order).
+--
+-- If no parameter is found, or parameter fails to parse, `next` is called
+-- which passes control to subsequent routes and middleware.
+param :: ParsableParam a => Text -> RouteM e a
+param name = do
+  pM <- fmap snd . L.find ((==) name . fst) <$> params
+  maybe next (either (const next) pure . parseParam) pM
+
+-- | Get a parameter or error if missing or parse failure.
+param' :: ParsableParam a => Text -> RouteM e (Either Text a)
+param' name = do
+  pM <- fmap snd . L.find ((==) name . fst) <$> params
+  return $ maybe (Left ("missing parameter: " <> name)) parseParam pM
+
+-- | Get an optional parameter. `Nothing` is returned for missing parameter or
+-- parse failure.
+paramMaybe :: ParsableParam a => Text -> RouteM e (Maybe a)
+paramMaybe name = do
+  pM <- fmap snd . L.find ((==) name . fst) <$> params
+  return $ maybe Nothing (rightToMaybe . parseParam) pM
+
+-- | Get all parameters from query, path, cookie, and body (in that order).
+params :: RouteM e [Param]
+params = fst <$> parseBody defaultParseRequestBodyOptions
+
+-- | Get uploaded `FileInfo`.
+file :: Text -> RouteM e (Maybe (FileInfo BL.ByteString))
+file name = fmap snd . L.find ((==) (encodeUtf8 name) . fst) <$> files
+
+-- | Get all uploaded files.
+files :: RouteM e [File BL.ByteString]
+files = snd <$> parseBody defaultParseRequestBodyOptions
+
+-- | Get the value of a request `Header`. Header names are case-insensitive.
+header :: Text -> RouteM e (Maybe Text)
+header name = do
+  let ciname = CI.mk (encodeUtf8 name)
+  fmap (decodeUtf8 . snd) . L.find ((==) ciname . fst) <$> headers
+
+-- | Get the request headers.
+headers :: RouteM e [Header]
+headers = requestHeaders <$> request
+
+-- | Get the JSON value from request body.
+bodyJson :: JSON.FromJSON a => RouteM e (Either String a)
+bodyJson = do
+  jsonE <- parseBodyJson
+  case jsonE of
+    Left e -> return (Left e)
+    Right v -> case JSON.fromJSON v of
+      JSON.Error e -> return (Left e)
+      JSON.Success a -> return (Right a)
+
+-- | Get the WAI `Request`.
+request :: RouteM e Request
+request = reqWai <$> routeState
+
+-- | Send a `Response`.
+--
+-- > send $ text "Hello, World!"
+--
+-- Send an `html` response:
+--
+-- > send $ html "<h1>Hello, World!</h1>"
+--
+-- Modify the `status`:
+--
+-- > send $ status status404 $ text "Not Found"
+--
+-- Send a response `withHeader`:
+--
+-- > send $ withHeader (hServer, "Twain + Warp") $ text "Hello"
+--
+-- Send a response `withCookie`:
+--
+-- > send $ withCookie "key" "val" $ text "Hello"
+send :: Response -> RouteM e a
+send res = RouteM $ \_ -> return $ Left (Respond res)
+
+-- | Pass control to the next route or middleware.
+next :: RouteM e a
+next = RouteM $ \_ -> return (Left Next)
+
+-- | Construct a `Text` response.
+--
+-- Sets the Content-Type and Content-Length headers.
+text :: Text -> Response
+text body =
+  let lbs = BL.fromStrict (encodeUtf8 body)
+      typ = (hContentType, "text/plain; charset=utf-8")
+      len = (hContentLength, Char8.pack (show (BL.length lbs)))
+   in raw status200 [typ, len] lbs
+
+-- | Construct an HTML response.
+--
+-- Sets the Content-Type and Content-Length headers.
+html :: BL.ByteString -> Response
+html body =
+  let lbs = body
+      typ = (hContentType, "text/html; charset=utf-8")
+      len = (hContentLength, Char8.pack (show (BL.length lbs)))
+   in raw status200 [typ, len] lbs
+
+-- | Construct a JSON response using `ToJSON`.
+--
+-- Sets the Content-Type and Content-Length headers.
+json :: ToJSON a => a -> Response
+json val =
+  let lbs = JSON.encode val
+      typ = (hContentType, "application/json; charset=utf-8")
+      len = (hContentLength, Char8.pack (show (BL.length lbs)))
+   in raw status200 [typ, len] lbs
+
+-- | Construct an XML response.
+--
+-- Sets the Content-Type and Content-Length headers.
+xml :: BL.ByteString -> Response
+xml body =
+  let lbs = body
+      typ = (hContentType, "application/xml; charset=utf-8")
+      len = (hContentLength, Char8.pack (show (BL.length lbs)))
+   in raw status200 [typ, len] lbs
+
+-- | Construct a raw response from a lazy `ByteString`.
+raw :: Status -> [Header] -> BL.ByteString -> Response
+raw status headers body = responseLBS status headers body
+
+-- | Set the `Status` for a `Response`.
+status :: Status -> Response -> Response
+status s = mapResponseStatus (const s)
+
+-- | Add a `Header` to response.
+withHeader :: Header -> Response -> Response
+withHeader header = mapResponseHeaders (header :)
+
+-- | Add a cookie to the response with the given key and value.
+--
+-- Note: This uses `defaultSetCookie`.
+withCookie :: Text -> Text -> Response -> Response
+withCookie key val res =
+  let setCookie =
+        defaultSetCookie
+          { setCookieName = encodeUtf8 key,
+            setCookieValue = encodeUtf8 val
+          }
+      header = (CI.mk "Set-Cookie", setCookieByteString setCookie)
+   in mapResponseHeaders (header :) res
+
+-- | Add a `SetCookie` to the response.
+withCookie' :: SetCookie -> Response -> Response
+withCookie' setCookie res =
+  let header = (CI.mk "Set-Cookie", setCookieByteString setCookie)
+   in mapResponseHeaders (header :) res
+
+-- | Add a header to expire (unset) a cookie with the given key.
+expireCookie :: Text -> Response -> Response
+expireCookie key res = do
+  let zeroTime = UTCTime (ModifiedJulianDay 0) (secondsToDiffTime 0)
+      setCookie =
+        defaultSetCookie
+          { setCookieName = encodeUtf8 key,
+            setCookieExpires = Just zeroTime
+          }
+      header = (CI.mk "Set-Cookie", setCookieByteString setCookie)
+   in mapResponseHeaders (header :) res
+
+-- | Create a redirect response with 301 status (Moved Permanently).
+redirect301 :: Text -> Response
+redirect301 url = raw status301 [(hLocation, encodeUtf8 url)] ""
+
+-- | Create a redirect response with 302 status (Found).
+redirect302 :: Text -> Response
+redirect302 url = raw status302 [(hLocation, encodeUtf8 url)] ""
+
+-- | Create a redirect response 303 status (See Other).
+redirect303 :: Text -> Response
+redirect303 url = raw status303 [(hLocation, encodeUtf8 url)] ""
diff --git a/src/Web/Twain/Internal.hs b/src/Web/Twain/Internal.hs
new file mode 100644
--- /dev/null
+++ b/src/Web/Twain/Internal.hs
@@ -0,0 +1,112 @@
+module Web.Twain.Internal where
+
+import Control.Exception (throwIO)
+import Control.Monad (join)
+import Control.Monad.IO.Class (liftIO)
+import qualified Data.Aeson as JSON
+import qualified Data.ByteString as B
+import Data.ByteString.Builder (toLazyByteString)
+import qualified Data.ByteString.Lazy as BL
+import Data.Int
+import Data.List as L
+import Data.Text as T
+import Data.Text.Encoding
+import Network.HTTP.Types (Method, hCookie, status204)
+import Network.Wai (Application, Middleware, Request, lazyRequestBody, queryString, requestHeaders, requestMethod, responseLBS)
+import Network.Wai.Parse (File, ParseRequestBodyOptions, lbsBackEnd, parseRequestBodyEx)
+import Web.Cookie (SetCookie, parseCookiesText, renderSetCookie)
+import Web.Twain.Types
+
+type MaxRequestSizeBytes = Int64
+
+routeState :: RouteM e (RouteState e)
+routeState = RouteM $ \s -> return (Right (s, s))
+
+setRouteState :: RouteState e -> RouteM e ()
+setRouteState s = RouteM $ \_ -> return (Right ((), s))
+
+concatParams :: RouteState e -> [Param]
+concatParams p =
+  reqBodyParams p
+    <> reqCookieParams p
+    <> reqPathParams p
+    <> reqQueryParams p
+
+composeMiddleware :: [Middleware] -> Application
+composeMiddleware = L.foldl' (\a m -> m a) emptyApp
+
+routeMiddleware ::
+  Maybe Method ->
+  PathPattern ->
+  RouteM e a ->
+  e ->
+  Middleware
+routeMiddleware method pat (RouteM route) env app req respond =
+  case match method pat req of
+    Nothing -> app req respond
+    Just pathParams -> do
+      let st =
+            RouteState
+              { reqBodyParams = [],
+                reqBodyFiles = [],
+                reqPathParams = pathParams,
+                reqQueryParams = decodeQueryParam <$> queryString req,
+                reqCookieParams = cookieParams req,
+                reqBodyJson = Left "missing JSON body",
+                reqBodyParsed = False,
+                reqEnv = env,
+                reqWai = req
+              }
+      action <- route st
+      case action of
+        Left (Respond res) -> respond res
+        _ -> app req respond
+
+match :: Maybe Method -> PathPattern -> Request -> Maybe [Param]
+match method (MatchPath f) req
+  | maybe True (requestMethod req ==) method = f req
+  | otherwise = Nothing
+
+parseBody :: ParseRequestBodyOptions -> RouteM e ([Param], [File BL.ByteString])
+parseBody opts = do
+  s <- routeState
+  if reqBodyParsed s
+    then return (concatParams s, reqBodyFiles s)
+    else do
+      (ps, fs) <- liftIO $ parseRequestBodyEx opts lbsBackEnd (reqWai s)
+      let sb =
+            s
+              { reqBodyParams = decodeBsParam <$> ps,
+                reqBodyFiles = fs,
+                reqBodyParsed = True
+              }
+      setRouteState sb
+      return (concatParams sb, reqBodyFiles sb)
+
+parseBodyJson :: RouteM e (Either String JSON.Value)
+parseBodyJson = do
+  s <- routeState
+  if reqBodyParsed s
+    then return (reqBodyJson s)
+    else do
+      jsonE <- liftIO $ JSON.eitherDecode <$> lazyRequestBody (reqWai s)
+      setRouteState $ s {reqBodyJson = jsonE, reqBodyParsed = True}
+      return jsonE
+
+cookieParams :: Request -> [Param]
+cookieParams req =
+  let headers = snd <$> L.filter ((==) hCookie . fst) (requestHeaders req)
+   in join $ parseCookiesText <$> headers
+
+setCookieByteString :: SetCookie -> B.ByteString
+setCookieByteString setCookie =
+  BL.toStrict (toLazyByteString (renderSetCookie setCookie))
+
+decodeQueryParam :: (B.ByteString, Maybe B.ByteString) -> Param
+decodeQueryParam (a, b) = (decodeUtf8 a, maybe "" decodeUtf8 b)
+
+decodeBsParam :: (B.ByteString, B.ByteString) -> Param
+decodeBsParam (a, b) = (decodeUtf8 a, decodeUtf8 b)
+
+emptyApp :: Application
+emptyApp req respond = respond $ responseLBS status204 [] ""
diff --git a/src/Web/Twain/Types.hs b/src/Web/Twain/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Web/Twain/Types.hs
@@ -0,0 +1,190 @@
+module Web.Twain.Types where
+
+import Control.Exception (SomeException)
+import Control.Monad (ap)
+import Control.Monad.IO.Class (MonadIO, liftIO)
+import Data.Aeson as JSON
+import qualified Data.ByteString as B
+import qualified Data.ByteString.Lazy as BL
+import Data.Either.Combinators (mapRight)
+import Data.Int
+import Data.List as L
+import Data.String (IsString, fromString)
+import Data.Text as T
+import Data.Text.Encoding
+import qualified Data.Text.Lazy as TL
+import Data.Word
+import Network.Wai (Middleware, Request, Response, pathInfo)
+import Network.Wai.Handler.Warp (defaultOnExceptionResponse)
+import Network.Wai.Parse (File, ParseRequestBodyOptions)
+import Numeric.Natural
+
+-- | TwainM provides a monad interface for composing routes and middleware.
+newtype TwainM e a = TwainM (TwainState e -> (a, TwainState e))
+
+data TwainState e
+  = TwainState
+      { middlewares :: [Middleware],
+        environment :: e,
+        onExceptionResponse :: (SomeException -> Response)
+      }
+
+instance Functor (TwainM e) where
+  fmap f (TwainM g) = TwainM $ \s ->
+    let (a, sb) = g s
+     in (f a, sb)
+
+instance Applicative (TwainM e) where
+  pure = return
+  (<*>) = ap
+
+instance Monad (TwainM e) where
+  return a = TwainM (\s -> (a, s))
+  (TwainM m) >>= fn = TwainM $ \s ->
+    let (a, sb) = m s
+        (TwainM mb) = fn a
+     in mb sb
+
+modify :: (TwainState e -> TwainState e) -> TwainM e ()
+modify f = TwainM (\s -> ((), f s))
+
+exec :: TwainM e a -> e -> TwainState e
+exec (TwainM f) e = snd (f (TwainState [] e defaultOnExceptionResponse))
+
+-- | `RouteM` is a Reader-like monad that can "short-circuit" and return a WAI
+-- response using a given environment. This provides convenient branching with
+-- do notation for redirects, error responses, etc.
+data RouteM e a
+  = RouteM (RouteState e -> IO (Either RouteAction (a, RouteState e)))
+
+data RouteAction
+  = Respond Response
+  | Next
+
+data RouteState e
+  = RouteState
+      { reqBodyParams :: [Param],
+        reqBodyFiles :: [File BL.ByteString],
+        reqPathParams :: [Param],
+        reqQueryParams :: [Param],
+        reqCookieParams :: [Param],
+        reqBodyJson :: Either String JSON.Value,
+        reqBodyParsed :: Bool,
+        reqEnv :: e,
+        reqWai :: Request
+      }
+
+instance Functor (RouteM e) where
+  fmap f (RouteM g) = RouteM $ \s -> mapRight (\(a, b) -> (f a, b)) `fmap` g s
+
+instance Applicative (RouteM e) where
+  pure = return
+  (<*>) = ap
+
+instance Monad (RouteM e) where
+  return a = RouteM $ \s -> return (Right (a, s))
+  (RouteM act) >>= fn = RouteM $ \s -> do
+    eres <- act s
+    case eres of
+      Left ract -> return (Left ract)
+      Right (a, sb) -> do
+        let (RouteM fres) = fn a
+        fres sb
+
+instance MonadIO (RouteM e) where
+  liftIO act = RouteM $ \s -> act >>= \a -> return (Right (a, s))
+
+type Param = (Text, Text)
+
+data PathPattern = MatchPath (Request -> Maybe [Param])
+
+instance IsString PathPattern where
+  fromString s = MatchPath (matchPath (T.pack s))
+
+matchPath :: Text -> Request -> Maybe [Param]
+matchPath path req =
+  go (splitPath path) (pathInfo req) (Just [])
+  where
+    splitPath = L.filter (not . T.null) . T.split (== '/')
+    go (p : ps) (r : rs) m@(Just pms) =
+      if not (T.null p) && T.head p == ':'
+        then go ps rs (Just ((T.drop 1 p, r) : pms))
+        else if p == r then go ps rs m else Nothing
+    go [] [] pms = pms
+    go _ _ _ = Nothing
+
+-- | Parse values from request parameters.
+class ParsableParam a where
+  parseParam :: Text -> Either Text a
+
+  -- | Default implementation parses comma-delimited lists.
+  parseParamList :: Text -> Either Text [a]
+  parseParamList t = mapM parseParam (T.split (== ',') t)
+
+-- ParsableParam class and instance code is from Andrew Farmer and Scotty
+-- framework, with slight modifications.
+
+instance ParsableParam TL.Text where parseParam = Right . TL.fromStrict
+
+instance ParsableParam T.Text where parseParam = Right
+
+instance ParsableParam B.ByteString where parseParam = Right . encodeUtf8
+
+instance ParsableParam BL.ByteString where parseParam = Right . BL.fromStrict . encodeUtf8
+
+instance ParsableParam Char where
+  parseParam t = case T.unpack t of
+    [c] -> Right c
+    _ -> Left "parseParam Char: no parse"
+  parseParamList = Right . T.unpack -- String
+
+instance ParsableParam () where
+  parseParam t = if T.null t then Right () else Left "parseParam Unit: no parse"
+
+instance (ParsableParam a) => ParsableParam [a] where parseParam = parseParamList
+
+instance ParsableParam Bool where
+  parseParam t =
+    if t' == T.toCaseFold "true"
+      then Right True
+      else
+        if t' == T.toCaseFold "false"
+          then Right False
+          else Left "parseParam Bool: no parse"
+    where
+      t' = T.toCaseFold t
+
+instance ParsableParam Double where parseParam = readEither
+
+instance ParsableParam Float where parseParam = readEither
+
+instance ParsableParam Int where parseParam = readEither
+
+instance ParsableParam Int8 where parseParam = readEither
+
+instance ParsableParam Int16 where parseParam = readEither
+
+instance ParsableParam Int32 where parseParam = readEither
+
+instance ParsableParam Int64 where parseParam = readEither
+
+instance ParsableParam Integer where parseParam = readEither
+
+instance ParsableParam Word where parseParam = readEither
+
+instance ParsableParam Word8 where parseParam = readEither
+
+instance ParsableParam Word16 where parseParam = readEither
+
+instance ParsableParam Word32 where parseParam = readEither
+
+instance ParsableParam Word64 where parseParam = readEither
+
+instance ParsableParam Natural where parseParam = readEither
+
+-- | Useful for creating 'ParsableParam' instances for things that already implement 'Read'.
+readEither :: Read a => Text -> Either Text a
+readEither t = case [x | (x, "") <- reads (T.unpack t)] of
+  [x] -> Right x
+  [] -> Left "readEither: no parse"
+  _ -> Left "readEither: ambiguous parse"
diff --git a/twain.cabal b/twain.cabal
new file mode 100644
--- /dev/null
+++ b/twain.cabal
@@ -0,0 +1,52 @@
+cabal-version: 1.12
+
+-- This file has been generated from package.yaml by hpack version 0.33.0.
+--
+-- see: https://github.com/sol/hpack
+--
+-- hash: e9b3b8be1e3550c9e2a2a692102a1a947006bd579d57a084aacef52344e4b74d
+
+name:           twain
+version:        1.0.0.0
+synopsis:       Tiny web application framework for WAI.
+description:    Twain is tiny web application framework for WAI. It provides routing, parameter parsing, and a reader-like monad for composing responses from an environment.
+category:       Web
+homepage:       https://github.com/alexmingoia/twain#readme
+bug-reports:    https://github.com/alexmingoia/twain/issues
+author:         Alex Mingoia
+maintainer:     alex@alexmingoia.com
+copyright:      2021 Alexander C. Mingoia
+license:        BSD3
+license-file:   LICENSE
+build-type:     Simple
+extra-source-files:
+    README.md
+    changelog.md
+
+source-repository head
+  type: git
+  location: https://github.com/alexmingoia/twain
+
+library
+  exposed-modules:
+      Web.Twain Web.Twain.Types
+  other-modules:
+      Web.Twain.Internal
+  hs-source-dirs:
+      src
+  default-extensions: OverloadedStrings
+  build-depends:
+      aeson >=1.4 && <1.7
+    , base >=4.7 && <5
+    , bytestring >=0.10 && <0.11
+    , case-insensitive >=1.2 && <1.3
+    , cookie >=0.4 && <0.6
+    , either >=5.0 && <5.1
+    , http-types >=0.12 && <0.13
+    , text >=1.2.3 && <1.3
+    , time >=1.8 && <1.9.9
+    , transformers >=0.5.6 && <0.6
+    , wai >=3.2 && <3.3
+    , wai-extra >=3.0 && <3.2
+    , warp >=3.2 && <3.4
+  default-language: Haskell2010
