@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 +553 -2
- package/dist/chunk-KYTKA3YV.js +217 -0
- package/dist/chunk-NRB77EMM.js +6 -0
- package/dist/hook.js +95 -0
- package/dist/index.js +25 -0
- package/dist/main.js +2745 -0
- package/dist/version-IVSD7EGG.js +6 -0
- package/local/raydr_local.py +244 -0
- package/package.json +36 -4
- package/web/assets/index-9v58_QZ6.css +2 -0
- package/web/assets/index-CA1TzOHc.js +8 -0
- package/web/index.html +20 -0
package/README.md
CHANGED
|
@@ -1,3 +1,554 @@
|
|
|
1
|
-
#
|
|
1
|
+
# raydr
|
|
2
2
|
|
|
3
|
-
|
|
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.
|