diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@
 The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
 and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
 
+## 0.6.9
+### Added
+- More and better documentation than in version 0.6.8.
+
 ## 0.6.8
 ### Added
 - Update documentation everywhere.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -1,3 +1,466 @@
-# Not Quite Erlang
+# NQE: Not Quite Erlang
 
-Haskell framework for concurrency inspired by Erlang/OTP.
+[![Hackage](https://img.shields.io/hackage/v/nqe.svg)](https://hackage.haskell.org/package/nqe)
+[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/jprupp/nqe/blob/master/LICENSE)
+
+NQE is a small Haskell library for concurrency in the style of Erlang/OTP. It
+gives you processes that talk to each other through mailboxes, supervisors
+that start and watch over them, and publishers that broadcast messages to
+subscribers.
+
+Everything is built on [STM](https://hackage.haskell.org/package/stm) and
+[unliftio](https://hackage.haskell.org/package/unliftio). It works in `IO` and
+in any monad with a `MonadUnliftIO` instance, such as `ReaderT` stacks or
+`LoggingT`. It has no C dependencies.
+
+- [API documentation](https://hackage.haskell.org/package/nqe)
+- [Changelog](https://github.com/jprupp/nqe/blob/master/CHANGELOG.md)
+- [Report a bug](https://github.com/jprupp/nqe/issues)
+
+## Contents
+
+- [Why NQE](#why-nqe)
+- [Design principles](#design-principles)
+- [Features](#features)
+- [Installation](#installation)
+- [Quick start](#quick-start)
+- [Tutorial: a counter service](#tutorial-a-counter-service)
+- [Concepts](#concepts)
+- [Usage guide](#usage-guide)
+- [Module overview](#module-overview)
+- [Things to watch out for](#things-to-watch-out-for)
+- [Development](#development)
+- [Related projects](#related-projects)
+- [License](#license)
+
+## Why NQE
+
+Most concurrency bugs come from threads sharing mutable state: two threads
+change the same thing, and the result depends on who got there first. Locks
+and transactions help, but as a program grows it becomes hard to know which
+thread may touch what.
+
+Erlang popularised a different discipline. Each piece of state has one owner,
+a *process*. Other code cannot touch that state; it can only send the process
+a message and, if it needs an answer, wait for a reply. The process handles one
+message at a time, so its state is never seen half-changed. Processes are
+grouped under *supervisors* that know what to do when one of them dies, so
+failures are handled in one place instead of in every thread.
+
+Haskell already has the raw materials for this: cheap threads, STM and the
+`async` package. What it lacks is a shared vocabulary. Every project ends up
+writing its own request/reply helpers, its own "run this thread and make sure
+it dies with me", its own broadcast channel. NQE is that vocabulary, kept small
+on purpose. It was written to structure
+[haskoin-node](https://github.com/jprupp/haskoin-node) and
+[haskoin-store](https://github.com/jprupp/haskoin-store), where peers, the
+header chain, the block importer and the cache are all NQE processes.
+
+Use NQE when your program is naturally a set of long-lived components that
+talk to each other: network services, nodes, pipelines, background workers. If
+you only need to run a few things in parallel and collect their results, plain
+`async` is simpler.
+
+## Design principles
+
+- **Ownership over sharing.** A process owns its state. Others hold a
+  `Mailbox`, which can send but not receive, so they cannot interfere with it.
+- **Build on STM, don't hide it.** Channels are STM queues and replies are STM
+  actions. Anything NQE does can be combined with your own transactions.
+- **The caller decides where replies go.** A request carries a callback
+  (`Listen`) instead of a reply channel. `query` points it at a variable and
+  waits, but you could point it at a queue, a counter or a log.
+- **Back-pressure by default where it matters.** Bounded inboxes block fast
+  producers instead of letting queues eat memory.
+- **No silent failures.** Processes are linked to the thread that starts them,
+  and supervisors always cancel their children when they stop.
+- **Scoped lifetimes.** The `with` functions tie a process, supervisor or
+  subscription to a block of code, so it cannot outlive it, even on an
+  exception.
+- **Not quite Erlang.** No distribution, no registry, no hot code reloading and
+  no automatic restarts. Those features add a lot of machinery that most
+  Haskell programs never use. Supervisors report deaths; your code decides
+  whether a restart makes sense.
+
+## Features
+
+| Area        | What you get                                                                           |
+| ----------- | -------------------------------------------------------------------------------------- |
+| Processes   | Threads with an inbox, started scoped (`withProcess`) or unscoped (`process`)          |
+| Mailboxes   | Unbounded or bounded channels; bounded ones block senders, which gives back-pressure   |
+| Messaging   | Asynchronous `send`, synchronous `query` with optional timeouts, selective receive     |
+| Supervisors | Four restart-free strategies: kill all, ignore graceful exits, ignore all, or notify   |
+| Pub/sub     | Publishers that forward every event to any number of subscribers                       |
+| Conduit     | A sink that feeds a conduit stream into a mailbox                                      |
+
+### What is not included
+
+- No distribution: processes live in one Haskell program, not across a network.
+- No automatic restarts. Supervisors stop, ignore or report dead children; your
+  code decides whether to start them again.
+- No process registry or names. Pass `Mailbox` or `Process` values around
+  instead.
+
+## Installation
+
+NQE is on [Hackage](https://hackage.haskell.org/package/nqe) and in Stackage
+nightly snapshots. It is pure Haskell, with no C libraries to install, and
+builds with any GHC that current Stackage snapshots support.
+
+### Step by step with Stack
+
+1. Install [Stack](https://docs.haskellstack.org/) (`curl -sSL
+   https://get.haskellstack.org/ | sh`, or through your package manager).
+2. Create a project and add the dependencies:
+
+   ```sh
+   stack new nqe-demo simple
+   cd nqe-demo
+   ```
+
+   In `nqe-demo.cabal`, add `nqe` and `unliftio` to `build-depends` of the
+   executable.
+3. Paste the [quick start](#quick-start) program into `src/Main.hs`.
+4. Build and run it:
+
+   ```sh
+   stack run
+   ```
+
+   It prints `Pong`.
+
+### Step by step with Cabal
+
+```sh
+cabal update
+mkdir nqe-demo && cd nqe-demo
+cabal init --non-interactive --exe
+```
+
+Add `nqe` and `unliftio` to `build-depends` in `nqe-demo.cabal`, put the quick
+start program in `app/Main.hs`, and run it with `cabal run`.
+
+### Adding NQE to an existing project
+
+With Cabal, list it in `build-depends`:
+
+```cabal
+build-depends:
+    base
+  , nqe
+  , unliftio
+```
+
+With Stack and hpack, list it in `package.yaml`:
+
+```yaml
+dependencies:
+  - nqe
+  - unliftio
+```
+
+If your Stack resolver does not include NQE, or has an older version, add it
+to `stack.yaml`:
+
+```yaml
+extra-deps:
+  - nqe-0.6.8
+```
+
+## Quick start
+
+A server that answers `Ping` with `Pong`, and a client that asks it once:
+
+```haskell
+import Control.Monad (forever)
+import NQE
+import UnliftIO
+
+data Pong = Pong deriving Show
+
+newtype Ping = Ping (Listen Pong)
+
+pongServer :: Inbox Ping -> IO ()
+pongServer inbox = forever $ do
+    Ping reply <- receive inbox
+    atomically (reply Pong)
+
+main :: IO ()
+main = withProcess pongServer $ \p -> do
+    pong <- query Ping p
+    print pong -- Pong
+```
+
+`withProcess` runs `pongServer` in a new thread, passes its handle to the
+function, and stops the thread when the function returns. If the server throws,
+the exception is re-thrown in `main`.
+
+## Tutorial: a counter service
+
+This walks through building a small service the NQE way. The service keeps a
+count, other threads increment it and read it, and a supervisor makes sure the
+program stops cleanly if anything goes wrong.
+
+### 1. Describe the messages
+
+A process is defined by the messages it understands. Requests that need an
+answer carry a `Listen` callback for the reply:
+
+```haskell
+data CounterMsg
+  = Increment Int              -- fire and forget
+  | GetCount (Listen Int)      -- request with a reply
+```
+
+### 2. Write the process
+
+The process owns its state. Here the state is passed along the loop, so there
+is no shared variable at all:
+
+```haskell
+counter :: Inbox CounterMsg -> IO ()
+counter inbox = go 0
+  where
+    go n =
+      receive inbox >>= \case
+        Increment k -> go (n + k)
+        GetCount reply -> atomically (reply n) >> go n
+```
+
+### 3. Give it a friendly interface
+
+Callers should not need to know about messages. Wrap them in functions that
+take the `Mailbox`, the send-only end:
+
+```haskell
+increment :: Mailbox CounterMsg -> Int -> IO ()
+increment mbox k = send (Increment k) mbox
+
+getCount :: Mailbox CounterMsg -> IO Int
+getCount = query GetCount
+```
+
+### 4. Start it and use it
+
+```haskell
+{-# LANGUAGE LambdaCase #-}
+
+import Control.Monad (replicateM_)
+import NQE
+import UnliftIO
+import UnliftIO.Concurrent (threadDelay)
+
+main :: IO ()
+main = do
+  (inbox, mbox) <- newMailbox
+  withSupervisor IgnoreGraceful $ \sup -> do
+    _ <- addChild sup (counter inbox)
+    _ <- addChild sup (replicateM_ 1000 (increment mbox 1))
+    _ <- addChild sup (replicateM_ 1000 (increment mbox 1))
+    threadDelay 100000
+    getCount mbox >>= print -- 2000
+```
+
+The strategy is the important decision. The two workers are expected to finish,
+so a normal exit must not bring the group down; but if anything crashes,
+including the counter, the rest cannot be trusted, so the whole group should
+stop and the exception should reach `main`. That is exactly `IgnoreGraceful`.
+With `KillAll`, the first worker to finish would stop the counter too, and
+`getCount` would wait forever.
+
+### 5. Where to go from here
+
+- Make the inbox bounded with `newBoundedInbox` if producers could outrun the
+  counter.
+- Publish every change with a `Publisher` so that other components can react
+  to it.
+- Use `queryS 5 GetCount mbox` instead of `query` when the counter might be
+  gone, so callers do not hang.
+
+## Concepts
+
+| Type            | What it is                                                                 |
+| --------------- | -------------------------------------------------------------------------- |
+| `Inbox msg`     | Receiving end of a channel. Owned by one process.                          |
+| `Mailbox msg`   | Sending end of a channel. Share it with any thread.                        |
+| `Process msg`   | A running thread (`Async ()`) together with its `Mailbox`.                 |
+| `Listen a`      | An `a -> STM ()` callback, used for replies and subscriptions.             |
+| `Supervisor`    | A process that starts, monitors and stops child threads.                   |
+| `Publisher msg` | A process that forwards every message it gets to its subscribers.          |
+
+### Channels
+
+`InChan` and `OutChan` are the classes for receiving and sending. `TQueue`,
+`TBQueue`, `Inbox`, `Mailbox` and `Process` have instances, so `send`,
+`receive` and friends work on all of them. `wrapChannel` turns any channel with
+both instances into an `Inbox`.
+
+Mailboxes and inboxes carry a unique identifier. Two of them are equal when
+they point at the same channel, and they can be keys in a `HashMap`.
+
+### Synchronous requests
+
+A request carries a `Listen` callback. The receiver runs the callback in `STM`
+to deliver its reply. `query` creates the reply variable, sends the request and
+waits:
+
+```haskell
+data Request = GetCount (Listen Int)
+
+count <- query GetCount counterProcess
+```
+
+### Thread linking
+
+`withProcess` and `process` link the new thread to the caller with
+`UnliftIO.link`. An exception in the child is re-thrown in the parent as an
+`ExceptionInLinkedThread`, so a failing process is never silent.
+
+## Usage guide
+
+### Sending and receiving
+
+```haskell
+send msg mailbox                     -- queue a message
+msg <- receive inbox                 -- block for the next one
+empty <- mailboxEmpty inbox
+```
+
+`receiveMatch` takes the first message that a function accepts and leaves the
+others in the inbox in their original order:
+
+```haskell
+n <- receiveMatch inbox $ \case
+    Result n -> Just n
+    _ -> Nothing
+```
+
+Each of `query` and `receiveMatch` has a version with a timeout in seconds
+(`queryS`, `receiveMatchS`) or microseconds (`queryU`, `receiveMatchU`). They
+return `Nothing` if the time runs out.
+
+`sendSTM`, `receiveSTM` and `receiveMatchSTM` combine with other `STM` actions
+in one transaction.
+
+### Bounded inboxes
+
+```haskell
+inbox <- newBoundedInbox 100
+```
+
+Senders block while the inbox holds 100 messages. Use `mailboxFullSTM` to drop
+messages instead of blocking:
+
+```haskell
+atomically $ mailboxFullSTM mbox >>= \full -> unless full (sendSTM msg mbox)
+```
+
+### Supervisors
+
+A supervisor runs child actions of type `IO ()` and reacts when one stops,
+according to its `Strategy`:
+
+| Strategy         | A child returns normally   | A child throws                               |
+| ---------------- | -------------------------- | -------------------------------------------- |
+| `KillAll`        | stop everything            | stop everything and re-throw                 |
+| `IgnoreGraceful` | ignore it                  | stop everything and re-throw                 |
+| `IgnoreAll`      | ignore it                  | ignore it                                    |
+| `Notify listen`  | call `listen (child, Nothing)` | call `listen (child, Just exception)`    |
+
+```haskell
+withSupervisor KillAll $ \sup -> do
+    _ <- addChild sup worker1
+    w2 <- addChild sup worker2
+    removeChild sup w2           -- stop one child and wait for it
+    mainLoop
+```
+
+When the supervisor stops, it cancels all its children. Children run in `IO`;
+use `withRunInIO` or `toIO` to turn an action in your own monad into a
+`ChildAction`.
+
+### Publishers
+
+```haskell
+withPublisher $ \pub ->
+    withSubscription pub $ \inbox -> do
+        publish "hello" pub
+        receive inbox >>= putStrLn
+```
+
+`withSubscription` creates an unbounded inbox, subscribes it, and unsubscribes
+it when the function returns. For a bounded or filtered subscription, call
+`subscribe` with your own `Listen` action and `unsubscribe` later.
+
+### Conduit
+
+`conduitMailbox` is a sink that sends every value it receives to a mailbox:
+
+```haskell
+runConduit $ sourceHandle stdin .| linesUnboundedAsciiC .| conduitMailbox mbox
+```
+
+With a bounded mailbox, a slow consumer slows down the producer.
+
+## Module overview
+
+| Module                               | Contents                                               |
+| ------------------------------------ | ------------------------------------------------------ |
+| `NQE`                                | Re-exports everything below                            |
+| `Control.Concurrent.NQE.Process`     | Inboxes, mailboxes, processes, send, receive, query    |
+| `Control.Concurrent.NQE.Supervisor`  | Supervisors, strategies, adding and removing children  |
+| `Control.Concurrent.NQE.Publisher`   | Publishers and subscriptions                           |
+| `Control.Concurrent.NQE.Conduit`     | `conduitMailbox`                                       |
+
+## Things to watch out for
+
+- A `Listen` action runs inside the receiver's `STM` transaction. Keep it short
+  and never let it retry, or the receiver blocks.
+- `query` waits forever if the receiver never answers. Use `queryS` or `queryU`
+  when the other side might be gone.
+- `process`, `publisher` and `supervisor` start threads that you must cancel
+  yourself. Prefer the `with` versions, which clean up on exceptions.
+- A subscription made with `subscribe` stays until `unsubscribe`. Forgetting to
+  unsubscribe leaks the callback and everything it refers to.
+- `withSubscription` uses an unbounded inbox. Read from it, or it grows without
+  limit.
+
+## Development
+
+```sh
+git clone https://github.com/jprupp/nqe.git
+cd nqe
+
+stack build                        # build the library
+stack test                         # run the tests
+stack haddock --no-haddock-deps    # build the API documentation
+```
+
+`cabal build` and `cabal test` work too.
+
+`package.yaml` is the source of the package description. Change it rather than
+`nqe.cabal`, which [hpack](https://github.com/sol/hpack) generates.
+
+The tests are in `test/Spec.hs` and use [hspec](https://hspec.github.io/).
+
+### Contributing
+
+Pull requests are welcome.
+
+- Keep the style of the code around your change.
+- Add tests for the behaviour you change.
+- Describe the change in `CHANGELOG.md`, which follows
+  [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Versions follow the
+  [Haskell Package Versioning Policy](https://pvp.haskell.org/).
+
+## Related projects
+
+- [haskoin-node](https://github.com/jprupp/haskoin-node) is a Bitcoin
+  peer-to-peer node built from NQE processes and supervisors.
+- [haskoin-store](https://github.com/jprupp/haskoin-store) is a block chain
+  indexer that uses NQE for its block store and cache writer.
+
+## License
+
+NQE is released under the
+[MIT License](https://github.com/jprupp/nqe/blob/master/LICENSE).
diff --git a/nqe.cabal b/nqe.cabal
--- a/nqe.cabal
+++ b/nqe.cabal
@@ -5,7 +5,7 @@
 -- see: https://github.com/sol/hpack
 
 name:           nqe
-version:        0.6.8
+version:        0.6.9
 synopsis:       Concurrency library in the style of Erlang/OTP
 description:    Please see the README on GitHub at <https://github.com/jprupp/nqe#readme>
 category:       Control
diff --git a/src/Control/Concurrent/NQE/Conduit.hs b/src/Control/Concurrent/NQE/Conduit.hs
--- a/src/Control/Concurrent/NQE/Conduit.hs
+++ b/src/Control/Concurrent/NQE/Conduit.hs
@@ -3,9 +3,9 @@
 {-# LANGUAGE RankNTypes            #-}
 {-|
 Module      : Control.Concurrent.NQE.Conduit
-Copyright   : No rights reserved
-License     : UNLICENSE
-Maintainer  : xenog@protonmail.com
+Copyright   : Haskoin Developers
+License     : MIT
+Maintainer  : jprupp@protonmail.ch
 Stability   : experimental
 Portability : POSIX
 
@@ -18,6 +18,18 @@
 import           Data.Typeable
 
 -- | Consumes messages from a 'Conduit' and sends them to a channel.
+--
+-- Every value received from upstream is sent to the mailbox using 'send'. If
+-- the mailbox is bounded and full, the conduit blocks until there is space,
+-- providing back-pressure to upstream. The conduit finishes when upstream does.
+--
+-- @
+-- runConduit $ sourceHandle h .| conduitMailbox mailbox
+-- @
+--
+-- This is the bridge between streaming code and processes: a conduit that reads
+-- a socket or a file can feed a process directly, and a bounded mailbox turns the
+-- process's pace into back-pressure on the stream.
 conduitMailbox ::
        (MonadIO m, OutChan mbox, Typeable msg)
     => mbox msg
diff --git a/src/Control/Concurrent/NQE/Process.hs b/src/Control/Concurrent/NQE/Process.hs
--- a/src/Control/Concurrent/NQE/Process.hs
+++ b/src/Control/Concurrent/NQE/Process.hs
@@ -5,9 +5,9 @@
 {-# LANGUAGE RankNTypes                #-}
 {-|
 Module      : Control.Concurrent.NQE.Process
-Copyright   : No rights reserved
-License     : UNLICENSE
-Maintainer  : xenog@protonmail.com
+Copyright   : Haskoin Developers
+License     : MIT
+Maintainer  : jprupp@protonmail.ch
 Stability   : experimental
 Portability : POSIX
 
@@ -18,6 +18,82 @@
 actor in Scala, or an object in the original (Alan Kay) sense of the word. To
 implement synchronous communication NQE makes use of 'STM' actions embedded in
 asynchronous messages.
+
+= Why processes and mailboxes
+
+Shared mutable state is the usual source of trouble in concurrent programs:
+two threads change the same thing, and the result depends on timing. The
+Erlang answer, which NQE borrows, is to give each piece of state an owner. A
+process owns its state outright, and other threads can only ask it to do
+things by sending messages. The process handles one message at a time, so its
+state is never seen half-changed and needs no locks.
+
+Haskell already has excellent building blocks for this: lightweight threads,
+'STM' and the @async@ package. NQE does not replace them. It is a thin layer
+that names the patterns people end up writing by hand on top of them, so that
+programs built from many cooperating threads read the same way everywhere.
+
+= Design choices
+
+* __Everything is STM.__ Channels are 'TQueue' or 'TBQueue', and replies are
+  'STM' actions. This means a send, a receive and a reply can be combined with
+  any other transaction, and selective receive ('receiveMatch') can be done
+  atomically.
+
+* __Replies are callbacks, not return channels.__ A request carries a 'Listen'
+  action that the receiver runs. The sender decides where the reply goes: a
+  'TMVar' for 'query', a queue for a stream of replies, or nowhere at all.
+  The receiver does not need to know.
+
+* __Sending and receiving are separate capabilities.__ An 'Inbox' can receive;
+  a 'Mailbox' can only send. Handing out a 'Mailbox' lets other threads talk to
+  a process without being able to steal its messages.
+
+* __Bounded channels give back-pressure.__ A fast producer writing to a
+  bounded inbox blocks until the consumer catches up, instead of filling
+  memory.
+
+* __Failures are loud.__ 'withProcess' and 'process' link the new thread to its
+  parent, so an exception in a process is re-thrown in the thread that started
+  it rather than disappearing.
+
+* __Not quite Erlang.__ There is no distribution, no process registry, no
+  hot code loading and no automatic restarts. Those features cost a lot of
+  complexity and most Haskell programs that want actors do not need them.
+
+= Overview
+
+* An 'Inbox' is the receiving end of a channel. Only the process that owns it
+  should read from it.
+* A 'Mailbox' is the sending end of the same channel. It can be shared freely
+  with any thread that needs to talk to the process.
+* A 'Process' bundles the 'Async' handle of a running thread together with its
+  'Mailbox'.
+
+Channels can be unbounded ('newInbox') or bounded ('newBoundedInbox'). Sending
+to a full bounded channel blocks until there is space.
+
+= Synchronous requests
+
+Request/response communication is done by embedding a 'Listen' action in a
+message. The receiving process runs the action to deliver its reply. The
+'query' family of functions takes care of creating the reply variable and
+waiting on it:
+
+@
+data Ping = Ping ('Listen' Pong)
+data Pong = Pong
+
+pongServer :: 'Inbox' Ping -> IO ()
+pongServer inbox = forever $ do
+    Ping reply <- 'receive' inbox
+    atomically (reply Pong)
+
+main :: IO ()
+main = 'withProcess' pongServer $ \\p -> do
+    Pong <- 'query' Ping p
+    return ()
+@
 -}
 module Control.Concurrent.NQE.Process where
 
@@ -28,15 +104,44 @@
 import           UnliftIO
 
 -- | 'STM' function that receives an event and does something with it.
+--
+-- This is typically used as a callback embedded in a message so that the
+-- receiver can reply to the sender, or by a 'Control.Concurrent.NQE.Publisher'
+-- to forward events to subscribers. Because it runs in 'STM' it must not block
+-- for long, and should normally just write to a 'TMVar', 'TQueue' or similar.
+--
+-- Using an 'STM' callback rather than a reply channel keeps the receiver
+-- ignorant of how the sender wants its answer, and lets the reply be part of the
+-- same transaction as whatever else the receiver does.
 type Listen a = a -> STM ()
 
 -- | Channel that only allows messages to be sent to it.
+--
+-- A 'Mailbox' hides the underlying channel implementation, so that any type
+-- with an 'OutChan' instance can be used. Each mailbox carries a 'Unique'
+-- identifier, which is used for its 'Eq' and 'Hashable' instances. Two
+-- mailboxes are equal if and only if they point to the same channel.
+--
+-- Obtain one from an 'Inbox' using 'inboxToMailbox', or create a pair with
+-- 'newMailbox'.
+--
+-- The point of a separate send-only type is the principle of least authority:
+-- code that only needs to talk to a process should not be able to read its
+-- messages. The existential type also hides which kind of channel is behind it,
+-- so a bounded and an unbounded process look the same to their clients.
 data Mailbox msg =
     forall mbox. (OutChan mbox) =>
                  Mailbox !(mbox msg)
                          !Unique
 
 -- | Channel that allows to send or receive messages.
+--
+-- An 'Inbox' is the receiving end of a channel and should be owned by a single
+-- process. Like 'Mailbox', it carries a 'Unique' identifier used for equality.
+-- Create one with 'newInbox', 'newBoundedInbox' or 'wrapChannel'.
+--
+-- An inbox is the private end of a process. Keep it inside the process and
+-- share the 'Mailbox' made from it with 'inboxToMailbox'.
 data Inbox msg =
     forall mbox. (OutChan mbox, InChan mbox) =>
                  Inbox !(mbox msg)
@@ -53,12 +158,24 @@
         f (Inbox _ u) = u
 
 -- | 'Async' handle and 'Mailbox' for a process.
+--
+-- A 'Process' is an instance of 'OutChan', so messages can be sent to it
+-- directly using 'send' or 'query'. Use 'getProcessAsync' to wait for the
+-- process or cancel it.
 data Process msg = Process
     { getProcessAsync   :: Async ()
+      -- ^ handle of the thread running the process
     , getProcessMailbox :: Mailbox msg
+      -- ^ mailbox used to send messages to the process
     } deriving Eq
 
 -- | Class for implementation of an 'Inbox'.
+--
+-- Instances are provided for 'TQueue', 'TBQueue' and 'Inbox'.
+--
+-- The classes exist so that the same functions work on raw STM queues, on
+-- 'Inbox' and 'Mailbox', and on 'Process' handles. They are the extension point
+-- for other channel types.
 class InChan mbox where
     -- | Are there messages queued?
     mailboxEmptySTM :: mbox msg -> STM Bool
@@ -68,10 +185,14 @@
     requeueSTM :: msg -> mbox msg -> STM ()
 
 -- | Class for implementation of a 'Mailbox'.
+--
+-- Instances are provided for 'TQueue', 'TBQueue', 'Mailbox', 'Inbox' and
+-- 'Process'.
 class OutChan mbox where
     -- | Is this bounded channel full? Always 'False' for unbounded channels.
     mailboxFullSTM :: mbox msg -> STM Bool
-    -- | Send a message to this channel.
+    -- | Send a message to this channel. Retries (blocks) if a bounded channel
+    -- is full.
     sendSTM :: msg -> mbox msg -> STM ()
 
 instance InChan TQueue where
@@ -121,29 +242,49 @@
 inboxToMailbox :: Inbox msg -> Mailbox msg
 inboxToMailbox (Inbox m u) = Mailbox m u
 
--- | Wrap a channel in an 'Inbox'
+-- | Wrap a channel in an 'Inbox'. A fresh 'Unique' identifier is generated, so
+-- wrapping the same channel twice yields two 'Inbox' values that are not equal.
 wrapChannel ::
        (MonadIO m, InChan mbox, OutChan mbox) => mbox msg -> m (Inbox msg)
 wrapChannel mbox = Inbox mbox <$> liftIO newUnique
 
--- | Create an unbounded 'Inbox'.
+-- | Create an unbounded 'Inbox' backed by a 'TQueue'.
 newInbox :: MonadIO m => m (Inbox msg)
 newInbox = newTQueueIO >>= \c -> wrapChannel c
 
--- | 'Inbox' with upper bound on number of allowed queued messages.
+-- | Create an 'Inbox' backed by a 'TBQueue' with an upper bound on the number
+-- of queued messages. Senders block while the inbox is full.
+--
+-- Prefer a bounded inbox whenever the producer can outpace the consumer, such
+-- as a process fed by the network. Blocking the producer is almost always better
+-- than letting the queue, and the program's memory, grow without limit.
 newBoundedInbox :: MonadIO m => Natural -> m (Inbox msg)
 newBoundedInbox i = newTBQueueIO (fromIntegral i) >>= \c -> wrapChannel c
 
--- | Send a message to a channel.
+-- | Send a message to a channel. Blocks if the channel is bounded and full.
 send :: (MonadIO m, OutChan mbox) => msg -> mbox msg -> m ()
 send msg = atomically . sendSTM msg
 
--- | Receive a message from a channel.
+-- | Receive the next message from a channel. Blocks until a message is
+-- available.
 receive :: (InChan mbox, MonadIO m) => mbox msg -> m msg
 receive mbox = receiveMatch mbox Just
 
 -- | Send request to channel and wait for a response. The @request@ 'STM' action
 -- will be created by this function.
+--
+-- The first argument is usually a data constructor that takes a 'Listen'
+-- callback as its last field. This function creates an empty 'TMVar', builds
+-- the request with a callback that fills it, sends the request, and blocks until
+-- the receiver runs the callback.
+--
+-- If the receiver never answers, this function blocks forever. See 'queryU' and
+-- 'queryS' for versions with a timeout.
+--
+-- This is the NQE equivalent of a function call across threads. It is also the
+-- main way to deadlock an NQE program: a process that queries another process
+-- that is, directly or indirectly, querying it back will wait forever. Keep
+-- requests flowing in one direction, or use 'queryS' with a timeout.
 query ::
        (MonadIO m, OutChan mbox)
     => (Listen response -> request)
@@ -177,6 +318,11 @@
 -- | Test all messages in a channel against the supplied function and return the
 -- first matching message. Will block until a match is found. Messages that do
 -- not match remain in the channel.
+--
+-- Selective receive lets a process wait for one particular reply while leaving
+-- other messages for later, as Erlang's @receive@ does. Use it sparingly: every
+-- message that does not match is examined on each call, so a long queue of
+-- unmatched messages makes it slow.
 receiveMatch :: (MonadIO m, InChan mbox) => mbox msg -> (msg -> Maybe a) -> m a
 receiveMatch mbox = atomically . receiveMatchSTM mbox
 
@@ -200,7 +346,9 @@
     -> m (Maybe a)
 receiveMatchS s mbox f = timeout (s * 1000 * 1000) $ receiveMatch mbox f
 
--- | Match a message in the channel as an atomic 'STM' action.
+-- | Match a message in the channel as an atomic 'STM' action. Messages that do
+-- not match are put back in the channel in their original order. Retries if no
+-- message matches.
 receiveMatchSTM :: InChan mbox => mbox msg -> (msg -> Maybe a) -> STM a
 receiveMatchSTM mbox f = go []
   where
@@ -212,7 +360,8 @@
                     return x
                 Nothing -> go (msg : acc)
 
--- | Check if the channel is empty.
+-- | Check if the channel is empty. The result may be stale as soon as it is
+-- returned if other threads are writing to the channel.
 mailboxEmpty :: (MonadIO m, InChan mbox) => mbox msg -> m Bool
 mailboxEmpty = atomically . mailboxEmptySTM
 
@@ -224,6 +373,15 @@
 -- | Run a process in the background and pass it to a function. Stop the
 -- background process once the function returns. Background process exceptions
 -- are re-thrown in the current thread.
+--
+-- The process receives the 'Inbox' of a newly-created unbounded channel. The
+-- function receives the corresponding 'Process' handle. The background thread is
+-- linked to the current one, so if it throws, the exception is re-thrown here
+-- wrapped in 'ExceptionInLinkedThread'.
+--
+-- This is the function to reach for first. Tying the lifetime of the process to
+-- a scope means it cannot outlive the code that uses it, and linking means it
+-- cannot fail silently.
 withProcess ::
        MonadUnliftIO m => (Inbox msg -> m ()) -> (Process msg -> m a) -> m a
 withProcess p f = do
@@ -232,6 +390,10 @@
 
 -- | Run a process in the background and return the 'Process' handle. Background
 -- process exceptions are re-thrown in the current thread.
+--
+-- Unlike 'withProcess', the process is not stopped automatically. Cancel it with
+-- 'cancel' on 'getProcessAsync' when it is no longer needed. Prefer
+-- 'withProcess' where possible, since it is exception-safe.
 process :: MonadUnliftIO m => (Inbox msg -> m ()) -> m (Process msg)
 process p = do
     (i, m) <- newMailbox
diff --git a/src/Control/Concurrent/NQE/Publisher.hs b/src/Control/Concurrent/NQE/Publisher.hs
--- a/src/Control/Concurrent/NQE/Publisher.hs
+++ b/src/Control/Concurrent/NQE/Publisher.hs
@@ -1,9 +1,9 @@
 {-# LANGUAGE FlexibleContexts           #-}
 {-|
 Module      : Control.Concurrent.NQE.Publisher
-Copyright   : No rights reserved
-License     : UNLICENSE
-Maintainer  : xenog@protonmail.com
+Copyright   : Haskoin Developers
+License     : MIT
+Maintainer  : jprupp@protonmail.ch
 Stability   : experimental
 Portability : POSIX
 
@@ -14,7 +14,41 @@
 If a subscriber has been added to a publisher using the 'subscribe' function, it
 needs to be removed later using 'unsubscribe' when it is no longer needed, or
 the publisher will continue calling its 'Listen' action in the future, likely
-causing memory leaks.
+causing memory leaks. Prefer 'withSubscription', which unsubscribes
+automatically.
+
+= Why a publisher process
+
+Broadcasting is common: many parts of a program want to hear about the same
+events, such as new blocks or connected peers, and the code producing them
+should not need to know who is listening. A publisher decouples the two. The
+producer sends each event once; subscribers come and go without the producer
+noticing.
+
+= Design choices
+
+* __Subscribers are 'Listen' actions, not inboxes.__ That leaves every policy
+  decision to the subscriber: it can filter events, transform them, drop them
+  when its queue is full, or write them anywhere an 'STM' action can.
+
+* __Delivery is a single transaction.__ Each event is handed to all current
+  subscribers in one 'STM' transaction, so they all see the same events in the
+  same order. The cost is that one slow 'Listen' action slows the whole
+  publisher, which is why those actions must be quick and must not block.
+
+* __The loop is flat.__ The publisher loop sequences with @>>=@ rather than
+  'Control.Monad.forever', so that it runs in constant memory even in monads
+  like @LoggingT@, where @forever@ leaks.
+
+= Example
+
+@
+'withPublisher' $ \\pub ->
+    'withSubscription' pub $ \\inbox -> do
+        'publish' "hello" pub
+        msg <- 'receive' inbox
+        print msg
+@
 -}
 module Control.Concurrent.NQE.Publisher
     ( Subscriber
@@ -40,7 +74,8 @@
 import           UnliftIO
 
 -- | Handle of a subscriber to a process. Should be kept in order to
--- unsubscribe.
+-- unsubscribe. Subscribers are compared by a 'Unique' identifier assigned by the
+-- publisher when they subscribe.
 data Subscriber msg = Subscriber (Listen msg) Unique
 
 instance Eq (Subscriber msg) where
@@ -51,23 +86,39 @@
 instance Hashable (Subscriber msg) where
     hashWithSalt i (Subscriber _ u) = hashWithSalt i u
 
--- | Messages that a publisher will take.
+-- | Messages that a publisher will take. Normally these are not built
+-- directly; use 'subscribe', 'unsubscribe' and 'publish' instead.
 data PublisherMessage msg
     = Subscribe !(Listen msg) !(Listen (Subscriber msg))
+      -- ^ add a 'Listen' action to the subscriber list, and reply with the
+      -- resulting 'Subscriber' handle
     | Unsubscribe !(Subscriber msg)
+      -- ^ remove a subscriber from the list
     | Event msg
+      -- ^ forward a message to every subscriber
 
--- | Alias for a publisher process.
+-- | Alias for a publisher process. Since it is a 'Process', it can be stopped
+-- by cancelling its 'getProcessAsync' handle.
 type Publisher msg = Process (PublisherMessage msg)
 
+-- | Send a message to all subscribers of a publisher. The message is queued in
+-- the publisher's mailbox and forwarded asynchronously.
 publish :: MonadIO m => msg -> Publisher msg -> m ()
 publish = send . Event
 
+-- | 'STM' version of 'publish'. Useful to publish a message atomically together
+-- with other 'STM' actions.
 publishSTM :: msg -> Publisher msg -> STM ()
 publishSTM = sendSTM . Event
 
 -- | Create a mailbox, subscribe it to a publisher and pass it to the supplied
--- function . End subscription when function returns.
+-- function. End subscription when function returns, even if it throws an
+-- exception. The inbox is unbounded, so the caller should consume messages from
+-- it regularly to avoid unbounded memory growth.
+--
+-- This is the safe way to subscribe, because the subscription cannot outlive
+-- the scope. For a bounded or filtered subscription, write your own bracket
+-- around 'subscribe' and 'unsubscribe' with your own 'Listen' action.
 withSubscription ::
        MonadUnliftIO m => Publisher msg -> (Inbox msg -> m a) -> m a
 withSubscription pub f = do
@@ -76,7 +127,10 @@
         unsub = unsubscribe pub
     bracket sub unsub $ \_ -> f inbox
 
--- | 'Listen' to events from a publisher.
+-- | 'Listen' to events from a publisher. Blocks until the publisher has
+-- registered the subscriber, and returns a handle that must later be passed to
+-- 'unsubscribe'. The 'Listen' action runs inside the publisher's 'STM'
+-- transaction for every event, so it should be fast and must not retry.
 subscribe :: MonadIO m => Publisher msg -> Listen msg -> m (Subscriber msg)
 subscribe pub sub = Subscribe sub `query` pub
 
@@ -90,11 +144,13 @@
 withPublisher :: MonadUnliftIO m => (Publisher msg -> m a) -> m a
 withPublisher = withProcess publisherProcess
 
--- | Start a publisher in the background.
+-- | Start a publisher in the background. It must be stopped manually by
+-- cancelling 'getProcessAsync'. Prefer 'withPublisher' where possible.
 publisher :: MonadUnliftIO m => m (Publisher msg)
 publisher = process publisherProcess
 
--- | Start a publisher in the current thread.
+-- | Start a publisher in the current thread. Runs forever, processing
+-- 'PublisherMessage' values from the inbox.
 publisherProcess :: MonadUnliftIO m => Inbox (PublisherMessage msg) -> m ()
 publisherProcess inbox = newTVarIO [] >>= runReaderT go
   where
diff --git a/src/Control/Concurrent/NQE/Supervisor.hs b/src/Control/Concurrent/NQE/Supervisor.hs
--- a/src/Control/Concurrent/NQE/Supervisor.hs
+++ b/src/Control/Concurrent/NQE/Supervisor.hs
@@ -6,9 +6,9 @@
 
 -- |
 -- Module      : Control.Concurrent.NQE.Supervisor
--- Copyright   : No rights reserved
--- License     : UNLICENSE
--- Maintainer  : xenog@protonmail.com
+-- Copyright   : Haskoin Developers
+-- License     : MIT
+-- Maintainer  : jprupp@protonmail.ch
 -- Stability   : experimental
 -- Portability : POSIX
 --
@@ -18,6 +18,41 @@
 -- not need to be written in an overly-defensive style. They help prevent problems
 -- caused by processes dying quietly in the background, potentially locking an
 -- entire application.
+--
+-- = Why supervisors
+--
+-- In a program with many threads, the dangerous failure is the quiet one: a
+-- thread dies, nobody notices, and some other thread waits forever for a reply
+-- that will never come. A supervisor makes the life and death of a group of
+-- threads somebody's responsibility. It owns its children, it is told when one
+-- stops, and it applies a policy that you chose up front.
+--
+-- = Design choices
+--
+-- * __No automatic restarts.__ Erlang supervisors restart children. NQE
+--   supervisors only stop, ignore or report them. Restarting correctly needs
+--   knowledge that only your code has, such as which state to start from and
+--   how often a restart is acceptable, so the decision is left to you. 'Notify'
+--   exists so that you can implement any restart policy you like.
+--
+-- * __Children run in 'IO'.__ A supervisor outlives any one of its children and
+--   may run them in contexts unrelated to where they were added, so it cannot
+--   share an arbitrary monadic context with them. Convert with
+--   'UnliftIO.withRunInIO' when adding a child.
+--
+-- * __Stopping is total.__ When a supervisor stops, for whatever reason, all
+--   of its children are cancelled. Nothing it started can leak.
+--
+-- A child is any @IO ()@ action. It is started with 'addChild', which returns
+-- its 'Async' handle, and can be stopped with 'removeChild'. When the supervisor
+-- itself stops, all of its children are cancelled.
+--
+-- @
+-- 'withSupervisor' 'KillAll' $ \\sup -> do
+--     _ <- 'addChild' sup worker1
+--     _ <- 'addChild' sup worker2
+--     mainLoop
+-- @
 module Control.Concurrent.NQE.Supervisor
   ( ChildAction,
     Child,
@@ -39,33 +74,49 @@
 import Data.List
 import UnliftIO
 
--- | Alias for child action to be executed asynchronously by supervisor.
+-- | Alias for child action to be executed asynchronously by supervisor. Use
+-- 'UnliftIO.toIO' or 'UnliftIO.withRunInIO' to turn an action in another monad
+-- into a 'ChildAction'.
 type ChildAction = IO ()
 
--- | Thread handler for child.
+-- | Thread handler for child. Returned by 'addChild' and used to remove the
+-- child with 'removeChild', or to identify it in a 'Notify' callback.
 type Child = Async ()
 
--- | Send this message to a supervisor to add or remove a child.
+-- | Send this message to a supervisor to add or remove a child. Normally these
+-- messages are sent using 'addChild' and 'removeChild'.
 data SupervisorMessage
-  = AddChild
+  = -- | start a child and reply with its handle
+    AddChild
       !ChildAction
       !(Listen Child)
-  | RemoveChild
+  | -- | stop a child and reply once it is stopped
+    RemoveChild
       !Child
       !(Listen ())
 
--- | Alias for supervisor process.
+-- | Alias for supervisor process. Since it is a 'Process', it can be stopped by
+-- cancelling 'getProcessAsync', which also cancels all its children.
 type Supervisor = Process SupervisorMessage
 
 -- | Supervisor strategies to decide what to do when a child stops.
+--
+-- Pick the strategy by asking what a dead child means for the rest of the group.
+-- If the group is useless without every child, use 'KillAll'. If children are
+-- jobs that may finish but must not crash, use 'IgnoreGraceful'. If children are
+-- independent, such as one per network connection, use 'IgnoreAll' or 'Notify'.
 data Strategy
-  = -- | send a 'SupervisorNotif' to 'Mailbox' when child dies
+  = -- | run the 'Listen' action with the child handle and the exception that
+    -- stopped it, or 'Nothing' if it stopped gracefully; then keep running
     Notify (Listen (Child, Maybe SomeException))
-  | -- | kill all processes and propagate exception upstream
+  | -- | if a child stops for any reason, kill all children and stop the
+    -- supervisor; if the child threw an exception, re-throw it from the
+    -- supervisor
     KillAll
-  | -- | ignore processes that stop without raising an exception
+  | -- | ignore children that stop without raising an exception; if a child
+    -- throws, kill all children and re-throw the exception from the supervisor
     IgnoreGraceful
-  | -- | keep running if a child dies and ignore it
+  | -- | keep running if a child dies and ignore it, whatever the reason
     IgnoreAll
 
 -- | Run a supervisor asynchronously and pass its mailbox to a function.
@@ -78,11 +129,15 @@
   m a
 withSupervisor = withProcess . supervisorProcess
 
--- | Run a supervisor as an asynchronous process.
+-- | Run a supervisor as an asynchronous process. The supervisor thread is
+-- linked to the current thread, so exceptions it throws are re-thrown here. It
+-- must be stopped manually; prefer 'withSupervisor' where possible.
 supervisor :: (MonadUnliftIO m) => Strategy -> m Supervisor
 supervisor strat = process (supervisorProcess strat)
 
--- | Run a supervisor in the current thread.
+-- | Run a supervisor in the current thread. Returns when the strategy dictates
+-- that the supervisor should stop. All children are cancelled when this
+-- function exits, whether normally or due to an exception.
 supervisorProcess ::
   (MonadUnliftIO m) =>
   Strategy ->
@@ -103,6 +158,9 @@
 -- | Add a new 'ChildAction' to the supervisor. Will return the 'Child' that was
 -- just started. This function will not block or raise an exception if the child
 -- dies.
+--
+-- It does not block on the child, so it is safe to call from a child of the same
+-- supervisor, for example to spawn helpers.
 addChild :: (MonadIO m) => Supervisor -> ChildAction -> m Child
 addChild sup action = AddChild action `query` sup
 
diff --git a/src/NQE.hs b/src/NQE.hs
--- a/src/NQE.hs
+++ b/src/NQE.hs
@@ -1,12 +1,29 @@
 {-|
 Module      : NQE
-Copyright   : No rights reserved
-License     : UNLICENSE
-Maintainer  : xenog@protonmail.com
+Copyright   : Haskoin Developers
+License     : MIT
+Maintainer  : jprupp@protonmail.ch
 Stability   : experimental
 Portability : POSIX
 
 Concurrency library inspired by Erlang/OTP.
+
+NQE helps you build programs out of many small threads that each own their
+state and talk by passing messages, instead of threads that share mutable
+state. It adds a few well-known patterns on top of 'STM' and @async@:
+mailboxes, request and reply, supervision and publish/subscribe. Each module
+explains why it works the way it does.
+
+This module re-exports everything in the library:
+
+* "Control.Concurrent.NQE.Process": processes, inboxes, mailboxes and
+  synchronous queries.
+* "Control.Concurrent.NQE.Supervisor": supervisors that start, monitor and stop
+  child threads.
+* "Control.Concurrent.NQE.Publisher": a process that broadcasts messages to any
+  number of subscribers.
+* "Control.Concurrent.NQE.Conduit": glue to feed a 'Data.Conduit.ConduitT'
+  stream into a mailbox.
 -}
 module NQE
     ( -- * Processes and Mailboxes
