diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+## [_Unreleased_](https://github.com/freckle/asana-hs/compare/v1.0.0.0...main)
+
+## [v1.0.0.0](https://github.com/freckle/asana-hs/tree/v1.0.0.0)
+
+First released version.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2021 Renaissance Learning Inc
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+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 OR COPYRIGHT HOLDERS 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.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,59 @@
+# Asana
+
+Haskell client for the Asana API.
+
+## API Key
+
+1. Visit *Settings > Apps > Developer apps*, create a Personal Access Token
+
+## Simple Usage
+
+For example, to make a quick script to list all incomplete tasks in a project:
+
+```hs
+import Asana.Api.Request
+import Asana.Api.Task
+import Data.Text (pack)
+import System.Environment (getEnv)
+import Control.Monad.Logger (runStdoutLoggingT)
+import Control.Monad.Reader (runReaderT)
+
+main :: IO ()
+main = do
+  key <- AsanaAccessKey . pack <$> getEnv "ASANA_ACCESS_KEY"
+
+  runStdoutLoggingT $ flip runReaderT key $ do
+    let projectId = "..."
+    tasks <- getProjectTasks projectId IncompletedTasks
+    print tasks
+```
+
+## Advanced Usage
+
+This library implements the `Has`-class pattern for use in a `ReaderT`-style
+application.
+
+```hs
+data App = App
+  { -- ...
+  , appAsanaAccessKey :: ApiKey
+  }
+
+instance HasAsanaAccessKey App where
+  asanaAccessKeyL = lens appAsanaAccessKey $ \x y -> x { appAsanaAccessKey = y }
+
+loadApp :: IO App
+loadApp = undefined
+
+main :: IO ()
+main = do
+  app <- loadApp
+  runStdoutLoggingT $ runReaderT run app
+
+run :: (MonadLogger m, MonadReader env m, HasAsanaAccessKey env) => m ()
+run = undefined
+```
+
+---
+
+[CHANGELOG](./CHANGELOG.md) | [LICENSE](./LICENSE)
diff --git a/asana.cabal b/asana.cabal
new file mode 100644
--- /dev/null
+++ b/asana.cabal
@@ -0,0 +1,74 @@
+cabal-version:      1.18
+name:               asana
+version:            1.0.0.0
+license:            MIT
+license-file:       LICENSE
+maintainer:         Freckle Education
+homepage:           https://github.com/freckle/asana-hs#readme
+bug-reports:        https://github.com/freckle/asana-hs/issues
+synopsis:           Asana API Client
+description:        Please see README.md
+category:           Utils
+build-type:         Simple
+extra-source-files: package.yaml
+extra-doc-files:
+    README.md
+    CHANGELOG.md
+
+source-repository head
+    type:     git
+    location: https://github.com/freckle/asana-hs
+
+library
+    exposed-modules:
+        Asana.Api.CustomField
+        Asana.Api.Gid
+        Asana.Api.Named
+        Asana.Api.Prelude
+        Asana.Api.Project
+        Asana.Api.Request
+        Asana.Api.Task
+        Asana.Api.Task.Search
+
+    hs-source-dirs:     library
+    other-modules:      Paths_asana
+    default-language:   Haskell2010
+    default-extensions:
+        BangPatterns DataKinds DeriveAnyClass DeriveFoldable DeriveFunctor
+        DeriveGeneric DeriveLift DeriveTraversable DerivingStrategies
+        FlexibleContexts FlexibleInstances GADTs GeneralizedNewtypeDeriving
+        LambdaCase MultiParamTypeClasses NoImplicitPrelude
+        NoMonomorphismRestriction OverloadedStrings RankNTypes
+        RecordWildCards ScopedTypeVariables StandaloneDeriving
+        TypeApplications TypeFamilies
+
+    ghc-options:
+        -Weverything -Wno-missing-exported-signatures
+        -Wno-missing-import-lists -Wno-missing-local-signatures
+        -Wno-monomorphism-restriction -Wno-unsafe -Wno-safe
+
+    build-depends:
+        aeson >=1.3.1.1,
+        aeson-casing >=0.1.0.5,
+        base >=4.11.1.0 && <5,
+        bytestring >=0.10.8.2,
+        hashable >=1.2.7.0,
+        http-conduit >=2.3.2,
+        iso8601-time >=0.1.5,
+        microlens >=0.4.9.1,
+        microlens-mtl >=0.1.11.1,
+        monad-logger >=0.3.30,
+        mtl >=2.2.2,
+        scientific >=0.3.6.2,
+        text >=1.2.3.1,
+        time >=1.8.0.2,
+        unliftio >=0.2.9.0,
+        unliftio-core >=0.1.2.0,
+        unordered-containers >=0.2.9.0
+
+    if impl(ghc >=9.2)
+        ghc-options: -Wno-missing-kind-signatures
+
+    if impl(ghc >=8.10)
+        ghc-options:
+            -Wno-missing-safe-haskell-mode -Wno-prepositive-qualified-module
diff --git a/library/Asana/Api/CustomField.hs b/library/Asana/Api/CustomField.hs
new file mode 100644
--- /dev/null
+++ b/library/Asana/Api/CustomField.hs
@@ -0,0 +1,98 @@
+module Asana.Api.CustomField
+  ( CustomField(..)
+  , CustomFields(..)
+  , customEnumId
+  , EnumOption(..)
+  , putCustomField
+  , putCustomFields
+  ) where
+
+import Asana.Api.Prelude
+
+import Asana.Api.Gid (Gid, gidToText)
+import Asana.Api.Request
+import Data.Aeson
+import Data.Aeson.Casing (aesonPrefix, snakeCase)
+import Data.List (find)
+import Data.Scientific (Scientific)
+import Data.String (fromString)
+import qualified Data.Text as T
+
+data CustomField
+  = CustomNumber Gid Text (Maybe Scientific)
+  | CustomEnum Gid Text [EnumOption] (Maybe Text)
+  | CustomText Gid Text (Maybe Text)
+  | Other -- ^ Unexpected types dumped here
+  deriving stock (Eq, Generic, Show)
+
+newtype CustomFields = CustomFields { getCustomFields :: [CustomField] }
+  deriving stock (Show, Eq)
+  deriving newtype (FromJSON)
+
+instance ToJSON CustomFields where
+  toJSON (CustomFields fields) = object $ concatMap toPair fields
+   where
+    toPair = \case
+      CustomNumber gid _ n -> [gidToKey gid .= n]
+      e@(CustomEnum gid _ _ _) -> [gidToKey gid .= customEnumId e]
+      _ -> []
+
+    -- fromString will give us Text for aeson-1.x and Key for aeson-2.x
+    gidToKey = fromString . T.unpack . gidToText
+
+data EnumOption = EnumOption
+  { eoGid :: Gid
+  , eoName :: Text
+  }
+  deriving stock (Eq, Generic, Show)
+
+instance FromJSON EnumOption where
+  parseJSON = genericParseJSON $ aesonPrefix snakeCase
+
+-- | Return a @'CustomField'@s value's Enum id, if possible
+--
+-- - Must be a @'CustomEnum'@
+-- - Must have a value
+-- - Must have an option with the same name as that value
+--
+customEnumId :: CustomField -> Maybe Gid
+customEnumId (CustomEnum _ _ opts mValue) = do
+  value <- mValue
+  option <- find ((== value) . eoName) opts
+  pure $ eoGid option
+customEnumId _ = Nothing
+
+instance FromJSON CustomField where
+  parseJSON = withObject "CustomField" $ \o -> do
+    oType <- o .: "type"
+
+    case (oType :: Text) of
+      "text" -> CustomText <$> o .: "gid" <*> o .: "name" <*> o .: "text_value"
+      "number" ->
+        CustomNumber <$> o .: "gid" <*> o .: "name" <*> o .: "number_value"
+      "enum" -> do
+        value <- o .: "enum_value"
+        CustomEnum
+          <$> (o .: "gid")
+          <*> (o .: "name")
+          <*> (o .: "enum_options")
+          <*> case value of
+                Object vo -> vo .:? "name"
+                _ -> pure Nothing
+      _ -> pure Other
+
+putCustomField
+  :: (MonadUnliftIO m, MonadLogger m, MonadReader env m, HasAsanaAccessKey env)
+  => Gid
+  -> CustomField
+  -> m ()
+putCustomField taskId = putCustomFields taskId . CustomFields . pure
+
+putCustomFields
+  :: (MonadUnliftIO m, MonadLogger m, MonadReader env m, HasAsanaAccessKey env)
+  => Gid
+  -> CustomFields
+  -> m ()
+putCustomFields taskId fields =
+  void $ put ("/tasks/" <> T.unpack (gidToText taskId)) $ ApiData
+    (object ["custom_fields" .= fields])
diff --git a/library/Asana/Api/Gid.hs b/library/Asana/Api/Gid.hs
new file mode 100644
--- /dev/null
+++ b/library/Asana/Api/Gid.hs
@@ -0,0 +1,28 @@
+-- | A globally unique identifier
+module Asana.Api.Gid
+  ( Gid
+  , AsanaReference(..)
+  , gidToText
+  , textToGid
+  ) where
+
+import Asana.Api.Prelude
+
+import Data.Aeson
+  (FromJSON(..), FromJSONKey, ToJSON, ToJSONKey, genericParseJSON)
+import Data.Aeson.Casing (aesonPrefix, snakeCase)
+import Data.Hashable (Hashable)
+
+newtype Gid = Gid { gidToText :: Text }
+  deriving stock (Eq, Generic, Show)
+  deriving newtype (FromJSON, ToJSON, ToJSONKey, FromJSONKey, Hashable)
+
+-- | An object @{ gid: <Gid> }@
+newtype AsanaReference = AsanaReference { arGid :: Gid }
+  deriving stock (Eq, Generic, Show)
+
+instance FromJSON AsanaReference where
+  parseJSON = genericParseJSON $ aesonPrefix snakeCase
+
+textToGid :: Text -> Gid
+textToGid = Gid
diff --git a/library/Asana/Api/Named.hs b/library/Asana/Api/Named.hs
new file mode 100644
--- /dev/null
+++ b/library/Asana/Api/Named.hs
@@ -0,0 +1,19 @@
+-- | Anything with a compact @{ id, name }@ representation
+module Asana.Api.Named
+  ( Named(..)
+  ) where
+
+import Asana.Api.Prelude
+
+import Asana.Api.Gid (Gid)
+import Data.Aeson (FromJSON, genericParseJSON, parseJSON)
+import Data.Aeson.Casing (aesonPrefix, snakeCase)
+
+data Named = Named
+  { nGid :: Gid
+  , nName :: Text
+  }
+  deriving stock (Eq, Generic, Show)
+
+instance FromJSON Named where
+  parseJSON = genericParseJSON $ aesonPrefix snakeCase
diff --git a/library/Asana/Api/Prelude.hs b/library/Asana/Api/Prelude.hs
new file mode 100644
--- /dev/null
+++ b/library/Asana/Api/Prelude.hs
@@ -0,0 +1,22 @@
+module Asana.Api.Prelude
+  ( module X
+  ) where
+
+import Prelude as X
+
+import Control.Arrow as X ((&&&), (***))
+import Control.Monad.IO.Unlift as X (MonadUnliftIO)
+import Control.Monad.Logger.CallStack as X
+import Control.Monad.Reader as X
+import Data.Bifunctor as X (first, second)
+import Data.ByteString as X (ByteString)
+import Data.Foldable as X (for_)
+import Data.Maybe as X
+  (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe)
+import Data.Text as X (Text, pack, unpack)
+import Data.Traversable as X (for)
+import GHC.Generics as X (Generic)
+import Lens.Micro as X (Lens', lens)
+import Lens.Micro.Mtl as X (view)
+import Text.Read as X (readMaybe)
+import UnliftIO.Exception as X (Exception(..), catch, throwIO)
diff --git a/library/Asana/Api/Project.hs b/library/Asana/Api/Project.hs
new file mode 100644
--- /dev/null
+++ b/library/Asana/Api/Project.hs
@@ -0,0 +1,20 @@
+module Asana.Api.Project
+  ( Project(..)
+  ) where
+
+import Asana.Api.Prelude
+
+import Asana.Api.Gid (Gid)
+import Data.Aeson (FromJSON, genericParseJSON, parseJSON)
+import Data.Aeson.Casing (aesonPrefix, snakeCase)
+import Data.Time (UTCTime)
+
+data Project = Project
+  { pGid :: Gid
+  , pName :: Text
+  , pCreatedAt :: UTCTime
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON Project where
+  parseJSON = genericParseJSON $ aesonPrefix snakeCase
diff --git a/library/Asana/Api/Request.hs b/library/Asana/Api/Request.hs
new file mode 100644
--- /dev/null
+++ b/library/Asana/Api/Request.hs
@@ -0,0 +1,266 @@
+module Asana.Api.Request
+  ( AsanaAccessKey(..)
+  , HasAsanaAccessKey(..)
+  , Single(..)
+  , Page(..)
+  , NextPage(..)
+  , ApiData(..)
+  , getAll
+  , getAllParams
+  , getSingle
+  , put
+  , post
+  , maxRequests
+  ) where
+
+import Asana.Api.Prelude
+
+import Data.Aeson
+import Data.Aeson.Casing (aesonPrefix, snakeCase)
+import qualified Data.ByteString.Lazy as BSL
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as T
+import qualified Data.Text.Encoding.Error as T
+import Network.HTTP.Simple
+  ( JSONException(JSONConversionException, JSONParseException)
+  , Request
+  , Response
+  , addRequestHeader
+  , getResponseBody
+  , getResponseHeader
+  , getResponseStatusCode
+  , httpJSON
+  , parseRequest_
+  , setRequestBodyJSON
+  , setRequestMethod
+  )
+import UnliftIO.Concurrent (threadDelay)
+
+newtype AsanaAccessKey = AsanaAccessKey
+    { unAsanaAccessKey :: Text
+    }
+
+class HasAsanaAccessKey env where
+  asanaAccessKeyL :: Lens' env AsanaAccessKey
+
+instance HasAsanaAccessKey AsanaAccessKey where
+  asanaAccessKeyL = id
+
+maxRequests :: Int
+maxRequests = 50
+
+-- | Type for a single-resource response, containing @{ data: { ... } }@
+newtype Single a = Single
+  { sData :: a
+  }
+  deriving newtype (Eq, Show)
+  deriving stock Generic
+
+instance FromJSON a => FromJSON (Single a) where
+  parseJSON = genericParseJSON $ aesonPrefix snakeCase
+
+-- | Type for a list-resource response, containing @{ data: [{ ... }] }@
+data Page a = Page
+  { pData :: [a]
+  , pNextPage :: Maybe NextPage
+  }
+  deriving stock (Eq, Generic, Show)
+
+instance FromJSON a => FromJSON (Page a) where
+  parseJSON = genericParseJSON $ aesonPrefix snakeCase
+
+-- | The @next_page@ element of a paginated response
+data NextPage = NextPage
+  { npOffset :: Text
+  , npPath :: Text
+  , npUri :: Text
+  }
+  deriving stock (Eq, Generic, Show)
+
+instance FromJSON NextPage where
+  parseJSON = genericParseJSON $ aesonPrefix snakeCase
+
+-- | Generic type for un/wrapping an item as @{ data: <item> }@
+newtype ApiData a = ApiData
+  { adData :: a
+  }
+  deriving newtype (Show, Eq)
+  deriving stock Generic
+
+instance FromJSON a => FromJSON (ApiData a) where
+  parseJSON = genericParseJSON $ aesonPrefix snakeCase
+
+instance ToJSON a => ToJSON (ApiData a) where
+  toJSON = genericToJSON $ aesonPrefix snakeCase
+  toEncoding = genericToEncoding $ aesonPrefix snakeCase
+
+-- | Naively GET all pages of a paginated resource
+getAll
+  :: ( MonadUnliftIO m
+     , MonadLogger m
+     , MonadReader env m
+     , HasAsanaAccessKey env
+     , FromJSON a
+     )
+  => String
+  -> m [a]
+getAll path = getAllParams path []
+
+getAllParams
+  :: ( MonadUnliftIO m
+     , MonadLogger m
+     , MonadReader env m
+     , HasAsanaAccessKey env
+     , FromJSON a
+     )
+  => String
+  -> [(String, String)]
+  -> m [a]
+getAllParams path params = go Nothing
+ where
+  go mOffset = do
+    Page d mNextPage <- get path params 50 mOffset
+
+    maybe (pure d) (fmap (d ++) . go . Just . T.unpack . npOffset) mNextPage
+
+-- | Get a single resource
+getSingle
+  :: ( MonadUnliftIO m
+     , MonadLogger m
+     , MonadReader env m
+     , HasAsanaAccessKey env
+     , FromJSON a
+     )
+  => String
+  -> m a
+getSingle path = sData <$> get path [] 1 Nothing
+
+get
+  :: ( MonadUnliftIO m
+     , MonadLogger m
+     , MonadReader env m
+     , HasAsanaAccessKey env
+     , FromJSON a
+     )
+  => String
+  -> [(String, String)]
+  -> Int
+  -> Maybe String
+  -> m a
+get path params limit mOffset = do
+  AsanaAccessKey key <- view asanaAccessKeyL
+  let
+    request =
+      parseRequest_
+        $ "https://app.asana.com/api/1.0"
+        <> path
+        <> "?limit="
+        <> show limit -- Ignored on not paging responses
+        <> maybe "" ("&offset=" <>) mOffset
+        <> concatMap (\(k, v) -> "&" <> k <> "=" <> v) params
+  response <- retry 50 $ httpJSON (addAuthorization key request)
+  when (300 <= getResponseStatusCode response)
+    $ logWarnNS "Asana"
+    $ "GET failed, status: "
+    <> pack (show $ getResponseStatusCode response)
+  pure $ getResponseBody response
+
+put
+  :: ( MonadUnliftIO m
+     , MonadLogger m
+     , MonadReader env m
+     , HasAsanaAccessKey env
+     , ToJSON a
+     )
+  => String
+  -> a
+  -> m Value
+put = httpAction "PUT"
+
+post
+  :: ( MonadUnliftIO m
+     , MonadLogger m
+     , MonadReader env m
+     , HasAsanaAccessKey env
+     , ToJSON a
+     )
+  => String
+  -> a
+  -> m Value
+post = httpAction "POST"
+
+httpAction
+  :: ( MonadUnliftIO m
+     , MonadLogger m
+     , MonadReader env m
+     , HasAsanaAccessKey env
+     , ToJSON a
+     )
+  => ByteString
+  -> String
+  -> a
+  -> m Value
+httpAction verb path payload = do
+  AsanaAccessKey key <- view asanaAccessKeyL
+  let request = parseRequest_ $ "https://app.asana.com/api/1.0" <> path
+
+  response <- retry 10 $ httpJSON
+    (setRequestMethod verb . setRequestBodyJSON payload $ addAuthorization
+      key
+      request
+    )
+  when (300 <= getResponseStatusCode response) $ logWarnNS "Asana" $ mconcat
+    [ "Request failed"
+    , "\n  method: " <> T.decodeUtf8 verb
+    , "\n  status: " <> pack (show $ getResponseStatusCode response)
+    , "\n  body  : " <> T.decodeUtf8
+      (BSL.toStrict $ encode $ toJSON $ getResponseBody @Value response)
+    ]
+
+  pure $ getResponseBody response
+
+addAuthorization :: Text -> Request -> Request
+addAuthorization key =
+  addRequestHeader "Authorization" $ "Bearer " <> T.encodeUtf8 key
+
+retry
+  :: forall a m
+   . (MonadUnliftIO m, MonadLogger m)
+  => Int
+  -> m (Response a)
+  -> m (Response a)
+retry attempt go
+  | attempt <= 0 = go
+  | otherwise = handler =<< go `catch` handleParseError
+ where
+  handleParseError :: JSONException -> m (Response a)
+  handleParseError e = case e of
+    JSONParseException _ rsp _ -> orThrow e rsp
+    JSONConversionException _ rsp _ -> orThrow e rsp
+
+  orThrow :: Exception e => e -> Response b -> m (Response a)
+  orThrow e response
+    | getResponseStatusCode response == 429 = do
+      let seconds = getResponseDelay response
+      logWarnNS "Asana" $ "Retrying after " <> pack (show seconds) <> " seconds"
+      threadDelay $ seconds * 1000000
+      retry (pred attempt) go
+    | otherwise = liftIO $ throwIO e
+
+  handler :: Response a -> m (Response a)
+  handler response
+    | getResponseStatusCode response == 429 = do
+      let seconds = getResponseDelay response
+      logWarnNS "Asana" $ "Retrying after " <> pack (show seconds) <> " seconds"
+      threadDelay $ seconds * 100000
+      retry (pred attempt) go
+    | otherwise = pure response
+
+getResponseDelay :: Response a -> Int
+getResponseDelay =
+  fromMaybe 0
+    . readMaybe
+    . T.unpack
+    . T.decodeUtf8With T.lenientDecode
+    . mconcat
+    . getResponseHeader "Retry-After"
diff --git a/library/Asana/Api/Task.hs b/library/Asana/Api/Task.hs
new file mode 100644
--- /dev/null
+++ b/library/Asana/Api/Task.hs
@@ -0,0 +1,150 @@
+module Asana.Api.Task
+  ( Task(..)
+  , Membership(..)
+  , TaskStatusFilter(..)
+  , ResourceSubtype(..)
+  , PostTask(..)
+  , getTask
+  , getProjectTasks
+  , getProjectTasksCompletedSince
+  , postTask
+  , putCompleted
+  , taskUrl
+  , extractNumberField
+  , extractEnumField
+  ) where
+
+import Asana.Api.Prelude
+
+import Asana.Api.CustomField
+import Asana.Api.Gid
+import Asana.Api.Named
+import Asana.Api.Request
+import Data.Aeson
+import Data.Aeson.Casing (aesonPrefix, snakeCase)
+import Data.HashMap.Strict (HashMap)
+import qualified Data.Text as T
+import Data.Time (UTCTime, getCurrentTime)
+import Data.Time.ISO8601 (formatISO8601)
+
+data Membership = Membership
+  { mProject :: Named
+  , mSection :: Maybe Named
+  }
+  deriving stock (Eq, Generic, Show)
+
+instance FromJSON Membership where
+  parseJSON = genericParseJSON $ aesonPrefix snakeCase
+
+data ResourceSubtype = DefaultTask | Milestone | Section
+  deriving stock (Eq, Generic, Show)
+
+instance FromJSON ResourceSubtype where
+  parseJSON =
+    genericParseJSON $ defaultOptions { constructorTagModifier = snakeCase }
+
+data Task = Task
+  { tAssignee :: Maybe Named
+  , tName :: Text
+  , tCompleted :: Bool
+  , tCompletedAt :: Maybe UTCTime
+  , tCreatedAt :: UTCTime
+  , tCustomFields :: CustomFields
+  , tMemberships :: [Membership]
+  , tGid :: Gid
+  , tResourceSubtype :: ResourceSubtype
+  , tNotes :: Text
+  , tProjects :: [AsanaReference]
+  }
+  deriving stock (Eq, Generic, Show)
+
+instance FromJSON Task where
+  parseJSON = genericParseJSON $ aesonPrefix snakeCase
+
+-- | Return all details for a task by id
+getTask
+  :: (MonadUnliftIO m, MonadLogger m, MonadReader env m, HasAsanaAccessKey env)
+  => Gid
+  -> m Task
+getTask taskId = getSingle $ "/tasks/" <> T.unpack (gidToText taskId)
+
+data PostTask = PostTask
+  { ptProjects :: [Gid]
+  , ptCustomFields :: HashMap Gid Text
+  , ptName :: Text
+  , ptNotes :: Text
+  , ptParent :: Maybe Gid
+  }
+  deriving stock Generic
+
+instance FromJSON PostTask where
+  parseJSON = genericParseJSON $ aesonPrefix snakeCase
+
+instance ToJSON PostTask where
+  toJSON = genericToJSON $ aesonPrefix snakeCase
+  toEncoding = genericToEncoding $ aesonPrefix snakeCase
+
+-- | Create a new 'Task'
+postTask
+  :: (MonadUnliftIO m, MonadLogger m, MonadReader env m, HasAsanaAccessKey env)
+  => PostTask
+  -> m (Result Task)
+postTask body = fmap adData . fromJSON <$> post "/tasks" (ApiData body)
+
+-- | Return compact task details for a project
+--
+-- Iterating ourselves and returning @['Task']@ is a better interface but
+-- precludes us logging things each time we request an element. So we return
+-- @'Named'@ for now and let the caller use @'getTask'@ themselves.
+--
+getProjectTasks
+  :: (MonadUnliftIO m, MonadLogger m, MonadReader env m, HasAsanaAccessKey env)
+  => Gid
+  -> TaskStatusFilter
+  -> m [Named]
+getProjectTasks projectId taskStatusFilter = do
+  now <- liftIO getCurrentTime
+  getAllParams
+    (T.unpack $ "/projects/" <> gidToText projectId <> "/tasks")
+    (completedSince now)
+
+ where
+  completedSince now = case taskStatusFilter of
+    AllTasks -> []
+    IncompletedTasks -> [("completed_since", formatISO8601 now)]
+
+data TaskStatusFilter = IncompletedTasks | AllTasks
+
+getProjectTasksCompletedSince
+  :: (MonadUnliftIO m, MonadLogger m, MonadReader env m, HasAsanaAccessKey env)
+  => Gid
+  -> UTCTime
+  -> m [Named]
+getProjectTasksCompletedSince projectId since = getAllParams
+  (T.unpack $ "/projects/" <> gidToText projectId <> "/tasks")
+  [("completed_since", formatISO8601 since)]
+
+putCompleted
+  :: (MonadUnliftIO m, MonadLogger m, MonadReader env m, HasAsanaAccessKey env)
+  => Gid
+  -> Bool
+  -> m ()
+putCompleted taskId completed =
+  void $ put ("/tasks/" <> T.unpack (gidToText taskId)) $ ApiData
+    (object ["completed" .= completed])
+
+taskUrl :: Task -> Text
+taskUrl Task {..} = "https://app.asana.com/0/0/" <> gidToText tGid <> "/f"
+
+extractNumberField :: Text -> Task -> Maybe CustomField
+extractNumberField fieldName Task {..} =
+  listToMaybe $ flip mapMaybe (getCustomFields tCustomFields) $ \case
+    customField@(CustomNumber _ t _) -> customField <$ guard (t == fieldName)
+    _ -> Nothing
+
+extractEnumField :: Text -> Task -> Maybe CustomField
+extractEnumField fieldName Task {..} =
+  listToMaybe $ flip mapMaybe (getCustomFields tCustomFields) $ \case
+    customField@(CustomEnum _ t _ _) ->
+      if t == fieldName then Just customField else Nothing
+    _ -> Nothing
diff --git a/library/Asana/Api/Task/Search.hs b/library/Asana/Api/Task/Search.hs
new file mode 100644
--- /dev/null
+++ b/library/Asana/Api/Task/Search.hs
@@ -0,0 +1,57 @@
+module Asana.Api.Task.Search
+  ( SearchWorkspace(..)
+  , TaskTypeFilter(..)
+  , searchWorkspace
+  ) where
+
+import Asana.Api.Prelude
+
+import Asana.Api.Gid
+import Asana.Api.Named
+import Asana.Api.Request
+import Asana.Api.Task
+import Data.HashMap.Strict (HashMap)
+import qualified Data.HashMap.Strict as HashMap
+import Data.List (intercalate)
+import qualified Data.Text as T
+
+data TaskTypeFilter = TasksOnly | SubtasksOnly | AllTaskTypes
+
+data SearchWorkspace = SearchWorkspace
+  { swWorkspaceId :: Gid
+  , swProjectIds :: [Gid]
+  , swTaskStatusFilter :: TaskStatusFilter
+  , swCustomFields :: HashMap Gid Text
+  , swTaskTypeFilter :: TaskTypeFilter
+  }
+
+-- | Search for tasks within a workspace
+searchWorkspace
+  :: (MonadUnliftIO m, MonadLogger m, MonadReader env m, HasAsanaAccessKey env)
+  => SearchWorkspace
+  -> m [Named]
+searchWorkspace SearchWorkspace {..} =
+  getAllParams
+      (T.unpack $ "/workspaces/" <> gidToText swWorkspaceId <> "/tasks/search")
+    $ ( "projects.all"
+      , intercalate "," $ map (T.unpack . gidToText) swProjectIds
+      )
+    : customFieldParams
+    <> completed
+    <> isSubtask
+ where
+  customFieldParams =
+    map
+        (\(a, b) ->
+          ("custom_fields." <> T.unpack (gidToText a) <> ".value", T.unpack b)
+        )
+      $ HashMap.toList swCustomFields
+
+  completed = case swTaskStatusFilter of
+    AllTasks -> []
+    IncompletedTasks -> [("completed", "false")]
+
+  isSubtask = case swTaskTypeFilter of
+    AllTaskTypes -> []
+    TasksOnly -> [("is_subtask", "false")]
+    SubtasksOnly -> [("is_subtask", "true")]
diff --git a/package.yaml b/package.yaml
new file mode 100644
--- /dev/null
+++ b/package.yaml
@@ -0,0 +1,89 @@
+name: asana
+version: 1.0.0.0
+maintainer: Freckle Education
+category: Utils
+github: freckle/asana-hs
+synopsis: Asana API Client
+description: Please see README.md
+
+extra-doc-files:
+  - README.md
+  - CHANGELOG.md
+
+extra-source-files:
+  - package.yaml
+
+ghc-options:
+  - -Weverything
+  - -Wno-missing-exported-signatures # re-enables missing-signatures
+  - -Wno-missing-import-lists
+  - -Wno-missing-local-signatures
+  - -Wno-monomorphism-restriction
+  - -Wno-unsafe
+  - -Wno-safe
+
+when:
+  - condition: "impl(ghc >= 9.2)"
+    ghc-options:
+      - -Wno-missing-kind-signatures
+  - condition: "impl(ghc >= 8.10)"
+    ghc-options:
+      - -Wno-missing-safe-haskell-mode
+      - -Wno-prepositive-qualified-module
+
+dependencies:
+  - base < 5
+
+default-extensions:
+  - BangPatterns
+  - DataKinds
+  - DeriveAnyClass
+  - DeriveFoldable
+  - DeriveFunctor
+  - DeriveGeneric
+  - DeriveLift
+  - DeriveTraversable
+  - DerivingStrategies
+  - FlexibleContexts
+  - FlexibleInstances
+  - GADTs
+  - GeneralizedNewtypeDeriving
+  - LambdaCase
+  - MultiParamTypeClasses
+  - NoImplicitPrelude
+  - NoMonomorphismRestriction
+  - OverloadedStrings
+  - RankNTypes
+  - RecordWildCards
+  - ScopedTypeVariables
+  - StandaloneDeriving
+  - TypeApplications
+  - TypeFamilies
+
+library:
+  source-dirs: library
+  dependencies:
+    - aeson
+    - aeson-casing
+    - bytestring
+    - hashable
+    - http-conduit
+    - iso8601-time
+    - microlens
+    - microlens-mtl
+    - monad-logger
+    - mtl
+    - scientific
+    - text
+    - time
+    - unliftio
+    - unliftio-core
+    - unordered-containers
+# tests:
+#   spec:
+#     main: Spec.hs
+#     source-dirs: tests
+#     ghc-options: -threaded -rtsopts "-with-rtsopts=-N"
+#     dependencies:
+#       - asana
+#       - hspec
