reader
by nevescloud
README.md
# reader
**Read Claude on your e-reader — and tap back.**
[](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=E-Ink%20Reader&connectorUrl=https%3A%2F%2Freader.neves.cloud%2Fmcp)
Ask Claude for something long and it appears on your Kindle, Kobo, reMarkable,
PocketBook or Boox in about three seconds — through the browser the device
already has. No jailbreak, no email, no EPUB, no account, no app.
And it reads back: Claude can put tappable buttons under what it wrote, so you
answer from the device and the conversation carries on.
## Start
```
1. Add the connector https://reader.neves.cloud/mcp
2. On the e-reader, open reader.neves.cloud → shows a 5-char code
3. Tell Claude "send that to my reader, code 8427X"
```
The e-reader **types nothing**. It shows a code; you read it off the screen and
say it out loud. That constraint drives most of the design below.
## The tools
| Tool | What it does |
|---|---|
| `send_to_reader` | Puts markdown on the screen. Optionally with tappable choices. |
| `await_reader_choice` | Blocks until they tap — a choice, a quick action, or text they marked. |
| `check_reader` | Is a device polling, what is it showing, what page are they on. |
| `send_drill` | Hands a whole multiple-choice deck to the server, which runs the loop. |
| `await_drill_report` | Per-item results once the deck clears. |
| `resume_drill` | Puts the drill question back after an interruption. |
Markdown, GFM tables and SVG (fenced ` ```svg ` or raw `<svg>`) render crisply in
grayscale. `mode: "append"` streams a long piece in chunks without losing the
reader's place.
Beyond the buttons Claude sends, the device always offers three of its own:
**↻ simpler**, **→ more**, and **✎ explain** — mark a word, sentence or block and
Claude explains it in place.
## How it works
```
"send that to my reader, code 8427X"
│
Claude
│
┌─────────────────────┴──────────────────────┐
▼ ▼
reader.neves.cloud/mcp mcp.neves.cloud/mcp
anonymous · no sign-in GitHub OAuth · remembers
calls the DO in-process your reader
│ │
│ Bearer READER_TOKEN
│ → /_api/send
└─────────────────────┬──────────────────────┘
▼
┌───────────────────────────────┐
│ Durable Object "<code>" │
│ { v, html, title } · 6h TTL │
└───────────────────────────────┘
│ ▲
polls /s/<code> │ │ taps /c/<code>
2.5s → 30s ▼ │ (also ws /w/<code>,
e-reader — stock browser, e-ink one frame per tap)
```
Polling rather than WebSocket or SSE is deliberate: e-ink repaints in about a
second and these devices' WS/SSE are unreliable, so a 2.5s short-poll with
full-document replace is both the right granularity *and* the robust one.
Two front doors, same tools, same core operations (`src/ops.ts`) — so their
append, render and delivery behaviour cannot drift apart:
- **`reader.neves.cloud/mcp`** — anonymous, served by this Worker (`src/mcp.ts`),
calling the Session Durable Objects in-process. No OAuth anywhere on this
origin. The 5-character code is required on every call: with no accounts,
there is nothing to hang a saved pairing on.
- **`mcp.neves.cloud/mcp`** — GitHub sign-in buys a saved pairing (`code` becomes
optional after the first send) and a `pair_reader` tool. It reaches this
Worker's write API over a service binding.
## Deck mode: a drill runs on the device, not through the model
A multiple-choice drill with an answer key is a deterministic loop, but run
screen-by-screen through an agent it costs two round-trips per item — an 18-item
deck is 36 — for work a state machine does.
`send_drill` hands the **whole deck** over: question, choices, answer index,
optional feedback, plus a policy (`requeue_until_correct`, `shuffle`). From there
the Durable Object scores each tap, renders the feedback screen and turns the
page itself. **Pages advance at device speed, with no model in the loop.**
`await_drill_report` returns per-item results at the end: what they tapped,
first-try correctness, retries, seconds.
Its edges stay open. A quick action is the one tap that genuinely needs a model —
it **parks** the drill and surfaces as an ordinary pending tap. Reply with
`mode: "append"` and the next answer-tap resumes it on its own; `resume_drill`
re-renders the question when you want a clean screen. A `mode: "replace"` send
ends the drill, which is also how you cancel one.
The boundary is deliberate: deck mode is only for a deck authored up front with
closed-form answers. Teaching, discussion, partial credit and adaptive
re-explaining stay on `send_to_reader` / `await_reader_choice`. The calling agent
picks the mode.
## Security
The code is the whole capability, so the keyspace is the security parameter:
30<sup>5</sup> = 24,300,000 (base32 minus every character you could misread off
e-ink — no `I`/`L`/`O`/`U`, no `0`/`1`). Unthrottled, that is a search space
rather than a secret, so two per-IP budgets sit in front of it:
- a loose ceiling on every code-bearing device route, sized well above a real
reader's 2.5s poll — it caps how fast one address can mint Durable Objects;
- a tight budget spent **only on first contact with a code**. A device pays it
once and is never fresh again; a guesser pays it on every guess. At 30/min, one
address needs on the order of a year and a half to walk the keyspace.
The MCP endpoint gets only the loose ceiling on purpose: those requests arrive
from the client's shared egress, so a tight per-IP budget there would make users
throttle each other.
The write API (`/_api/*`) is a separate matter — shared-secret bearer,
fail-closed: an unset secret rejects every write.
**Treat the code like a password.** Anyone holding it can see that screen and
send to it. Open `reader.neves.cloud/new` for a fresh one.
## Privacy
No accounts, no email, no analytics, no third-party sharing, nothing used for
training. A session holds the document, your taps and your place in it, and
**deletes itself after 6 hours in which neither side touches it**. One
first-party cookie remembers your code for 48 hours.
Full policy: **https://reader.neves.cloud/privacy** — and `privacyPage` in
`src/pages.ts`, so every claim in it is checkable against the code here.
## Support
Open an issue: https://github.com/nevescloud/reader/issues
## Develop
```sh
npm install
npm run dev # wrangler dev — local Durable Objects, no Cloudflare auth needed
npm run typecheck
npm test # markdown renderer, code-alphabet invariants, the drill machine
# write API — set READER_TOKEN in .dev.vars (e.g. "dev"); always required, fail-closed
curl -H "Authorization: Bearer dev" -X POST localhost:8787/_api/send \
-d '{"code":"ABCDE","content":"# hi"}'
```
## Deploy
```sh
wrangler secret put READER_TOKEN # one-time: the shared secret the write API expects
npm run deploy # binds reader.neves.cloud (DNS auto-provisioned)
```
## Layout
One Worker, served at the origin root.
| File | What lives there |
|---|---|
| `src/index.ts` | Router: `/mcp` · `/_api/*` (write, token) · `/s` poll · `/c` tap · `/w` feed · `/<code>` reader · `/privacy` · `/` entry |
| `src/ops.ts` | `sendDoc` / `awaitChoice` / `readStatus` / `startDrill` / `awaitDrillReport` / `resumeDrill` — the single source both front doors call |
| `src/mcp.ts` | `ReaderMcp` — the anonymous Streamable-HTTP server and its six tools |
| `src/session.ts` | One Durable Object per code: document + version, pending tap, parked waiters, drill state, hibernating feed sockets, 6h expiry |
| `src/drill.ts` | Deck mode's rules, pure — validation, tap→next-state, the three screens, the report. No storage, no rendering, so it tests without a DO harness |
| `src/limit.ts` | The two anti-enumeration budgets (see **Security**) |
| `src/md.ts` | Markdown → clean reading HTML |
| `src/pages.ts` | `landingPage` · `privacyPage` · `readerPage` (e-ink serif, paginated, ES5-only inline script for ~2012 WebKit) |
| `src/util.ts` | Host, code alphabet, public-URL constants — single source |
## License
MIT — see [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityActive
ResponsivenessUnresponsive