diff --git a/docs/plans/2026-06-21-add-book-form.md b/docs/plans/2026-06-21-add-book-form.md
new file mode 100644
index 0000000..37b8d79
--- /dev/null
+++ b/docs/plans/2026-06-21-add-book-form.md
@@ -0,0 +1,555 @@
+# Add Book Form — Implementation Plan
+
+**Goal:** Add a form at `/add` to create new books in `spine.org`, plus the write function and "Add book" link on the index page.
+
+**Architecture:** Mustache template for the form, `spine-add-book` function writes to the Org file using standard Org commands, `/add` servlet handles both GET (show form) and POST (process submission + redirect).
+
+**Tech Stack:** Emacs Lisp, `org-element` / Org commands, Mustache templates.
+
+---
+
+## File structure
+
+| File | Responsibility |
+|------|---------------|
+| `templates/add.mustache` | New — form template with Pico CSS + Tabler Icons shell |
+| `templates/index.mustache` | Modified — add "Add book" button in header |
+| `spine.el` | New functions: `spine-add-book`, `spine-add-form-model`. New handler: `/add`. |
+| `test/spine-add-book-test.el` | New — ERT tests for `spine-add-book` |
+
+---
+
+### Task 1: Create the add-book form template
+
+**Files:**
+- Create: `templates/add.mustache`
+
+- [ ] **Step 1: Write `templates/add.mustache`**
+
+```html
+
+
+
+
+
+
+Spine — Add Book
+
+
+
+
+
+
+ {{app_title}} · add book
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Verify template file exists**
+
+```bash
+cat templates/add.mustache | head -3
+```
+
+Expected: ``
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add templates/add.mustache
+git commit -m "feat: add add-book form template"
+```
+
+---
+
+### Task 2: Add "Add book" link to index page
+
+**Files:**
+- Modify: `templates/index.mustache`
+
+- [ ] **Step 1: Add a link in the index header**
+
+Find the `` section in `templates/index.mustache` (line 79-82):
+
+```html
+
+ {{app_title}}
+ {{total_books}} books · {{reading_count}} reading
+
+```
+
+Replace with:
+
+```html
+
+
+
+
+ {{total_books}} books · {{reading_count}} reading
+ Add book
+
+
+
+```
+
+- [ ] **Step 2: Verify the template renders**
+
+```bash
+emacs --quick --batch \
+ --eval "(setq spine-skip-server-start t spine-org-file \"$(pwd)/sample-books.org\")" \
+ --load spine.el \
+ --eval "(progn (message \"Has Add book: %s\" (if (string-match \"Add book\" (spine-render \"index.mustache\" (spine-index-model (spine-books) nil))) \"yes\" \"NO\")))"
+```
+(defun spine-add-book (&rest args)
+ "Add a new WANT book to `spine-org-file'.
+Keyword arguments: :title :author :category :format :isbn :cover
+ :rec_by :rec_note :date_added :initial_note
+Signals an error on failure."
+ (let ((title (plist-get args :title))
+ (author (plist-get args :author))
+ (category (plist-get args :category))
+ (format (plist-get args :format))
+ (isbn (plist-get args :isbn))
+ (cover (plist-get args :cover))
+ (rec-by (plist-get args :rec_by))
+ (rec-note (plist-get args :rec_note))
+ (date-added (plist-get args :date_added))
+ (initial-note (plist-get args :initial_note)))
+ (unless title
+ (error "spine-add-book: title is required"))
+ (let ((org-file (expand-file-name spine-org-file)))
+ ;; Create file if it doesn't exist
+ (unless (file-exists-p org-file)
+ (with-temp-file org-file
+ (insert "#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)\n\n")))
+ (with-current-buffer (find-file-noselect org-file)
+ (unwind-protect
+ (progn
+ (goto-char (point-max))
+ ;; Insert new headline
+ (org-insert-heading nil t)
+ (insert title)
+ ;; Set TODO state
+ (org-todo "WANT")
+ ;; Set properties (only non-empty)
+ (when (and author (> (length author) 0))
+ (org-set-property "AUTHOR" author))
+ (when (and format (> (length format) 0))
+ (org-set-property "FORMAT" format))
+ (when (and isbn (> (length isbn) 0))
+ (org-set-property "ISBN" isbn))
+ (when (and cover (> (length cover) 0))
+ (org-set-property "COVER" cover))
+ (when (and rec-by (> (length rec-by) 0))
+ (org-set-property "REC_BY" rec-by))
+ (when (and rec-note (> (length rec-note) 0))
+ (org-set-property "REC_NOTE" rec-note))
+ (when (and date-added (> (length date-added) 0))
+ (org-set-property "ADDED" (format "[%s]" date-added)))
+ ;; Set ID
+ (org-set-property "ID"
+ (format "%04x-%s"
+ (random 65535)
+ (substring (sha1 (concat title (random) "")) 0 4)))
+ ;; Set tags
+ (when (and category (> (length category) 0))
+ (org-set-tags (list category)))
+ ;; Add initial note
+ (when (and initial-note (> (length initial-note) 0))
+ (let ((date (if (and date-added (> (length date-added) 0))
+ date-added
+ (format-time-string "%Y-%m-%d"))))
+ (goto-char (org-entry-end-position))
+ (insert (format "\n- [%s] %s\n" date initial-note)))))
+ (save-buffer)
+ t)
+ (kill-buffer))))))
+```
+
+- [ ] **Step 2: Verify batch load**
+
+```bash
+emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el
+```
+
+Expected: exits cleanly, no errors.
+```bash
+rm -f /tmp/spine-test-add.org
+emacs --quick --batch \
+ --eval "(setq spine-skip-server-start t spine-org-file \"/tmp/spine-test-add.org\")" \
+ --load spine.el \
+ --eval "(progn (spine-add-book :title \"Test Book\" :author \"Test Author\" :format \"ebook\" :category \"scifi\" :date_added \"2026-06-21\" :initial_note \"This is a test\") (let ((books (spine-books))) (message \"Book count: %d\" (length books)) (message \"Title: %s\" (plist-get (car books) :title)) (message \"ID: %s\" (plist-get (car books) :id)) (message \"Notes: %S\" (plist-get (car books) :notes))))" 2>&1
+```
+
+Expected: `Book count: 1`, `Title: Test Book`, ID present, one note.
+
+- [ ] **Step 4: Clean up and commit**
+
+```bash
+rm -f /tmp/spine-test-add.org
+git add spine.el
+git commit -m "feat: add spine-add-book write function"
+```
+
+---
+
+### Task 4: Wire up /add handler and spine-add-form-model
+
+**Files:**
+- Modify: `spine.el`
+
+- [ ] **Step 1: Add `spine-add-form-model` function**
+
+Insert before `spine-index-model`:
+
+```elisp
+(defun spine-add-form-model (books)
+ "Build the view model ht for templates/add.mustache from BOOKS."
+ (let ((authors nil)
+ (categories nil))
+ (dolist (book books)
+ (let ((author (plist-get book :author)))
+ (when (and author (> (length author) 0)
+ (not (member author authors)))
+ (push author authors)))
+ (dolist (tag (or (plist-get book :tags) nil))
+ (unless (member tag categories)
+ (push tag categories))))
+ (ht ("app_title" "spine")
+ ("existing_authors" (sort authors #'string<))
+ ("existing_categories" (sort categories #'string<)))))
+```
+
+- [ ] **Step 2: Add `/add` servlet**
+
+Replace or insert after the existing `index` servlet handler:
+
+```elisp
+(defservlet add text/html (path query request)
+ (let ((method (cadr (car request))))
+ (if (equal method "POST")
+ (progn
+ (spine-add-book
+ :title (cdr (assoc "title" query))
+ :author (cdr (assoc "author" query))
+ :category (cdr (assoc "category" query))
+ :format (cdr (assoc "format" query))
+ :isbn (cdr (assoc "isbn" query))
+ :cover (cdr (assoc "cover" query))
+ :rec_by (cdr (assoc "rec_by" query))
+ :rec_note (cdr (assoc "rec_note" query))
+ :date_added (cdr (assoc "date_added" query))
+ :initial_note (cdr (assoc "initial_note" query)))
+ (httpd-redirect t "/index" 303))
+ (let* ((books (spine-books))
+ (model (if books
+ (spine-add-form-model books)
+ (ht ("app_title" "spine")))))
+ (insert (spine-render "add.mustache" model))))))
+```
+
+- [ ] **Step 3: Verify batch load**
+
+```bash
+emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el
+```
+
+Expected: exits cleanly.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add spine.el
+git commit -m "feat: wire up /add handler with form model"
+```
+
+---
+
+### Task 5: Test spine-add-book with ERT
+
+**Files:**
+- Create: `test/spine-add-book-test.el`
+
+- [ ] **Step 1: Write `test/spine-add-book-test.el`**
+
+```elisp
+;;; spine-add-book-test.el — ERT tests for spine-add-book
+
+(require 'ert)
+(require 'cl-lib)
+
+(setq spine-skip-server-start t)
+(setq spine-org-file (expand-file-name "test-add-temp.org"
+ (file-name-directory load-file-name)))
+(load-file (expand-file-name "../spine.el"
+ (file-name-directory load-file-name)))
+
+(defun spine-add-book-test--cleanup ()
+ "Remove the test Org file if it exists."
+ (ignore-errors (delete-file spine-org-file)))
+
+(ert-deftest spine-add-book-creates-headline ()
+ "spine-add-book creates a new WANT headline."
+ (spine-add-book-test--cleanup)
+ (unwind-protect
+ (progn
+ (spine-add-book :title "Test Book" :author "Author Name")
+ (let ((books (spine-books)))
+ (should books)
+ (should (= (length books) 1))
+ (let ((book (car books)))
+ (should (equal (plist-get book :title) "Test Book"))
+ (should (equal (plist-get book :status) "WANT"))
+ (should (equal (plist-get book :author) "Author Name"))
+ (should (plist-get book :id)))))
+ (spine-add-book-test--cleanup)))
+
+(ert-deftest spine-add-book-sets-all-fields ()
+ "spine-add-book sets all provided fields."
+ (spine-add-book-test--cleanup)
+ (unwind-protect
+ (progn
+ (spine-add-book
+ :title "Full Test"
+ :author "Jane Doe"
+ :format "hardcover"
+ :isbn "978-1234567890"
+ :cover "covers/test.jpg"
+ :rec_by "Friend"
+ :rec_note "You'll love this"
+ :date_added "2026-06-21"
+ :category "scifi"
+ :initial_note "Looking forward to this")
+ (let* ((books (spine-books))
+ (book (car books)))
+ (should (equal (plist-get book :title) "Full Test"))
+ (should (equal (plist-get book :author) "Jane Doe"))
+ (should (equal (plist-get book :format) "hardcover"))
+ (should (equal (plist-get book :isbn) "978-1234567890"))
+ (should (equal (plist-get book :cover) "covers/test.jpg"))
+ (should (equal (plist-get book :rec_by) "Friend"))
+ (should (equal (plist-get book :rec_note) "You'll love this"))
+ (should (equal (plist-get book :added) "[2026-06-21]"))
+ (should (equal (plist-get book :tags) '("scifi")))
+ (should (equal (plist-get book :notes)
+ '(("2026-06-21" "Looking forward to this"))))))
+ (spine-add-book-test--cleanup)))
+
+(ert-deftest spine-add-book-omits-empty-fields ()
+ "spine-add-book does not set empty/omitted fields."
+ (spine-add-book-test--cleanup)
+ (unwind-protect
+ (progn
+ (spine-add-book :title "Minimal Book")
+ (let* ((books (spine-books))
+ (book (car books)))
+ (should (equal (plist-get book :title) "Minimal Book"))
+ (should-not (plist-get book :author))
+ (should-not (plist-get book :format))
+ (should-not (plist-get book :isbn))
+ (should-not (plist-get book :tags))))
+ (spine-add-book-test--cleanup)))
+
+(ert-deftest spine-add-book-requires-title ()
+ "spine-add-book signals error when title is missing."
+ (spine-add-book-test--cleanup)
+ (unwind-protect
+ (should-error (spine-add-book :author "No Title"))
+ (spine-add-book-test--cleanup)))
+
+(ert-deftest spine-add-book-generates-unique-ids ()
+ "spine-add-book generates different IDs for each book."
+ (spine-add-book-test--cleanup)
+ (unwind-protect
+ (progn
+ (spine-add-book :title "Book One")
+ (spine-add-book :title "Book Two")
+ (let* ((books (spine-books))
+ (id1 (plist-get (nth 0 books) :id))
+ (id2 (plist-get (nth 1 books) :id)))
+ (should (not (equal id1 id2)))))
+ (spine-add-book-test--cleanup)))
+```
+
+- [ ] **Step 2: Run the tests**
+
+```bash
+emacs --quick --batch \
+ --directory "$(pwd)" \
+ --load test/spine-add-book-test.el \
+ -f ert-run-tests-batch-and-exit
+```
+
+Expected: all 5 tests pass.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add test/spine-add-book-test.el
+git commit -m "test: add ERT tests for spine-add-book"
+```
+
+---
+
+### Task 6: Smoke test end-to-end
+
+**Files:**
+- No changes — verification only.
+
+- [ ] **Step 1: Start server with temp Org file**
+
+```bash
+rm -f /tmp/spine-add-test.org
+emacsclient --socket-name=spine --eval '(kill-emacs)' 2>/dev/null
+pkill -f "emacs --quick" 2>/dev/null
+sleep 1
+emacs --quick \
+ --eval "(setq spine-org-file \"/tmp/spine-add-test.org\")" \
+ --load spine.el &
+sleep 5
+```
+
+- [ ] **Step 2: Verify form page loads**
+
+```bash
+curl -s http://localhost:8080/add | grep -c "Add book"
+```
+
+Expected: `4` (title, button, etc.)
+
+- [ ] **Step 3: Verify "Add book" link on index**
+
+```bash
+curl -s http://localhost:8080/index | grep -c "Add book"
+```
+
+Expected: `1`
+
+- [ ] **Step 4: Submit a new book via form**
+
+```bash
+curl -s -X POST http://localhost:8080/add \
+ -d "title=Neuromancer" \
+ -d "author=William Gibson" \
+ -d "format=ebook" \
+ -d "category=scifi" \
+ -d "date_added=2026-06-21" \
+ -d "initial_note=The sky was the color of television" \
+ -o /dev/null -w "HTTP %{http_code}\n" \
+ -L
+```
+
+Expected: HTTP 200 (after redirect).
+
+- [ ] **Step 5: Verify new book appears on index**
+
+```bash
+curl -s http://localhost:8080/index | grep "Neuromancer"
+```
+
+Expected: `Neuromancer` visible in output.
+
+- [ ] **Step 6: Stop server**
+
+```bash
+kill %1 2>/dev/null
+rm -f /tmp/spine-add-test.org
+```
+
+---
+
+### Task 7: Run all tests and final verification
+
+**Files:**
+- No changes — verification only.
+
+- [ ] **Step 1: Run all ERT test suites**
+
+```bash
+# Run spine-books tests
+emacs --quick --batch --directory "$(pwd)" \
+ --load test/spine-books-test.el \
+ -f ert-run-tests-batch-and-exit
+
+# Run spine-add-book tests
+emacs --quick --batch --directory "$(pwd)" \
+ --load test/spine-add-book-test.el \
+ -f ert-run-tests-batch-and-exit
+```
+
+Expected: 6/6 + 5/5 = all pass.
+
+- [ ] **Step 2: Git status**
+
+```bash
+git status --short
+git log --oneline -4
+```