diff --git a/LAUNCH.md b/LAUNCH.md
new file mode 100644
index 0000000..1dd5dcd
--- /dev/null
+++ b/LAUNCH.md
@@ -0,0 +1,282 @@
+# LAUNCH.md — starting the app, and why it fails on this host
+
+## TL;DR
+
+**This host (`development`, x86_64) has no card reader attached. Failure is
+expected and correct.** To see the app work without hardware, use `--demo`:
+
+```bash
+.venv/bin/python -m rdm_card_reader --demo
+```
+
+To see the reader path run end-to-end without hardware, the test suite drives a
+pseudo-terminal pretending to be an RDM6300:
+
+```bash
+.venv/bin/python -m pytest tests/ -q # 152 tests, includes e2e PTY tests
+```
+
+---
+
+## Quick start
+
+All commands run from the project root. The virtualenv is already built.
+
+```bash
+.venv/bin/python -m rdm_card_reader --demo # simulate a card, no hardware
+.venv/bin/python -m rdm_card_reader --list-ports # what serial ports exist
+.venv/bin/python -m rdm_card_reader # real reader (fails here)
+.venv/bin/python -m rdm_card_reader --serve # HTTP bridge for a web page
+```
+
+After `pip install .` the same thing is available as `rdm-card-reader …`.
+
+---
+
+## What happens on this host, and why
+
+### `--list-ports` works fine
+
+```console
+$ .venv/bin/python -m rdm_card_reader --list-ports --verbose-ports
+Candidate serial ports (tried in this order):
+ OK /dev/ttyS0
+ --- /dev/ttyAMA0
+ --- /dev/ttyUSB0
+ --- /dev/ttyACM0
+
+Identified serial ports (best candidate first):
+ [score 5] /dev/ttyS0 :: n/a
+ by-id : (none)
+ by-path : (none)
+ location : (unknown)
+ usb id : (not USB)
+ driver : (unknown)
+```
+
+Two things to read from this:
+
+* `OK /dev/ttyS0` — the device node **exists**, but existence is not proof of a
+ working reader. This container has a UART node with no hardware behind it.
+* `score 5` and `(not USB)` — nothing here looks like a USB-serial adapter.
+ A real reader scores 60–100 and shows a chip name (`CH340`, `FT232R`, …).
+
+### Starting the reader fails
+
+```console
+$ .venv/bin/python -m rdm_card_reader
+-> Opening /dev/ttyS0 ...
+-> Opening /dev/ttyAMA0 ...
+-> Opening /dev/ttyUSB0 ...
+-> Opening /dev/ttyACM0 ...
+ x /dev/ttyS0: Could not configure port: (5, 'Input/output error')
+ x /dev/ttyAMA0: device not found
+ x /dev/ttyUSB0: device not found
+ x /dev/ttyACM0: device not found
+
+x Could not open any serial port. Check:
+ 1. the reader is connected (rdm-card-reader --list-ports)
+ 2. UART is enabled: sudo raspi-config -> Interface Options -> Serial
+ 3. the user is in dialout: sudo usermod -aG dialout $USER
+```
+
+This is the **correct** outcome. Note the distinct error for `/dev/ttyS0`:
+`Input/output error` (errno 5) means the node exists but the underlying hardware
+does not answer — different from `device not found` for the ports that do not
+exist at all. The app reports which is which rather than a generic failure.
+
+### `--autodetect` also fails, as designed
+
+With no USB adapter present there is nothing to autodetect, so it falls through
+to the default port order and fails the same way. Crucially, it **refuses to
+guess** when several adapters *are* present (`exit 3`) — picking the wrong
+reader means authenticating the wrong person, so ambiguity is an error rather
+than a coin flip.
+
+### `--demo` works, because it touches no hardware
+
+```console
+$ .venv/bin/python -m rdm_card_reader --demo --demo-count 1
+Demo mode - no hardware is used, values are simulated.
+
+----------------------------------------------------------
+ CARD INSERTED - 2026-10-01 15:53:10
+ id : 439041101 (0x1A2B3C4D)
+ type : 0x0F
+ checksum : 79
+ valid : True
+ port : demo
+----------------------------------------------------------
+ CARD REMOVED - 2026-10-01 15:53:11 id: 439041101 (0x1A2B3C4D)
+
+Demo finished.
+```
+
+`--demo` needs neither the hardware nor the `rdm6300` library, so it is the
+fastest way to confirm the build is sane.
+
+---
+
+## How the app starts
+
+```mermaid
+flowchart TD
+ A["rdm-card-reader
(console script → cli:main)"] --> B{which mode?}
+ B -->|--list-ports| P["print ports, exit 0"]
+ B -->|--demo| D["simulated cards, no hardware"]
+ B -->|--serve or plain| C["1. ensure libraries"]
+ C --> C1{"rdm6300 importable?"}
+ C1 -->|yes| E
+ C1 -->|no| C2["install from bundled wheels
(--no-index, SHA256 verified)"]
+ C2 --> C3["reload_library()
re-import rdm6300"]
+ C3 -->|still missing| X2["exit 2"]
+ C3 -->|ok| E["2. resolve the serial port"]
+ E --> E1{"--port given?"}
+ E1 -->|yes| E2["match it; refuse if ambiguous
→ exit 3"]
+ E1 -->|no| E3["--autodetect: only a single USB adapter
else fall back to default order"]
+ E2 --> F
+ E3 --> F["3. open the port"]
+ F -->|all ports fail| X1["exit 1"]
+ F --> G["4. reader.start() — blocking read loop"]
+ G --> H{"mode"}
+ H -->|plain| I["print each card event"]
+ H -->|--serve| J["latch the tap in the EventHub"]
+ J --> K["HTTP server on 127.0.0.1:8765"]
+ K --> L["web page polls /api/card
and claims it once"]
+```
+
+The numbered stages, and where each lives:
+
+| # | Stage | Code | On failure |
+| --- | --- | --- | --- |
+| 1 | Ensure `rdm6300` + `pyserial` | `bootstrap.ensure_dependencies()` | `exit 2` |
+| 2 | Resolve which serial port | `ports.resolve()` | `exit 1` (none), `exit 3` (ambiguous) |
+| 3 | Open the port | `reader.open_reader()` | `exit 1` |
+| 4 | Read frames forever | `rdm6300.BaseReader.start()` | — |
+| 5 | Publish events | `card.format_event()` or `bridge.EventHub` | — |
+
+Stages 1 and 2 are deliberately **fail-fast and loud**. A reader that starts up
+having silently picked the wrong port, or without its library, is worse than one
+that refuses to start — the first failure mode is invisible until someone
+authenticates as the wrong person.
+
+### Stage 1 in detail: dependency install
+
+The `rdm6300` and `pyserial` wheels are **shipped inside the package**
+(`rdm_card_reader/wheels/`), so this works on a device with no internet:
+
+```console
+Reader library not found - checking dependencies...
+Missing reader dependencies: rdm6300
+Using the wheels bundled with this package:
+ .../site-packages/rdm_card_reader/wheels
+Installing from .../rdm_card_reader/wheels
+Dependencies ready: rdm6300
+```
+
+Nothing is installed when the libraries are already importable, so a normal
+start does no network work and no `pip` call at all. The install uses
+`--no-index` and verifies `SHA256SUMS` first; a wheel that fails its checksum is
+**refused rather than installed**.
+
+`reload_library()` matters here: the module-level `import rdm6300` already ran
+and failed at process start, so without re-importing, a successful install would
+still look like a missing library.
+
+### Stage 4 in detail: reading is not polling
+
+The reader loop **blocks** on serial reads and the library calls back when it
+assembles a valid frame. Nothing here polls. The *web page* polls (or subscribes
+to SSE) — a different thing, and worth keeping straight.
+
+A card is reported on insert and again on removal; the library de-duplicates
+repeated reads while the card sits on the antenna, so you do not get a stream of
+repeats. Removal is detected by a `--heartbeat` (default `0.5s`) of silence.
+
+---
+
+## Exit codes
+
+| Code | Meaning | Seen on this host? |
+| --- | --- | --- |
+| `0` | Ran normally, or stopped with Ctrl+C | with `--demo`, `--list-ports` |
+| `1` | No serial port could be opened | **yes** — this is the expected result here |
+| `2` | `rdm6300` missing and could not be installed | only if offline *and* wheels tampered |
+| `3` | Port selector matched several adapters (refused to guess) | needs 2+ adapters |
+
+> When testing with a pipe, remember `head` closes the pipe early and the shell
+> then reports `128+SIGPIPE`. Redirect to a file instead if you want the real code:
+> `… > out.txt 2>&1; echo $?`
+
+---
+
+## When a reader *is* attached
+
+On the Raspberry Pi the same command should end with a ready line rather than an
+error:
+
+```console
+-> Using /dev/rdm-reader (matched /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0)
+ OK reader on /dev/rdm-reader ; bridge on http://127.0.0.1:8765 (mode: login)
+```
+
+Then compare this host against the Pi:
+
+| | this host (`development`) | Raspberry Pi with reader |
+| --- | --- | --- |
+| Architecture | x86_64 | armv7l / aarch64 |
+| Reader | none | RDM6300 on USB-serial or UART |
+| `/dev/ttyUSB*` | absent | present |
+| `--list-ports` score | 5, `(not USB)` | 60–100, e.g. `CH340 1a86:7523` |
+| `/dev/rdm-reader` | absent | udev symlink |
+| Serial access group | n/a | user must be in `dialout` |
+| Expected `rdm-card-reader` | `exit 1` | stays running |
+
+For a real device, `deploy/install.sh` sets all of this up:
+
+```bash
+sudo ./deploy/install.sh --origin https://your.web.app
+journalctl -u rdm-card-reader -f
+curl http://127.0.0.1:8765/api/card # tap a card first
+```
+
+---
+
+## Testing the reader path without a reader
+
+Because the app talks plain serial, a pseudo-terminal is enough to exercise the
+whole chain. `tests/test_e2e_pty.py` writes real RDM6300 frames into a PTY and
+asserts on the printed events; `tests/test_e2e_bridge.py` does the same and then
+claims the tap over HTTP exactly as a web page would.
+
+To hand-feed a single frame yourself:
+
+```python
+import os, pty, struct, subprocess, sys, time
+
+def frame(value, card_type=0x0F):
+ id_bytes = struct.pack(">I", value)
+ checksum = card_type
+ for b in id_bytes:
+ checksum ^= b
+ return (bytes([card_type]) + id_bytes + bytes([checksum])).hex().upper().encode()
+
+master, slave = pty.openpty()
+port = os.ttyname(slave)
+proc = subprocess.Popen([sys.executable, "-u", "-m", "rdm_card_reader", "--port", port])
+time.sleep(2) # let the child open the port FIRST
+os.write(master, b"\x02" + frame(0x00C4A0B5) + b"\x03")
+time.sleep(1)
+proc.terminate()
+```
+
+Two traps worth knowing, both of which cost me a debugging cycle:
+
+* **Send the frame after the child has opened the port.** A PTY buffers, so
+ frames written too early are discarded and it looks like a reader bug.
+* **Read the child's stdout from a thread.** With text-mode buffered pipes,
+ `select()` will not report the data even though it is there.
+
+Frame layout, since it is easy to get wrong: the 12 hex nibbles are
+**type, id, checksum**, and the checksum is the XOR of the type byte **and** all
+four id bytes — not the id bytes alone.
diff --git a/README.md b/README.md
index 4be0f58..bed777b 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,10 @@ print what happens: **card inserted** with the card data, then **card removed**.
Built for a Raspberry Pi workstation, but it runs anywhere with a serial port.
+> Starting it on a machine with no reader attached? See **[LAUNCH.md](LAUNCH.md)**
+> — it explains the startup sequence, why it exits `1` with no hardware, and how
+> to exercise the reader path anyway.
+
---
## What it does