diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 Urbit
+
+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,81 @@
+# Haskell Urbit API
+
+[![License MIT](https://img.shields.io/badge/license-MIT-brightgreen.svg)](https://opensource.org/licenses/MIT)
+[![Hackage](https://img.shields.io/hackage/v/urbit-api.svg?style=flat)](https://hackage.haskell.org/package/urbit-api)
+[![builds.sr.ht status](https://builds.sr.ht/~ben/urbit-api.svg)](https://builds.sr.ht/~ben/urbit-api?)
+
+
+
+This library helps you talk to your Urbit from Haskell, via HTTP.
+
+The Urbit API is a command-query API that lets you hook into apps running on
+your Urbit. You can submit commands (called "pokes") and subscribe to
+responses.
+
+See the `test.hs` file for some example usages.
+
+## Design
+
+The Urbit vane `eyre` is responsible for defining the API interface. The path to
+the API is `/~/channel/...`, where we send messages to the global log (called
+`poke`s) which are then dispatched to the appropriate apps. To receive
+responses, we stream messages from a path associated with the app, such as
+`/mailbox/~/~zod/mc`. Internally, I believe Urbit calls these `wire`s.
+
+`urbit-api` handles most of the path, session, and HTTP request stuff
+automatically. See the
+[haddocks](https://hackage.haskell.org/package/urbit-api/docs/Urbit-API.html)
+for more details.
+
+This library is built on req, conduit, and aeson, all of which are very stable
+and usable libraries for working with HTTP requests and web data.
+
+## Example usage
+
+```haskell
+import qualified Data.Aeson as Aeson
+import Data.Aeson ((.=))
+import qualified Data.Text as Text
+import qualified Data.UUID.V4 as UUID
+
+import Urbit.API
+
+main :: IO ()
+main = do
+  let fakezod = Ship
+    { uid = "0123456789abcdef",
+      name = "zod",
+      lastEventId = 1,
+      url = "http://localhost:8081",
+      code = "lidlut-tabwed-pillex-ridrup"
+    }
+
+  -- Establish connection
+  sess <- connect ship
+
+  -- Send a message by poking the chat-hook
+  uuid <- UUID.nextRandom
+  poke sess ship "zod" "chat-hook" "json" $
+    Aeson.object
+      [ "message"
+          .= Aeson.object
+            [ "path" .= Text.pack "/~/~zod/mc",
+              "envelope"
+                .= Aeson.object
+                  [ "uid" .= UUID.toText uuid,
+                    "number" .= (1 :: Int),
+                    "author" .= Text.pack "~zod",
+                    "when" .= (1602118786225 :: Int),
+                    "letter" .= Aeson.object ["text" .= Text.pack "hello world from haskell!"]
+                  ]
+            ]
+      ]
+```
+
+## TODO
+
+- fix test suite on travis (OOM when trying to compile urbit)
+- more sophisticated test cases, also use cabal test instead of homegrown thing
+- add an exe that wraps the library with a cli
+- port to ghcjs
+- put some examples in the docs
diff --git a/Urbit/API.hs b/Urbit/API.hs
new file mode 100644
--- /dev/null
+++ b/Urbit/API.hs
@@ -0,0 +1,208 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+-- |
+-- Module: Urbit.API
+-- Copyright: © 2020–present Ben Sima
+-- License: MIT
+--
+-- Maintainer: Ben Sima <ben@bsima.me>
+-- Stability: experimental
+-- Portability: non-portableo
+--
+-- === About the Urbit API
+--
+-- The Urbit API is a command-query API that lets you hook into apps running on
+-- your Urbit. You can submit commands and subscribe to responses.
+--
+-- The Urbit vane @eyre@ is responsible for defining the API interface. The HTTP
+-- path to the API is @\/~\/channel\/...@, where we send messages to the global
+-- log (called @poke@s) which are then dispatched to the appropriate apps. To
+-- receive responses, we stream messages from a path associated with the app,
+-- such as @\/mailbox\/~\/~zod\/mc@. Internally, I believe Urbit calls these
+-- @wire@s.
+--
+-- === About this library
+--
+-- This library helps you talk to your Urbit from Haskell, via HTTP. It handles
+-- most of the path, session, and HTTP request stuff automatically. You'll need
+-- to know what app and mark (data type) to send to, which path/wire listen to,
+-- and the shape of the message. The latter can be found in the Hoon source
+-- code, called the @vase@ on the poke arm.
+--
+-- This library is built on req, conduit, and aeson, all of which are very
+-- stable and usable libraries for working with HTTP requests and web data.
+-- Released under the MIT License, same as Urbit.
+module Urbit.API
+  ( -- * Types
+    Ship (..),
+    Session,
+
+    -- * Functions
+    connect,
+    poke,
+    ack,
+    subscribe,
+  )
+where
+
+import Conduit (ConduitM, runConduitRes, (.|))
+import qualified Conduit
+import qualified Control.Exception as Exception
+import Data.Aeson ((.=))
+import qualified Data.Aeson as Aeson
+import Data.ByteString (ByteString)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Network.HTTP.Client as HTTP
+import Network.HTTP.Req ((=:))
+import qualified Network.HTTP.Req as Req
+import qualified Network.HTTP.Req.Conduit as Req
+import qualified Text.URI as URI
+
+-- | Some information about your ship needed to establish connection.
+data Ship = Ship
+  { -- | A random string for your channel
+    uid :: Text,
+    -- | The @\@p@ of your ship
+    name :: Text,
+    -- | Track the latest event we saw (needed for poking)
+    lastEventId :: Int,
+    -- | Network access point, with port if necessary, like
+    -- @https://sampel-palnet.arvo.network@, or @http://localhost:8080@
+    url :: Text,
+    -- | Login code, @+code@ in the dojo. Don't share this publically
+    code :: Text
+  }
+  deriving (Show)
+
+channelUrl :: Ship -> Text
+channelUrl Ship {url, uid} = url <> "/~/channel/" <> uid
+
+nextEventId :: Ship -> Int
+nextEventId Ship {lastEventId} = lastEventId + 1
+
+-- | A wrapper type for the session cookies.
+type Session = HTTP.CookieJar
+
+-- | Connect and login to the ship.
+connect :: Ship -> IO Session
+connect ship =
+  Req.useURI <$> (URI.mkURI $ url ship <> "/~/login") >>= \case
+    Nothing -> error "could not parse ship url"
+    Just uri ->
+      Req.runReq Req.defaultHttpConfig $
+        Req.responseCookieJar <$> either con con uri
+  where
+    body = "password" =: (code ship)
+    con (url, opts) =
+      Req.req Req.POST url (Req.ReqBodyUrlEnc body) Req.bsResponse $
+        opts
+
+-- | Poke a ship.
+poke ::
+  Aeson.ToJSON a =>
+  -- | Session cookie from 'connect'
+  Session ->
+  -- | Your ship
+  Ship ->
+  -- | Name of the ship to poke
+  Text ->
+  -- | Name of the gall application you want to poke
+  Text ->
+  -- | The mark of the message you are sending
+  Text ->
+  -- | The actual JSON message, serialized via aeson
+  a ->
+  IO Req.BsResponse
+poke sess ship shipName app mark json =
+  Req.useURI <$> (URI.mkURI $ channelUrl ship) >>= \case
+    Nothing -> error "could not parse ship url"
+    Just uri ->
+      Req.runReq Req.defaultHttpConfig $
+        either con con uri
+  where
+    con (url, opts) =
+      Req.req
+        Req.POST
+        url
+        (Req.ReqBodyJson body)
+        Req.bsResponse
+        $ opts <> Req.cookieJar sess
+    body =
+      [ Aeson.object
+          [ "id" .= nextEventId ship,
+            "action" .= Text.pack "poke",
+            "ship" .= shipName,
+            "app" .= app,
+            "mark" .= mark,
+            "json" .= json
+          ]
+      ]
+
+-- | Acknowledge receipt of a message. (This clears it from the ship's queue.)
+ack ::
+  -- | Session cookie from 'connect'
+  Session ->
+  -- | Your ship
+  Ship ->
+  -- | The event number
+  Int ->
+  IO Req.BsResponse
+ack sess ship eventId =
+  Req.useURI <$> (URI.mkURI $ channelUrl ship) >>= \case
+    Nothing -> error "could not parse ship url"
+    Just uri ->
+      Req.runReq Req.defaultHttpConfig $
+        either con con uri
+  where
+    con (url, opts) =
+      Req.req
+        Req.POST
+        url
+        (Req.ReqBodyJson body)
+        Req.bsResponse
+        $ opts <> Req.cookieJar sess
+    body =
+      [ Aeson.object
+          [ "action" .= Text.pack "ack",
+            "event-id" .= eventId
+          ]
+      ]
+
+instance Req.MonadHttp (ConduitM i o (Conduit.ResourceT IO)) where
+  handleHttpException = Conduit.liftIO . Exception.throwIO
+
+-- | Subscribe to ship events on some path.
+subscribe ::
+  -- | Session cookie from 'connect'
+  Session ->
+  -- | Your ship
+  Ship ->
+  -- | The path to subscribe to.
+  Text ->
+  -- | A handler conduit to receive the response from the server, e.g.
+  -- @Data.Conduit.Binary.sinkFile "my-file.out"@
+  ConduitM ByteString Conduit.Void (Conduit.ResourceT IO) a ->
+  IO a
+subscribe sess ship path fn =
+  Req.useURI <$> (URI.mkURI $ url ship <> "/" <> path) >>= \case
+    Nothing -> error "could not parse ship url"
+    Just uri -> runConduitRes $ do
+      either con con uri $ \request manager ->
+        Conduit.bracketP
+          (HTTP.responseOpen request manager)
+          HTTP.responseClose
+          Req.responseBodySource
+          .| fn
+  where
+    con (url, opts) =
+      Req.req'
+        Req.POST
+        url
+        Req.NoReqBody
+        $ opts <> Req.cookieJar sess
diff --git a/urbit-api.cabal b/urbit-api.cabal
new file mode 100644
--- /dev/null
+++ b/urbit-api.cabal
@@ -0,0 +1,50 @@
+name:                urbit-api
+version:             0.2.0.0
+synopsis:            Talk to Urbit from Haskell
+description:
+    @urbit-api@ is a Haskell library that helps you connect to the Urbit
+    API.
+    .
+    Built on req, conduit, and aeson for stability and simplicity.
+homepage:            https://github.com/bsima/haskell-urbit-api
+license:             BSD3
+license-file:        LICENSE
+author:              Ben Sima
+maintainer:          bsima@me.com
+copyright:           2020 Ben Sima
+category:            Web
+build-type:          Simple
+cabal-version:       >=1.10
+extra-source-files:  README.md
+
+library
+  default-language:    Haskell2010
+  exposed-modules:
+    Urbit.API
+  build-depends:
+    base >= 4.7 && < 5,
+    aeson,
+    bytestring,
+    conduit,
+    conduit-extra,
+    http-client,
+    modern-uri,
+    req,
+    req-conduit,
+    text,
+    uuid
+
+-- executable urlock
+--   hs-source-dirs:      .
+--   main-is:             Main.hs
+--   default-language:    Haskell2010
+--   build-depends:
+--     base >= 4.7 && < 5,
+--     urbit-api
+--
+-- Test-Suite test-urbit-api
+--   type: exitcode-stdio-1.0
+--   main-is: test.hs
+--   build-depends:
+--     base >= 4.7 && < 5,
+--     urbit-api
