@tensorkit/raydr 0.0.0-stage → 0.1.0

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,3 +1,554 @@
1
- # Temporary Holding Version
1
+ # raydr
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Long planning conversations with a coding agent keep you at the desk, reading. The agent works
4
+ for a while, then stops to ask a question or wait for a permission, and if you have walked away
5
+ it sits there until you come back and notice. Voice assistants that promise to fix this put a
6
+ second model between you and the agent: they summarise, paraphrase, and answer on your behalf,
7
+ so what you hear is no longer what the agent said.
8
+
9
+ raydr takes the other route. It speaks the agent's own words, verbatim, and only when the agent
10
+ has something for you: a finished reply, a menu of choices, a permission prompt. You hear it on
11
+ your phone or across the room, and you can answer from the phone with a tap or by talking. The
12
+ agent's terminal stays the source of truth; raydr only reads what Claude Code reports and types
13
+ the keystrokes you would have typed.
14
+
15
+ It runs on your machine, talks to ElevenLabs for speech, and needs nothing else.
16
+
17
+ <p>
18
+ <img src="docs/screenshots/phone-waiting.png" width="260" alt="Phone: a session is waiting for you with a three-option question in the tray">
19
+ <img src="docs/screenshots/phone-listening.png" width="260" alt="Phone: a reply being spoken, the orb breathing, the transcript in ink">
20
+ </p>
21
+ <img src="docs/screenshots/desktop.png" width="820" alt="Desktop: session rail on the left, transcript in the middle, prompts from every session on the right">
22
+
23
+ ## Contents
24
+
25
+ - [What raydr is](#what-raydr-is)
26
+ - [How it works](#how-it-works)
27
+ - [Where things live](#where-things-live)
28
+ - [Requirements](#requirements)
29
+ - [Install](#install)
30
+ - [First run, step by step](#first-run-step-by-step)
31
+ - [Daily use](#daily-use)
32
+ - [The interface](#the-interface)
33
+ - [Reaching it from a phone](#reaching-it-from-a-phone)
34
+ - [When things happen](#when-things-happen)
35
+ - [Command reference](#command-reference)
36
+ - [HTTP API](#http-api)
37
+ - [Configuration](#configuration)
38
+ - [Security model](#security-model)
39
+ - [Troubleshooting](#troubleshooting)
40
+ - [Known limits](#known-limits)
41
+ - [Development](#development)
42
+
43
+ ## What raydr is
44
+
45
+ raydr is a bridge between Claude Code and a web page. Claude Code reports events through its
46
+ hook system; raydr turns the ones worth hearing into speech and streams them to a small app
47
+ that works on a phone. From that app you can answer the agent's menus, approve or deny a
48
+ permission prompt, and send a typed or spoken message into the session.
49
+
50
+ What you hear:
51
+
52
+ - **Replies.** When Claude finishes a turn, its final message is spoken. Markdown, file paths,
53
+ URLs, hashes and code blocks are rendered into something a voice can say ("page dot tsx",
54
+ "code block, 4 lines") while the transcript keeps the original text.
55
+ - **Questions.** When Claude calls `AskUserQuestion`, the question and its options are spoken
56
+ ("Question: … Options: one, …; two, …") and the options appear as buttons.
57
+ - **Permission prompts.** When Claude Code shows a permission dialog you hear "Claude needs your
58
+ permission" and get Allow and Deny.
59
+
60
+ What raydr is not:
61
+
62
+ - **No model in the loop.** Nothing is summarised or rephrased by another LLM. Brief mode cuts a
63
+ reply to its first paragraph; it does not rewrite it.
64
+ - **No cloud relay.** The server binds to your machine's loopback interface. Audio goes to
65
+ ElevenLabs for synthesis and, when you talk back, for transcription; nothing else leaves.
66
+ - **No chat channel.** There is no Telegram, Slack or email integration; the page is the client.
67
+ - **No autonomy.** raydr never answers anything by itself. Every answer is a tap or a
68
+ recording you made.
69
+
70
+ ## How it works
71
+
72
+ ```
73
+ Claude Code ──command hooks──▶ raydr server ──SSE──▶ web app (phone / desktop)
74
+ │ ▲ │
75
+ ElevenLabs│ │ElevenLabs Scribe │ taps, voice
76
+ speech ▼ │ ▼
77
+ mp3 cache └──────────── POST /commands, /transcribe
78
+ │
79
+ herdr agent send-keys / prompt
80
+ ▼
81
+ the Claude Code pane
82
+ ```
83
+
84
+ **Output path.** `raydr hooks install` adds three hooks to your Claude Code settings: `Stop`,
85
+ `PreToolUse` with matcher `AskUserQuestion`, and `Notification`. Each hook runs `raydr hook
86
+ <event>`, a tiny command that forwards the JSON Claude Code writes to its stdin to
87
+ `POST /hooks/<event>` on the server, with the access key as a bearer token. It always exits 0
88
+ without output, so a stopped server never surfaces an error inside Claude Code.
89
+
90
+ The server answers the hook at once, then decides what the event means: a `Stop` becomes an
91
+ `assistant_message`, an `AskUserQuestion` a `question`, a `permission_prompt` notification a
92
+ `permission`. It renders the speakable text, asks ElevenLabs for an mp3, and publishes the event
93
+ over a server-sent events stream with the URL of the clip. The clip starts streaming to the
94
+ browser as soon as the first bytes arrive from ElevenLabs; a client that requests it mid-synthesis
95
+ gets the whole file. Clips are cached on disk for a day.
96
+
97
+ **Input path.** The app sends commands (`answer_question`, `permission_reply`, `say`) to
98
+ `POST /commands`. The server maps the session to the [Herdr](https://herdr.dev) pane that runs
99
+ it (the hook carries the pane id from `HERDR_PANE_ID`) and injects keystrokes with `herdr agent
100
+ send-keys`, or types a message with `herdr agent prompt`. The outcome comes back as a
101
+ `command_result` event. Voice goes through `POST /transcribe` (ElevenLabs Scribe) and then out
102
+ as a `say`. Outside Herdr, raydr still speaks; it just cannot type back.
103
+
104
+ ## Where things live
105
+
106
+ | What | Where | Notes |
107
+ |---|---|---|
108
+ | Configuration | `~/.config/raydr/env` | `KEY=VALUE` lines, mode 600; created by `raydr init` |
109
+ | Pid file and log | `~/.local/state/raydr/server.pid`, `server.log` | written by `raydr start`; the log is never rotated |
110
+ | Per-directory settings | `~/.local/state/raydr/sessions.json` | brief mode, keyed by working directory |
111
+ | Audio cache | `~/.cache/raydr/audio/` | `<id>.mp3` per clip, `label-<name>.mp3` per session name; pruned after 24 h at startup |
112
+ | Hooks | `~/.claude/settings.json` (or `./.claude/settings.local.json` with `--project`) | three command hooks pointing at this checkout's CLI |
113
+ | `/raydr` command | `~/.claude/commands/raydr.md` (or `./.claude/commands/`) | copied by `hooks install`; also `raydr --command` |
114
+ | Session opt-ins | `~/.local/state/raydr/sessions/<session id>.json` | written by `/raydr`; the hooks forward only sessions with a file |
115
+ | Web bundle | `apps/web/dist` in the checkout | served by the server; rebuilt by `pnpm -r build` |
116
+ | Server | `http://127.0.0.1:47391` | loopback only; `RAYDR_HOST` / `RAYDR_PORT` move it |
117
+ | Sessions | any Claude Code session whose settings carry the hooks | named after its working directory |
118
+
119
+ `XDG_CONFIG_HOME`, `XDG_STATE_HOME` and `XDG_CACHE_HOME` are honoured; `RAYDR_ENV_FILE`,
120
+ `RAYDR_STATE_DIR` and `RAYDR_CACHE_DIR` override the individual locations.
121
+
122
+ ## Requirements
123
+
124
+ - **Node 24** or newer and **pnpm** (`corepack enable` gives you the pinned version).
125
+ - An **ElevenLabs API key** with text-to-speech permission. Without it every event still flows
126
+ to the page, just silently; with it, speech and transcription work.
127
+ - **Herdr** on the same machine if you want to answer from the phone. Claude Code must run inside
128
+ a Herdr pane for commands to reach it. Listening works without Herdr.
129
+ - **Claude Code** with hook support (2.1 or newer).
130
+ - A browser. Safari on iOS and Chrome on Android and desktop are what the app is built for.
131
+
132
+ ## Install
133
+
134
+ raydr is one npm package, `@tensorkit/raydr`: the CLI, the server and the web app together.
135
+
136
+ ```sh
137
+ npm i -g @tensorkit/raydr
138
+ raydr --version
139
+ ```
140
+
141
+ To work on raydr itself, install from a clone instead:
142
+
143
+ ```sh
144
+ git clone git@github.com:tensorkithq/raydr.git
145
+ cd raydr
146
+ pnpm install
147
+ pnpm -r build
148
+ ```
149
+
150
+ Expected: `pnpm -r build` ends with `apps/web build: ✓ built in …ms` and `Done` lines for the
151
+ server and the `raydr` package. Then put `raydr` on your PATH, either way:
152
+
153
+ ```sh
154
+ pnpm link --global # needs a one-time `pnpm setup` so pnpm has a global bin directory
155
+ cd packages/cli && npm link # or: the same thing through npm, no setup step
156
+ ```
157
+
158
+ `raydr --help` prints the usage block and `raydr doctor` reports which install it is running
159
+ from (`npm package` or `linked checkout`). Running the CLI straight from GitHub with
160
+ `pnpm dlx github:tensorkithq/raydr` does not work: pnpm refuses to run a git-hosted package's
161
+ build script unless it is on a workspace allowlist, which `dlx` cannot be given.
162
+
163
+ The command file for Claude Code is part of the install: `raydr --command` prints it and
164
+ `raydr hooks install` copies it into place, so no other repository is needed.
165
+
166
+ ## First run, step by step
167
+
168
+ 1. **Create the configuration.**
169
+ ```
170
+ $ raydr init
171
+ Speech provider (elevenlabs / openai / kokoro) [elevenlabs]:
172
+ Transcription provider (elevenlabs / openai / whisper) [elevenlabs]:
173
+ ElevenLabs API key (hidden):
174
+ ElevenLabs voice id (blank for default):
175
+ Port (blank for 47391):
176
+ wrote ~/.config/raydr/env (mode 600): speech elevenlabs, transcription elevenlabs
177
+ next: raydr hooks install, then raydr start
178
+ ```
179
+ Only the keys the chosen providers need are asked for, typed without echo. Choosing `kokoro`
180
+ or `whisper` offers to run `raydr local setup` right away. `init` also generates
181
+ `RAYDR_ACCESS_KEY`, the password for the page. Prefer the prompts over `--elevenlabs-key`
182
+ and `--openai-key`, which would land in your shell history; `--speech` and `--transcription`
183
+ script the choice.
184
+
185
+ 2. **Install the hooks and the `/raydr` command.**
186
+ ```
187
+ $ raydr hooks install
188
+ backup written to ~/.claude/settings.json.raydr-backup-2026-…
189
+ installed hooks in ~/.claude/settings.json
190
+ installed /raydr command at ~/.claude/commands/raydr.md
191
+ they will post to http://127.0.0.1:47391
192
+ ```
193
+ A timestamped backup is written beside the settings file first. Existing hooks are untouched.
194
+ The command file calls `raydr` by name, so `raydr` must be on the PATH Claude Code sees
195
+ (`pnpm link --global` does that; the installer warns if it is not). The file is also
196
+ available on its own: `raydr --command > ~/.claude/commands/raydr.md`.
197
+
198
+ 3. **Start the server.**
199
+ ```
200
+ $ raydr start
201
+ raydr started (pid 12345) on http://127.0.0.1:47391
202
+ log: ~/.local/state/raydr/server.log
203
+ ```
204
+ Without an ElevenLabs key `start` and `serve` stop with `no ElevenLabs key configured; run
205
+ raydr init`; pass `--no-speech` to run the API without speech on purpose. Any command run
206
+ before the env file exists ends with a reminder to run `raydr init`.
207
+
208
+ 4. **Check everything.**
209
+ ```
210
+ $ raydr doctor
211
+ [ ok ] node v24.19.0
212
+ [ ok ] herdr status: running
213
+ [ ok ] env file ~/.config/raydr/env (mode 600)
214
+ [ ok ] elevenlabs key present
215
+ [ ok ] hooks ~/.claude/settings.json (Stop, PreToolUse, Notification)
216
+ [ ok ] server http://127.0.0.1:47391 pid 12345, speech elevenlabs
217
+ [ ok ] elevenlabs api key accepted (2-character synthesis)
218
+ ```
219
+ The last line spends two characters of your ElevenLabs quota; `--offline` skips it. Anything
220
+ marked `FAIL` exits with status 1.
221
+
222
+ 5. **Open the page.** Visit `http://127.0.0.1:47391`, paste the access key (it is the
223
+ `RAYDR_ACCESS_KEY` line in the env file), and tap Connect. The key is kept in the browser's
224
+ local storage.
225
+
226
+ 6. **Opt a session in.** Sessions are silent until they join. In any Claude Code session type
227
+ `/raydr`; it prints `raydr: on for this session (annotator, pane w1:p5) · server running on
228
+ http://127.0.0.1:47391` and the chip appears on the page, named after the directory. `/raydr`
229
+ again turns it off, `/raydr status` just reports. Tap the chip: that first tap also unlocks
230
+ audio, which browsers require before a page may play sound.
231
+
232
+ 7. **Hear a reply.** The next time Claude finishes a turn in that session, you hear it. The
233
+ transcript shows the same words; Said and Written toggles show the spoken rendering and the
234
+ original text when they differ.
235
+
236
+ ## Daily use
237
+
238
+ **Start per login.** On desktop Linux, user processes end at logout, so run `raydr start` once
239
+ per login session (or from your shell profile). `raydr status` tells you whether it is up,
240
+ `raydr logs -f` follows the log, `raydr stop` ends it. `raydr serve` runs it in the foreground
241
+ instead, which is handy when developing.
242
+
243
+ **Answer a menu.** A question rises into the tray above the dock. Tap an option (or several, for
244
+ a multi-select question), then Send. The server presses the matching keys in the terminal and the
245
+ card collapses to "Answered: …" when the keystrokes land.
246
+
247
+ **Answer a permission.** Tap Allow, then tap it again within 1.5 s while it reads "Tap again to
248
+ allow"; or tap Deny once. Allow presses `1` in the dialog; Deny presses Escape, which in Claude Code ends the
249
+ turn without a reply.
250
+
251
+ **Talk back.** Hold the black microphone button; playback pauses while it is down. Release to see
252
+ the transcript, then Send. The text is typed into the session as a new message. Slide up to
253
+ cancel; presses under 300 ms send nothing. The keyboard button reveals a text field for typing.
254
+
255
+ **Brief mode.** Long-press a chip (or use the switch on the focused chip) and choose Brief:
256
+ only the first paragraph of each reply is spoken, with a leading heading joined to it. The choice
257
+ is stored on the server per working directory, so it survives restarts.
258
+
259
+ **Also speak.** Long-press a chip that is not focused and choose Also speak: its replies play even
260
+ while you follow another session, each introduced by the session's spoken name (a short tone if
261
+ the name clip is missing).
262
+
263
+ **Skip and speed.** The skip button drops the current clip; holding it jumps to the latest. Speed
264
+ (1×, 1.25×, 1.5×) is in the settings sheet behind the header, and beside it on desktop.
265
+
266
+ **Keyboard map (any width).** Space held is the microphone, → skips, 1–9 focus sessions in rail
267
+ order. Keys typed into a text field stay in the field.
268
+
269
+ **Opt in and out.** `/raydr` inside a session toggles it; the choice is a small file under the
270
+ state directory that the hooks check on every event, so it costs nothing when off. Set
271
+ `RAYDR_SESSIONS=all` in the env file to hear every session without opting in (the old
272
+ behaviour); `RAYDR_OFF=1` in a pane's environment silences that pane regardless.
273
+
274
+ **Limit to a project.** `raydr hooks install --project` writes the hooks into
275
+ `./.claude/settings.local.json` of the current directory instead of the user-wide file, so only
276
+ sessions in that project report. `raydr hooks status` lists every settings file that carries
277
+ raydr hooks.
278
+
279
+ **Uninstall completely.**
280
+ ```sh
281
+ raydr hooks uninstall # removes exactly the raydr entries; add --project for a project file
282
+ raydr stop
283
+ rm -rf ~/.local/state/raydr ~/.cache/raydr ~/.config/raydr
284
+ pnpm unlink --global # from the checkout, if you linked it
285
+ ```
286
+
287
+ ## The interface
288
+
289
+ The focused session is the page; every other session is a chip. On a phone, top to bottom: the
290
+ header (an orb beside the session name and a sub-line such as "speaking · clip 2 of 3"), the
291
+ chips row, the transcript, the attention tray, and the dock. Above 900 px the chips become a
292
+ left rail that also shows the keyboard map, the tray becomes a right column listing prompts from
293
+ every session, and the text field is always visible.
294
+
295
+ **Sessions and focus.** Sessions arrive silent. Each chip shows a count of unheard replies and a
296
+ small orb when the session has a prompt waiting. Tap a chip to focus it: only the focused session
297
+ is heard, its transcript is shown, and the microphone and text field send to it. Focus and
298
+ also-speak are remembered by working directory, so restarting Claude Code in the same project
299
+ restores them even though the session id changes.
300
+
301
+ **The orb** is the single status instrument: it breathes and turns while a clip plays, sits still
302
+ at 70 % while a prompt waits on you, and fades to 35 % when quiet. Under
303
+ `prefers-reduced-motion` only the opacity changes.
304
+
305
+ **Ink as time.** In the transcript the clip being spoken is full black, clips already heard are
306
+ grey, and clips that have arrived but not yet played are lighter still. Only the current clip
307
+ carries a timestamp.
308
+
309
+ **Tray and guardrails.** Questions and permissions never sit in the stream; they rise into the
310
+ tray, one at a time with a "1 of n" counter, focused session first and then oldest first.
311
+ Prompts from other sessions are answerable from the tray without changing focus, labelled
312
+ "<session> asks". A confirmed answer leaves a grey one-line record; a refused one stays in the
313
+ tray with the reason so it can be retried. Menu answers are select-then-Send and permissions are
314
+ tap-twice-to-allow (the first tap arms the button for 1.5 s, the second sends), so a pocket tap
315
+ never approves anything.
316
+
317
+ **History.** Transcripts live in the browser only: each session's items and last-played marker
318
+ are kept in the page's local storage keyed by working directory (300 items per session, 20
319
+ sessions), so a refresh shows them before the server replays anything. "Clear history on this
320
+ device" in the settings sheet wipes them; the server keeps nothing beyond its own event replay
321
+ buffer.
322
+
323
+ **Catch-up.** At most two clips wait in the queue. When a third arrives the oldest are dropped and
324
+ the transcript notes "skipped N older replies", so a long absence never means a backlog.
325
+
326
+ ## Providers
327
+
328
+ Speech (what you hear) and transcription (what you say) are chosen independently at
329
+ `raydr init` and can be switched later by editing two lines in the env file and restarting.
330
+
331
+ | Provider | Kind | What it needs | Cost | Notes |
332
+ |---|---|---|---|---|
333
+ | `elevenlabs` (default) | speech and transcription | `ELEVENLABS_API_KEY` | premium: a paid plan, billed per character and per minute | the most natural voices; Scribe for transcription |
334
+ | `openai` | speech and transcription | `OPENAI_API_KEY` | `gpt-4o-mini-tts` about $12 per 1M output characters, `gpt-4o-mini-transcribe` about $0.003 per minute (OpenAI's pricing page, September 2026) | instructable voice: `RAYDR_SPEECH_INSTRUCTIONS` sets the delivery, `RAYDR_OPENAI_VOICE` the voice (default `alloy`) |
335
+ | `kokoro` | speech | `raydr local setup` (Python 3.10–3.13 or `uv`, about 1.5 GB venv, 330 MB model) | free after download | runs on CPU in a local sidecar; about 2× realtime on 8 cores; `RAYDR_KOKORO_VOICE` (default `af_heart`) |
336
+ | `whisper` | transcription | the same setup (faster-whisper, 460 MB for `small.en`) | free after download | int8 on CPU; a 7 s take transcribes in about 2 s; `RAYDR_WHISPER_MODEL` (default `small.en`) |
337
+
338
+ The local sidecar (`packages/cli/local/raydr_local.py`) is started and stopped with the server
339
+ (`raydr start` / `stop`; `serve` runs it inline) and answers on `RAYDR_LOCAL_PORT` (47392) on
340
+ loopback only. `raydr local setup` is idempotent: it checks Python, the venv, each package, the
341
+ model caches and the bundled espeak-ng before touching anything, and prints a checklist. No
342
+ system package is required; if the bundled espeak-ng cannot load on your system, Kokoro still
343
+ speaks every dictionary word and skips the rest, and `apt install espeak-ng` /
344
+ `brew install espeak-ng` restores the fallback.
345
+
346
+ **Choosing a voice.** Provider tables do not tell you how a voice sounds in your room. Put the
347
+ same paragraph through each candidate (`raydr init --force --speech openai`, restart, trigger a
348
+ reply; then `--speech kokoro`, and so on) and listen on the phone you will use before deciding.
349
+ Speed, warmth and how names are pronounced differ more than the price does.
350
+
351
+ ## Reaching it from a phone
352
+
353
+ The server binds to loopback and should stay that way. To listen from a phone, put something in
354
+ front of it that terminates TLS and reaches `127.0.0.1:47391` from inside the machine: a
355
+ [Caddy](https://caddyserver.com) site, Tailscale Serve, or an SSH tunnel from the phone. With
356
+ Caddy and a hostname that resolves to the machine:
357
+
358
+ ```
359
+ raydr.example.com {
360
+ reverse_proxy 127.0.0.1:47391
361
+ }
362
+ ```
363
+
364
+ Open `https://raydr.example.com` on the phone and paste the access key. The access key is the
365
+ only thing standing between the internet and your sessions, so do not expose the port itself and
366
+ do not put the page behind a hostname you would not want guessed. Microphone capture requires a
367
+ secure context, so plain `http://` from another device gives you listening but not talking back.
368
+
369
+ ## When things happen
370
+
371
+ | Moment | What happens |
372
+ |---|---|
373
+ | You change server code (`apps/server`, `packages/cli`) | rebuild with `pnpm -r build`, then `raydr stop` and `raydr start`; a running server keeps its old code |
374
+ | You change the web app (`apps/web`) | rebuild; a page reload is enough, the server serves `apps/web/dist` from disk |
375
+ | You run `raydr hooks install` | sessions already running pick the hooks up at their next event; nothing to restart |
376
+ | A session starts | nothing is heard until `/raydr` is run in it (or `RAYDR_SESSIONS=all`); the chip appears the moment it joins and disappears when it leaves |
377
+ | You run `/raydr` | the opt-in file is written and the server is told at once; the next Claude Code event from that session is forwarded. Claude Code reads command files at startup, so a session started before `hooks install` needs a restart to see `/raydr` |
378
+ | Claude finishes a turn | the reply is spoken if the session is focused or set to also speak; otherwise the chip's unheard count rises |
379
+ | Claude asks a question | a `question` event arrives immediately; the chip gains a small orb and the card enters the tray |
380
+ | Claude shows a permission dialog | Claude Code sends the notification about **6 seconds** after the dialog appears; the Allow control only works while the dialog is still open, and a reply sent for a prompt that was answered in the terminal fails with "not showing a permission dialog right now" |
381
+ | You tap Allow or an option | the server presses keys in the pane; the card collapses on the `command_result`, usually within a second |
382
+ | The microphone or text field is disabled | the session has no Herdr pane (started outside Herdr), Claude is waiting on a dialog in the terminal, or the browser has no `MediaRecorder` (an insecure context); the dock says which |
383
+ | The page reloads | the transcript resumes from the last event this browser saw; replayed history is shown but not spoken |
384
+ | You log out of the machine | the background server ends; `raydr start` again after login |
385
+
386
+ ## Command reference
387
+
388
+ | Command | What it does | Flags |
389
+ |---|---|---|
390
+ | `raydr init` | Pick providers, write `~/.config/raydr/env` (mode 600) and generate the access key | `--speech P`, `--transcription P`, `--elevenlabs-key K`, `--openai-key K` (scripts only), `--voice-id V`, `--port P`, `--force` (rewrite, keep keys), `--rotate-key` |
391
+ | `raydr local setup` | Install the Python sidecar for `kokoro` / `whisper`: Python, venv, packages, models, espeak probe; idempotent | |
392
+ | `raydr local serve` | Run the sidecar in the foreground | |
393
+ | `raydr start` | Run the server detached; pid and log under the state directory. Refuses if the port is taken and says whether that is raydr, and without an ElevenLabs key unless `--no-speech` | `--no-speech` |
394
+ | `raydr stop` | SIGTERM the background server and wait up to 5 s. Only signals a pid it can prove is raydr | |
395
+ | `raydr status` | Server address, pid, uptime, speech provider, connected clients, pid-file state, log path, hook files | |
396
+ | `raydr logs` | Last 50 lines of the server log | `-f` to follow |
397
+ | `raydr serve` | Run the server in the foreground (Ctrl-C stops it); same key check as `start` | `--no-speech` |
398
+ | `raydr doctor` | Check node, herdr, the env file and its mode, each selected provider (key accepted by a two-character synthesis, or venv, models and sidecar for local ones), hooks and the server. Exit 1 on any failure | `--offline` skips the API calls |
399
+ | `raydr hooks install` | Add the three command hooks after writing a timestamped backup and copy the `/raydr` command beside the settings file; replaces any raydr hooks it finds first, so reinstalling never duplicates | `--settings <path>`, `--project`, `--url <url>` |
400
+ | `raydr command` | Print the `/raydr` command file (`--command` and `--skill` are aliases) | `--install` writes it to `~/.claude/commands/raydr.md`, with `--project` to `./.claude/commands/` |
401
+ | `raydr session on\|off\|toggle\|status` | What `/raydr` runs: keep or remove this session's opt-in file and tell the server; one line of output | reads `CLAUDE_CODE_SESSION_ID`, `HERDR_PANE_ID`, `PWD` |
402
+ | `raydr hooks uninstall` | Remove exactly the raydr entries | `--settings <path>`, `--project` |
403
+ | `raydr hooks status` | List every settings file that carries raydr hooks and the server address | |
404
+ | `raydr hook <event>` | Used by the installed hooks: forwards stdin to the server with the access key and pane id, 4 s budget, always exits 0 silently; drops sessions that have not opted in unless `RAYDR_SESSIONS=all`; `RAYDR_OFF=1` skips the post | `--url <url>` |
405
+
406
+ `--project` wins over `--settings`. Every command reads `~/.config/raydr/env` plus the process
407
+ environment; only `serve` and `start` will create the access key if it is missing.
408
+
409
+ ## HTTP API
410
+
411
+ For building on the server. Every route except `/healthz` and the web app needs the access key,
412
+ as `Authorization: Bearer <key>` or `?key=<key>` (what `<audio>` and `EventSource` can send).
413
+
414
+ | Route | Purpose |
415
+ |---|---|
416
+ | `POST /hooks/{pretooluse,stop,notification}` | Claude Code hook payload as JSON; answers 200 at once and processes afterwards |
417
+ | `POST /sessions/announce` | `{ sessionId, cwd, label, paneId?, state: "joined" \| "left" }` from `raydr session`; publishes `session_seen` or `session_left` |
418
+ | `GET /events` | SSE stream: `session_seen`, `session_left`, `assistant_message`, `question`, `permission`, `command_result`. Replays recent history, honours `Last-Event-ID`, then sends `event: live` |
419
+ | `GET /sessions` | Sessions seen since startup, with pane id, label clip and speak mode |
420
+ | `PUT /sessions/:id/settings` | `{ "speakMode": "full" \| "summary" }` for that session's directory |
421
+ | `POST /commands` | `answer_question { toolUseId, selections: number[][] }`, `permission_reply { decision }`, `say { text }`, each with a client `commandId` and `sessionId`; answers `202 { commandId }`, result arrives as a `command_result` event |
422
+ | `POST /transcribe` | multipart `file` (up to 10 MB) → `{ text }`; 400 for bad audio, 502 when ElevenLabs fails, 503 when unconfigured |
423
+ | `GET /audio/:id.mp3` | The clip; streams while still being synthesised |
424
+ | `GET /healthz` | `{ ok, speech, clients, pid, port, uptimeSeconds }`, no key |
425
+ | `GET /*` | The web app |
426
+
427
+ Event and command shapes are typed in `packages/protocol/src/index.ts`. Ids are monotonic across
428
+ server restarts, so a client can dedupe on them.
429
+
430
+ ## Configuration
431
+
432
+ Read from `~/.config/raydr/env`; a variable in the process environment overrides the file.
433
+
434
+ | Variable | Default | Purpose |
435
+ |---|---|---|
436
+ | `RAYDR_ACCESS_KEY` | generated by `init` (or the server on first run) | Required by every authenticated route and sent by the hooks |
437
+ | `RAYDR_SPEECH_PROVIDER` | `elevenlabs` | `elevenlabs`, `openai` or `kokoro` |
438
+ | `RAYDR_TRANSCRIPTION_PROVIDER` | `elevenlabs` | `elevenlabs`, `openai` or `whisper` |
439
+ | `ELEVENLABS_API_KEY` | | Needed by the `elevenlabs` providers |
440
+ | `ELEVENLABS_VOICE_ID` | ElevenLabs' "Rachel" | Any voice id from your account |
441
+ | `OPENAI_API_KEY` | | Needed by the `openai` providers |
442
+ | `RAYDR_OPENAI_VOICE` | `alloy` | OpenAI voice name |
443
+ | `RAYDR_SPEECH_INSTRUCTIONS` | a calm, unhurried colleague | Delivery instruction for OpenAI's instructable voices |
444
+ | `RAYDR_KOKORO_VOICE` | `af_heart` | Kokoro voice |
445
+ | `RAYDR_WHISPER_MODEL` | `small.en` | faster-whisper model name |
446
+ | `RAYDR_LOCAL_PORT` | `47392` | Loopback port of the local sidecar |
447
+ | `RAYDR_LOCAL_DIR` | `~/.local/share/raydr` | venv, model caches and setup record for the sidecar |
448
+ | `RAYDR_HOST` | `127.0.0.1` | Bind address; keep it on loopback |
449
+ | `RAYDR_PORT` | `47391` | Bind port |
450
+ | `RAYDR_MAX_SPEECH_CHARS` | `1500` | Longer replies are cut at a sentence boundary before synthesis |
451
+ | `RAYDR_ENV_FILE` | `~/.config/raydr/env` | Read a different env file (process environment only) |
452
+ | `RAYDR_STATE_DIR` | `~/.local/state/raydr` | Pid file, log, per-directory settings |
453
+ | `RAYDR_CACHE_DIR` | `~/.cache/raydr/audio` | mp3 cache |
454
+ | `RAYDR_WEB_DIR` | `apps/web/dist` | Built web app to serve |
455
+ | `RAYDR_SESSIONS` | `opt-in` | `all` forwards every session without `/raydr` |
456
+ | `RAYDR_OFF` | | `1` or `true` in a Claude Code pane's environment silences that pane's hooks |
457
+
458
+ ## Security model
459
+
460
+ The server listens on loopback only. Every route except `/healthz` and the static page requires
461
+ the access key: the browser sends it, and so do the hooks, which read it from the env file. Hook
462
+ bodies must be JSON, so a web page cannot inject events or trigger paid synthesis with a
463
+ cross-origin form post. The env file is created with mode 600 and the server warns when it is
464
+ readable by others.
465
+
466
+ Commands only ever become keystrokes in a Herdr pane the server has seen a hook from, and only
467
+ while Herdr reports that pane as waiting on a dialog (for permissions) or the question is still
468
+ the latest one (for menus). Transcript files named in hook payloads are read only from Claude
469
+ Code's own transcript directory, `.jsonl` only, last 256 KB only.
470
+
471
+ To use it from a phone, keep the loopback bind and put TLS in front (see above). The access key
472
+ lives in the browser's local storage; rotate it with `raydr init --force --rotate-key`, restart
473
+ the server, and reconnect.
474
+
475
+ ## Troubleshooting
476
+
477
+ | Symptom | Likely cause and fix |
478
+ |---|---|
479
+ | Events show in the transcript but nothing plays | Audio is not unlocked: tap anywhere once. Or the clip failed: `raydr logs` shows `speech failed: ElevenLabs 401 … payment_issue` or `quota_exceeded` (settle or top up the ElevenLabs account), `OpenAI 429` (rate or quota), or `local sidecar unreachable` (`raydr status` shows the sidecar; `raydr local setup` if it never came up) |
480
+ | `raydr start` says a provider is not configured | The env file selects a provider without its key or setup; the line names the variable or command. `raydr init --force` re-picks providers keeping existing keys |
481
+ | The local sidecar takes long to answer | Models load on first start (about 7 s warm-up here); `raydr status` shows `models loading` until then. First-ever setup downloads about 800 MB |
482
+ | Claude Code prints `Stop hook error: connect ECONNREFUSED` | Hooks from an older version that used HTTP hooks; run `raydr hooks install` again. Current command hooks never print anything |
483
+ | The page says the key was rejected | The key in the browser and the env file differ (rotated key, or a different env file). Reconnect with the current `RAYDR_ACCESS_KEY` |
484
+ | A session never appears | It has not opted in: run `/raydr` in it (`raydr session status` inside the session says). Otherwise `raydr hooks status` shows no hooks, the pane has `RAYDR_OFF=1`, or the session started before the hooks and command were installed (restart it) |
485
+ | `/raydr` says raydr is not installed | `raydr` is not on the PATH Claude Code sees; `npm i -g @tensorkit/raydr`, or from a checkout `cd packages/cli && npm link`, then start a new session |
486
+ | A reply is spoken twice or old replies replay | Two servers are running against the same hooks (`raydr status` and `raydr start` refuse a second one on the same port); or the browser's `raydr.lastEventId` was cleared |
487
+ | The phone cannot reach the page | The server is on loopback by design; reach it through a TLS proxy or tunnel. Do not set `RAYDR_HOST=0.0.0.0` |
488
+ | The microphone button is greyed out | The dock says why: no Herdr pane for the session, Claude waiting on a terminal dialog, or no `MediaRecorder` (needs `https://` or localhost) |
489
+ | Allow does nothing or says Claude is not showing a dialog | The dialog was already answered in the terminal, or the notification has not arrived yet (about 6 s after the dialog); wait for the card, then tap |
490
+ | `raydr stop` says the pid file was stale | The server had already exited or the pid belongs to something else; the file was removed. Use `raydr status` |
491
+ | `raydr start` says the port is in use by another program | Set `RAYDR_PORT` in the env file and reinstall the hooks so they post to the new port |
492
+
493
+ ## Known limits
494
+
495
+ - Several questions in a single `AskUserQuestion` call are answered in order with a fixed 300 ms
496
+ gap and remain unverified live; single questions of either kind are verified.
497
+ - Multi-select answers assume the menu cursor starts on option 1, as it does for a fresh menu; if
498
+ it was moved in the terminal first, the wrong options are toggled.
499
+ - Free-text "Other" answers to menus are not supported.
500
+ - Hooks installed both user-wide and with `--project` in the same project fire twice, so each
501
+ reply is reported twice; pick one level per project.
502
+ - Denying a permission ends Claude's turn without a reply (that is how Escape behaves in Claude
503
+ Code); Allow lets it continue.
504
+ - The server log is appended and never rotated.
505
+ - Install from GitHub with `pnpm dlx` is blocked by pnpm's build-script policy; clone instead.
506
+ - The local providers need Python 3.10–3.13 (Kokoro's dependencies have no wheels for 3.14 yet);
507
+ `raydr local setup` uses `uv` to fetch 3.12 when it is on PATH. OpenAI adapters were written
508
+ against the documented API and tested with a fake server, not a live key.
509
+ - Sessions are known only for the life of the server process; a restart starts with an empty
510
+ list until each session reports again.
511
+
512
+ ## Development
513
+
514
+ ```
515
+ packages/protocol event and command types shared by server and web (types only)
516
+ packages/cli the raydr binary: lifecycle, init, doctor, hooks, the hook forwarder
517
+ apps/server node:http server, hook parsing, speech rendering, audio store, Herdr driver
518
+ apps/web Vite + React + Tailwind + Zustand client; see apps/web/README.md
519
+ ```
520
+
521
+ The four checks, run from the root:
522
+
523
+ ```sh
524
+ pnpm -r typecheck
525
+ pnpm check # Biome lint and format
526
+ pnpm -r build
527
+ pnpm -r test # node:test; 41 server, 54 web, 20 cli
528
+ ```
529
+
530
+ `pnpm --filter ./apps/web dev` runs Vite with the API proxied to a local server. Server and CLI
531
+ tests run on the TypeScript sources directly; the CLI test script builds first because the hook
532
+ forwarder is exercised as the real `dist/index.js`. Conventional commits, small and by intent.
533
+
534
+ The publishable package is `packages/cli` (npm name `@tensorkit/raydr`, binary `raydr`). Its build embeds the `/raydr`
535
+ command file and the version, bundles the CLI, the hook forwarder and the server into `dist/`
536
+ with esbuild (the forwarder stays a separate small entry so hook latency does not pay for the
537
+ server), and copies `apps/web/dist` in as `web/`. Nothing from the workspace survives as a
538
+ runtime dependency.
539
+
540
+ ## Releasing
541
+
542
+ ```sh
543
+ # from a clean main with the four checks green
544
+ # bump "version" in packages/cli/package.json, then regenerate and check the embedded version
545
+ pnpm -r build
546
+ git diff --exit-code packages/cli/src/version.ts || git add packages/cli/src/version.ts
547
+ pnpm --filter @tensorkit/raydr exec npm pack --dry-run # inspect the file list
548
+ # commit as "chore(release): 0.1.1" (package.json and version.ts together)
549
+ git tag v0.1.1 && git push --tags
550
+ pnpm publish --filter @tensorkit/raydr --access public # runs the full build through prepack
551
+ ```
552
+
553
+ A GitHub Actions workflow that publishes on a `v*` tag (with an npm token in the repository
554
+ secrets) would make the last step automatic; it is optional and not set up yet.