diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml
new file mode 100644
index 000000000..d4ca0f39b
--- /dev/null
+++ b/.github/workflows/gh-pages.yml
@@ -0,0 +1,36 @@
+name: github pages
+
+on:
+ push:
+ branches:
+ - main
+ release:
+ types: [created]
+ pull_request:
+
+jobs:
+ deploy:
+ runs-on: ubuntu-20.04
+ concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Cancel Previous Runs
+ uses: styfle/cancel-workflow-action@0.11.0
+ with:
+ access_token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Setup mdBook
+ uses: peaceiris/actions-mdbook@v1
+ with:
+ mdbook-version: 'latest'
+
+ - run: mdbook build book
+
+ - name: Deploy
+ uses: peaceiris/actions-gh-pages@v3
+ if: github.event_name == 'release'
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./book/book
diff --git a/book/book.toml b/book/book.toml
new file mode 100644
index 000000000..cc8de95c6
--- /dev/null
+++ b/book/book.toml
@@ -0,0 +1,9 @@
+[book]
+authors = ["Kevin R. Thornton"]
+language = "en"
+multilingual = false
+src = "src"
+title = "The tskit (rust) book"
+
+[output.html]
+preferred-dark-theme = "light"
diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md
new file mode 100644
index 000000000..df6c522e1
--- /dev/null
+++ b/book/src/SUMMARY.md
@@ -0,0 +1,7 @@
+# Summary
+
+- [Introduction](./introduction.md)
+- [Working with table collections](./working_with_table_collections.md)
+ - [Creation](./table_collection_creation.md)
+ - [Adding rows](./table_collection_adding_rows.md)
+ - [Validating table collection contents](./table_collection_validation.md)
diff --git a/book/src/introduction.md b/book/src/introduction.md
new file mode 100644
index 000000000..dc6325e35
--- /dev/null
+++ b/book/src/introduction.md
@@ -0,0 +1,42 @@
+# Introduction
+
+## Do you need `tskit-rust`?
+
+The use-cases for `tskit-rust` are the same as for `tskit-c`:
+
+1. Developing a new performance-oriented application.
+2. The input/output of this application will be a `.trees` file.
+
+## What does `tskit-rust` add?
+
+Briefly, you get the performance of `C` and the strong safety guarantees of `rust`.
+
+## What is `tskit-rust` missing?
+
+The crate does not cover the entire `C` API.
+However, client code can make direct calls to that API via the module `tskit::bindings`.
+
+## Adding `tskit` as a dependency to a rust project
+
+In your `Cargo.toml`:
+
+```{toml}
+[dependencies]
+tskit = "~X.Y.Z"
+```
+
+The latest version to fill in `X.Y.Z` can be found [here](https://crates.io/crates/tskit).
+See [here](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html) for how to specify version numbers.
+
+### Feature flags.
+
+`tskit` defines several [cargo features](https://doc.rust-lang.org/cargo/reference/features.html).
+These are defined in the [API docs](https://docs.rs/tskit/latest/tskit/#optional-features).
+
+## Conventions used in this document
+
+We assume a working knowledge of rust.
+Thus, we skip over the details of things like matching idioms, etc.,
+and just `.unwrap()`.
+
+We also assume familiarity with the `tskit` [data model](https://tskit.dev/tskit/docs/stable/data-model.html).
diff --git a/book/src/table_collection_adding_rows.md b/book/src/table_collection_adding_rows.md
new file mode 100644
index 000000000..6db751228
--- /dev/null
+++ b/book/src/table_collection_adding_rows.md
@@ -0,0 +1,26 @@
+## Adding rows to tables
+
+* For each table type (node, edge., etc.), we have a function to add a row.
+* We can only add rows to tables in mutable `TableCollection` instances.
+
+For example, to add a node:
+
+
+```rust, noplaygound, ignore
+{{#include ../../tests/book_table_collection.rs:add_node_without_metadata}}
+```
+
+We see from the `if let` pattern that functions adding rows return
+[`Result`](https://doc.rust-lang.org/std/result/enum.Result.html).
+In general, errors only occur when the C back-end fails to allocate memory
+to expand the table columns.
+If we add a row with invalid data, no error is returned!
+To catch such errors, we must explicitly check table integrity (see [below](table_collection_validation.md#checking-table-integrity)).
+
+Again, we can take advantage of being able to pass in any type that is `Into<_>` the required newtype:
+
+```rust, noplaygound, ignore
+{{#include ../../tests/book_table_collection.rs:add_node_without_metadata_using_into}}
+```
+
+See the [API docs](https://docs.rs/tskit) for more details and examples.
diff --git a/book/src/table_collection_creation.md b/book/src/table_collection_creation.md
new file mode 100644
index 000000000..7246ca285
--- /dev/null
+++ b/book/src/table_collection_creation.md
@@ -0,0 +1,18 @@
+## Creation
+
+We initialize a `TableCollection` with a sequence length.
+In `tskit-c`, the genome length is a C `double`.
+Here it is a [newtype](https://doc.rust-lang.org/rust-by-example/generics/new_types.html) called `tskit::Position`:
+
+```rust, noplaygound, ignore
+{{#include ../../tests/book_table_collection.rs:create_table_collection_with_newtype}}
+```
+
+The newtype pattern gives type safety by disallowing you to send a position to a function where a time is required, etc..
+However, it can be inconvenient to type out the full type names every time.
+Thus, the API defines most functions taking arguments `Into` where `T` is one of our newtypes.
+This design means that the following is equivalent to what we wrote above:
+
+```rust, noplaygound, ignore
+{{#include ../../tests/book_table_collection.rs:create_table_collection}}
+```
diff --git a/book/src/table_collection_validation.md b/book/src/table_collection_validation.md
new file mode 100644
index 000000000..69d186207
--- /dev/null
+++ b/book/src/table_collection_validation.md
@@ -0,0 +1,11 @@
+## Checking table integrity
+
+The data model involves lazy checking of inputs.
+In other words, we can add invalid row data that is not caught by the "add row" functions.
+We inherit this behavior from the C API.
+
+We can check that the tables contain valid data by:
+
+```rust, noplaygound, ignore
+{{#include ../../tests/book_table_collection.rs:integrity_check}}
+```
diff --git a/book/src/working_with_table_collections.md b/book/src/working_with_table_collections.md
new file mode 100644
index 000000000..7dcbb8ff3
--- /dev/null
+++ b/book/src/working_with_table_collections.md
@@ -0,0 +1,3 @@
+# Working with table collections
+
+The next sections cover how to work with the `TableCollection`, which is one of `tskit`'s fundamental types.
diff --git a/tests/book_table_collection.rs b/tests/book_table_collection.rs
new file mode 100644
index 000000000..b53988ecb
--- /dev/null
+++ b/tests/book_table_collection.rs
@@ -0,0 +1,68 @@
+#[test]
+fn simple_table_collection_creation_with_newtype() {
+ // ANCHOR: create_table_collection_with_newtype
+ let sequence_length = tskit::Position::from(100.0);
+ if let Ok(tables) = tskit::TableCollection::new(sequence_length) {
+ assert_eq!(tables.sequence_length(), sequence_length);
+ // In tskit, the various newtypes can be compared to
+ // the low-level types they wrap.
+ assert_eq!(tables.sequence_length(), 100.0);
+ } else {
+ panic!(
+ "TableCollection creation sequence length = {} failed",
+ sequence_length
+ );
+ }
+ // ANCHOR_END: create_table_collection_with_newtype
+}
+
+#[test]
+fn simple_table_collection_creation() {
+ // ANCHOR: create_table_collection
+ let tables = tskit::TableCollection::new(100.0).unwrap();
+ // ANCHOR_END: create_table_collection
+ assert_eq!(tables.sequence_length(), 100.0);
+}
+
+#[test]
+fn add_node_without_metadata() {
+ {
+ // ANCHOR: add_node_without_metadata
+ let mut tables = tskit::TableCollection::new(100.0).unwrap();
+ if let Ok(node_id) = tables.add_node(
+ 0, // Node flags
+ tskit::Time::from(0.0), // Birth time
+ tskit::PopulationId::NULL, // Population id
+ tskit::IndividualId::NULL, // Individual id
+ ) {
+ assert_eq!(node_id, 0);
+ }
+ // ANCHOR_END: add_node_without_metadata
+ }
+ {
+ let mut tables = tskit::TableCollection::new(100.0).unwrap();
+ // ANCHOR: add_node_without_metadata_using_into
+ let node_id = tables.add_node(0, 0.0, -1, -1).unwrap();
+ // ANCHOR_END: add_node_without_metadata_using_into
+ assert_eq!(node_id, 0);
+ }
+}
+
+#[test]
+fn add_node_handle_error() {
+ // ANCHOR: integrity_check
+ let mut tables = tskit::TableCollection::new(100.0).unwrap();
+ // Everything about this edge is wrong...
+ tables.add_edge(-1.0, 110.0, 0, 1).unwrap();
+ // ...and we can catch that here
+ match tables.check_integrity(tskit::TableIntegrityCheckFlags::default()) {
+ Ok(code) => panic!("expected Err(e) but got code: {}", code),
+ // tskit::TskitError can be formatted into the same
+ // error messages that tskit-c/tskit-python give.
+ Err(e) => println!("{}", e),
+ }
+ // ANCHOR_END: integrity_check
+ assert!(tables
+ .check_integrity(tskit::TableIntegrityCheckFlags::default())
+ .is_err());
+}