readyroom 0.1.2 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,14 +1,54 @@
1
- # readyroom
2
-
3
- An MCP server that lets coding agents request a human decision without hijacking whichever window is focused. Agents enqueue a **ticket**; the human **drains** the queue from one page when they choose to. The design invariant is **N agents, one interrupt channel** — every decision below serves it.
4
-
5
- Three audiences, three entry points:
1
+ <p align="center">
2
+ <a href="https://www.npmjs.com/package/readyroom"><img alt="npm" src="https://img.shields.io/npm/v/readyroom?color=0e646e&labelColor=0B2140"></a>
3
+ <img alt="node &ge; 22" src="https://img.shields.io/badge/node-%E2%89%A5%2022-0e646e?labelColor=0B2140">
4
+ <img alt="three ways to run it: npx, Docker, Cloudflare Worker" src="https://img.shields.io/badge/run-npx%20%7C%20docker%20%7C%20worker-0e646e?labelColor=0B2140">
5
+ <img alt="license: Unlicense" src="https://img.shields.io/badge/license-Unlicense-0e646e?labelColor=0B2140">
6
+ </p>
6
7
 
7
- - **`npx readyroom`** — one developer, one machine, working in thirty seconds. No config, no container, no account.
8
- - **Self-hosted Docker on a Raspberry Pi** — the homelab owner. Always-on, arm64, LAN-reachable, survives reboots.
9
- - **Cloudflare Worker** — a team with no infrastructure to run. Always-on, reachable from anywhere, no box to patch.
8
+ # readyroom
10
9
 
11
- ---
10
+ **The bridge runs the ship. The ready room decides.**
11
+
12
+ > [!WARNING]
13
+ > readyroom is under active development. Don't rely on it for anything that matters until v1.
14
+
15
+ ## What is this?
16
+
17
+ An agent that reaches a decision it should not make alone has two bad options today. It can
18
+ guess, and the guess becomes fact downstream. Or it can stop and wait, which holds its session
19
+ open until a human notices, and usually surfaces as a modal that hijacks whatever you were
20
+ doing.
21
+
22
+ readyroom gives it a third. The agent writes a **ticket** — one question, optional choices,
23
+ minimal context — and goes back to work. You drain the tickets from one page when you get to
24
+ them. The page groups them under the agent that asked, so you always know who is waiting on
25
+ you, and the agent collects the answer the next time it asks.
26
+
27
+ The name is the design. On a ship, the bridge is where the work happens and the **ready room**
28
+ is where the captain steps away to decide something that should not be decided in the noise.
29
+ The whole invariant is **N agents, one interrupt channel**: however many agents you run, they
30
+ share one place to ask and one place for you to answer.
31
+
32
+ ## Why readyroom
33
+
34
+ - **No agent ever blocks.** `ask_human` enqueues a ticket and returns in under 200 ms. A
35
+ blocking tool holds the MCP session open and wedges the client the moment you step away
36
+ from the keyboard for twenty minutes.
37
+ - **The queue survives a restart.** Tickets are written atomically to disk, or to a single
38
+ Durable Object on the Worker. A restart that forgot pending tickets would mean an agent
39
+ waiting forever on a question nobody can see any more.
40
+ - **You know which agent is asking.** Tickets are grouped by **stream** — the agent that
41
+ asked — with the newest stream first. A flat list of questions from four agents is not a
42
+ queue you can drain; it is a pile.
43
+ - **It never takes over your screen.** There is no modal and no focus stealing. The page is a
44
+ **status board, not a chat log**: it shows what is waiting and clears when you have answered.
45
+ - **Draining is keyboard-first.** `j`/`k` to move, `1`-`5` to pick an option, `Enter` to
46
+ submit, `u` to undo. A queue with only a mouse is the slow path, and the point of a queue is
47
+ that it drains quickly.
48
+ - **The log is append-only and never pruned.** An approval log is the only record that a human
49
+ authorised something, so it is kept in full. That is what a log is.
50
+ - **Three ways to run it, one program.** `npx` for one machine, Docker for an always-on box,
51
+ a Cloudflare Worker for when your agents cannot reach either.
12
52
 
13
53
  ## Vocabulary
14
54
 
@@ -19,21 +59,25 @@ These words are used exactly, in code, comments, tool descriptions, and docs. No
19
59
  - **drain** — the human answering tickets from the page.
20
60
  - **heads-up page** — the single HTML page served at `GET /`.
21
61
 
22
- The tool names (`ask_human`, `wait_for_answer`, `list_pending`, `cancel_ticket`) are the agent-facing contract and do not change with the product name. The product name is a separate layer, the way `ripgrep` ships `rg`.
23
-
24
- ---
62
+ The tool names (`ask_human`, `wait_for_answer`, `list_pending`, `cancel_ticket`) are the
63
+ agent-facing contract and do not change with the product name. The product name is a separate
64
+ layer, the way `ripgrep` ships `rg`.
25
65
 
26
66
  ## The one rule that matters
27
67
 
28
68
  **`ask_human` enqueues and returns. It never waits.**
29
69
 
30
- A blocking tool holds the MCP session open and wedges the client when the human is heads-down for twenty minutes. Enqueue-then-poll keeps the transport healthy. `ask_human` returns `{ id, status: "pending" }` in under 200 ms.
70
+ A blocking tool holds the MCP session open and wedges the client when the human is heads-down
71
+ for twenty minutes. Enqueue-then-poll keeps the transport healthy. `ask_human` returns
72
+ `{ id, status: "pending" }` in under 200 ms.
31
73
 
32
- This rule is also what makes the Worker target possible at all. A Workers invocation cannot hold a session open for twenty minutes; a request that enqueues and returns is stateless enough to run per-request on the edge. There is no blocking variant on any target.
74
+ This rule is also what makes the Worker target possible at all. A Workers invocation cannot
75
+ hold a session open for twenty minutes; a request that enqueues and returns is stateless enough
76
+ to run per-request on the edge. There is no blocking variant on any target.
33
77
 
34
- The `ask_human` tool description is where the agent learns the protocol: *on `pending`, continue with independent work and call `wait_for_answer` again later; do not poll in a tight loop.*
35
-
36
- ---
78
+ The `ask_human` tool description is where the agent learns the protocol: *on `pending`,
79
+ continue with independent work and call `wait_for_answer` again later; do not poll in a tight
80
+ loop.*
37
81
 
38
82
  ## The four MCP tools
39
83
 
@@ -58,7 +102,8 @@ ask_human({
58
102
 
59
103
  ### `wait_for_answer`
60
104
 
61
- Waits for the human to answer, then returns the result. Returns immediately if the ticket is already answered or expired.
105
+ Waits for the human to answer, then returns the result. Returns immediately if the ticket is
106
+ already answered or expired.
62
107
 
63
108
  ```ts
64
109
  wait_for_answer({
@@ -91,11 +136,24 @@ cancel_ticket({ id: string }) => { id: string, status: "cancelled" }
91
136
 
92
137
  ### The `HUMAN ANSWER:` prefix
93
138
 
94
- A delivered human answer is prefixed with `HUMAN ANSWER:` so a model can never confuse it with its own earlier guess. An answer that came from `default_answer` gets a clearly different prefix — `DEFAULT ANSWER (applied on expiry; no human responded):` — because labelling the agent's own default as a human answer would be worse than no prefix at all.
139
+ A delivered human answer is prefixed with `HUMAN ANSWER:` so a model can never confuse it with
140
+ its own earlier guess. An answer that came from `default_answer` gets a clearly different
141
+ prefix — `DEFAULT ANSWER (applied on expiry; no human responded):` — because labelling the
142
+ agent's own default as a human answer would be worse than no prefix at all.
143
+
144
+ ## Three ways aboard
95
145
 
96
- ---
146
+ They run the same program. They differ in where it lives and who can reach it.
97
147
 
98
- ## Install
148
+ | | `npx` | Docker from GHCR | Cloudflare Worker |
149
+ |---|---|---|---|
150
+ | setup | `npx readyroom` | pull `ghcr.io/therebelrobot/readyroom` | `wrangler deploy` |
151
+ | runs | on the host, as you | in a container, as a non-root user | on the edge, with no box to patch |
152
+ | state | `./.state` on disk | a volume mount | one SQLite Durable Object |
153
+ | reachable from | loopback only, by default | loopback; your LAN through a reverse proxy | anywhere, behind Cloudflare Access |
154
+ | MCP transport | stdio + Streamable HTTP | stdio + Streamable HTTP | Streamable HTTP |
155
+ | live updates | SSE | SSE | polling; the page falls back on its own |
156
+ | best for | trying it in a minute | always-on, arm64, survives reboots | agents that cannot reach your house |
99
157
 
100
158
  ### npx — one developer, one machine, thirty seconds
101
159
 
@@ -103,18 +161,29 @@ A delivered human answer is prefixed with `HUMAN ANSWER:` so a model can never c
103
161
  npx readyroom
104
162
  ```
105
163
 
106
- No config, no container, no account. The server speaks stdio MCP (wire it into your MCP client) and also serves Streamable HTTP on the same process for clients that want it.
164
+ No config, no container, no account. The server speaks stdio MCP (wire it into your MCP client)
165
+ and also serves Streamable HTTP on the same process for clients that want it.
107
166
 
108
- **The singleton.** Three MCP clients spawning `npx readyroom` would otherwise produce three processes, three attempts to bind port 8971, and three writers to one `tickets.json` — which breaks the single-writer invariant and can corrupt the queue. So:
167
+ **The singleton.** Three MCP clients spawning `npx readyroom` would otherwise produce three
168
+ processes, three attempts to bind port 8971, and three writers to one `tickets.json` — which
169
+ breaks the single-writer invariant and can corrupt the queue. So:
109
170
 
110
- - The **first** process to start becomes the **daemon**: it binds the port, owns the store, and writes `${READYROOM_STATE_DIR}/daemon.json` containing `{ pid, port, started_at }`.
111
- - Every **subsequent** process becomes a **client**: it reads `daemon.json`, confirms the pid is alive and `/healthz` answers, then proxies its MCP tool calls to the daemon over `http://127.0.0.1:<port>`. It serves stdio on its own file descriptors and nothing else. It never writes `tickets.json`.
171
+ - The **first** process to start becomes the **daemon**: it binds the port, owns the store, and
172
+ writes `${READYROOM_STATE_DIR}/daemon.json` containing `{ pid, port, started_at }`.
173
+ - Every **subsequent** process becomes a **client**: it reads `daemon.json`, confirms the pid is
174
+ alive and `/healthz` answers, then proxies its MCP tool calls to the daemon over
175
+ `http://127.0.0.1:<port>`. It serves stdio on its own file descriptors and nothing else. It
176
+ never writes `tickets.json`.
112
177
 
113
- If `daemon.json` is stale (pid dead, or the port refuses), the starting process acquires the daemon role itself and overwrites the file. If the daemon is unreachable but a foreign service holds the port, the process **refuses to start** with a message naming the port — it never silently falls back to a second writer.
178
+ If `daemon.json` is stale (pid dead, or the port refuses), the starting process acquires the
179
+ daemon role itself and overwrites the file. If the daemon is unreachable but a foreign service
180
+ holds the port, the process **refuses to start** with a message naming the port — it never
181
+ silently falls back to a second writer.
114
182
 
115
183
  ### Self-hosted Docker on a Raspberry Pi
116
184
 
117
- Always-on, arm64, LAN-reachable, survives reboots. The image is multi-arch (`linux/amd64,linux/arm64`); arm64 is required because the deploy target is a Pi.
185
+ Always-on, arm64, LAN-reachable, survives reboots. The image is multi-arch
186
+ (`linux/amd64,linux/arm64`); arm64 is required because the deploy target is a Pi.
118
187
 
119
188
  ```sh
120
189
  docker run --rm -d \
@@ -124,7 +193,8 @@ docker run --rm -d \
124
193
  ghcr.io/therebelrobot/readyroom:latest
125
194
  ```
126
195
 
127
- Or as a mergeable compose snippet. It binds to loopback; LAN exposure goes through the reverse proxy.
196
+ Or as a mergeable compose snippet. It binds to loopback; LAN exposure goes through the reverse
197
+ proxy.
128
198
 
129
199
  ```yaml
130
200
  services:
@@ -143,13 +213,18 @@ services:
143
213
  - ./readyroom-state:/app/.state
144
214
  ```
145
215
 
146
- The container runs the same Node entry as npx, in stdio-plus-HTTP mode, as a non-root user, and handles `SIGTERM`.
216
+ The container runs the same Node entry as npx, in stdio-plus-HTTP mode, as a non-root user, and
217
+ handles `SIGTERM`.
147
218
 
148
- > **GHCR packages start private.** After the first publish run, flip the package to public in the GitHub package settings, or `docker pull` will fail with an authentication error.
219
+ > [!NOTE]
220
+ > **GHCR packages start private.** After the first publish run, flip the package to public in
221
+ > the GitHub package settings, or `docker pull` will fail with an authentication error.
149
222
 
150
223
  ### Cloudflare Worker
151
224
 
152
- Always-on, reachable from anywhere, no box to patch. The Worker serves the heads-up page and the Streamable HTTP MCP endpoint from the same origin, backed by one SQLite Durable Object instance.
225
+ Always-on, reachable from anywhere, no box to patch. The Worker serves the heads-up page and
226
+ the Streamable HTTP MCP endpoint from the same origin, backed by one SQLite Durable Object
227
+ instance.
153
228
 
154
229
  ```sh
155
230
  # 1. Supply the token as a secret (never committed).
@@ -159,9 +234,165 @@ wrangler secret put READYROOM_TOKEN
159
234
  npm run deploy:worker
160
235
  ```
161
236
 
162
- The MCP endpoint is per-agent: **`POST /mcp/{stream}`**. Each agent gets its own URL, configured once in the client. There is no `cwd` on Workers, so `basename(cwd)` cannot be the fallback — the stream is required and comes from the path segment or an explicit `stream` tool argument. If neither is present, the tool call fails with an error naming both ways to supply it. No default is invented.
237
+ The MCP endpoint is per-agent: **`POST /mcp/{stream}`**. Each agent gets its own URL, configured
238
+ once in the client. There is no `cwd` on Workers, so `basename(cwd)` cannot be the fallback —
239
+ the stream is required and comes from the path segment or an explicit `stream` tool argument.
240
+ If neither is present, the tool call fails with an error naming both ways to supply it. No
241
+ default is invented.
242
+
243
+ ## MCP Configuration
244
+
245
+ An MCP client reaches readyroom over one of two transports. How you run the server decides
246
+ which one is available.
247
+
248
+ ### Entry points
249
+
250
+ | Command | Target | Transports | State |
251
+ |---|---|---|---|
252
+ | `npx readyroom` | Node, on the host | stdio + Streamable HTTP | `./.state` on disk |
253
+ | `npm start` (`node dist/server.mjs`) | Node, built bundle | stdio + Streamable HTTP | `./.state` on disk |
254
+ | `npm run dev` (`tsx watch --import ./scripts/html-loader-register.mjs src/index.ts`) | Node, development | stdio + Streamable HTTP | `./.state` on disk |
255
+ | `docker run … ghcr.io/therebelrobot/readyroom:latest` | Docker | stdio + Streamable HTTP | volume mount |
256
+ | `npm run dev:worker` (`wrangler dev`) | Cloudflare Worker, local | Streamable HTTP only | Durable Object |
257
+ | `npm run deploy:worker` (`wrangler deploy`) | Cloudflare Worker, edge | Streamable HTTP only | Durable Object |
258
+
259
+ On the Node targets a process resolves into a **daemon** or a **client** (see
260
+ [the singleton](#three-ways-aboard)): the first process becomes the daemon and serves both
261
+ transports on the port; later processes join as clients and serve stdio only, proxying their
262
+ tool calls to the daemon. The role is decided at start — there is nothing to configure.
263
+
264
+ ### Transports
265
+
266
+ - **stdio** — Node targets only. The MCP client spawns the process and speaks the protocol over
267
+ its file descriptors. The server writes nothing to stdout but MCP frames; all logs go to
268
+ stderr. Select it by configuring the client to run a `command`.
269
+ - **Streamable HTTP** — every target. On Node the daemon mounts it at `POST /mcp` (methods
270
+ `GET`, `POST`, `DELETE`) on the same port. On the Worker it is the only transport, mounted at
271
+ `/mcp` (stream supplied as the `/mcp/{stream}` path segment). Select it by pointing the client at the URL.
272
+
273
+ The two are not exclusive on Node: one daemon serves stdio and HTTP at once, so one agent can
274
+ run over stdio and another over HTTP against the same queue.
275
+
276
+ #### The `/mcp` endpoint is Origin-guarded
277
+
278
+ Both HTTP mounts reject a request whose `Origin` header is missing or not on the allowlist,
279
+ with `403`. On Node the allowlist is built from the server's own origins — `http://127.0.0.1:<port>`
280
+ and `http://localhost:<port>` — plus any `READYROOM_ALLOWED_ORIGINS` entries. On the Worker it
281
+ is the Worker's own origin plus the same extras. See `src/security.ts`.
282
+
283
+ > [!NOTE]
284
+ > An HTTP MCP client must send an `Origin` header that matches the allowlist. A client that
285
+ > sends no `Origin` at all is rejected. If your client sends a different origin (or none), add
286
+ > it to `READYROOM_ALLOWED_ORIGINS`.
287
+
288
+ #### Streams over HTTP
289
+
290
+ On Node the stream defaults to `READYROOM_STREAM`, else `basename(cwd)`, and can be overridden
291
+ per call with the `stream` tool argument. On the Worker there is no `cwd`: the stream comes from
292
+ the `POST /mcp/{stream}` path segment, or an explicit `stream` argument. If neither is present,
293
+ the call fails with an error naming both ways to supply it.
294
+
295
+ ### Configuration options
296
+
297
+ All six are read from the process environment on the Node targets, and, on the Worker, only
298
+ `READYROOM_TOKEN` and `READYROOM_ALLOWED_ORIGINS` are read from `[vars]` / `secrets` in
299
+ `wrangler.toml`. `src/config.ts` is the single source of truth.
300
+
301
+ | Variable | Default | Required? | Meaning |
302
+ |---|---|---|---|
303
+ | `READYROOM_STREAM` | `basename(cwd)` | optional | Default stream for tickets. Overridable per call via the `stream` tool argument. Not applicable on the Worker. |
304
+ | `READYROOM_TOKEN` | *(unset)* | **mandatory on Docker and the Worker**, optional on Node | Shared secret for `/api/*` and the Node `/internal/tool-call` proxy. |
305
+ | `READYROOM_BIND` | `127.0.0.1` | optional | Bind address. A non-loopback value refuses to start unless `READYROOM_TOKEN` is set. Not applicable on the Worker. |
306
+ | `READYROOM_STATE_DIR` | `./.state` | optional | Directory holding `tickets.json`, `audit.jsonl`, and `daemon.json`. Not applicable on the Worker. |
307
+ | `READYROOM_PORT` | `8971` | optional | TCP port. Must not collide with 3000-3899, which a worktree helper allocates. Not applicable on the Worker. |
308
+ | `READYROOM_ALLOWED_ORIGINS` | *(unset)* | optional | Comma-separated extra origins allowed on the `/mcp` Origin guard, appended to the server's own origin. |
309
+
310
+ The per-target Worker mapping is in [Environment variables](#environment-variables).
311
+
312
+ ### MCP client configuration
313
+
314
+ #### stdio (Claude Desktop and other MCP clients)
315
+
316
+ ```json
317
+ {
318
+ "mcpServers": {
319
+ "readyroom": {
320
+ "command": "npx",
321
+ "args": ["-y", "readyroom"],
322
+ "env": {
323
+ "READYROOM_STREAM": "my-agent",
324
+ "READYROOM_STATE_DIR": "/home/you/.readyroom"
325
+ }
326
+ }
327
+ }
328
+ }
329
+ ```
330
+
331
+ Every client that spawns readyroom becomes the daemon or a client automatically; you do not set
332
+ the role. Give each agent its own stream with `READYROOM_STREAM` (or the `stream` argument to
333
+ `ask_human`). If any process in a shared daemon group sets `READYROOM_TOKEN`, every process in
334
+ that group must set the same value — the client forwards it to the daemon.
335
+
336
+ For a built or installed copy, replace `command` with the absolute path to `dist/server.mjs`
337
+ (or the `readyroom` binary on `PATH`).
338
+
339
+ #### Streamable HTTP (Node daemon)
340
+
341
+ ```json
342
+ {
343
+ "mcpServers": {
344
+ "readyroom": {
345
+ "url": "http://127.0.0.1:8971/mcp",
346
+ "headers": {
347
+ "Origin": "http://127.0.0.1:8971"
348
+ }
349
+ }
350
+ }
351
+ }
352
+ ```
353
+
354
+ #### Streamable HTTP (Cloudflare Worker)
355
+
356
+ The stream is the path segment, so each agent gets its own URL.
357
+
358
+ ```json
359
+ {
360
+ "mcpServers": {
361
+ "readyroom": {
362
+ "url": "https://readyroom.example.com/mcp/my-agent",
363
+ "headers": {
364
+ "Origin": "https://readyroom.example.com"
365
+ }
366
+ }
367
+ }
368
+ }
369
+ ```
370
+
371
+ ### Auth and secrets
163
372
 
164
- ---
373
+ `src/security.ts` holds both guards.
374
+
375
+ - **`READYROOM_TOKEN`** protects `/api/*` (the page's API) and, on Node, the `/internal/tool-call`
376
+ proxy between a client process and its daemon. The comparison is constant-time via the shared
377
+ `src/core/constantTimeEqual.ts`, so Node and the Worker get the identical guarantee.
378
+ - **The `/mcp` MCP endpoint is guarded by the `Origin` header, not by the token.** A missing or
379
+ mismatched `Origin` is rejected with `403`; the MCP specification requires this of HTTP
380
+ transports. `READYROOM_ALLOWED_ORIGINS` extends that allowlist.
381
+ - On Docker and the Worker the token is **mandatory**. On Node it is optional for a loopback
382
+ bind and mandatory for any non-loopback bind — the refusal rule in `src/config.ts` enforces it.
383
+
384
+ > [!NOTE]
385
+ > `src/config.ts` and the [Environment variables](#environment-variables) table describe
386
+ > `READYROOM_TOKEN` as the secret for "the MCP endpoint". In the shipped code the token is
387
+ > enforced on `/api/*` (and the Node `/internal/tool-call` proxy) only; the `/mcp` endpoint is
388
+ > authenticated by its `Origin` guard instead. This section documents the code's actual behavior.
389
+
390
+ ##### A note on secrets
391
+
392
+ Treat `READYROOM_TOKEN` as a bearer secret. On the Worker, set it with
393
+ `wrangler secret put READYROOM_TOKEN` and never commit it. On Docker and npx, supply it through
394
+ an `--env-file` or your process manager, not a shell history. Never expose the server to a
395
+ public network directly — see [Never expose this to a public network directly](#never-expose-this-to-a-public-network-directly).
165
396
 
166
397
  ## Environment variables
167
398
 
@@ -178,7 +409,8 @@ Five variables, plus one optional extra.
178
409
 
179
410
  ### Mapping to the Worker
180
411
 
181
- On the Worker these are **`vars` and `secrets` in the Wrangler config, not process environment**. The mapping is explicit:
412
+ On the Worker these are **`vars` and `secrets` in the Wrangler config, not process
413
+ environment**. The mapping is explicit:
182
414
 
183
415
  | Node env var | Worker equivalent |
184
416
  |---|---|
@@ -189,30 +421,50 @@ On the Worker these are **`vars` and `secrets` in the Wrangler config, not proce
189
421
  | `READYROOM_STATE_DIR` | Not applicable — there is no state dir; the Durable Object owns the state. |
190
422
  | `READYROOM_PORT` | Not applicable — the Worker is addressed by URL. |
191
423
 
192
- ---
193
-
194
424
  ## The loopback bind rule, per target
195
425
 
196
- An approval endpoint is a privilege-escalation surface — whoever can answer a ticket can grant an agent permission to act. The bind rule exists for that reason.
197
-
198
- - **npx** — binds `127.0.0.1` by default. `READYROOM_BIND` may override, but when it is anything other than a loopback address the server **refuses to start** unless `READYROOM_TOKEN` is set. With no token configured, `/api/*` is reachable only on loopback, where the OS is the access control.
199
- - **Docker** — the container binds `0.0.0.0` internally and the port is mapped to `127.0.0.1` on the host. The process therefore *always* sees a non-loopback bind, so the token is mandatory and the refusal rule always applies. **The loopback guarantee comes from the port mapping, not from the process.**
200
- - **Worker** — there is no loopback and no safe fallback configuration. The token is mandatory, the refusal rule has no analogue, and the service is public by construction. This is not pretended otherwise.
201
-
202
- ---
203
-
204
- ## Security
205
-
206
- 1. **Every `/api/*` route requires `Authorization: Bearer $READYROOM_TOKEN`,** compared in constant time. The comparison is a shared `constantTimeEqual(a, b)` that compares digests byte by byte with no early return — `node:crypto.timingSafeEqual` is a Node builtin and is not reliably present on Workers, so one shared implementation is the only way all three targets get the same guarantee.
207
- 2. **The page collects the token once and stores it in `localStorage`,** attaching it to every request. A `401` drops the stored token and re-prompts.
208
- 3. **The Streamable HTTP MCP endpoint rejects a missing or mismatched `Origin` with `403`.** The MCP specification requires this of HTTP transports, and it is the reason the SDK ships host and origin validation guards. It is the browser-side guard against a malicious page in the same browser POSTing to the local MCP endpoint (DNS rebinding / cross-origin).
426
+ An approval endpoint is a privilege-escalation surface — whoever can answer a ticket can grant
427
+ an agent permission to act. The bind rule exists for that reason.
428
+
429
+ - **npx** — binds `127.0.0.1` by default. `READYROOM_BIND` may override, but when it is
430
+ anything other than a loopback address the server **refuses to start** unless
431
+ `READYROOM_TOKEN` is set. With no token configured, `/api/*` is reachable only on loopback,
432
+ where the OS is the access control.
433
+ - **Docker** — the container binds `0.0.0.0` internally and the port is mapped to `127.0.0.1`
434
+ on the host. The process therefore *always* sees a non-loopback bind, so the token is
435
+ mandatory and the refusal rule always applies. **The loopback guarantee comes from the port
436
+ mapping, not from the process.**
437
+ - **Worker** — there is no loopback and no safe fallback configuration. The token is mandatory,
438
+ the refusal rule has no analogue, and the service is public by construction. This is not
439
+ pretended otherwise.
440
+
441
+ ## Who has the conn
442
+
443
+ Whoever can answer a ticket can grant an agent permission to act. Three rules follow.
444
+
445
+ 1. **Every `/api/*` route requires `Authorization: Bearer $READYROOM_TOKEN`,** compared in
446
+ constant time. The comparison is a shared `constantTimeEqual(a, b)` that compares digests
447
+ byte by byte with no early return — `node:crypto.timingSafeEqual` is a Node builtin and is
448
+ not reliably present on Workers, so one shared implementation is the only way all three
449
+ targets get the same guarantee.
450
+ 2. **The page collects the token once and stores it in `localStorage`,** attaching it to every
451
+ request. A `401` drops the stored token and re-prompts.
452
+ 3. **The Streamable HTTP MCP endpoint rejects a missing or mismatched `Origin` with `403`.**
453
+ The MCP specification requires this of HTTP transports, and it is the reason the SDK ships
454
+ host and origin validation guards. It is the browser-side guard against a malicious page in
455
+ the same browser POSTing to the local MCP endpoint (DNS rebinding / cross-origin).
209
456
 
210
457
  The reason for all three: an approval endpoint is a privilege-escalation surface.
211
458
 
212
459
  ### Never expose this to a public network directly
213
460
 
214
- - **LAN access goes through the existing reverse proxy with its own auth in front.** The container (or npx process) binds loopback; the reverse proxy terminates TLS, enforces its own authentication, and forwards to `127.0.0.1:8971`. The proxy is the only thing on the LAN that can reach the port.
215
- - **Worker deployments should sit behind Cloudflare Access.** The Worker is public by construction; Cloudflare Access is the layer that keeps it from being reachable by anyone with the URL.
461
+ - **LAN access goes through the existing reverse proxy with its own auth in front.** The
462
+ container (or npx process) binds loopback; the reverse proxy terminates TLS, enforces its own
463
+ authentication, and forwards to `127.0.0.1:8971`. The proxy is the only thing on the LAN that
464
+ can reach the port.
465
+ - **Worker deployments should sit behind Cloudflare Access.** The Worker is public by
466
+ construction; Cloudflare Access is the layer that keeps it from being reachable by anyone
467
+ with the URL.
216
468
 
217
469
  Topology:
218
470
 
@@ -222,11 +474,10 @@ agent (MCP client) ──stdio──▶ readyroom (loopback :8971)
222
474
  human browser ──HTTPS──▶ reverse proxy (own auth) ──▶ 127.0.0.1:8971
223
475
  ```
224
476
 
225
- ---
477
+ ## The queue and the log
226
478
 
227
- ## Storage
228
-
229
- One interface, two implementations. The ticket lifecycle — enqueue, expire, answer, undo — is written once against the interface and does not branch on target.
479
+ One interface, two implementations. The ticket lifecycle — enqueue, expire, answer, undo — is
480
+ written once against the interface and does not branch on target.
230
481
 
231
482
  ```ts
232
483
  interface TicketStore {
@@ -240,50 +491,81 @@ interface TicketStore {
240
491
 
241
492
  ### `FileTicketStore` (npx / Docker)
242
493
 
243
- `${READYROOM_STATE_DIR}/tickets.json`, default `./.state`. Writes are atomic: write `tickets.json.tmp`, `fsync`, `rename`. Single process, single writer, loaded on boot. The queue must survive a container restart — a restart that forgets pending tickets means an agent waits forever.
494
+ `${READYROOM_STATE_DIR}/tickets.json`, default `./.state`. Writes are atomic: write
495
+ `tickets.json.tmp`, `fsync`, `rename`. Single process, single writer, loaded on boot. The queue
496
+ must survive a container restart — a restart that forgets pending tickets means an agent waits
497
+ forever.
244
498
 
245
- Every state change is also appended to `${READYROOM_STATE_DIR}/audit.jsonl` — one JSON object per line.
499
+ Every state change is also appended to `${READYROOM_STATE_DIR}/audit.jsonl` — one JSON object
500
+ per line.
246
501
 
247
502
  ### `DurableObjectTicketStore` (Worker)
248
503
 
249
- One Durable Object instance, SQLite-backed via `ctx.storage.sql`, tables `tickets` and `audit`. A single DO instance is a single writer with a transaction boundary — exactly the `FileTicketStore` invariant expressed on the edge. It is addressed deterministically with `idFromName("queue")` so every request lands on the same instance. The migration uses `new_sqlite_classes`; `new_classes` would leave the SQL API unavailable.
504
+ One Durable Object instance, SQLite-backed via `ctx.storage.sql`, tables `tickets` and `audit`.
505
+ A single DO instance is a single writer with a transaction boundary — exactly the
506
+ `FileTicketStore` invariant expressed on the edge. It is addressed deterministically with
507
+ `idFromName("queue")` so every request lands on the same instance. The migration uses
508
+ `new_sqlite_classes`; `new_classes` would leave the SQL API unavailable.
250
509
 
251
510
  ### Invariants that hold on both
252
511
 
253
- - **The audit log is append-only and never pruned.** This is a **deliberate difference from a retention policy** — an approval log is the only record that a human authorised something, so it is kept in full.
254
- - **Expiry is evaluated lazily on read.** A ticket is expired if `expires_at` has passed and it is still pending; the read path resolves it to `expired` and applies `default_answer` if one was set. Workers have no long-lived process, so a timer-based expiry would silently not happen there, and divergence between targets on when a ticket expires is worse than a few wasted reads. A Cron Trigger on Workers is an optional optimization, never the contract.
512
+ - **The audit log is append-only and never pruned.** This is a **deliberate difference from a
513
+ retention policy** — an approval log is the only record that a human authorised something, so
514
+ it is kept in full. That is what a log is.
515
+ - **Expiry is evaluated lazily on read.** A ticket is expired if `expires_at` has passed and it
516
+ is still pending; the read path resolves it to `expired` and applies `default_answer` if one
517
+ was set. Workers have no long-lived process, so a timer-based expiry would silently not happen
518
+ there, and divergence between targets on when a ticket expires is worse than a few wasted
519
+ reads. A Cron Trigger on Workers is an optional optimization, never the contract.
255
520
  - **The undo window is five minutes,** measured against the audit entry's timestamp.
256
521
 
257
- ---
258
-
259
522
  ## The Worker's three consequences
260
523
 
261
524
  The Worker has no state dir, no bind address, and no daemon role. Three consequences follow:
262
525
 
263
- 1. **The token is mandatory.** There is no loopback and no safe fallback, so a token-less request to any `/api/*` route is rejected. (On the Node target, a missing token is an allow — the bind refusal rule guarantees a token-less server is only reachable on loopback.)
264
- 2. **`/api/events` returns `501`** with a short plain-text body. The heads-up page's polling fallback carries the Worker target; no Durable-Object-held stream fleet is built for v1.
265
- 3. **`wait_for_answer` clamps its timeout** to a short window (5 seconds) and returns `status: "pending"` promptly rather than erroring. This is the **one place the three targets differ in observable behavior.** Returning `pending` promptly means the agent's documented protocol — call again later — is already the correct handling, so no agent needs to know which target it is talking to.
266
-
267
- ---
526
+ 1. **The token is mandatory.** There is no loopback and no safe fallback, so a token-less
527
+ request to any `/api/*` route is rejected. (On the Node target, a missing token is an allow —
528
+ the bind refusal rule guarantees a token-less server is only reachable on loopback.)
529
+ 2. **`/api/events` returns `501`** with a short plain-text body. The heads-up page's polling
530
+ fallback carries the Worker target; no Durable-Object-held stream fleet is built for v1.
531
+ 3. **`wait_for_answer` clamps its timeout** to a short window (5 seconds) and returns
532
+ `status: "pending"` promptly rather than erroring. This is the **one place the three targets
533
+ differ in observable behavior.** Returning `pending` promptly means the agent's documented
534
+ protocol — call again later — is already the correct handling, so no agent needs to know
535
+ which target it is talking to.
268
536
 
269
537
  ## The heads-up page
270
538
 
271
- `public/index.html`. One file, shared by all three targets. All CSS and JS inline. No CDN, no external origin, no build step. It renders correctly when opened from the server with the network otherwise cut. It is inlined at build time into each target's bundle.
272
-
273
- - **Live.** Subscribes to `/api/events`; if the connection fails, falls back to polling `/api/tickets` every 5 s. This fallback is the Worker target's live-update path, so it is load-bearing, not a nicety.
274
- - **Grouped by stream.** Tickets grouped under a stream heading, newest stream first. You need to know which agent is asking.
275
- - **Card contents.** Stream name, ticket age (`4m`), urgency badge, the question, the context, option buttons, a free-text field, and an Answer button.
276
- - **Keyboard-first.** `j`/`k` move between tickets, `1`-`5` pick an option, `Enter` submits, `u` undoes the last answer. Draining a queue with a mouse is the slow path.
539
+ The page is a **status board**, not a chat log. It shows what is waiting and clears when you
540
+ have answered — which is why there is no markdown parser and no message history.
541
+
542
+ `public/index.html`. One file, shared by all three targets. All CSS and JS inline. No CDN, no
543
+ external origin, no build step. It renders correctly when opened from the server with the
544
+ network otherwise cut. It is inlined at build time into each target's bundle.
545
+
546
+ - **Live.** Subscribes to `/api/events`; if the connection fails, falls back to polling
547
+ `/api/tickets` every 5 s. This fallback is the Worker target's live-update path, so it is
548
+ load-bearing, not a nicety.
549
+ - **Grouped by stream.** Tickets grouped under a stream heading, newest stream first. You need
550
+ to know which agent is asking.
551
+ - **Card contents.** Stream name, ticket age (`4m`), urgency badge, the question, the context,
552
+ option buttons, a free-text field, and an Answer button.
553
+ - **Keyboard-first.** `j`/`k` move between tickets, `1`-`5` pick an option, `Enter` submits, `u`
554
+ undoes the last answer. Draining a queue with a mouse is the slow path.
277
555
  - **Tab title carries the count.** `(3) heads-up`.
278
- - **No colour-only signalling.** Urgency and status are carried by text and borders as well as hue, so the page is legible on an e-ink display and in grayscale print.
279
- - **No animation on the critical path.** Respects `prefers-reduced-motion`. No hover-only affordances — every action is reachable by keyboard and by tap.
280
- - **Undo.** Answered tickets stay in a "recent" section for 5 minutes with an Undo button that calls `/api/undo`.
281
- - **First paint is synchronous.** Rendered at the end of the script, not inside `requestAnimationFrame`, and re-rendered on `window` `load` — the page may be embedded in a hidden or zero-sized frame, where rAF never fires.
556
+ - **No colour-only signalling.** Urgency and status are carried by text and borders as well as
557
+ hue, so the page is legible on an e-ink display and in grayscale print.
558
+ - **No animation on the critical path.** Respects `prefers-reduced-motion`. No hover-only
559
+ affordances — every action is reachable by keyboard and by tap.
560
+ - **Undo.** Answered tickets stay in a "recent" section for 5 minutes with an Undo button that
561
+ calls `/api/undo`.
562
+ - **First paint is synchronous.** Rendered at the end of the script, not inside
563
+ `requestAnimationFrame`, and re-rendered on `window` `load` — the page may be embedded in a
564
+ hidden or zero-sized frame, where rAF never fires. The empty board reads "Nothing on the
565
+ board."
282
566
 
283
567
  `context` is rendered as text with line breaks preserved. There is no markdown parser.
284
568
 
285
- ---
286
-
287
569
  ## HTTP route table
288
570
 
289
571
  ```
@@ -296,26 +578,39 @@ POST /api/undo -> { id } -> 200 | 409 outside the undo window
296
578
  POST /mcp/{stream} -> the Streamable HTTP MCP endpoint (Origin-guarded)
297
579
  ```
298
580
 
299
- Unknown route → `404`. Known route, wrong method → `405`. The Node target additionally serves `POST /internal/tool-call`, the loopback proxy the client process uses.
300
-
301
- ---
581
+ Unknown route -> `404`. Known route, wrong method -> `405`. The Node target additionally serves
582
+ `POST /internal/tool-call`, the loopback proxy the client process uses.
302
583
 
303
584
  ## CI/CD
304
585
 
305
586
  Four GitHub Actions workflows:
306
587
 
307
- - **`.github/workflows/ci.yml`** — on push and PR: `npm ci`, typecheck, build, `wrangler deploy --dry-run`, `docker build`.
308
- - **`.github/workflows/publish.yml`** — on the default branch and `v*` tags: buildx for `linux/amd64,linux/arm64` (arm64 is required; the deploy target is a Pi), push to GHCR, auth via the built-in `GITHUB_TOKEN` with `packages: write` (no PAT, no repo secrets), `docker/metadata-action` tags (`latest` on the default branch, semver on `v*`, `sha-<short>`), GHA build cache scoped per image, one matrix entry per image. Supply chain: `provenance: mode=max`, `sbom: true`, and `actions/attest-build-provenance` pushed to the registry.
309
- - **`.github/workflows/publish-npm.yml`** — on `v*` tags: `npm publish` with provenance, using npm Trusted Publishing (OIDC) rather than a stored token. It needs `id-token: write` and the trusted-publisher entry configured on the package.
310
- - **`.github/workflows/deploy-worker.yml`** — on `v*` tags: `wrangler deploy` with `CLOUDFLARE_API_TOKEN`. **Optional and off by default** — it only runs when the repository variable `ENABLE_WORKER_DEPLOY` is `'true'`. A Worker deploy needs the operator's own Cloudflare account, so it is a documented manual step (`npm run deploy:worker`) unless the deploy token is supplied.
588
+ - **`.github/workflows/ci.yml`** — on push and PR: `npm ci`, typecheck, build,
589
+ `wrangler deploy --dry-run`, `docker build`.
590
+ - **`.github/workflows/publish.yml`** — on the default branch and `v*` tags: buildx for
591
+ `linux/amd64,linux/arm64` (arm64 is required; the deploy target is a Pi), push to GHCR, auth
592
+ via the built-in `GITHUB_TOKEN` with `packages: write` (no PAT, no repo secrets),
593
+ `docker/metadata-action` tags (`latest` on the default branch, semver on `v*`, `sha-<short>`),
594
+ GHA build cache scoped per image, one matrix entry per image. Supply chain:
595
+ `provenance: mode=max`, `sbom: true`, and `actions/attest-build-provenance` pushed to the
596
+ registry.
597
+ - **`.github/workflows/publish-npm.yml`** — on `v*` tags: `npm publish` with provenance, using
598
+ npm Trusted Publishing (OIDC) rather than a stored token. It needs `id-token: write` and the
599
+ trusted-publisher entry configured on the package.
600
+ - **`.github/workflows/deploy-worker.yml`** — on `v*` tags: `wrangler deploy` with
601
+ `CLOUDFLARE_API_TOKEN`. **Optional and off by default** — it only runs when the repository
602
+ variable `ENABLE_WORKER_DEPLOY` is `'true'`. A Worker deploy needs the operator's own
603
+ Cloudflare account, so it is a documented manual step (`npm run deploy:worker`) unless the
604
+ deploy token is supplied.
311
605
 
312
606
  `.github/dependabot.yml` covers `github-actions`, `npm`, and `docker`.
313
607
 
314
608
  ### npm publishing
315
609
 
316
- Trusted publishing (OIDC) is used, so no `NODE_AUTH_TOKEN` is stored. **The very first publish must be interactive** (`npm login && npm publish`) because a trusted-publisher entry can only be configured against a package that already exists on the registry. After that, the workflow publishes via OIDC.
317
-
318
- ---
610
+ Trusted publishing (OIDC) is used, so no `NODE_AUTH_TOKEN` is stored. **The very first publish
611
+ must be interactive** (`npm login && npm publish`) because a trusted-publisher entry can only be
612
+ configured against a package that already exists on the registry. After that, the workflow
613
+ publishes via OIDC.
319
614
 
320
615
  ## Development
321
616
 
@@ -332,27 +627,34 @@ Repo conventions:
332
627
 
333
628
  - TypeScript, Node >= 22. `module` and `moduleResolution` = `ESNext` / `bundler`.
334
629
  - **Never** put `.js` extensions in import paths. Import `./queue`, not `./queue.js`.
335
- - `tsx` for dev, `esbuild` for build. Bundle to a single file, so there is no runtime import resolution.
336
- - Zero runtime dependencies except `@modelcontextprotocol/sdk`. `wrangler` and `@cloudflare/workers-types` are devDependencies; Durable Objects are a platform primitive and need no package.
337
- - **All logging goes to stderr.** Nothing may be written to stdout except the MCP protocol. A stray `console.log` in a shared module breaks the npx target only — the hardest one to notice.
630
+ - `tsx` for dev, `esbuild` for build. Bundle to a single file, so there is no runtime import
631
+ resolution.
632
+ - Zero runtime dependencies except `@modelcontextprotocol/sdk`. `wrangler` and
633
+ `@cloudflare/workers-types` are devDependencies; Durable Objects are a platform primitive and
634
+ need no package.
635
+ - **All logging goes to stderr.** Nothing may be written to stdout except the MCP protocol. A
636
+ stray `console.log` in a shared module breaks the npx target only — the hardest one to notice.
338
637
  - Unlicense.
339
638
 
340
639
  Three entry points, one shared core:
341
640
 
342
641
  - `src/index.ts` — Node entry: daemon-or-client, stdio MCP, HTTP, `FileTicketStore`.
343
- - `src/worker.ts` — Worker entry: `export default { fetch }`, the Durable Object class, `DurableObjectTicketStore`.
344
- - `src/core/*` — ticket lifecycle, validation, the four tool definitions, the page. No target-specific imports anywhere under `src/core/`.
345
-
346
- ---
642
+ - `src/worker.ts` — Worker entry: `export default { fetch }`, the Durable Object class,
643
+ `DurableObjectTicketStore`.
644
+ - `src/core/*` — ticket lifecycle, validation, the four tool definitions, the page. No
645
+ target-specific imports anywhere under `src/core/`.
347
646
 
348
647
  ## Out of scope
349
648
 
350
- No multi-user accounts, no roles, no permissions, no rich markdown rendering, no database, no mobile app, no push notifications, no WebSockets, no cloud sync, no LLM in the decision path. **The human decides.**
351
-
352
- Also not in scope: any compliance posture. `readyroom` is a convenience gate, not an authorization-of-record system for regulated data. It is not covered by a BAA on any target, its audit log has no retention policy, and nothing in it should be treated as evidence a human authorized a regulated action. Users who need that should look elsewhere.
649
+ No multi-user accounts, no roles, no permissions, no rich markdown rendering, no database, no
650
+ mobile app, no push notifications, no WebSockets, no cloud sync, no LLM in the decision path.
651
+ **The human decides.**
353
652
 
354
- ---
653
+ Also not in scope: any compliance posture. `readyroom` is a convenience gate, not an
654
+ authorization-of-record system for regulated data. It is not covered by a BAA on any target, its
655
+ audit log has no retention policy, and nothing in it should be treated as evidence a human
656
+ authorized a regulated action. Users who need that should look elsewhere.
355
657
 
356
658
  ## License
357
659
 
358
- Unlicense.
660
+ Public domain, under [The Unlicense](LICENSE).